feat(reimbursement-small-team-form-validation): 完善小团队报销填报校验
这个提交包含在:
@@ -26,6 +26,7 @@ AUTH_CODE_PEPPER="<generate-a-separate-random-secret>"
|
||||
# EMAIL_LOGIN_CODE_LOCK_SECONDS="1800"
|
||||
# DEV_SHOW_PASSWORD_RESET_CODE="false"
|
||||
APP_TIMEZONE="Asia/Shanghai"
|
||||
# REIMBURSEMENT_MAX_AGE_DAYS="365" # 仅允许 180 或 365;超期只提示,不自动阻断
|
||||
STORAGE_DIR="generated/storage"
|
||||
# STORAGE_DRIVER="local" # local|s3(MinIO/S3 兼容)
|
||||
# S3_ENDPOINT="" # 例如:http://127.0.0.1:9000(MinIO)
|
||||
|
||||
@@ -383,6 +383,7 @@ npm run job:ocr-once
|
||||
- `JWT_SECRET`: JWT & signed-url token 签名密钥
|
||||
- `FIELD_ENCRYPTION_KEY`: 敏感字段加密密钥(AES-256-GCM;base64 32 bytes;用于银行卡/身份证等)
|
||||
- `APP_TIMEZONE`: 默认 `Asia/Shanghai`
|
||||
- `REIMBURSEMENT_MAX_AGE_DAYS`: 报销费用日期提示期限,默认 `365`,仅允许 `180` 或 `365`;超期和跨自然年度只预警,不自动阻断
|
||||
- `STORAGE_DIR`: 默认 `generated/storage`(相对 `server/`)
|
||||
- `SERVE_CLIENT_DIST`: 是否由 server 托管 `client/dist`(默认 `false`)
|
||||
- `CLIENT_DIST_DIR`: 前端构建产物目录(默认 `../client/dist`,相对 `server/`)
|
||||
|
||||
@@ -64,6 +64,14 @@ function getConfig() {
|
||||
min: nodeEnv === "production" ? 1 : 0,
|
||||
max: nodeEnv === "production" ? 1 : 8,
|
||||
});
|
||||
const reimbursementMaxAgeDays = parseBoundedInteger(
|
||||
"REIMBURSEMENT_MAX_AGE_DAYS",
|
||||
process.env.REIMBURSEMENT_MAX_AGE_DAYS ?? 365,
|
||||
{ min: 180, max: 365 },
|
||||
);
|
||||
if (![180, 365].includes(reimbursementMaxAgeDays)) {
|
||||
throw new Error("Invalid REIMBURSEMENT_MAX_AGE_DAYS");
|
||||
}
|
||||
|
||||
const passwordResetCodeTtlSeconds = parseBoundedInteger(
|
||||
"PASSWORD_RESET_CODE_TTL_SECONDS",
|
||||
@@ -192,6 +200,7 @@ function getConfig() {
|
||||
wecomApprovalCallbackEvidenceFile: process.env.WECOM_APPROVAL_CALLBACK_EVIDENCE_FILE ?? "",
|
||||
|
||||
appTimezone: process.env.APP_TIMEZONE ?? "Asia/Shanghai",
|
||||
reimbursementMaxAgeDays,
|
||||
storageDir: path.resolve(serverRoot, storageDir),
|
||||
storageDriver: process.env.STORAGE_DRIVER ?? "local",
|
||||
s3Endpoint: process.env.S3_ENDPOINT ?? "",
|
||||
|
||||
@@ -928,12 +928,12 @@ function parseItemBody(body) {
|
||||
}
|
||||
|
||||
const amountNumber = Number(parsed.data.amount);
|
||||
if (!Number.isFinite(amountNumber) || amountNumber < 0) {
|
||||
if (!Number.isFinite(amountNumber) || amountNumber <= 0) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { amount: "invalid" },
|
||||
details: { amount: "must_be_positive" },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3712,12 +3712,12 @@ async function reimbursementRoutes(fastify) {
|
||||
|
||||
if (typeof parsedBody.data.amount === "string") {
|
||||
const amountNumber = Number(parsedBody.data.amount);
|
||||
if (!Number.isFinite(amountNumber) || amountNumber < 0) {
|
||||
if (!Number.isFinite(amountNumber) || amountNumber <= 0) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { amount: "invalid" },
|
||||
details: { amount: "must_be_positive" },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -3952,6 +3952,36 @@ async function reimbursementRoutes(fastify) {
|
||||
});
|
||||
}
|
||||
|
||||
const reimbursementItems = await tx.reimbursementItem.findMany({
|
||||
where: { reimbursement_id: reimbursementId },
|
||||
orderBy: [{ created_at: "asc" }, { id: "asc" }],
|
||||
select: { id: true, amount: true },
|
||||
});
|
||||
if (!reimbursementItems.length) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { items: "at_least_one_required" },
|
||||
});
|
||||
}
|
||||
const invalidAmountItems = reimbursementItems
|
||||
.filter((item) => !Number.isFinite(Number(item.amount)) || Number(item.amount) <= 0)
|
||||
.map((item) => item.id);
|
||||
if (invalidAmountItems.length) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { items: "positive_amount_required", itemIds: invalidAmountItems },
|
||||
});
|
||||
}
|
||||
|
||||
reimbursement.total_amount = await recomputeTotalAmount(
|
||||
tx,
|
||||
reimbursementId,
|
||||
request.employee.id,
|
||||
);
|
||||
ensureNonNegativeAmount(reimbursement.total_amount);
|
||||
|
||||
const travelItem = await tx.reimbursementItem.findFirst({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const { AppError } = require("../utils/AppError");
|
||||
const { resolveApplicableBudgetPoolByContext } = require("./budgetPool");
|
||||
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("./featureFlags");
|
||||
const { currentDateString, parseDateOnlyString } = require("../utils/date");
|
||||
const {
|
||||
buildApprovalWorkflowSnapshot,
|
||||
parseWorkflowSteps,
|
||||
@@ -79,6 +80,130 @@ function buildDisabledPrecheck(entityType, entityId) {
|
||||
};
|
||||
}
|
||||
|
||||
function dateOnlyDiffDays(laterDate, earlierDate) {
|
||||
return Math.floor(
|
||||
(parseDateOnlyString(laterDate).getTime() - parseDateOnlyString(earlierDate).getTime())
|
||||
/ (24 * 60 * 60 * 1000),
|
||||
);
|
||||
}
|
||||
|
||||
async function buildReimbursementOperationalChecks(tx, { reimbursementId, config }) {
|
||||
const reimbursement = await tx.reimbursementForm.findUnique({
|
||||
where: { id: reimbursementId },
|
||||
select: {
|
||||
id: true,
|
||||
items: {
|
||||
orderBy: [{ created_at: "asc" }, { id: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
expense_date: true,
|
||||
expense_type: { select: { category: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!reimbursement) {
|
||||
throw buildNotFoundError();
|
||||
}
|
||||
|
||||
const [invoices, attachmentCount] = await Promise.all([
|
||||
tx.invoicePool.findMany({
|
||||
where: { linked_reimbursement_id: reimbursementId, status: "bound" },
|
||||
select: { linked_item_id: true },
|
||||
}),
|
||||
tx.reimbursementAttachment.count({ where: { reimbursement_id: reimbursementId } }),
|
||||
]);
|
||||
const linkedInvoiceCounts = new Map();
|
||||
for (const invoice of invoices) {
|
||||
if (!invoice.linked_item_id) continue;
|
||||
linkedInvoiceCounts.set(
|
||||
invoice.linked_item_id,
|
||||
(linkedInvoiceCounts.get(invoice.linked_item_id) ?? 0) + 1,
|
||||
);
|
||||
}
|
||||
|
||||
const businessDate = currentDateString(config.appTimezone);
|
||||
const businessYear = businessDate.slice(0, 4);
|
||||
const maxAgeDays = config.reimbursementMaxAgeDays ?? 365;
|
||||
const timelinessChecks = reimbursement.items.map((item) => {
|
||||
const expenseDate = item.expense_date.toISOString().slice(0, 10);
|
||||
const ageDays = dateOnlyDiffDays(businessDate, expenseDate);
|
||||
return {
|
||||
itemId: item.id,
|
||||
expenseDate,
|
||||
ageDays,
|
||||
maxAgeDays,
|
||||
overdue: ageDays > maxAgeDays,
|
||||
crossCalendarYear: expenseDate.slice(0, 4) !== businessYear,
|
||||
};
|
||||
});
|
||||
const evidenceItems = reimbursement.items.map((item) => {
|
||||
const linkedInvoiceCount = linkedInvoiceCounts.get(item.id) ?? 0;
|
||||
return {
|
||||
itemId: item.id,
|
||||
linkedInvoiceCount,
|
||||
invoiceState: linkedInvoiceCount > 0 ? "linked" : "unlinked",
|
||||
};
|
||||
});
|
||||
const assetItemCount = reimbursement.items.filter(
|
||||
(item) => item.expense_type?.category === "assets",
|
||||
).length;
|
||||
const warnings = [];
|
||||
|
||||
for (const check of timelinessChecks) {
|
||||
if (check.overdue) {
|
||||
warnings.push(buildIssue(
|
||||
`expense_date_overdue_${check.itemId}`,
|
||||
"费用日期超过当前报销期限",
|
||||
`该明细距今 ${check.ageDays} 天,当前期限为 ${check.maxAgeDays} 天,请提交前与财务确认。`,
|
||||
));
|
||||
}
|
||||
if (check.crossCalendarYear) {
|
||||
warnings.push(buildIssue(
|
||||
`expense_date_cross_year_${check.itemId}`,
|
||||
"费用日期跨自然年度",
|
||||
`该明细发生于 ${check.expenseDate},请提交前与财务确认年度归属。`,
|
||||
));
|
||||
}
|
||||
}
|
||||
const unlinkedCount = evidenceItems.filter((item) => item.invoiceState === "unlinked").length;
|
||||
if (unlinkedCount > 0) {
|
||||
warnings.push(buildIssue(
|
||||
"reimbursement_items_without_linked_invoice",
|
||||
"部分明细尚未关联发票",
|
||||
`共有 ${unlinkedCount} 条明细未关联发票;如票据属于整单或无需发票,请在报销说明中写明。`,
|
||||
));
|
||||
}
|
||||
if (assetItemCount > 0) {
|
||||
warnings.push(buildIssue(
|
||||
"reimbursement_asset_registration_required",
|
||||
"报销完成后需核对资产登记",
|
||||
`共有 ${assetItemCount} 条明细可能形成资产,报销完成后由管理员核对并手动建卡。`,
|
||||
));
|
||||
}
|
||||
|
||||
return {
|
||||
evidenceSummary: {
|
||||
invoiceCount: invoices.length,
|
||||
attachmentCount,
|
||||
items: evidenceItems,
|
||||
},
|
||||
timelinessChecks,
|
||||
assetHint: { required: assetItemCount > 0, itemCount: assetItemCount },
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
function mergeReimbursementOperationalChecks(precheck, operationalChecks) {
|
||||
return {
|
||||
...precheck,
|
||||
evidenceSummary: operationalChecks.evidenceSummary,
|
||||
timelinessChecks: operationalChecks.timelinessChecks,
|
||||
assetHint: operationalChecks.assetHint,
|
||||
warnings: [...precheck.warnings, ...operationalChecks.warnings],
|
||||
};
|
||||
}
|
||||
|
||||
function buildIssue(code, title, detail) {
|
||||
return {
|
||||
code,
|
||||
@@ -1029,8 +1154,14 @@ async function resolveEntityPrecheck({
|
||||
if (entityType !== "reimbursement") {
|
||||
return disabledPrecheck;
|
||||
}
|
||||
const standardChecks = await checkExpenseStandards(prisma, { reimbursementId: entityId });
|
||||
return mergeReimbursementPrecheck(disabledPrecheck, standardChecks);
|
||||
const [standardChecks, operationalChecks] = await Promise.all([
|
||||
checkExpenseStandards(prisma, { reimbursementId: entityId }),
|
||||
buildReimbursementOperationalChecks(prisma, { reimbursementId: entityId, config }),
|
||||
]);
|
||||
return mergeReimbursementPrecheck(
|
||||
mergeReimbursementOperationalChecks(disabledPrecheck, operationalChecks),
|
||||
standardChecks,
|
||||
);
|
||||
}
|
||||
|
||||
const precheck = await buildEntityPrecheck(prisma, {
|
||||
@@ -1044,8 +1175,14 @@ async function resolveEntityPrecheck({
|
||||
return precheck;
|
||||
}
|
||||
|
||||
const standardChecks = await checkExpenseStandards(prisma, { reimbursementId: entityId });
|
||||
return mergeReimbursementPrecheck(precheck, standardChecks);
|
||||
const [standardChecks, operationalChecks] = await Promise.all([
|
||||
checkExpenseStandards(prisma, { reimbursementId: entityId }),
|
||||
buildReimbursementOperationalChecks(prisma, { reimbursementId: entityId, config }),
|
||||
]);
|
||||
return mergeReimbursementPrecheck(
|
||||
mergeReimbursementOperationalChecks(precheck, operationalChecks),
|
||||
standardChecks,
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
@@ -1062,6 +1199,7 @@ module.exports = {
|
||||
createRiskFlagsFromPrecheck,
|
||||
buildEntityPrecheck,
|
||||
buildReimbursementPrecheck,
|
||||
buildReimbursementOperationalChecks,
|
||||
buildPurchaseRequestPrecheck,
|
||||
buildTravelRequestPrecheck,
|
||||
checkExpenseStandards,
|
||||
|
||||
@@ -131,6 +131,27 @@ describe("auth security configuration", () => {
|
||||
expect(() => getConfig()).toThrow(`Invalid ${name}`);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[undefined, 365],
|
||||
["180", 180],
|
||||
["365", 365],
|
||||
])("accepts the configured reimbursement age policy", (value, expected) => {
|
||||
stubBaseEnv();
|
||||
vi.stubEnv("REIMBURSEMENT_MAX_AGE_DAYS", value);
|
||||
|
||||
expect(getConfig().reimbursementMaxAgeDays).toBe(expected);
|
||||
});
|
||||
|
||||
it.each(["0", "181", "366", "invalid"])(
|
||||
"rejects unsupported reimbursement age policy %s",
|
||||
(value) => {
|
||||
stubBaseEnv();
|
||||
vi.stubEnv("REIMBURSEMENT_MAX_AGE_DAYS", value);
|
||||
|
||||
expect(() => getConfig()).toThrow("Invalid REIMBURSEMENT_MAX_AGE_DAYS");
|
||||
},
|
||||
);
|
||||
|
||||
it("ignores a forged X-Forwarded-For header by default", async () => {
|
||||
stubBaseEnv();
|
||||
const app = await buildIpProbeApp();
|
||||
|
||||
@@ -101,6 +101,12 @@ async function buildApp(options = {}) {
|
||||
},
|
||||
reimbursementItem: {
|
||||
findFirst: vi.fn(async () => (options.travelItem ? { id: "travel-item" } : null)),
|
||||
findMany: vi.fn(async () => options.reimbursementItems ?? [
|
||||
{ id: "item-1", amount: reimbursement.total_amount },
|
||||
]),
|
||||
aggregate: vi.fn(async () => ({
|
||||
_sum: { amount: options.authoritativeTotal ?? reimbursement.total_amount },
|
||||
})),
|
||||
},
|
||||
budgetLedgerEntry: {
|
||||
create: vi.fn(async () => ({ id: "budget-ledger-1" })),
|
||||
@@ -245,6 +251,52 @@ describe("reimbursements routes expense standard integration", () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("rejects submit when the reimbursement has no items", async () => {
|
||||
const { app, tx } = await buildApp({ reimbursementItems: [] });
|
||||
|
||||
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` });
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().error.details).toEqual({ items: "at_least_one_required" });
|
||||
expect(tx.reimbursementForm.update).not.toHaveBeenCalled();
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("rejects submit and identifies items with non-positive amounts", async () => {
|
||||
const { app, tx } = await buildApp({
|
||||
reimbursementItems: [
|
||||
{ id: "item-zero", amount: "0.00" },
|
||||
{ id: "item-valid", amount: "20.00" },
|
||||
],
|
||||
});
|
||||
|
||||
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` });
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().error.details).toEqual({
|
||||
items: "positive_amount_required",
|
||||
itemIds: ["item-zero"],
|
||||
});
|
||||
expect(tx.workflowInstance.create).not.toHaveBeenCalled();
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("recomputes a stale total before selecting the approval rule", async () => {
|
||||
resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck());
|
||||
const { app, tx } = await buildApp({ authoritativeTotal: "320.00" });
|
||||
|
||||
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(tx.reimbursementForm.update).toHaveBeenCalledWith(expect.objectContaining({
|
||||
data: expect.objectContaining({ total_amount: "320.00" }),
|
||||
}));
|
||||
expect(tx.approvalRule.findFirst).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: expect.objectContaining({ min_amount: { lte: "320.00" } }),
|
||||
}));
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("returns standardChecks from reimbursement precheck", async () => {
|
||||
resolveEntityPrecheckMock.mockResolvedValue(
|
||||
buildPrecheck({
|
||||
|
||||
@@ -42,7 +42,7 @@ describe("environment recovery classification contract", () => {
|
||||
|
||||
it("classifies every env contract key exactly once", async () => {
|
||||
const result = await validate();
|
||||
expect(result.stdout).toContain("环境恢复分类键数: 164");
|
||||
expect(result.stdout).toContain("环境恢复分类键数: 165");
|
||||
});
|
||||
|
||||
it("skips tracked paths deleted in the current worktree before reading metadata", async () => {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
const {
|
||||
buildReimbursementOperationalChecks,
|
||||
} = require("../../../src/services/feeControl");
|
||||
|
||||
const REIMBURSEMENT_ID = "11111111-1111-4111-8111-111111111111";
|
||||
|
||||
function buildTx() {
|
||||
return {
|
||||
reimbursementForm: {
|
||||
findUnique: vi.fn(async () => ({
|
||||
id: REIMBURSEMENT_ID,
|
||||
items: [
|
||||
{
|
||||
id: "item-office",
|
||||
expense_date: new Date("2026-02-05T00:00:00.000Z"),
|
||||
expense_type: { category: "office" },
|
||||
},
|
||||
{
|
||||
id: "item-asset",
|
||||
expense_date: new Date("2025-08-03T00:00:00.000Z"),
|
||||
expense_type: { category: "assets" },
|
||||
},
|
||||
],
|
||||
})),
|
||||
},
|
||||
invoicePool: {
|
||||
findMany: vi.fn(async () => [
|
||||
{ linked_item_id: "item-office" },
|
||||
{ linked_item_id: "item-office" },
|
||||
{ linked_item_id: null },
|
||||
]),
|
||||
},
|
||||
reimbursementAttachment: {
|
||||
count: vi.fn(async () => 2),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildReimbursementOperationalChecks", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-08-04T04:00:00.000Z"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("summarizes evidence, timeliness and asset candidates without blocking", async () => {
|
||||
const result = await buildReimbursementOperationalChecks(buildTx(), {
|
||||
reimbursementId: REIMBURSEMENT_ID,
|
||||
config: { appTimezone: "Asia/Shanghai", reimbursementMaxAgeDays: 365 },
|
||||
});
|
||||
|
||||
expect(result.evidenceSummary).toEqual({
|
||||
invoiceCount: 3,
|
||||
attachmentCount: 2,
|
||||
items: [
|
||||
{ itemId: "item-office", linkedInvoiceCount: 2, invoiceState: "linked" },
|
||||
{ itemId: "item-asset", linkedInvoiceCount: 0, invoiceState: "unlinked" },
|
||||
],
|
||||
});
|
||||
expect(result.timelinessChecks).toEqual([
|
||||
expect.objectContaining({ itemId: "item-office", ageDays: 180, overdue: false, crossCalendarYear: false }),
|
||||
expect.objectContaining({ itemId: "item-asset", ageDays: 366, overdue: true, crossCalendarYear: true }),
|
||||
]);
|
||||
expect(result.assetHint).toEqual({ required: true, itemCount: 1 });
|
||||
expect(result.warnings.map((item) => item.code)).toEqual(expect.arrayContaining([
|
||||
"expense_date_overdue_item-asset",
|
||||
"expense_date_cross_year_item-asset",
|
||||
"reimbursement_items_without_linked_invoice",
|
||||
"reimbursement_asset_registration_required",
|
||||
]));
|
||||
});
|
||||
|
||||
it("uses the 180-day policy and keeps the boundary day eligible", async () => {
|
||||
const result = await buildReimbursementOperationalChecks(buildTx(), {
|
||||
reimbursementId: REIMBURSEMENT_ID,
|
||||
config: { appTimezone: "Asia/Shanghai", reimbursementMaxAgeDays: 180 },
|
||||
});
|
||||
|
||||
expect(result.timelinessChecks[0]).toEqual(expect.objectContaining({
|
||||
ageDays: 180,
|
||||
maxAgeDays: 180,
|
||||
overdue: false,
|
||||
}));
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户