feat(reimbursement-small-team-form-validation): 完善小团队报销填报校验
这个提交包含在:
@@ -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,
|
||||
|
||||
在新工单中引用
屏蔽一个用户