feat(reimbursement-small-team-form-validation): 完善小团队报销填报校验

这个提交包含在:
2026-08-04 23:28:56 +08:00
父节点 f33c67eb20
当前提交 50c14c1b85
共修改 28 个文件,包含 1095 行新增和 30 行删除
+142 -4
查看文件
@@ -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,