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

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