fix: 退回报销支持留痕删除(09-04-reimbursement-state-gap-finance-view)

这个提交包含在:
2026-09-10 16:38:37 +08:00
父节点 e5685f18b2
当前提交 73fcd5a65a
共修改 27 个文件,包含 696 行新增和 94 行删除
+1 -1
查看文件
@@ -502,7 +502,7 @@ async function portalRoutes(fastify) {
teamStatusBoard,
] = await Promise.all([
fastify.prisma.workflowTask.count({ where: { status: "pending", OR: assigneeFilters } }),
fastify.prisma.reimbursementForm.count({ where: { OR: [{ owner_id: request.employee.id }, { submitter_id: request.employee.id }] } }),
fastify.prisma.reimbursementForm.count({ where: { deleted_at: null, OR: [{ owner_id: request.employee.id }, { submitter_id: request.employee.id }] } }),
fastify.prisma.leaveRequest.count({ where: { owner_id: request.employee.id } }),
fastify.prisma.assetCard.count({ where: { assignee_employee_id: request.employee.id } }),
roleCapabilities.canUseFinance
+1 -1
查看文件
@@ -842,7 +842,7 @@ async function projectsRoutes(fastify) {
const projectId = parseId(request.params?.id);
await ensureReadableProject(fastify, request, projectId);
const items = await fastify.prisma.reimbursementForm.findMany({
where: { project_id: projectId },
where: { project_id: projectId, deleted_at: null },
orderBy: { updated_at: "desc" },
select: { id: true, form_number: true, status: true, total_amount: true, reimbursement_date: true, updated_at: true },
});
+169 -52
查看文件
@@ -633,17 +633,36 @@ function buildProcessingProgress({ reimbursement, workflowInstance, paymentBatch
});
}
return {
stages: [
stageByKey.get("submitted"),
...(approvalStages.length > 0
? approvalStages
: [stageByKey.get("manager_approval"), stageByKey.get("finance_review")]),
stageByKey.get("payment_batch_created"),
stageByKey.get("payment_batch_review"),
stageByKey.get("payment_and_receipt"),
],
};
const stages = [
stageByKey.get("submitted"),
...(approvalStages.length > 0
? approvalStages
: [stageByKey.get("manager_approval"), stageByKey.get("finance_review")]),
stageByKey.get("payment_batch_created"),
stageByKey.get("payment_batch_review"),
stageByKey.get("payment_and_receipt"),
];
if (reimbursement.deleted_at) {
for (const stage of stages) {
if (["pending", "current"].includes(stage.state)) {
Object.assign(stage, { state: "terminated", detail: "报销单已删除,不再继续处理", targetHref: null });
} else if (stage.state === "exception") {
stage.detail = "历史审批结果,报销单已删除";
}
}
stages.push({
key: "deleted",
label: "删除报销单",
state: "terminated",
actorName: null,
occurredAt: isoDateTimeOrNull(reimbursement.deleted_at),
result: "已删除",
comment: null,
detail: "仅保留历史记录",
targetHref: null,
});
}
return { stages };
}
function ensureReimbursementParticipant(request, reimbursement) {
@@ -812,7 +831,7 @@ function buildReimbursementStatsConditions({
formAlias = "rf",
ownerAlias = "owner_employee",
}) {
const conditions = [Prisma.sql`1 = 1`];
const conditions = [Prisma.sql`${sqlIdentifier(formAlias, "deleted_at")} IS NULL`];
if (dateFrom) {
conditions.push(Prisma.sql`${sqlIdentifier(formAlias, "reimbursement_date")} >= ${dateFrom}`);
@@ -2266,6 +2285,7 @@ async function reimbursementRoutes(fastify) {
owner_id: true,
department_id: true,
created_at: true,
deleted_at: true,
reimbursement_date: true,
total_amount: true,
status: true,
@@ -2604,6 +2624,7 @@ async function reimbursementRoutes(fastify) {
: null,
createdAt: reimbursement.created_at?.toISOString?.() ?? null,
submittedAt: latestWorkflowInstance?.created_at?.toISOString?.() ?? null,
deletedAt: reimbursement.deleted_at?.toISOString?.() ?? null,
reimbursementDate: reimbursement.reimbursement_date.toISOString().slice(0, 10),
totalAmount: toMoneyString(reimbursement.total_amount),
status: reimbursement.status,
@@ -2797,7 +2818,7 @@ async function reimbursementRoutes(fastify) {
const result = await fastify.prisma.$transaction(async (tx) => {
const reimbursementRows = await tx.$queryRaw`
SELECT id, owner_id, submitter_id, status
SELECT id, owner_id, submitter_id, status, deleted_at, paid_at
FROM "reimbursement_forms"
WHERE id = ${reimbursementId}::uuid
FOR UPDATE
@@ -2814,10 +2835,61 @@ async function reimbursementRoutes(fastify) {
const reimbursement = reimbursementRows[0];
ensureReimbursementParticipant(request, reimbursement);
if (reimbursement.status !== "draft") {
if (reimbursement.deleted_at) {
return { reimbursementId, unboundInvoiceCount: 0 };
}
if (!["draft", "rejected"].includes(reimbursement.status) || reimbursement.paid_at) {
throw buildBusinessValidationError("reimbursement.status_invalid_delete_status", { status: "invalid_delete_status" }, 409);
}
const activeWorkflow = await tx.workflowInstance.findFirst({
where: {
entity_type: "reimbursement",
entity_id: reimbursementId,
OR: [{ current_status: "pending" }, { workflow_tasks: { some: { status: "pending" } } }],
},
select: { id: true },
});
const paymentReservation = await tx.paymentBatchItem.findFirst({
where: {
entity_id: reimbursementId,
payment_batch: { entity_type: "reimbursement", status: { not: "cancelled" } },
},
select: { id: true },
});
if (activeWorkflow || paymentReservation) {
throw buildBusinessValidationError("reimbursement.status_invalid_delete_status", { status: "invalid_delete_status" }, 409);
}
const history = await tx.workflowInstance.findFirst({
where: { entity_type: "reimbursement", entity_id: reimbursementId },
select: { id: true },
});
const retainHistory = reimbursement.status === "rejected" || Boolean(history);
const invoices = await tx.$queryRaw`
SELECT id, linked_item_id, storage_path, original_name, mime_type, file_size, file_sha256,
uploaded_by_employee_id, status
FROM "invoice_pool"
WHERE linked_reimbursement_id = ${reimbursementId}::uuid
ORDER BY id
FOR UPDATE
`;
if (invoices.some((invoice) => invoice.status !== "bound")) {
throw buildBusinessValidationError("reimbursement.status_invalid_delete_status", { status: "invalid_delete_status" }, 409);
}
if (retainHistory && invoices.length) {
await tx.reimbursementAttachment.createMany({
data: invoices.map((invoice) => ({
reimbursement_id: reimbursementId,
storage_path: invoice.storage_path,
original_name: invoice.original_name,
mime_type: invoice.mime_type,
file_size: invoice.file_size,
file_sha256: invoice.file_sha256,
uploaded_by: invoice.uploaded_by_employee_id,
attachment_type: "invoice",
})),
});
}
const unbound = await tx.invoicePool.updateMany({
where: {
linked_reimbursement_id: reimbursementId,
@@ -2831,8 +2903,35 @@ async function reimbursementRoutes(fastify) {
},
});
await tx.reimbursementForm.delete({
where: { id: reimbursementId },
if (retainHistory) {
await tx.reimbursementForm.update({
where: { id: reimbursementId },
data: {
status: "cancelled",
deleted_at: new Date(),
deleted_by: request.employee.id,
updated_by: request.employee.id,
},
});
} else {
await tx.reimbursementForm.delete({ where: { id: reimbursementId } });
}
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "REIMBURSEMENT_DELETED",
targetType: "reimbursement",
targetId: reimbursementId,
extra: {
previousStatus: reimbursement.status,
retainedHistory: retainHistory,
releasedInvoices: invoices.map((invoice) => ({
invoiceId: invoice.id,
itemId: invoice.linked_item_id,
fileSha256: invoice.file_sha256,
})),
},
}),
});
return {
@@ -2956,42 +3055,54 @@ async function reimbursementRoutes(fastify) {
contentType: file.mimeType,
});
const created = await fastify.prisma.reimbursementAttachment.create({
data: {
reimbursement_id: reimbursementId,
storage_path: storagePath,
original_name: file.originalName,
mime_type: file.mimeType,
file_size: file.fileSize,
file_sha256: fileSha256,
uploaded_by: request.employee.id,
...governanceCreateData(governanceFields, governanceDefaults),
},
select: {
id: true,
original_name: true,
mime_type: true,
file_size: true,
file_sha256: true,
governance_status: true,
sensitivity_level: true,
attachment_type: true,
reviewed_by: true,
reviewed_at: true,
review_note: true,
archived_by: true,
archived_at: true,
archive_note: true,
download_policy: true,
created_at: true,
uploader: {
select: {
id: true,
employee_no: true,
name: true,
const created = await fastify.prisma.$transaction(async (tx) => {
const currentRows = await tx.$queryRaw`
SELECT id, owner_id, submitter_id, status FROM "reimbursement_forms"
WHERE id = ${reimbursementId}::uuid FOR UPDATE
`;
const current = currentRows[0];
if (!current) throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
ensureReimbursementParticipant(request, current);
if (!["draft", "rejected"].includes(current.status)) {
throw buildBusinessValidationError("reimbursement.status_invalid_attachment_edit_status", { status: "invalid_attachment_edit_status" }, 409);
}
return tx.reimbursementAttachment.create({
data: {
reimbursement_id: reimbursementId,
storage_path: storagePath,
original_name: file.originalName,
mime_type: file.mimeType,
file_size: file.fileSize,
file_sha256: fileSha256,
uploaded_by: request.employee.id,
...governanceCreateData(governanceFields, governanceDefaults),
},
select: {
id: true,
original_name: true,
mime_type: true,
file_size: true,
file_sha256: true,
governance_status: true,
sensitivity_level: true,
attachment_type: true,
reviewed_by: true,
reviewed_at: true,
review_note: true,
archived_by: true,
archived_at: true,
archive_note: true,
download_policy: true,
created_at: true,
uploader: {
select: {
id: true,
employee_no: true,
name: true,
},
},
},
},
});
});
createdItems.push(created);
}
@@ -4565,9 +4676,15 @@ async function reimbursementRoutes(fastify) {
if (!["draft", "rejected"].includes(current.status)) {
throw buildBusinessValidationError("reimbursement.reimbursement_invalid_status_for_project_link", { reimbursement: "invalid_status_for_project_link" }, 409);
}
const updated = await fastify.prisma.reimbursementForm.update({
where: { id: reimbursementId },
const changed = await fastify.prisma.reimbursementForm.updateMany({
where: { id: reimbursementId, deleted_at: null, status: { in: ["draft", "rejected"] } },
data: { project_id: body.projectId ?? null, updated_by: request.employee.id },
});
if (!changed.count) {
throw buildBusinessValidationError("reimbursement.reimbursement_invalid_status_for_project_link", { reimbursement: "invalid_status_for_project_link" }, 409);
}
const updated = await fastify.prisma.reimbursementForm.findUnique({
where: { id: reimbursementId },
select: { id: true, project_id: true, project: { select: { id: true, project_no: true, name: true } } },
});
return sendData(reply, request, {
+1 -1
查看文件
@@ -38,7 +38,7 @@ function dateRange(dateFrom, dateTo) {
}
function buildFinanceLedgerWhere(filters = {}) {
const clauses = [];
const clauses = [{ deleted_at: null }];
const query = filters.q?.trim();
if (filters.status) {
@@ -47,11 +47,11 @@ function buildReadableReimbursementWhere({ employee, scope = "all", filters = {}
: buildViewAllReimbursementScopeWhere(employee);
if (scope === "own") {
return { AND: [scopeWhere, filters] };
return { AND: [scopeWhere, filters, { deleted_at: null }] };
}
return {
AND: [scopeWhere, buildReimbursementBusinessFactWhere(employee.id), filters],
AND: [scopeWhere, buildReimbursementBusinessFactWhere(employee.id), filters, { deleted_at: null }],
};
}