|
|
|
@@ -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, {
|
|
|
|
|