diff --git a/server/prisma/migrations/20260307131616_p17b_procurement_budget_archive/migration.sql b/server/prisma/migrations/20260307131616_p17b_procurement_budget_archive/migration.sql new file mode 100644 index 00000000..33ccae4e --- /dev/null +++ b/server/prisma/migrations/20260307131616_p17b_procurement_budget_archive/migration.sql @@ -0,0 +1,27 @@ +-- CreateTable +CREATE TABLE "purchase_order_archives" ( + "id" UUID NOT NULL, + "purchase_request_id" UUID NOT NULL, + "po_no" TEXT NOT NULL, + "contract_no" TEXT, + "archived_files" JSONB, + "archived_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "archived_by" UUID NOT NULL, + + CONSTRAINT "purchase_order_archives_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "purchase_order_archives_purchase_request_id_key" ON "purchase_order_archives"("purchase_request_id"); + +-- CreateIndex +CREATE INDEX "idx_purchase_order_archives_archived_by" ON "purchase_order_archives"("archived_by"); + +-- CreateIndex +CREATE INDEX "idx_purchase_order_archives_archived_at" ON "purchase_order_archives"("archived_at"); + +-- AddForeignKey +ALTER TABLE "purchase_order_archives" ADD CONSTRAINT "purchase_order_archives_purchase_request_id_fkey" FOREIGN KEY ("purchase_request_id") REFERENCES "purchase_requests"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "purchase_order_archives" ADD CONSTRAINT "purchase_order_archives_archived_by_fkey" FOREIGN KEY ("archived_by") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 52c755ee..517c2382 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -229,9 +229,10 @@ model Employee { leave_requests LeaveRequest[] leave_ledgers LeaveLedger[] owned_cost_centers CostCenter[] @relation("CostCenterOwner") - purchase_requests_applicant PurchaseRequest[] @relation("PurchaseRequestApplicant") - purchase_requests_submitted PurchaseRequest[] @relation("PurchaseRequestSubmitter") - purchase_attachments_uploaded PurchaseAttachment[] @relation("PurchaseAttachmentUploader") + purchase_requests_applicant PurchaseRequest[] @relation("PurchaseRequestApplicant") + purchase_requests_submitted PurchaseRequest[] @relation("PurchaseRequestSubmitter") + purchase_attachments_uploaded PurchaseAttachment[] @relation("PurchaseAttachmentUploader") + purchase_order_archives PurchaseOrderArchive[] @relation("PurchaseOrderArchiveArchivedBy") owned_invoices InvoicePool[] @relation("InvoiceOwner") uploaded_invoices InvoicePool[] @relation("InvoiceUploader") @@ -584,6 +585,7 @@ model PurchaseRequest { items PurchaseRequestItem[] attachments PurchaseAttachment[] + order_archive PurchaseOrderArchive? workflow_instances WorkflowInstance[] @@index([applicant_id, status, updated_at], map: "idx_purchase_requests_applicant_status_updated") @@ -637,6 +639,23 @@ model PurchaseAttachment { @@map("purchase_attachments") } +model PurchaseOrderArchive { + id String @id @default(uuid()) @db.Uuid + purchase_request_id String @unique @db.Uuid + po_no String + contract_no String? + archived_files Json? + archived_at DateTime @default(now()) + archived_by String @db.Uuid + + purchase_request PurchaseRequest @relation(fields: [purchase_request_id], references: [id], onDelete: Cascade) + archived_by_employee Employee @relation("PurchaseOrderArchiveArchivedBy", fields: [archived_by], references: [id], onDelete: Restrict) + + @@index([archived_by], map: "idx_purchase_order_archives_archived_by") + @@index([archived_at], map: "idx_purchase_order_archives_archived_at") + @@map("purchase_order_archives") +} + model ReimbursementForm { id String @id @default(uuid()) @db.Uuid form_number String? @unique diff --git a/server/src/routes/masterData.js b/server/src/routes/masterData.js index 05a27620..322b6093 100644 --- a/server/src/routes/masterData.js +++ b/server/src/routes/masterData.js @@ -364,7 +364,11 @@ function buildMasterDataViewPreHandler(fastify) { await fastify.authenticate(request); const canView = fastify.hasPermission(request.employee, "masterdata.view") || - fastify.hasPermission(request.employee, "masterdata.manage"); + fastify.hasPermission(request.employee, "masterdata.manage") || + fastify.hasPermission(request.employee, "procurement.create") || + fastify.hasPermission(request.employee, "procurement.view_own") || + fastify.hasPermission(request.employee, "procurement.view_all") || + fastify.hasPermission(request.employee, "procurement.manage"); if (!canView) { throw new AppError({ diff --git a/server/src/routes/masterDataSuppliers.js b/server/src/routes/masterDataSuppliers.js index 917d42a1..f3b136c0 100644 --- a/server/src/routes/masterDataSuppliers.js +++ b/server/src/routes/masterDataSuppliers.js @@ -159,7 +159,11 @@ async function ensureLegalEntityAvailable(tx, legalEntityId) { function assertMasterDataViewPermission(fastify, request) { const canView = fastify.hasPermission(request.employee, "masterdata.view") || - fastify.hasPermission(request.employee, "masterdata.manage"); + fastify.hasPermission(request.employee, "masterdata.manage") || + fastify.hasPermission(request.employee, "procurement.create") || + fastify.hasPermission(request.employee, "procurement.view_own") || + fastify.hasPermission(request.employee, "procurement.view_all") || + fastify.hasPermission(request.employee, "procurement.manage"); if (!canView) { throw new AppError({ diff --git a/server/src/routes/purchaseRequests.js b/server/src/routes/purchaseRequests.js index 9e54e6fd..a77def40 100644 --- a/server/src/routes/purchaseRequests.js +++ b/server/src/routes/purchaseRequests.js @@ -11,6 +11,7 @@ const { signPayload, verifyToken } = require("../utils/signedUrl"); const { parseWorkflowSteps, resolveActionableStep, toMoneyString } = require("../utils/workflow"); const { resolveApplicableBudgetPoolByContext } = require("../services/budgetPool"); const { issueDocumentNumber } = require("../services/documentSequence"); +const { reserveBudgetForEntity, releaseReservedBudgetIfAny } = require("../services/purchaseBudget"); const { sendNotificationEmailSafe } = require("../services/email"); const { createNotifications, resolveRecipientsForAssignee } = require("../services/notifications"); const { openReadStream, StorageNotFoundError, writeBuffer } = require("../services/storage"); @@ -58,6 +59,23 @@ const PURCHASE_PATCH_SCHEMA = z }) .strict(); +const PURCHASE_ARCHIVE_FILE_SCHEMA = z + .object({ + storagePath: z.string().trim().min(1).max(500), + originalName: z.string().trim().min(1).max(255), + mimeType: z.string().trim().min(1).max(100), + fileSize: z.coerce.number().int().min(0), + }) + .strict(); + +const PURCHASE_ARCHIVE_BODY_SCHEMA = z + .object({ + poNo: z.string().trim().min(1).max(64), + contractNo: z.string().trim().max(64).nullable().optional(), + archivedFiles: z.array(PURCHASE_ARCHIVE_FILE_SCHEMA).max(20).optional(), + }) + .strict(); + const PURCHASE_SUMMARY_SELECT = { id: true, request_no: true, @@ -170,6 +188,23 @@ const PURCHASE_DETAIL_SELECT = { }, }, }, + order_archive: { + select: { + id: true, + po_no: true, + contract_no: true, + archived_files: true, + archived_at: true, + archived_by: true, + archived_by_employee: { + select: { + id: true, + employee_no: true, + name: true, + }, + }, + }, + }, workflow_instances: { where: { entity_type: "purchase_request", @@ -250,6 +285,14 @@ function parsePatchBody(body) { return parsed.data; } +function parseArchiveBody(body) { + const parsed = PURCHASE_ARCHIVE_BODY_SCHEMA.safeParse(body ?? {}); + if (!parsed.success) { + throw buildValidationError(parsed.error.flatten()); + } + return parsed.data; +} + function normalizeBudgetControlMode(config) { return String(config.budgetControlMode ?? "warn").toLowerCase() === "block" ? "block" : "warn"; } @@ -414,6 +457,29 @@ function mapPurchaseRequestSummary(item) { }; } +function mapPurchaseOrderArchive(item) { + if (!item) { + return null; + } + + return { + id: item.id, + poNo: item.po_no, + contractNo: item.contract_no ?? null, + archivedAt: toIsoDateTime(item.archived_at), + archivedBy: item.archived_by, + archivedByEmployee: mapEmployeeBasic(item.archived_by_employee), + archivedFiles: Array.isArray(item.archived_files) + ? item.archived_files.map((entry) => ({ + storagePath: entry?.storagePath ?? null, + originalName: entry?.originalName ?? null, + mimeType: entry?.mimeType ?? null, + fileSize: Number(entry?.fileSize ?? 0), + })) + : [], + }; +} + function mapPurchaseRequestDetail(item, request) { const summary = mapPurchaseRequestSummary(item); const pendingWorkflow = item.workflow_instances?.[0] @@ -455,11 +521,15 @@ function mapPurchaseRequestDetail(item, request) { createdAt: toIsoDateTime(attachment.created_at), uploader: mapEmployeeBasic(attachment.uploader), })), + archiveOrder: mapPurchaseOrderArchive(item.order_archive), pendingWorkflow, permissions: { canEditDraft: item.status === "draft" && item.applicant_id === request.employee.id, canSubmit: item.status === "draft" && item.applicant_id === request.employee.id && request.employee.permissionCodes.includes("procurement.submit"), canWithdraw: item.status === "reviewing" && item.applicant_id === request.employee.id && request.employee.permissionCodes.includes("procurement.submit"), + canArchiveOrder: + item.status === "approved" && + (request.employee.permissionCodes.includes("*") || request.employee.permissionCodes.includes("procurement.manage")), }, }; } @@ -1134,6 +1204,15 @@ async function purchaseRequestRoutes(fastify) { }, }); + const budgetReservation = await reserveBudgetForEntity(tx, { + budgetPoolId: selectedBudgetPoolId, + entityType: "purchase_request", + entityId: purchaseRequestId, + amount: totalAmount, + actorId: request.employee.id, + note: `submit_reserve:${requestNo ?? purchaseRequestId}`, + }); + const workflowInstance = await tx.workflowInstance.create({ data: { workflow_id: workflowDefinition.id, @@ -1243,6 +1322,7 @@ async function purchaseRequestRoutes(fastify) { workflowTaskId: workflowTask.id, budgetPoolId: selectedBudgetPoolId, warnings, + budgetReservation, }, }), }); @@ -1255,6 +1335,7 @@ async function purchaseRequestRoutes(fastify) { currentTaskId: workflowTask.id, budgetPoolId: selectedBudgetPoolId, warnings, + budgetReservation, }; }); @@ -1334,6 +1415,13 @@ async function purchaseRequestRoutes(fastify) { }); } + const budgetRelease = await releaseReservedBudgetIfAny(tx, { + entityType: "purchase_request", + entityId: purchaseRequestId, + actorId: request.employee.id, + note: "withdraw_release_purchase_budget", + }); + await tx.purchaseRequest.update({ where: { id: purchaseRequestId }, data: { @@ -1350,6 +1438,7 @@ async function purchaseRequestRoutes(fastify) { targetId: purchaseRequestId, extra: { workflowInstanceId: workflowInstance?.id ?? null, + budgetRelease, }, }), }); @@ -1358,6 +1447,119 @@ async function purchaseRequestRoutes(fastify) { purchaseRequestId, status: "draft", workflowInstanceId: workflowInstance?.id ?? null, + budgetRelease, + }; + }); + + return sendData(reply, request, response); + }, + ); + + fastify.get( + "/purchase-requests/:id/archive-order", + { + schema: { tags: ["Purchase Requests"], summary: "Get Purchase Order Archive" }, + preHandler: fastify.authenticate, + }, + async (request, reply) => { + ensurePurchaseReadPermission(fastify, request); + const purchaseRequestId = parsePurchaseRequestId(request.params?.id); + await loadReadablePurchaseRequest(fastify.prisma, purchaseRequestId, request); + + const archive = await fastify.prisma.purchaseOrderArchive.findUnique({ + where: { purchase_request_id: purchaseRequestId }, + select: { + id: true, + po_no: true, + contract_no: true, + archived_files: true, + archived_at: true, + archived_by: true, + archived_by_employee: { + select: { + id: true, + employee_no: true, + name: true, + }, + }, + }, + }); + + return sendData(reply, request, { + archiveOrder: mapPurchaseOrderArchive(archive), + }); + }, + ); + + fastify.post( + "/purchase-requests/:id/archive-order", + { + schema: { tags: ["Purchase Requests"], summary: "Archive Purchase Order" }, + preHandler: fastify.requirePermission("procurement.manage"), + }, + async (request, reply) => { + const purchaseRequestId = parsePurchaseRequestId(request.params?.id); + const payload = parseArchiveBody(request.body); + let response = null; + + await fastify.prisma.$transaction(async (tx) => { + await loadReadablePurchaseRequest(tx, purchaseRequestId, request); + const locked = await loadPurchaseRequestForUpdate(tx, purchaseRequestId); + + if (locked.status !== "approved") { + throw buildValidationError({ status: "only_approved_archivable" }, 409); + } + + const archive = await tx.purchaseOrderArchive.upsert({ + where: { purchase_request_id: purchaseRequestId }, + update: { + po_no: payload.poNo, + contract_no: payload.contractNo?.trim() || null, + archived_files: payload.archivedFiles ?? [], + archived_at: new Date(), + archived_by: request.employee.id, + }, + create: { + purchase_request_id: purchaseRequestId, + po_no: payload.poNo, + contract_no: payload.contractNo?.trim() || null, + archived_files: payload.archivedFiles ?? [], + archived_by: request.employee.id, + }, + select: { + id: true, + po_no: true, + contract_no: true, + archived_files: true, + archived_at: true, + archived_by: true, + archived_by_employee: { + select: { + id: true, + employee_no: true, + name: true, + }, + }, + }, + }); + + await tx.auditLog.create({ + data: buildAuditLogData(request, { + actorId: request.employee.id, + action: "PURCHASE_ORDER_ARCHIVED", + targetType: "purchase_order_archive", + targetId: archive.id, + extra: { + purchaseRequestId, + poNo: archive.po_no, + contractNo: archive.contract_no ?? null, + archivedFileCount: Array.isArray(archive.archived_files) ? archive.archived_files.length : 0, + }, + }), + }); + + response = { + archiveOrder: mapPurchaseOrderArchive(archive), }; }); diff --git a/server/src/routes/workflow.js b/server/src/routes/workflow.js index f83ca85c..00d2ffc1 100644 --- a/server/src/routes/workflow.js +++ b/server/src/routes/workflow.js @@ -4,6 +4,7 @@ const { sendData } = require("../utils/reply"); const { resolvePermissionsForRoles, hasPermission, ROLE_PERMISSIONS, resolveEntityPermission } = require("../constants/permissions"); const { getWorkflowStepsSnapshot, resolveActionableStep, toMoneyString } = require("../utils/workflow"); const { consumeReservedLeave, releaseReservedLeave, formatDateOnly } = require("../services/leave"); +const { confirmBudgetForEntity, releaseReservedBudgetIfAny } = require("../services/purchaseBudget"); const { createNotifications, resolveRecipientsForAssignee, @@ -1170,6 +1171,13 @@ async function workflowRoutes(fastify) { updated_by: request.employee.id, }, }); + + await confirmBudgetForEntity(tx, { + entityType: "purchase_request", + entityId: workflowInstance.entity_id, + actorId: request.employee.id, + note: "approved_confirm_purchase_budget", + }); } await tx.workflowHistory.create({ @@ -1351,6 +1359,13 @@ async function workflowRoutes(fastify) { updated_by: request.employee.id, }, }); + + await releaseReservedBudgetIfAny(tx, { + entityType: "purchase_request", + entityId: workflowInstance.entity_id, + actorId: request.employee.id, + note: "rejected_release_purchase_budget", + }); } await tx.workflowHistory.create({ @@ -1531,6 +1546,13 @@ async function workflowRoutes(fastify) { updated_by: request.employee.id, }, }); + + await releaseReservedBudgetIfAny(tx, { + entityType: "purchase_request", + entityId: workflowInstance.entity_id, + actorId: request.employee.id, + note: "returned_release_purchase_budget", + }); } await tx.workflowHistory.create({ diff --git a/server/src/services/purchaseBudget.js b/server/src/services/purchaseBudget.js new file mode 100644 index 00000000..860e6615 --- /dev/null +++ b/server/src/services/purchaseBudget.js @@ -0,0 +1,254 @@ +const { AppError } = require("../utils/AppError"); + +function buildPurchaseBudgetValidationError(details, statusCode = 409) { + return new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode, + details, + }); +} + +function toMoneyNumber(value) { + const numeric = Number(value ?? 0); + if (!Number.isFinite(numeric)) { + return 0; + } + return Number(numeric.toFixed(2)); +} + +function toMoneyFixed(value) { + return toMoneyNumber(value).toFixed(2); +} + +function summarizeBudgetLedgerByPool(entries = []) { + const summary = new Map(); + + for (const entry of entries) { + const poolId = entry?.budget_pool_id; + if (!poolId) { + continue; + } + + if (!summary.has(poolId)) { + summary.set(poolId, { + budgetPoolId: poolId, + reserve: 0, + confirm: 0, + release: 0, + }); + } + + const current = summary.get(poolId); + const amount = toMoneyNumber(entry.amount); + if (entry.kind === "reserve") { + current.reserve = toMoneyNumber(current.reserve + amount); + } else if (entry.kind === "confirm") { + current.confirm = toMoneyNumber(current.confirm + amount); + } else if (entry.kind === "release") { + current.release = toMoneyNumber(current.release + amount); + } + } + + return Array.from(summary.values()).map((item) => ({ + ...item, + outstanding: toMoneyNumber(Math.max(0, item.reserve - item.release)), + confirmPending: toMoneyNumber(Math.max(0, item.reserve - item.release - item.confirm)), + })); +} + +async function listEntityBudgetEntries(tx, { entityType, entityId }) { + return tx.budgetLedgerEntry.findMany({ + where: { + entity_type: entityType, + entity_id: entityId, + }, + orderBy: [{ created_at: "asc" }, { id: "asc" }], + select: { + id: true, + budget_pool_id: true, + kind: true, + amount: true, + created_at: true, + }, + }); +} + +async function appendBudgetLedgerEntry(tx, { + budgetPoolId, + entityType, + entityId, + kind, + amount, + actorId, + note, +}) { + return tx.budgetLedgerEntry.create({ + data: { + budget_pool_id: budgetPoolId, + entity_type: entityType, + entity_id: entityId, + kind, + amount: toMoneyFixed(amount), + note, + created_by: actorId, + }, + }); +} + +async function reserveBudgetForEntity(tx, { + budgetPoolId, + entityType, + entityId, + amount, + actorId, + note, +}) { + if (!budgetPoolId) { + return { applied: false, reservedAmount: "0.00" }; + } + + const normalizedAmount = toMoneyNumber(amount); + if (normalizedAmount <= 0) { + return { applied: false, reservedAmount: "0.00" }; + } + + const existingEntries = await listEntityBudgetEntries(tx, { entityType, entityId }); + const currentPoolSummary = summarizeBudgetLedgerByPool(existingEntries) + .find((item) => item.budgetPoolId === budgetPoolId); + + if ((currentPoolSummary?.outstanding ?? 0) > 0) { + throw buildPurchaseBudgetValidationError({ budget: "reserve_already_exists" }); + } + + await appendBudgetLedgerEntry(tx, { + budgetPoolId, + entityType, + entityId, + kind: "reserve", + amount: normalizedAmount, + actorId, + note, + }); + + const budgetPool = await tx.budgetPool.findUnique({ + where: { id: budgetPoolId }, + select: { id: true, used_amount: true }, + }); + + if (!budgetPool) { + throw buildPurchaseBudgetValidationError({ budgetPoolId: "not_found" }); + } + + const nextUsedAmount = toMoneyNumber(budgetPool.used_amount) + normalizedAmount; + await tx.budgetPool.update({ + where: { id: budgetPoolId }, + data: { + used_amount: toMoneyFixed(nextUsedAmount), + updated_by: actorId, + }, + }); + + return { + applied: true, + reservedAmount: toMoneyFixed(normalizedAmount), + }; +} + +async function confirmBudgetForEntity(tx, { + entityType, + entityId, + actorId, + note, +}) { + const entries = await listEntityBudgetEntries(tx, { entityType, entityId }); + const grouped = summarizeBudgetLedgerByPool(entries); + const confirmed = []; + + for (const item of grouped) { + if (item.confirmPending <= 0) { + continue; + } + + await appendBudgetLedgerEntry(tx, { + budgetPoolId: item.budgetPoolId, + entityType, + entityId, + kind: "confirm", + amount: item.confirmPending, + actorId, + note, + }); + + confirmed.push({ + budgetPoolId: item.budgetPoolId, + amount: toMoneyFixed(item.confirmPending), + }); + } + + return { + applied: confirmed.length > 0, + items: confirmed, + }; +} + +async function releaseReservedBudgetIfAny(tx, { + entityType, + entityId, + actorId, + note, +}) { + const entries = await listEntityBudgetEntries(tx, { entityType, entityId }); + const grouped = summarizeBudgetLedgerByPool(entries); + const released = []; + + for (const item of grouped) { + if (item.outstanding <= 0) { + continue; + } + + await appendBudgetLedgerEntry(tx, { + budgetPoolId: item.budgetPoolId, + entityType, + entityId, + kind: "release", + amount: item.outstanding, + actorId, + note, + }); + + const budgetPool = await tx.budgetPool.findUnique({ + where: { id: item.budgetPoolId }, + select: { id: true, used_amount: true }, + }); + + if (budgetPool) { + const nextUsedAmount = Math.max(0, toMoneyNumber(budgetPool.used_amount) - item.outstanding); + await tx.budgetPool.update({ + where: { id: item.budgetPoolId }, + data: { + used_amount: toMoneyFixed(nextUsedAmount), + updated_by: actorId, + }, + }); + } + + released.push({ + budgetPoolId: item.budgetPoolId, + amount: toMoneyFixed(item.outstanding), + }); + } + + return { + applied: released.length > 0, + items: released, + }; +} + +module.exports = { + buildPurchaseBudgetValidationError, + summarizeBudgetLedgerByPool, + reserveBudgetForEntity, + confirmBudgetForEntity, + releaseReservedBudgetIfAny, +}; diff --git a/server/tests/unit/services/purchaseBudget.test.js b/server/tests/unit/services/purchaseBudget.test.js new file mode 100644 index 00000000..71bf75d6 --- /dev/null +++ b/server/tests/unit/services/purchaseBudget.test.js @@ -0,0 +1,41 @@ +const { + summarizeBudgetLedgerByPool, + buildPurchaseBudgetValidationError, +} = require("../../../src/services/purchaseBudget"); + +describe("purchaseBudget service", () => { + it("summarizes reserve confirm release by pool", () => { + expect( + summarizeBudgetLedgerByPool([ + { budget_pool_id: "pool-1", kind: "reserve", amount: "120.00" }, + { budget_pool_id: "pool-1", kind: "confirm", amount: "120.00" }, + { budget_pool_id: "pool-1", kind: "release", amount: "20.00" }, + { budget_pool_id: "pool-2", kind: "reserve", amount: "80.00" }, + ]), + ).toEqual([ + { + budgetPoolId: "pool-1", + reserve: 120, + confirm: 120, + release: 20, + outstanding: 100, + confirmPending: 0, + }, + { + budgetPoolId: "pool-2", + reserve: 80, + confirm: 0, + release: 0, + outstanding: 80, + confirmPending: 80, + }, + ]); + }); + + it("builds VALID_001 validation error", () => { + const error = buildPurchaseBudgetValidationError({ budget: "reserve_already_exists" }); + expect(error.code).toBe("VALID_001"); + expect(error.statusCode).toBe(409); + expect(error.details).toEqual({ budget: "reserve_already_exists" }); + }); +});