307 行
8.6 KiB
JavaScript
307 行
8.6 KiB
JavaScript
const {
|
|
checkExpenseStandards,
|
|
mergeReimbursementPrecheck,
|
|
} = require("../../../src/services/feeControl");
|
|
|
|
const REIMBURSEMENT_ID = "11111111-1111-4111-8111-111111111111";
|
|
const EXPENSE_TYPE_ID = "22222222-2222-4222-8222-222222222222";
|
|
const OTHER_EXPENSE_TYPE_ID = "33333333-3333-4333-8333-333333333333";
|
|
const DEPARTMENT_ID = "44444444-4444-4444-8444-444444444444";
|
|
|
|
function buildTx(options = {}) {
|
|
return {
|
|
reimbursementForm: {
|
|
findUnique: vi.fn().mockResolvedValue(
|
|
options.reimbursement ?? {
|
|
id: REIMBURSEMENT_ID,
|
|
department_id: DEPARTMENT_ID,
|
|
items: [
|
|
{
|
|
id: "item-1",
|
|
expense_type_id: EXPENSE_TYPE_ID,
|
|
amount: "150.00",
|
|
expense_type: {
|
|
id: EXPENSE_TYPE_ID,
|
|
name: "交通费",
|
|
},
|
|
},
|
|
],
|
|
},
|
|
),
|
|
},
|
|
expenseStandard: {
|
|
findMany: vi.fn().mockResolvedValue(
|
|
options.standards ?? [
|
|
{
|
|
id: "standard-global",
|
|
expense_type_id: EXPENSE_TYPE_ID,
|
|
name: "交通费全局上限",
|
|
max_amount: "120.00",
|
|
per_unit: "item",
|
|
control_mode: "warn",
|
|
scope_type: "global",
|
|
scope_key: null,
|
|
created_at: new Date("2026-04-08T09:00:00.000Z"),
|
|
},
|
|
],
|
|
),
|
|
},
|
|
};
|
|
}
|
|
|
|
describe("checkExpenseStandards", () => {
|
|
it("prefers department scoped standard over global standard", async () => {
|
|
const tx = buildTx({
|
|
standards: [
|
|
{
|
|
id: "standard-global",
|
|
expense_type_id: EXPENSE_TYPE_ID,
|
|
name: "交通费全局上限",
|
|
max_amount: "120.00",
|
|
per_unit: "item",
|
|
control_mode: "warn",
|
|
scope_type: "global",
|
|
scope_key: null,
|
|
created_at: new Date("2026-04-08T09:00:00.000Z"),
|
|
},
|
|
{
|
|
id: "standard-department",
|
|
expense_type_id: EXPENSE_TYPE_ID,
|
|
name: "交通费部门上限",
|
|
max_amount: "100.00",
|
|
per_unit: "item",
|
|
control_mode: "block",
|
|
scope_type: "department",
|
|
scope_key: DEPARTMENT_ID,
|
|
created_at: new Date("2026-04-08T10:00:00.000Z"),
|
|
},
|
|
],
|
|
});
|
|
|
|
const result = await checkExpenseStandards(tx, { reimbursementId: REIMBURSEMENT_ID });
|
|
|
|
expect(result).toEqual([
|
|
expect.objectContaining({
|
|
standardId: "standard-department",
|
|
standardName: "交通费部门上限",
|
|
controlMode: "block",
|
|
exceeded: true,
|
|
exceededAmount: "50.00",
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it("returns warn level check without blocking when amount only exceeds warn standard", async () => {
|
|
const tx = buildTx();
|
|
const result = await checkExpenseStandards(tx, { reimbursementId: REIMBURSEMENT_ID });
|
|
const merged = mergeReimbursementPrecheck(
|
|
{
|
|
entityType: "reimbursement",
|
|
entityId: REIMBURSEMENT_ID,
|
|
decision: "pass",
|
|
matchedBudgetControlRule: null,
|
|
budgetHint: null,
|
|
standardChecks: [],
|
|
warnings: [],
|
|
blockingReasons: [],
|
|
riskFlags: [],
|
|
nextActions: ["可直接提交"],
|
|
},
|
|
result,
|
|
);
|
|
|
|
expect(result[0]).toMatchObject({
|
|
controlMode: "warn",
|
|
exceeded: true,
|
|
});
|
|
expect(merged.decision).toBe("warn");
|
|
expect(merged.warnings).toHaveLength(1);
|
|
expect(merged.blockingReasons).toHaveLength(0);
|
|
});
|
|
|
|
it("turns decision into block when exceeded item hits block standard", async () => {
|
|
const tx = buildTx({
|
|
standards: [
|
|
{
|
|
id: "standard-block",
|
|
expense_type_id: EXPENSE_TYPE_ID,
|
|
name: "交通费阻断上限",
|
|
max_amount: "120.00",
|
|
per_unit: "item",
|
|
control_mode: "block",
|
|
scope_type: "global",
|
|
scope_key: null,
|
|
created_at: new Date("2026-04-08T09:00:00.000Z"),
|
|
},
|
|
],
|
|
});
|
|
|
|
const result = await checkExpenseStandards(tx, { reimbursementId: REIMBURSEMENT_ID });
|
|
const merged = mergeReimbursementPrecheck(
|
|
{
|
|
entityType: "reimbursement",
|
|
entityId: REIMBURSEMENT_ID,
|
|
decision: "pass",
|
|
matchedBudgetControlRule: null,
|
|
budgetHint: null,
|
|
standardChecks: [],
|
|
warnings: [],
|
|
blockingReasons: [],
|
|
riskFlags: [],
|
|
nextActions: ["可直接提交"],
|
|
},
|
|
result,
|
|
);
|
|
|
|
expect(result[0].controlMode).toBe("block");
|
|
expect(merged.decision).toBe("block");
|
|
expect(merged.blockingReasons).toHaveLength(1);
|
|
});
|
|
|
|
it("skips items that do not match any standard", async () => {
|
|
const tx = buildTx({
|
|
reimbursement: {
|
|
id: REIMBURSEMENT_ID,
|
|
department_id: DEPARTMENT_ID,
|
|
items: [
|
|
{
|
|
id: "item-2",
|
|
expense_type_id: OTHER_EXPENSE_TYPE_ID,
|
|
amount: "88.00",
|
|
expense_type: {
|
|
id: OTHER_EXPENSE_TYPE_ID,
|
|
name: "办公费",
|
|
},
|
|
},
|
|
],
|
|
},
|
|
standards: [
|
|
{
|
|
id: "standard-global",
|
|
expense_type_id: EXPENSE_TYPE_ID,
|
|
name: "交通费全局上限",
|
|
max_amount: "120.00",
|
|
per_unit: "item",
|
|
control_mode: "warn",
|
|
scope_type: "global",
|
|
scope_key: null,
|
|
created_at: new Date("2026-04-08T09:00:00.000Z"),
|
|
},
|
|
],
|
|
});
|
|
|
|
const result = await checkExpenseStandards(tx, { reimbursementId: REIMBURSEMENT_ID });
|
|
|
|
expect(result).toEqual([]);
|
|
});
|
|
|
|
it("keeps perUnit as display metadata without any day or trip aggregation", async () => {
|
|
const tx = buildTx({
|
|
reimbursement: {
|
|
id: REIMBURSEMENT_ID,
|
|
department_id: DEPARTMENT_ID,
|
|
items: [
|
|
{
|
|
id: "item-1",
|
|
expense_type_id: EXPENSE_TYPE_ID,
|
|
amount: "300.00",
|
|
expense_type: {
|
|
id: EXPENSE_TYPE_ID,
|
|
name: "交通费",
|
|
},
|
|
},
|
|
{
|
|
id: "item-2",
|
|
expense_type_id: EXPENSE_TYPE_ID,
|
|
amount: "300.00",
|
|
expense_type: {
|
|
id: EXPENSE_TYPE_ID,
|
|
name: "交通费",
|
|
},
|
|
},
|
|
],
|
|
},
|
|
standards: [
|
|
{
|
|
id: "standard-trip",
|
|
expense_type_id: EXPENSE_TYPE_ID,
|
|
name: "差旅交通上限",
|
|
max_amount: "250.00",
|
|
per_unit: "trip",
|
|
control_mode: "warn",
|
|
scope_type: "global",
|
|
scope_key: null,
|
|
created_at: new Date("2026-04-08T09:00:00.000Z"),
|
|
},
|
|
],
|
|
});
|
|
|
|
const result = await checkExpenseStandards(tx, { reimbursementId: REIMBURSEMENT_ID });
|
|
|
|
expect(result).toHaveLength(2);
|
|
expect(result[0]).toMatchObject({
|
|
perUnit: "trip",
|
|
exceededAmount: "50.00",
|
|
});
|
|
expect(result[1]).toMatchObject({
|
|
perUnit: "trip",
|
|
exceededAmount: "50.00",
|
|
});
|
|
});
|
|
|
|
it("preserves manual_review decision priority over warn and under block", () => {
|
|
const mergedWarn = mergeReimbursementPrecheck(
|
|
{
|
|
entityType: "reimbursement",
|
|
entityId: REIMBURSEMENT_ID,
|
|
decision: "manual_review",
|
|
matchedBudgetControlRule: null,
|
|
budgetHint: null,
|
|
standardChecks: [],
|
|
warnings: [],
|
|
blockingReasons: [],
|
|
riskFlags: [{ code: "budget_exceeded", level: "high", title: "预算超用", detail: "需要复核" }],
|
|
nextActions: ["允许提交,但会生成待复核风险项"],
|
|
},
|
|
[
|
|
{
|
|
itemId: "item-1",
|
|
expenseTypeId: EXPENSE_TYPE_ID,
|
|
expenseTypeName: "交通费",
|
|
amount: "150.00",
|
|
standardId: "standard-global",
|
|
standardName: "交通费全局上限",
|
|
maxAmount: "120.00",
|
|
perUnit: "item",
|
|
controlMode: "warn",
|
|
scopeType: "global",
|
|
scopeKey: null,
|
|
exceeded: true,
|
|
exceededAmount: "30.00",
|
|
},
|
|
],
|
|
);
|
|
|
|
const mergedBlock = mergeReimbursementPrecheck(mergedWarn, [
|
|
{
|
|
itemId: "item-2",
|
|
expenseTypeId: EXPENSE_TYPE_ID,
|
|
expenseTypeName: "交通费",
|
|
amount: "200.00",
|
|
standardId: "standard-block",
|
|
standardName: "交通费阻断上限",
|
|
maxAmount: "120.00",
|
|
perUnit: "item",
|
|
controlMode: "block",
|
|
scopeType: "global",
|
|
scopeKey: null,
|
|
exceeded: true,
|
|
exceededAmount: "80.00",
|
|
},
|
|
]);
|
|
|
|
expect(mergedWarn.decision).toBe("manual_review");
|
|
expect(mergedBlock.decision).toBe("block");
|
|
});
|
|
});
|