feat(P17B-1,P17B-2): 补齐采购预算闭环与归档模型接口
这个提交包含在:
@@ -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;
|
||||||
+22
-3
@@ -229,9 +229,10 @@ model Employee {
|
|||||||
leave_requests LeaveRequest[]
|
leave_requests LeaveRequest[]
|
||||||
leave_ledgers LeaveLedger[]
|
leave_ledgers LeaveLedger[]
|
||||||
owned_cost_centers CostCenter[] @relation("CostCenterOwner")
|
owned_cost_centers CostCenter[] @relation("CostCenterOwner")
|
||||||
purchase_requests_applicant PurchaseRequest[] @relation("PurchaseRequestApplicant")
|
purchase_requests_applicant PurchaseRequest[] @relation("PurchaseRequestApplicant")
|
||||||
purchase_requests_submitted PurchaseRequest[] @relation("PurchaseRequestSubmitter")
|
purchase_requests_submitted PurchaseRequest[] @relation("PurchaseRequestSubmitter")
|
||||||
purchase_attachments_uploaded PurchaseAttachment[] @relation("PurchaseAttachmentUploader")
|
purchase_attachments_uploaded PurchaseAttachment[] @relation("PurchaseAttachmentUploader")
|
||||||
|
purchase_order_archives PurchaseOrderArchive[] @relation("PurchaseOrderArchiveArchivedBy")
|
||||||
|
|
||||||
owned_invoices InvoicePool[] @relation("InvoiceOwner")
|
owned_invoices InvoicePool[] @relation("InvoiceOwner")
|
||||||
uploaded_invoices InvoicePool[] @relation("InvoiceUploader")
|
uploaded_invoices InvoicePool[] @relation("InvoiceUploader")
|
||||||
@@ -584,6 +585,7 @@ model PurchaseRequest {
|
|||||||
|
|
||||||
items PurchaseRequestItem[]
|
items PurchaseRequestItem[]
|
||||||
attachments PurchaseAttachment[]
|
attachments PurchaseAttachment[]
|
||||||
|
order_archive PurchaseOrderArchive?
|
||||||
workflow_instances WorkflowInstance[]
|
workflow_instances WorkflowInstance[]
|
||||||
|
|
||||||
@@index([applicant_id, status, updated_at], map: "idx_purchase_requests_applicant_status_updated")
|
@@index([applicant_id, status, updated_at], map: "idx_purchase_requests_applicant_status_updated")
|
||||||
@@ -637,6 +639,23 @@ model PurchaseAttachment {
|
|||||||
@@map("purchase_attachments")
|
@@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 {
|
model ReimbursementForm {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
form_number String? @unique
|
form_number String? @unique
|
||||||
|
|||||||
@@ -364,7 +364,11 @@ function buildMasterDataViewPreHandler(fastify) {
|
|||||||
await fastify.authenticate(request);
|
await fastify.authenticate(request);
|
||||||
const canView =
|
const canView =
|
||||||
fastify.hasPermission(request.employee, "masterdata.view") ||
|
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) {
|
if (!canView) {
|
||||||
throw new AppError({
|
throw new AppError({
|
||||||
|
|||||||
@@ -159,7 +159,11 @@ async function ensureLegalEntityAvailable(tx, legalEntityId) {
|
|||||||
function assertMasterDataViewPermission(fastify, request) {
|
function assertMasterDataViewPermission(fastify, request) {
|
||||||
const canView =
|
const canView =
|
||||||
fastify.hasPermission(request.employee, "masterdata.view") ||
|
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) {
|
if (!canView) {
|
||||||
throw new AppError({
|
throw new AppError({
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const { signPayload, verifyToken } = require("../utils/signedUrl");
|
|||||||
const { parseWorkflowSteps, resolveActionableStep, toMoneyString } = require("../utils/workflow");
|
const { parseWorkflowSteps, resolveActionableStep, toMoneyString } = require("../utils/workflow");
|
||||||
const { resolveApplicableBudgetPoolByContext } = require("../services/budgetPool");
|
const { resolveApplicableBudgetPoolByContext } = require("../services/budgetPool");
|
||||||
const { issueDocumentNumber } = require("../services/documentSequence");
|
const { issueDocumentNumber } = require("../services/documentSequence");
|
||||||
|
const { reserveBudgetForEntity, releaseReservedBudgetIfAny } = require("../services/purchaseBudget");
|
||||||
const { sendNotificationEmailSafe } = require("../services/email");
|
const { sendNotificationEmailSafe } = require("../services/email");
|
||||||
const { createNotifications, resolveRecipientsForAssignee } = require("../services/notifications");
|
const { createNotifications, resolveRecipientsForAssignee } = require("../services/notifications");
|
||||||
const { openReadStream, StorageNotFoundError, writeBuffer } = require("../services/storage");
|
const { openReadStream, StorageNotFoundError, writeBuffer } = require("../services/storage");
|
||||||
@@ -58,6 +59,23 @@ const PURCHASE_PATCH_SCHEMA = z
|
|||||||
})
|
})
|
||||||
.strict();
|
.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 = {
|
const PURCHASE_SUMMARY_SELECT = {
|
||||||
id: true,
|
id: true,
|
||||||
request_no: 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: {
|
workflow_instances: {
|
||||||
where: {
|
where: {
|
||||||
entity_type: "purchase_request",
|
entity_type: "purchase_request",
|
||||||
@@ -250,6 +285,14 @@ function parsePatchBody(body) {
|
|||||||
return parsed.data;
|
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) {
|
function normalizeBudgetControlMode(config) {
|
||||||
return String(config.budgetControlMode ?? "warn").toLowerCase() === "block" ? "block" : "warn";
|
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) {
|
function mapPurchaseRequestDetail(item, request) {
|
||||||
const summary = mapPurchaseRequestSummary(item);
|
const summary = mapPurchaseRequestSummary(item);
|
||||||
const pendingWorkflow = item.workflow_instances?.[0]
|
const pendingWorkflow = item.workflow_instances?.[0]
|
||||||
@@ -455,11 +521,15 @@ function mapPurchaseRequestDetail(item, request) {
|
|||||||
createdAt: toIsoDateTime(attachment.created_at),
|
createdAt: toIsoDateTime(attachment.created_at),
|
||||||
uploader: mapEmployeeBasic(attachment.uploader),
|
uploader: mapEmployeeBasic(attachment.uploader),
|
||||||
})),
|
})),
|
||||||
|
archiveOrder: mapPurchaseOrderArchive(item.order_archive),
|
||||||
pendingWorkflow,
|
pendingWorkflow,
|
||||||
permissions: {
|
permissions: {
|
||||||
canEditDraft: item.status === "draft" && item.applicant_id === request.employee.id,
|
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"),
|
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"),
|
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({
|
const workflowInstance = await tx.workflowInstance.create({
|
||||||
data: {
|
data: {
|
||||||
workflow_id: workflowDefinition.id,
|
workflow_id: workflowDefinition.id,
|
||||||
@@ -1243,6 +1322,7 @@ async function purchaseRequestRoutes(fastify) {
|
|||||||
workflowTaskId: workflowTask.id,
|
workflowTaskId: workflowTask.id,
|
||||||
budgetPoolId: selectedBudgetPoolId,
|
budgetPoolId: selectedBudgetPoolId,
|
||||||
warnings,
|
warnings,
|
||||||
|
budgetReservation,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -1255,6 +1335,7 @@ async function purchaseRequestRoutes(fastify) {
|
|||||||
currentTaskId: workflowTask.id,
|
currentTaskId: workflowTask.id,
|
||||||
budgetPoolId: selectedBudgetPoolId,
|
budgetPoolId: selectedBudgetPoolId,
|
||||||
warnings,
|
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({
|
await tx.purchaseRequest.update({
|
||||||
where: { id: purchaseRequestId },
|
where: { id: purchaseRequestId },
|
||||||
data: {
|
data: {
|
||||||
@@ -1350,6 +1438,7 @@ async function purchaseRequestRoutes(fastify) {
|
|||||||
targetId: purchaseRequestId,
|
targetId: purchaseRequestId,
|
||||||
extra: {
|
extra: {
|
||||||
workflowInstanceId: workflowInstance?.id ?? null,
|
workflowInstanceId: workflowInstance?.id ?? null,
|
||||||
|
budgetRelease,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -1358,6 +1447,119 @@ async function purchaseRequestRoutes(fastify) {
|
|||||||
purchaseRequestId,
|
purchaseRequestId,
|
||||||
status: "draft",
|
status: "draft",
|
||||||
workflowInstanceId: workflowInstance?.id ?? null,
|
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),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const { sendData } = require("../utils/reply");
|
|||||||
const { resolvePermissionsForRoles, hasPermission, ROLE_PERMISSIONS, resolveEntityPermission } = require("../constants/permissions");
|
const { resolvePermissionsForRoles, hasPermission, ROLE_PERMISSIONS, resolveEntityPermission } = require("../constants/permissions");
|
||||||
const { getWorkflowStepsSnapshot, resolveActionableStep, toMoneyString } = require("../utils/workflow");
|
const { getWorkflowStepsSnapshot, resolveActionableStep, toMoneyString } = require("../utils/workflow");
|
||||||
const { consumeReservedLeave, releaseReservedLeave, formatDateOnly } = require("../services/leave");
|
const { consumeReservedLeave, releaseReservedLeave, formatDateOnly } = require("../services/leave");
|
||||||
|
const { confirmBudgetForEntity, releaseReservedBudgetIfAny } = require("../services/purchaseBudget");
|
||||||
const {
|
const {
|
||||||
createNotifications,
|
createNotifications,
|
||||||
resolveRecipientsForAssignee,
|
resolveRecipientsForAssignee,
|
||||||
@@ -1170,6 +1171,13 @@ async function workflowRoutes(fastify) {
|
|||||||
updated_by: request.employee.id,
|
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({
|
await tx.workflowHistory.create({
|
||||||
@@ -1351,6 +1359,13 @@ async function workflowRoutes(fastify) {
|
|||||||
updated_by: request.employee.id,
|
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({
|
await tx.workflowHistory.create({
|
||||||
@@ -1531,6 +1546,13 @@ async function workflowRoutes(fastify) {
|
|||||||
updated_by: request.employee.id,
|
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({
|
await tx.workflowHistory.create({
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
在新工单中引用
屏蔽一个用户