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

这个提交包含在:
2026-09-10 16:38:37 +08:00
父节点 e5685f18b2
当前提交 73fcd5a65a
共修改 27 个文件,包含 696 行新增和 94 行删除
@@ -0,0 +1,10 @@
ALTER TABLE "reimbursement_forms"
ADD COLUMN "deleted_at" TIMESTAMP(3),
ADD COLUMN "deleted_by" UUID,
ADD CONSTRAINT "reimbursement_forms_deleted_by_fkey"
FOREIGN KEY ("deleted_by") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE,
ADD CONSTRAINT "reimbursement_forms_deleted_state_check"
CHECK (("deleted_at" IS NULL AND "deleted_by" IS NULL)
OR ("deleted_at" IS NOT NULL AND "deleted_by" IS NOT NULL AND "status" = 'cancelled'));
CREATE INDEX "idx_reimbursement_forms_deleted_at" ON "reimbursement_forms"("deleted_at");
+5
查看文件
@@ -575,6 +575,7 @@ model Department {
}
model Employee {
deleted_reimbursements ReimbursementForm[] @relation("ReimbursementDeletedBy")
id String @id @default(uuid()) @db.Uuid
employee_no String @unique @default(dbgenerated("generate_employee_number()"))
name String
@@ -1441,6 +1442,9 @@ model InventoryCheckItem {
}
model ReimbursementForm {
deleted_at DateTime?
deleted_by String? @db.Uuid
deleted_by_employee Employee? @relation("ReimbursementDeletedBy", fields: [deleted_by], references: [id], onDelete: Restrict)
id String @id @default(uuid()) @db.Uuid
form_number String? @unique
submitter_id String @db.Uuid
@@ -1491,6 +1495,7 @@ model ReimbursementForm {
@@index([status])
@@index([reimbursement_date])
@@index([owner_id, status, updated_at], map: "idx_reimbursement_owner_status_updated_at")
@@index([deleted_at], map: "idx_reimbursement_forms_deleted_at")
@@map("reimbursement_forms")
}
+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 }],
};
}
@@ -0,0 +1,135 @@
const Fastify = require("fastify");
const { AppError } = require("../../../src/utils/AppError");
const routes = require("../../../src/routes/reimbursements");
const FORM_ID = "11111111-1111-4111-8111-111111111111";
const ACTOR_ID = "22222222-2222-4222-8222-222222222222";
const OTHER_ID = "33333333-3333-4333-8333-333333333333";
const INVOICE_ID = "44444444-4444-4444-8444-444444444444";
async function setup(options = {}) {
let record = {
id: FORM_ID, owner_id: ACTOR_ID, submitter_id: OTHER_ID,
status: "rejected", deleted_at: null, paid_at: null, ...options.record,
};
const invoices = options.invoices ?? [{
id: INVOICE_ID, linked_item_id: "item-1", storage_path: "synthetic.pdf",
original_name: "synthetic.pdf", mime_type: "application/pdf", file_size: 20,
file_sha256: "synthetic-hash", uploaded_by_employee_id: ACTOR_ID, status: "bound",
}];
const tx = {
$queryRaw: vi.fn(async (strings) => strings.join("").includes('"invoice_pool"') ? invoices : [record]),
workflowInstance: {
findFirst: vi.fn(async ({ where }) => where.OR
? (options.active ? { id: "active" } : null)
: (options.history === false ? null : { id: "history" })),
},
paymentBatchItem: { findFirst: vi.fn(async () => options.payment ? { id: "batch-item" } : null) },
reimbursementAttachment: { createMany: vi.fn(async () => ({ count: invoices.length })) },
reimbursementForm: {
update: vi.fn(async ({ data }) => { record = { ...record, ...data }; return record; }),
delete: vi.fn(async () => record),
},
invoicePool: { updateMany: vi.fn(async () => ({ count: invoices.length })) },
auditLog: { create: vi.fn(async () => ({ id: "audit" })) },
};
const actor = { id: options.actorId ?? ACTOR_ID, permissionCodes: options.permissions ?? ["expense.delete_own"], roleCodes: [] };
const app = Fastify();
app.decorate("prisma", { $transaction: async (callback) => callback(tx) });
app.decorate("config", {});
app.decorate("authenticate", async (request) => { request.employee = actor; });
app.decorate("hasPermission", (employee, code) => employee.permissionCodes.includes(code) || employee.permissionCodes.includes("*"));
app.decorate("requirePermission", (code) => async (request) => {
request.employee = actor;
if (!app.hasPermission(actor, code)) throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
});
app.setErrorHandler((error, request, reply) => reply.status(error.statusCode ?? 500).send({ error: { code: error.code, details: error.details } }));
await app.register(routes, { prefix: "/api" });
return { app, tx, record };
}
describe("reimbursement retained deletion", () => {
const apps = [];
afterEach(async () => { await Promise.all(apps.splice(0).map((app) => app.close())); });
async function arrange(options) {
const result = await setup(options);
apps.push(result.app);
return { ...result, remove: () => result.app.inject({ method: "DELETE", url: `/api/reimbursements/${FORM_ID}` }) };
}
it.each(["rejected", "draft"])("retains history and immutable invoice references for historical %s", async (status) => {
const { remove, tx } = await arrange({ record: { status } });
expect((await remove()).statusCode).toBe(200);
expect(tx.reimbursementForm.delete).not.toHaveBeenCalled();
expect(tx.reimbursementForm.update).toHaveBeenCalledWith({
where: { id: FORM_ID },
data: { status: "cancelled", deleted_at: expect.any(Date), deleted_by: ACTOR_ID, updated_by: ACTOR_ID },
});
expect(tx.reimbursementAttachment.createMany.mock.calls[0][0].data[0]).toMatchObject({
reimbursement_id: FORM_ID, storage_path: "synthetic.pdf", file_sha256: "synthetic-hash", attachment_type: "invoice",
});
expect(tx.invoicePool.updateMany).toHaveBeenCalledWith({
where: { linked_reimbursement_id: FORM_ID, status: "bound" },
data: { status: "available", linked_reimbursement_id: null, linked_item_id: null, updated_by: ACTOR_ID },
});
expect(tx.auditLog.create.mock.calls[0][0].data).toMatchObject({
action: "REIMBURSEMENT_DELETED",
extra: { previousStatus: status, retainedHistory: true, releasedInvoices: [{ invoiceId: INVOICE_ID, itemId: "item-1", fileSha256: "synthetic-hash" }] },
});
expect(tx.$queryRaw.mock.calls.map(([strings]) => strings.join(""))[0]).toContain('"reimbursement_forms"');
expect(tx.$queryRaw.mock.calls.map(([strings]) => strings.join(""))[1]).toContain('"invoice_pool"');
});
it("physically deletes only never-submitted drafts", async () => {
const { remove, tx } = await arrange({ record: { status: "draft" }, history: false });
expect((await remove()).statusCode).toBe(200);
expect(tx.reimbursementForm.delete).toHaveBeenCalledWith({ where: { id: FORM_ID } });
expect(tx.reimbursementForm.update).not.toHaveBeenCalled();
expect(tx.reimbursementAttachment.createMany).not.toHaveBeenCalled();
});
it("does not assume rejected records without workflow are safe to erase", async () => {
const { remove, tx } = await arrange({ history: false });
expect((await remove()).statusCode).toBe(200);
expect(tx.reimbursementForm.delete).not.toHaveBeenCalled();
});
it("permits the submitter and retries without releasing invoices twice", async () => {
const { remove, tx } = await arrange({ actorId: OTHER_ID });
expect((await remove()).statusCode).toBe(200);
expect((await remove()).statusCode).toBe(200);
expect(tx.invoicePool.updateMany).toHaveBeenCalledTimes(1);
expect(tx.auditLog.create).toHaveBeenCalledTimes(1);
});
it.each([
{ actorId: INVOICE_ID, permissions: ["*"] },
{ actorId: INVOICE_ID, permissions: ["expense.delete_own", "expense.view_all"] },
{ permissions: ["expense.view_all"] },
{ actorId: INVOICE_ID, permissions: ["*"], record: { deleted_at: new Date(), status: "cancelled" } },
])("refuses unauthorized deletion even with full reading or wildcard", async (options) => {
const { remove, tx } = await arrange(options);
expect((await remove()).statusCode).toBe(403);
expect(tx.invoicePool.updateMany).not.toHaveBeenCalled();
expect(tx.auditLog.create).not.toHaveBeenCalled();
});
it.each([
{ record: { status: "reviewing" } },
{ record: { status: "approved" } },
{ record: { status: "paid" } },
{ record: { status: "cancelled" } },
{ record: { paid_at: new Date() } },
{ active: true },
{ payment: true },
{ invoices: [{ id: INVOICE_ID, status: "archived" }] },
])("refuses unsafe state without any write", async (options) => {
const { remove, tx } = await arrange(options);
const response = await remove();
expect(response.statusCode).toBe(409);
expect(response.json().error.details.reasonCode).toBe("reimbursement.status_invalid_delete_status");
expect(tx.invoicePool.updateMany).not.toHaveBeenCalled();
expect(tx.auditLog.create).not.toHaveBeenCalled();
expect(tx.reimbursementForm.update).not.toHaveBeenCalled();
});
});
@@ -234,6 +234,7 @@ async function buildApp({
const prisma = {
$transaction: vi.fn(async (callback) => callback(tx)),
reimbursementForm: {
updateMany: vi.fn(async () => ({ count: 1 })),
findUnique: vi.fn(async ({ where }) => {
if (where.id !== REIMBURSEMENT_ID) {
return null;
@@ -1083,7 +1084,7 @@ describe("POST /api/reimbursements/:id/link-project authorization", () => {
});
expect(response.statusCode, response.body).toBe(403);
expect(prisma.reimbursementForm.update).not.toHaveBeenCalled();
expect(prisma.reimbursementForm.updateMany).not.toHaveBeenCalled();
await app.close();
});
@@ -1101,7 +1102,7 @@ describe("POST /api/reimbursements/:id/link-project authorization", () => {
});
expect(response.statusCode, response.body).toBe(409);
expect(prisma.reimbursementForm.update).not.toHaveBeenCalled();
expect(prisma.reimbursementForm.updateMany).not.toHaveBeenCalled();
await app.close();
});
@@ -1119,12 +1120,33 @@ describe("POST /api/reimbursements/:id/link-project authorization", () => {
});
expect(response.statusCode, response.body).toBe(200);
expect(prisma.reimbursementForm.update).toHaveBeenCalledTimes(1);
expect(prisma.reimbursementForm.updateMany).toHaveBeenCalledWith({
where: { id: REIMBURSEMENT_ID, deleted_at: null, status: { in: ["draft", "rejected"] } },
data: { project_id: projectId, updated_by: SUBMITTER_ID },
});
await app.close();
});
});
describe("reimbursement maintenance authorization", () => {
it("marks deleted historical detail and terminates next-action projections", async () => {
const { app } = await buildApp({
employeeId: SUBMITTER_ID,
permissionCodes: ["expense.view_own"],
reimbursement: buildReimbursementRecord({ status: "cancelled", deleted_at: new Date("2026-09-10T01:00:00Z") }),
});
try {
const response = await app.inject({ method: "GET", url: `/api/reimbursements/${REIMBURSEMENT_ID}`, headers: { authorization: "Bearer test" } });
expect(response.statusCode).toBe(200);
const detail = response.json().data;
expect(detail.deletedAt).toBe("2026-09-10T01:00:00.000Z");
expect(detail.processingProgress.stages.some((stage) => ["pending", "current"].includes(stage.state))).toBe(false);
expect(detail.processingProgress.stages.at(-1)).toMatchObject({ key: "deleted", result: "已删除", state: "terminated" });
} finally {
await app.close();
}
});
const itemId = "77777777-7777-4777-8777-777777777777";
const expenseTypeId = "88888888-8888-4888-8888-888888888888";
@@ -9,6 +9,7 @@ describe("finance ledger service", () => {
expect(where).toEqual({
AND: [
{ deleted_at: null },
{
OR: expect.arrayContaining([
{ project: { is: { project_no: { contains: "交付项目", mode: "insensitive" } } } },
@@ -44,6 +44,7 @@ describe("reimbursement visibility", () => {
],
},
{ status: "draft" },
{ deleted_at: null },
],
});
});
@@ -57,6 +58,7 @@ describe("reimbursement visibility", () => {
AND: [
{ OR: [{ owner_id: ACTOR_ID }, { submitter_id: ACTOR_ID }] },
{ status: "draft" },
{ deleted_at: null },
],
});
});