fix: 退回报销支持留痕删除(09-04-reimbursement-state-gap-finance-view)
这个提交包含在:
@@ -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
|
||||
|
||||
@@ -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, {
|
||||
|
||||
在新工单中引用
屏蔽一个用户