R56 A1-A6:费用标准管控
这个提交包含在:
@@ -0,0 +1,306 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户