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:
@@ -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>(
|
||||
|
||||
Reference in New Issue
Block a user