Files
order_site/apps/backend/src/services/finance/finance-ledger-service.ts
T
yml2213 6b0078f680 fix(finance): 收口快手退款路由并修复销售事实变更后的总账同步
- 平台退款路由从通用订单路径改为
  POST /api/v1/admin/kuaishou-industry/orders/:orderId/refunds/approve-and-record,
  服务改名 approveAndRecordKuaishouRefund,明确仅快手 91
- 总账同步改为未关账期间按日整体重建销售凭证,解决收入回填/退款重算后
  旧凭证金额不更新的问题;删除与重建放在同一事务,已关账期间保持不动
- 文档明确 revenue:backfill 是存量订单退款的前置条件:
  revenue_amount_fen=0 会导致退款登记按钮置灰
- 本地生产副本已执行 revenue:backfill:375,447 行全部回填,
  order 375483 可退金额恢复为 237.00 元

验证:backend check 844(840 pass / 4 skip);frontend check 172 + build;
集成测试通过(代录、总账平衡、关账阻断、对账)。
2026-10-26 13:48:53 +08:00

616 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { PoolClient } from 'pg'
import { query, withTransaction } from '../../db/client.js'
import type { JsonObject } from '../../types/json.js'
import { createHttpError } from '../../utils/http.js'
import { formatShanghaiDate, shiftDate } from '../../utils/finance-date-range.js'
import { randomId } from '../../utils/random.js'
import { nowIso } from '../../utils/time.js'
import type { AdminSession } from '../admin/admin-auth-service.js'
/**
* 财务总账(影子账)。
*
* 设计:
* * 只做复式记账与试算平衡,不接管 worker_wallets / fund_accounts 的运行时余额;
* * 销售事实按日同步为收入/退款/成本凭证(幂等);
* * 允许财务手工录入调整凭证;
* * 期间 closed 后禁止新增该期间凭证。
*/
export type JournalLineInput = {
accountCode: string
debitFen?: number
creditFen?: number
memo?: string
dimensions?: JsonObject
}
export type PostJournalInput = {
eventType: string
refType: string
refId: string
occurredAt: string
memo?: string
metadata?: JsonObject
lines: JournalLineInput[]
}
export type JournalEntry = {
id: number
entryNo: string
eventType: string
refType: string
refId: string
occurredAt: string
periodKey: string
status: string
memo: string
metadata: JsonObject
lines: Array<{
lineNo: number
accountCode: string
accountName: string
debitFen: number
creditFen: number
memo: string
}>
}
export async function postJournalEntry(
input: PostJournalInput,
client?: PoolClient,
): Promise<{ created: boolean; entryId: number; entryNo: string }> {
const lines = normalizeLines(input.lines)
if (!client) {
return withTransaction((tx) => postJournalEntry(input, tx))
}
const occurredDate = normalizeTimestampDate(input.occurredAt)
const occurredAt = occurredDate.toISOString()
const periodKey = formatShanghaiDate(occurredDate).slice(0, 7)
await assertPeriodOpen(client, periodKey)
const entryNo = `FJ-${periodKey.replace('-', '')}-${randomId('', 8)}`
const inserted = await client.query<{ id: number; entry_no: string }>(
`
INSERT INTO finance_journal_entries (
entry_no, event_type, ref_type, ref_id, occurred_at, period_key,
status, memo, metadata_json, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, 'posted', $7, $8::jsonb, $9, $9)
ON CONFLICT (event_type, ref_type, ref_id) DO NOTHING
RETURNING id, entry_no
`,
[
entryNo,
input.eventType,
input.refType,
input.refId,
occurredAt,
periodKey,
String(input.memo || '').slice(0, 500),
JSON.stringify(input.metadata || {}),
nowIso(),
],
)
const entry = inserted.rows[0]
if (!entry) {
const existing = await client.query<{ id: number; entry_no: string }>(
`SELECT id, entry_no FROM finance_journal_entries
WHERE event_type = $1 AND ref_type = $2 AND ref_id = $3
LIMIT 1`,
[input.eventType, input.refType, input.refId],
)
return {
created: false,
entryId: Number(existing.rows[0]?.id || 0),
entryNo: existing.rows[0]?.entry_no || '',
}
}
for (const [index, line] of lines.entries()) {
await client.query(
`
INSERT INTO finance_journal_lines (
entry_id, line_no, account_code, debit_fen, credit_fen,
memo, dimensions_json, created_at
) VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8)
`,
[
entry.id,
index + 1,
line.accountCode,
line.debitFen,
line.creditFen,
line.memo,
JSON.stringify(line.dimensions || {}),
nowIso(),
],
)
}
return { created: true, entryId: Number(entry.id), entryNo: entry.entry_no }
}
export async function postManualVoucher(
input: {
occurredAt?: string
memo?: string
reference?: string
lines: JournalLineInput[]
},
_session: AdminSession,
) {
const occurredAt = normalizeTimestampDate(input.occurredAt || nowIso()).toISOString()
const refId = String(input.reference || '').trim() || randomId('MAN-', 8)
return postJournalEntry({
eventType: 'manual_voucher',
refType: 'manual',
refId,
occurredAt,
memo: input.memo || '手工调整凭证',
lines: input.lines,
})
}
/**
* 把销售事实按日同步为总账凭证:
* 收入:借 1101 应收平台 / 贷 4001 商品销售收入
* 退款:借 4101 退款冲减 / 贷 1101 应收平台
* 成本:借 5001 履约成本 / 贷 2001 应付履约款
* 幂等键:event_type + sales_fact + 日期。
*/
export async function syncFinanceLedgerFromFacts(input: { dateFrom: string; dateTo: string }) {
const dateFrom = normalizeDate(input.dateFrom)
const dateTo = normalizeDate(input.dateTo)
if (dateFrom > dateTo) {
throw createHttpError('开始日期不能晚于结束日期', {
statusCode: 400,
errorCode: 'finance_ledger_date_range_invalid',
})
}
const rows = await query<{
stat_date: string
gross_revenue_fen: string
refund_amount_fen: string
cost_amount_fen: string
}>(
`
SELECT
stat_date::text AS stat_date,
COALESCE(SUM(gross_revenue_fen), 0)::bigint AS gross_revenue_fen,
COALESCE(SUM(refund_amount_fen), 0)::bigint AS refund_amount_fen,
COALESCE(SUM(cost_amount_fen), 0)::bigint AS cost_amount_fen
FROM sales_daily_facts
WHERE stat_date BETWEEN $1::date AND $2::date
GROUP BY stat_date
ORDER BY stat_date
`,
[dateFrom, dateTo],
)
let created = 0
let replaced = 0
let skipped = 0
for (const row of rows.rows) {
const occurredAt = `${row.stat_date}T12:00:00+08:00`
const periodKey = formatShanghaiDate(new Date(occurredAt)).slice(0, 7)
const period = await query<{ status: string }>(
`SELECT status FROM finance_periods WHERE period_key = $1 LIMIT 1`,
[periodKey],
)
if (period.rows[0]?.status === 'closed') {
// 已关账期间不可改账;即使销售事实后来变化,也保留关账时点的凭证。
skipped += 1
continue
}
// 未关账期间:销售事实可能因退款/收入回填而变化,按日整体重建,
// 避免旧的幂等凭证一直停留在旧金额;删除与重建放在同一事务内。
const revenue = Number(row.gross_revenue_fen || 0)
const refund = Number(row.refund_amount_fen || 0)
const cost = Number(row.cost_amount_fen || 0)
const entries: PostJournalInput[] = []
if (revenue > 0) {
entries.push({
eventType: 'sales_revenue',
refType: 'sales_fact',
refId: row.stat_date,
occurredAt,
memo: `${row.stat_date} 销售收入`,
lines: [
{ accountCode: '1101', debitFen: revenue, memo: '应收平台' },
{ accountCode: '4001', creditFen: revenue, memo: '商品销售收入' },
],
})
}
if (refund > 0) {
entries.push({
eventType: 'sales_refund',
refType: 'sales_fact',
refId: row.stat_date,
occurredAt,
memo: `${row.stat_date} 退款冲减`,
lines: [
{ accountCode: '4101', debitFen: refund, memo: '退款冲减收入' },
{ accountCode: '1101', creditFen: refund, memo: '应收平台冲减' },
],
})
}
if (cost > 0) {
entries.push({
eventType: 'sales_cost',
refType: 'sales_fact',
refId: row.stat_date,
occurredAt,
memo: `${row.stat_date} 履约成本`,
lines: [
{ accountCode: '5001', debitFen: cost, memo: '履约成本' },
{ accountCode: '2001', creditFen: cost, memo: '应付履约款' },
],
})
}
const dayCreated = await withTransaction(async (client) => {
const deleted = await client.query(
`DELETE FROM finance_journal_entries WHERE ref_type = 'sales_fact' AND ref_id = $1`,
[row.stat_date],
)
replaced += deleted.rowCount || 0
let count = 0
for (const entry of entries) {
const result = await postJournalEntry(entry, client)
if (result.created) count += 1
}
return count
})
created += dayCreated
}
return { range: { fromDate: dateFrom, toDate: dateTo }, created, replaced, skipped }
}
export async function listJournalEntries(input: Record<string, unknown> = {}) {
const page = Math.max(1, Number(input.page) || 1)
const pageSize = Math.min(100, Math.max(1, Number(input.pageSize ?? input.page_size) || 20))
const params: unknown[] = []
const clauses: string[] = []
const fromDate = optionalDate(input.dateFrom ?? input.date_from)
const toDate = optionalDate(input.dateTo ?? input.date_to)
if (fromDate) {
params.push(fromDate)
clauses.push(
`e.occurred_at >= ($${params.length}::date::timestamp AT TIME ZONE 'Asia/Shanghai')`,
)
}
if (toDate) {
params.push(toDate)
clauses.push(
`e.occurred_at < (($${params.length}::date + interval '1 day')::timestamp AT TIME ZONE 'Asia/Shanghai')`,
)
}
const where = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : ''
const count = await query<{ total: number }>(
`SELECT COUNT(*)::int AS total FROM finance_journal_entries e ${where}`,
params,
)
const entries = await query<Record<string, unknown>>(
`
SELECT e.*, COALESCE(SUM(l.debit_fen), 0)::bigint AS debit_total_fen
FROM finance_journal_entries e
LEFT JOIN finance_journal_lines l ON l.entry_id = e.id
${where}
GROUP BY e.id
ORDER BY e.occurred_at DESC, e.id DESC
LIMIT $${params.length + 1} OFFSET $${params.length + 2}
`,
[...params, pageSize, (page - 1) * pageSize],
)
const ids = entries.rows.map((row) => Number(row.id))
const lines =
ids.length > 0
? await query<Record<string, unknown>>(
`
SELECT l.*, a.account_name
FROM finance_journal_lines l
JOIN finance_accounts a ON a.account_code = l.account_code
WHERE l.entry_id = ANY($1::bigint[])
ORDER BY l.entry_id, l.line_no
`,
[ids],
)
: { rows: [] }
const linesByEntry = new Map<number, Array<Record<string, unknown>>>()
for (const line of lines.rows) {
const entryId = Number(line.entry_id)
const list = linesByEntry.get(entryId) || []
list.push(line)
linesByEntry.set(entryId, list)
}
return {
items: entries.rows.map((row) => ({
id: Number(row.id),
entryNo: String(row.entry_no || ''),
eventType: String(row.event_type || ''),
refType: String(row.ref_type || ''),
refId: String(row.ref_id || ''),
occurredAt: String(row.occurred_at || ''),
periodKey: String(row.period_key || ''),
status: String(row.status || ''),
memo: String(row.memo || ''),
metadata: row.metadata_json || {},
debitTotalFen: Number(row.debit_total_fen || 0),
lines: (linesByEntry.get(Number(row.id)) || []).map((line) => ({
lineNo: Number(line.line_no),
accountCode: String(line.account_code || ''),
accountName: String(line.account_name || ''),
debitFen: Number(line.debit_fen || 0),
creditFen: Number(line.credit_fen || 0),
memo: String(line.memo || ''),
})),
})) satisfies JournalEntry[],
pagination: { page, pageSize, total: Number(count.rows[0]?.total || 0) },
}
}
export async function getTrialBalance(input: { dateFrom: string; dateTo: string }) {
const dateFrom = normalizeDate(input.dateFrom)
const dateTo = normalizeDate(input.dateTo)
const rows = await query<Record<string, unknown>>(
`
SELECT
a.account_code, a.account_name, a.account_type, a.normal_side,
COALESCE(SUM(l.debit_fen), 0)::bigint AS debit_fen,
COALESCE(SUM(l.credit_fen), 0)::bigint AS credit_fen,
CASE
WHEN a.normal_side = 'debit'
THEN COALESCE(SUM(l.debit_fen), 0)::bigint - COALESCE(SUM(l.credit_fen), 0)::bigint
ELSE COALESCE(SUM(l.credit_fen), 0)::bigint - COALESCE(SUM(l.debit_fen), 0)::bigint
END AS balance_fen
FROM finance_accounts a
LEFT JOIN finance_journal_lines l ON l.account_code = a.account_code
LEFT JOIN finance_journal_entries e
ON e.id = l.entry_id AND e.status = 'posted'
AND e.occurred_at >= ($1::date::timestamp AT TIME ZONE 'Asia/Shanghai')
AND e.occurred_at < (($2::date + interval '1 day')::timestamp AT TIME ZONE 'Asia/Shanghai')
WHERE a.status = 'active'
AND (e.id IS NOT NULL OR (l.id IS NULL))
GROUP BY a.account_code, a.account_name, a.account_type, a.normal_side
ORDER BY a.account_code
`,
[dateFrom, dateTo],
)
const items = rows.rows.map((row) => ({
accountCode: String(row.account_code || ''),
accountName: String(row.account_name || ''),
accountType: String(row.account_type || ''),
normalSide: String(row.normal_side || ''),
debitFen: Number(row.debit_fen || 0),
creditFen: Number(row.credit_fen || 0),
balanceFen: Number(row.balance_fen || 0),
}))
const totals = items.reduce(
(acc, item) => ({
debitFen: acc.debitFen + item.debitFen,
creditFen: acc.creditFen + item.creditFen,
balanceFen: acc.balanceFen + item.balanceFen,
}),
{ debitFen: 0, creditFen: 0, balanceFen: 0 },
)
return {
range: { dateFrom, dateTo },
items,
totals,
balanced: totals.debitFen === totals.creditFen,
}
}
export async function listFinancePeriods() {
const result = await query<Record<string, unknown>>(
`SELECT * FROM finance_periods ORDER BY period_key DESC LIMIT 60`,
)
return {
items: result.rows.map(mapPeriod),
}
}
export async function closeFinancePeriod(periodKeyInput: unknown, session: AdminSession) {
const periodKey = normalizePeriodKey(periodKeyInput)
const { startsAt, endsAt, dateFrom, dateTo } = periodRange(periodKey)
// 先补齐该期间的凭证,避免漏记。
await syncFinanceLedgerFromFacts({ dateFrom, dateTo })
const trial = await getTrialBalance({ dateFrom, dateTo })
const now = nowIso()
await query(
`
INSERT INTO finance_periods (
period_key, starts_at, ends_at, status, summary_json,
closed_at, closed_by_admin_user_id, created_at, updated_at
) VALUES ($1, $2, $3, 'closed', $4::jsonb, $5, $6, $5, $5)
ON CONFLICT (period_key) DO UPDATE
SET status = 'closed',
starts_at = EXCLUDED.starts_at,
ends_at = EXCLUDED.ends_at,
summary_json = EXCLUDED.summary_json,
closed_at = EXCLUDED.closed_at,
closed_by_admin_user_id = EXCLUDED.closed_by_admin_user_id,
updated_at = EXCLUDED.updated_at
`,
[
periodKey,
startsAt,
endsAt,
JSON.stringify({
totals: trial.totals,
balanced: trial.balanced,
itemCount: trial.items.length,
}),
now,
Number(session.userId || 0) || null,
],
)
return { periodKey, trial }
}
export async function reopenFinancePeriod(periodKeyInput: unknown) {
const periodKey = normalizePeriodKey(periodKeyInput)
const result = await query<Record<string, unknown>>(
`
UPDATE finance_periods
SET status = 'open', closed_at = NULL, closed_by_admin_user_id = NULL, updated_at = $2
WHERE period_key = $1
RETURNING *
`,
[periodKey, nowIso()],
)
if (!result.rows[0]) {
throw createHttpError('期间不存在或尚未关账', {
statusCode: 404,
errorCode: 'finance_period_not_found',
})
}
return mapPeriod(result.rows[0])
}
export async function listFinanceAccounts() {
const result = await query<Record<string, unknown>>(
`SELECT * FROM finance_accounts WHERE status = 'active' ORDER BY account_code`,
)
return {
items: result.rows.map((row) => ({
accountCode: String(row.account_code || ''),
accountName: String(row.account_name || ''),
accountType: String(row.account_type || ''),
normalSide: String(row.normal_side || ''),
description: String(row.description || ''),
})),
}
}
function normalizeLines(lines: JournalLineInput[]) {
if (!Array.isArray(lines) || lines.length < 2) {
throw createHttpError('凭证至少需要两条分录', {
statusCode: 400,
errorCode: 'finance_journal_lines_required',
})
}
let debit = 0
let credit = 0
const normalized = lines.map((line) => {
const accountCode = String(line.accountCode || '').trim()
if (!accountCode) {
throw createHttpError('凭证分录缺少科目', {
statusCode: 400,
errorCode: 'finance_journal_account_required',
})
}
const debitFen = Math.trunc(Number(line.debitFen || 0))
const creditFen = Math.trunc(Number(line.creditFen || 0))
if (
!Number.isFinite(debitFen) ||
!Number.isFinite(creditFen) ||
debitFen < 0 ||
creditFen < 0 ||
(debitFen > 0 && creditFen > 0) ||
(debitFen === 0 && creditFen === 0)
) {
throw createHttpError('凭证分录借贷金额无效', {
statusCode: 400,
errorCode: 'finance_journal_amount_invalid',
})
}
debit += debitFen
credit += creditFen
return {
accountCode,
debitFen,
creditFen,
memo: String(line.memo || '').slice(0, 300),
dimensions: line.dimensions || {},
}
})
if (debit !== credit) {
throw createHttpError(`凭证借贷不平衡(借 ${debit} / 贷 ${credit})`, {
statusCode: 400,
errorCode: 'finance_journal_unbalanced',
})
}
return normalized
}
async function assertPeriodOpen(client: PoolClient, periodKey: string) {
const result = await client.query<{ status: string }>(
`SELECT status FROM finance_periods WHERE period_key = $1 LIMIT 1`,
[periodKey],
)
if (result.rows[0]?.status === 'closed') {
throw createHttpError(`会计期间 ${periodKey} 已关账,不能新增凭证`, {
statusCode: 409,
errorCode: 'finance_period_closed',
})
}
}
function isPeriodClosedError(error: unknown) {
return (
error instanceof Error &&
(error as Error & { errorCode?: string }).errorCode === 'finance_period_closed'
)
}
function periodRange(periodKey: string) {
const [year, month] = periodKey.split('-').map(Number)
const startsAt = `${periodKey}-01T00:00:00+08:00`
const nextMonth = new Date(Date.UTC(year || 1970, (month || 1) - 1 + 1, 1))
const nextKey = nextMonth.toISOString().slice(0, 7)
const endsAt = `${nextKey}-01T00:00:00+08:00`
const lastDay = shiftDate(`${nextKey}-01`, -1)
return { startsAt, endsAt, dateFrom: `${periodKey}-01`, dateTo: lastDay }
}
function mapPeriod(row: Record<string, unknown>) {
return {
periodKey: String(row.period_key || ''),
startsAt: row.starts_at,
endsAt: row.ends_at,
status: String(row.status || 'open'),
summary: row.summary_json || {},
closedAt: row.closed_at,
closedByAdminUserId: row.closed_by_admin_user_id ? Number(row.closed_by_admin_user_id) : null,
}
}
function normalizeTimestampDate(value: unknown) {
const date = new Date(String(value || ''))
if (!Number.isFinite(date.getTime())) {
throw createHttpError('时间格式无效', {
statusCode: 400,
errorCode: 'finance_journal_time_invalid',
})
}
return date
}
function normalizeDate(value: unknown) {
const text = String(value || '').trim()
if (!/^\d{4}-\d{2}-\d{2}$/.test(text)) {
throw createHttpError('日期格式无效(YYYY-MM-DD)', {
statusCode: 400,
errorCode: 'finance_ledger_date_invalid',
})
}
return text
}
function optionalDate(value: unknown) {
const text = String(value || '').trim()
return /^\d{4}-\d{2}-\d{2}$/.test(text) ? text : ''
}
function normalizePeriodKey(value: unknown) {
const text = String(value || '').trim()
if (!/^\d{4}-\d{2}$/.test(text)) {
throw createHttpError('期间格式无效(YYYY-MM)', {
statusCode: 400,
errorCode: 'finance_period_invalid',
})
}
return text
}