feat(P17B-1,P17B-2): 补齐采购预算闭环与归档模型接口
这个提交包含在:
@@ -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,
|
||||
};
|
||||
在新工单中引用
屏蔽一个用户