feat(P17B-1,P17B-2): 补齐采购预算闭环与归档模型接口

这个提交包含在:
2026-03-07 22:00:50 +08:00
父节点 019423776e
当前提交 033cfd6833
共修改 8 个文件,包含 578 行新增和 5 行删除
+5 -1
查看文件
@@ -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({
+5 -1
查看文件
@@ -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({
+202
查看文件
@@ -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),
};
});
+22
查看文件
@@ -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({