feat(finance): 完成财务控制、L1 钱包硬化与迁移收敛

- P0/P1:影子总账追加调整、关账/退款/资金/积分幂等、成本版本追溯、对账订单项维度
- 迁移收敛:原 103–114 合并为 103_finance_foundation、104_finance_controls、105_work_order_consignment
- 数据:L1 钱包 BIGINT/CHECK/奖励幂等;重复奖励审计口径修正与人工核对清单
- 报表:销售毛利缺失成本收入提示,旧收支页明确现金流口径
- 文档:同步迁移基线、合并评估、代码复核与上线说明
This commit is contained in:
yml2213
2026-10-27 16:08:16 +08:00
parent a53321ef30
commit 9d5af359fa
47 changed files with 4527 additions and 1410 deletions
@@ -263,6 +263,10 @@ function normalizePayeeType(value: unknown): string {
return normalized
}
function normalizeRequestId(value: unknown): string {
return text(value).slice(0, 80)
}
function normalizeReconciliationStatus(value: unknown): string {
const normalized = text(value)
if (!RECONCILIATION_STATUSES.has(normalized)) {
@@ -1215,34 +1219,46 @@ async function insertEntry(
evidence: JsonObject[]
note: string
createdAt: string
requestId?: string
},
) {
const result = await client.query<FundEntryRow>(
`
INSERT INTO fund_entries (
entry_no, account_id, entry_type, amount_fen, balance_after_fen,
transfer_no, counterparty_account_id, reconciliation_id, occurred_at,
created_by_admin_user_id, evidence_json, note, created_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb, $12, $13)
RETURNING *
`,
[
generateNo('FE'),
input.accountId,
input.entryType,
input.amountFen,
input.balanceAfterFen,
input.transferNo || '',
input.counterpartyAccountId || null,
input.reconciliationId || null,
input.occurredAt,
input.actorUserId,
JSON.stringify(input.evidence),
input.note,
input.createdAt,
],
)
return result.rows[0]
try {
const result = await client.query<FundEntryRow>(
`
INSERT INTO fund_entries (
entry_no, account_id, entry_type, amount_fen, balance_after_fen,
transfer_no, counterparty_account_id, reconciliation_id, occurred_at,
created_by_admin_user_id, evidence_json, note, created_at, request_id
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb, $12, $13, $14)
RETURNING *
`,
[
generateNo('FE'),
input.accountId,
input.entryType,
input.amountFen,
input.balanceAfterFen,
input.transferNo || '',
input.counterpartyAccountId || null,
input.reconciliationId || null,
input.occurredAt,
input.actorUserId,
JSON.stringify(input.evidence),
input.note,
input.createdAt,
input.requestId || '',
],
)
return result.rows[0]
} catch (error) {
if ((error as { code?: string }).code === '23505') {
throw createHttpError('该请求已处理,请勿重复提交', {
statusCode: 409,
errorCode: 'fund_request_duplicate',
})
}
throw error
}
}
export async function createAdminFundFunding(payload: JsonObject, session: AdminSession) {
@@ -1259,6 +1275,7 @@ export async function createAdminFundFunding(payload: JsonObject, session: Admin
const evidence = normalizeEvidence(payload.evidence || payload.evidenceFiles)
const note = text(payload.note)
assertEvidenceOrNote(evidence, note)
const requestId = normalizeRequestId(payload.requestId ?? payload.request_id)
const now = nowIso()
return withTransaction(async (client) => {
@@ -1293,6 +1310,7 @@ export async function createAdminFundFunding(payload: JsonObject, session: Admin
evidence,
note,
createdAt: now,
requestId,
})
return {
@@ -1315,6 +1333,7 @@ export async function createAdminFundIssue(payload: JsonObject, session: AdminSe
const evidence = normalizeEvidence(payload.evidence || payload.evidenceFiles)
const note = text(payload.note)
assertEvidenceOrNote(evidence, note)
const requestId = normalizeRequestId(payload.requestId ?? payload.request_id)
const supervisorAccountId = await resolveSupervisorAccountId(payload, session)
const now = nowIso()
@@ -1374,6 +1393,7 @@ export async function createAdminFundIssue(payload: JsonObject, session: AdminSe
evidence,
note,
createdAt: now,
requestId,
})
const agentEntry = await insertEntry(client, {
accountId: agentAccountId,
@@ -1387,6 +1407,7 @@ export async function createAdminFundIssue(payload: JsonObject, session: AdminSe
evidence,
note,
createdAt: now,
requestId,
})
return {
@@ -1435,6 +1456,7 @@ export async function createAdminFundReturn(payload: JsonObject, session: AdminS
const evidence = normalizeEvidence(payload.evidence || payload.evidenceFiles)
const note = text(payload.note)
assertEvidenceOrNote(evidence, note)
const requestId = normalizeRequestId(payload.requestId ?? payload.request_id)
const agent = await selectAccountRow(agentAccountId)
if (!agent || agent.account_type !== 'agent') {
@@ -1513,6 +1535,7 @@ export async function createAdminFundReturn(payload: JsonObject, session: AdminS
evidence,
note,
createdAt: now,
requestId,
})
const supervisorEntry = await insertEntry(client, {
accountId: supervisorAccountId,
@@ -1526,6 +1549,7 @@ export async function createAdminFundReturn(payload: JsonObject, session: AdminS
evidence,
note,
createdAt: now,
requestId,
})
return {
@@ -1588,6 +1612,7 @@ export type FundExpenseWriteInput = {
note: string
actorUserId: number
allowDuplicate?: boolean
requestId?: string
}
/**
@@ -1667,6 +1692,7 @@ export async function writeFundExpense(
evidence: input.evidence,
note: input.note,
createdAt: now,
requestId: input.requestId || '',
})
const entryRow = requiredRow(entry, '写入支出流水失败')
@@ -1767,6 +1793,7 @@ export async function createAdminFundExpense(payload: JsonObject, session: Admin
}
const allowDuplicate = payload.allowDuplicate === true
const requestId = normalizeRequestId(payload.requestId ?? payload.request_id)
return withTransaction(async (client) => {
const written = await writeFundExpense(client, {
@@ -1785,6 +1812,7 @@ export async function createAdminFundExpense(payload: JsonObject, session: Admin
note,
actorUserId: session.userId,
allowDuplicate,
requestId,
})
return { expense: mapManualExpense(written.expense) }
})
@@ -2084,22 +2112,8 @@ export async function confirmAdminFundReconciliation(
})
}
const differenceFen = toNumber(reconciliation.difference_fen)
const reason = text(payload.reason) || reconciliation.reason
if (differenceFen !== 0) {
if (!hasFundPermission(session, 'fund.adjust')) {
throw createHttpError('有差异的对账需要财务或管理员调整', {
statusCode: 403,
errorCode: 'fund_adjust_forbidden',
})
}
if (!reason) {
throw createHttpError('有差异时必须填写差异原因', {
statusCode: 400,
errorCode: 'fund_reconciliation_reason_required',
})
}
}
const actualBalanceFen = toNumber(reconciliation.actual_balance_fen)
const now = nowIso()
return withTransaction(async (client) => {
@@ -2112,8 +2126,28 @@ export async function confirmAdminFundReconciliation(
})
}
// 关键:用“确认时点的当前余额”重算差额,而不是创建对账时的旧差额。
// 否则创建后发生的发放/支出/缴回会让调整后的余额仍然不等于实际盘点。
const currentBalanceFen = toNumber(account.balance_fen)
const differenceFen = actualBalanceFen - currentBalanceFen
if (differenceFen !== 0) {
const nextBalance = toNumber(account.balance_fen) + differenceFen
if (!hasFundPermission(session, 'fund.adjust')) {
throw createHttpError('有差异的对账需要财务或管理员调整', {
statusCode: 403,
errorCode: 'fund_adjust_forbidden',
})
}
if (!reason) {
throw createHttpError('有差异时必须填写差异原因', {
statusCode: 400,
errorCode: 'fund_reconciliation_reason_required',
})
}
}
if (differenceFen !== 0) {
const nextBalance = currentBalanceFen + differenceFen
if (nextBalance < 0) {
throw createHttpError('调整后余额不能为负', {
statusCode: 409,
@@ -2129,7 +2163,7 @@ export async function confirmAdminFundReconciliation(
entryType: 'adjustment',
amountFen: differenceFen,
balanceAfterFen: nextBalance,
reconciliationId: reconciliationId,
reconciliationId,
occurredAt: now,
actorUserId: session.userId,
evidence: normalizeEvidence(payload.evidence || reconciliation.evidence_json),
@@ -2141,11 +2175,16 @@ export async function confirmAdminFundReconciliation(
await client.query(
`
UPDATE fund_reconciliations
SET status = 'confirmed', reason = $1, confirmed_by_admin_user_id = $2,
confirmed_at = $3, updated_at = $3
SET status = 'confirmed',
reason = $1,
system_balance_fen = $5,
difference_fen = $6,
confirmed_by_admin_user_id = $2,
confirmed_at = $3,
updated_at = $3
WHERE id = $4
`,
[reason, session.userId, now, reconciliationId],
[reason, session.userId, now, reconciliationId, currentBalanceFen, differenceFen],
)
const updated = await client.query<ReconciliationRow>(