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

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