feat(finance): 支持代录指定客服账户,并上线影子总账/对账/关账/快手平台退款

- 人工确认履约:管理员/财务/运营可传 fundAccountId / creditOwnerAdminUserId
  指定客服备用金/积分账户;客服仍强制本人账户
- 新增迁移 112:finance_accounts / journal_entries / journal_lines /
  periods / reconciliations / reconciliation_items,种子 11 个科目
- 影子总账:销售事实按日生成收入/退款/成本凭证,支持手工凭证、试算平衡;
  不接管钱包余额;总账同步接入定时任务与退款重算
- 期间关账/反关账:关账先补凭证并校验试算平衡,closed 期间禁止新增凭证
- 平台对账:JSON 账单导入,按 provider + platform_order_id 匹配订单/退款,
  标记 matched/amount_mismatch/missing_order/missing_refund/duplicate
- 平台退款:快手行业(91)同意退款 API 接入,成功后落 source=platform;
  抖音/拼多多返回 501,保留手工退款
- 新增后台「财务 → 总账与对账」页面(总账/期间/对账三个 Tab)
- 集成测试扩展到代录、总账平衡、关账阻断、手工凭证、对账匹配

验证:backend check 844(840 pass / 4 skip);frontend check 172 + build;
迁移 112 在 scratch 库全量执行并重跑幂等;本地生产副本已升级到 112,
销售事实同步 56 条凭证,借贷合计 1,885,351,864 = 1,885,351,864。
This commit is contained in:
yml2213
2026-10-26 13:32:47 +08:00
parent f0eb8a5e05
commit 62763ddf0c
31 changed files with 2961 additions and 55 deletions
@@ -0,0 +1,600 @@
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 skipped = 0
for (const row of rows.rows) {
const occurredAt = `${row.stat_date}T12:00:00+08:00`
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: '应付履约款' },
],
})
}
for (const entry of entries) {
try {
const result = await postJournalEntry(entry)
if (result.created) created += 1
} catch (error) {
if (isPeriodClosedError(error)) {
skipped += 1
continue
}
throw error
}
}
}
return { range: { fromDate: dateFrom, toDate: dateTo }, created, 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
}
@@ -0,0 +1,413 @@
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 { nowIso } from '../../utils/time.js'
import type { AdminSession } from '../admin/admin-auth-service.js'
/**
* 平台对账:
* 导入平台账单行(销售/退款)→ 与 orders / order_refunds 匹配 →
* 标记 matched / amount_mismatch / missing_order / missing_refund / duplicate。
* 这是「手工账单导入」先行;平台 API 自动拉单后续可复用同一匹配器。
*/
type ReconciliationRowInput = {
platformOrderId?: unknown
platform_order_id?: unknown
entryType?: unknown
entry_type?: unknown
amountFen?: unknown
amount_fen?: unknown
externalRef?: unknown
external_ref?: unknown
occurredAt?: unknown
occurred_at?: unknown
}
export async function importFinanceReconciliation(payload: JsonObject, session: AdminSession) {
const provider = String(payload.provider || '').trim()
if (!provider) {
throw createHttpError('请选择平台', {
statusCode: 400,
errorCode: 'finance_reconciliation_provider_required',
})
}
const periodFrom = normalizeDate(payload.periodFrom ?? payload.period_from, '开始日期')
const periodTo = normalizeDate(payload.periodTo ?? payload.period_to, '结束日期')
if (periodFrom > periodTo) {
throw createHttpError('开始日期不能晚于结束日期', {
statusCode: 400,
errorCode: 'finance_reconciliation_period_invalid',
})
}
const rawRows = Array.isArray(payload.rows) ? (payload.rows as ReconciliationRowInput[]) : []
if (rawRows.length === 0 || rawRows.length > 5000) {
throw createHttpError('对账明细需为 1–5000 行', {
statusCode: 400,
errorCode: 'finance_reconciliation_rows_invalid',
})
}
const rows = rawRows.map((row, index) => normalizeRow(row, provider, index))
const now = nowIso()
return withTransaction(async (client) => {
const batch = await client.query<{ id: number }>(
`
INSERT INTO finance_reconciliations (
provider, period_from, period_to, source, status,
expected_fen, actual_fen, diff_fen, summary_json,
imported_by_admin_user_id, created_at, updated_at
) VALUES ($1, $2::date, $3::date, 'manual_import', 'imported', 0, 0, 0, '{}'::jsonb, $4, $5, $5)
RETURNING id
`,
[provider, periodFrom, periodTo, Number(session.userId || 0) || null, now],
)
const reconciliationId = Number(batch.rows[0]?.id || 0)
let expectedFen = 0
let actualFen = 0
let matchedCount = 0
let mismatchCount = 0
const seen = new Set<string>()
for (const [index, row] of rows.entries()) {
const duplicateKey =
row.entryType === 'sale'
? `${row.platformOrderId}|sale`
: `${row.platformOrderId}|refund|${row.externalRef || row.amountFen}`
const isDuplicate = seen.has(duplicateKey)
seen.add(duplicateKey)
const match = isDuplicate
? { status: 'duplicate' as const, orderId: null, refundId: null, expected: null }
: await matchRow(client, provider, row)
const expected = match.expected
const diff = expected === null ? null : row.amountFen - expected
if (match.status === 'matched') matchedCount += 1
else mismatchCount += 1
actualFen += row.amountFen
expectedFen += expected ?? 0
await client.query(
`
INSERT INTO finance_reconciliation_items (
reconciliation_id, line_no, provider, platform_order_id, external_ref,
entry_type, amount_fen, occurred_at, matched_order_id, matched_refund_id,
expected_fen, diff_fen, match_status, detail_json, created_at
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8::timestamptz, $9, $10,
$11, $12, $13, $14::jsonb, $15
)
`,
[
reconciliationId,
index + 1,
provider,
row.platformOrderId,
row.externalRef,
row.entryType,
row.amountFen,
row.occurredAt,
match.orderId,
match.refundId,
expected,
diff,
match.status,
JSON.stringify({
message:
match.status === 'matched'
? '金额与系统一致'
: match.status === 'duplicate'
? '同一批次内重复行'
: match.status === 'missing_order'
? '平台账单有销售单,系统无对应订单'
: match.status === 'missing_refund'
? '平台账单有退款,系统无对应退款记录'
: '金额与系统不一致',
}),
now,
],
)
}
const status = mismatchCount === 0 ? 'matched' : 'differences'
await client.query(
`
UPDATE finance_reconciliations
SET status = $2,
expected_fen = $3,
actual_fen = $4,
diff_fen = $4::bigint - $3::bigint,
summary_json = $5::jsonb,
updated_at = $6
WHERE id = $1
`,
[
reconciliationId,
status,
expectedFen,
actualFen,
JSON.stringify({ rows: rows.length, matched: matchedCount, mismatched: mismatchCount }),
now,
],
)
return {
reconciliationId,
status,
rows: rows.length,
matched: matchedCount,
mismatched: mismatchCount,
expectedFen,
actualFen,
diffFen: actualFen - expectedFen,
}
})
}
export async function listFinanceReconciliations(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 provider = String(input.provider || '').trim()
const params: unknown[] = []
const clauses: string[] = []
if (provider) {
params.push(provider)
clauses.push(`r.provider = $${params.length}`)
}
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : ''
const count = await query<{ total: number }>(
`SELECT COUNT(*)::int AS total FROM finance_reconciliations r ${where}`,
params,
)
const rows = await query<Record<string, unknown>>(
`
SELECT r.*, au.display_name AS imported_by_name
FROM finance_reconciliations r
LEFT JOIN admin_users au ON au.id = r.imported_by_admin_user_id
${where}
ORDER BY r.created_at DESC, r.id DESC
LIMIT $${params.length + 1} OFFSET $${params.length + 2}
`,
[...params, pageSize, (page - 1) * pageSize],
)
return {
items: rows.rows.map(mapReconciliation),
pagination: { page, pageSize, total: Number(count.rows[0]?.total || 0) },
}
}
export async function getFinanceReconciliation(idInput: unknown) {
const id = normalizeId(idInput)
const batch = await query<Record<string, unknown>>(
`
SELECT r.*, au.display_name AS imported_by_name
FROM finance_reconciliations r
LEFT JOIN admin_users au ON au.id = r.imported_by_admin_user_id
WHERE r.id = $1
LIMIT 1
`,
[id],
)
if (!batch.rows[0]) {
throw createHttpError('对账批次不存在', {
statusCode: 404,
errorCode: 'finance_reconciliation_not_found',
})
}
const items = await query<Record<string, unknown>>(
`SELECT * FROM finance_reconciliation_items WHERE reconciliation_id = $1 ORDER BY line_no`,
[id],
)
return {
...mapReconciliation(batch.rows[0]),
items: items.rows.map((row) => ({
id: Number(row.id),
lineNo: Number(row.line_no),
platformOrderId: String(row.platform_order_id || ''),
externalRef: String(row.external_ref || ''),
entryType: String(row.entry_type || ''),
amountFen: Number(row.amount_fen || 0),
expectedFen: row.expected_fen === null ? null : Number(row.expected_fen),
diffFen: row.diff_fen === null ? null : Number(row.diff_fen),
occurredAt: row.occurred_at,
matchedOrderId: row.matched_order_id ? Number(row.matched_order_id) : null,
matchedRefundId: row.matched_refund_id ? Number(row.matched_refund_id) : null,
matchStatus: String(row.match_status || ''),
detail: row.detail_json || {},
})),
}
}
export async function resolveFinanceReconciliation(
idInput: unknown,
note: unknown,
_session: AdminSession,
) {
const id = normalizeId(idInput)
const result = await query<Record<string, unknown>>(
`
UPDATE finance_reconciliations
SET status = 'resolved',
resolved_at = $2,
summary_json = jsonb_set(summary_json, '{resolutionNote}', to_jsonb($3::text), true),
updated_at = $2
WHERE id = $1 AND status IN ('differences', 'matched', 'imported')
RETURNING *
`,
[id, nowIso(), String(note || '').slice(0, 500)],
)
if (!result.rows[0]) {
throw createHttpError('对账批次不存在或已解决', {
statusCode: 404,
errorCode: 'finance_reconciliation_not_resolvable',
})
}
return mapReconciliation(result.rows[0])
}
async function matchRow(
client: PoolClient,
provider: string,
row: ReturnType<typeof normalizeRow>,
): Promise<{
status: 'matched' | 'amount_mismatch' | 'missing_order' | 'missing_refund'
orderId: number | null
refundId: number | null
expected: number | null
}> {
if (row.entryType === 'sale') {
const order = await client.query<{ id: number; total_amount: number }>(
`SELECT id, total_amount FROM orders
WHERE provider = $1 AND platform_order_id = $2
ORDER BY id DESC LIMIT 1`,
[provider, row.platformOrderId],
)
const found = order.rows[0]
if (!found) {
return { status: 'missing_order', orderId: null, refundId: null, expected: null }
}
const expected = Number(found.total_amount || 0)
return {
status: expected === row.amountFen ? 'matched' : 'amount_mismatch',
orderId: Number(found.id),
refundId: null,
expected,
}
}
const params: unknown[] = [provider, row.platformOrderId]
let externalClause = ''
if (row.externalRef) {
params.push(row.externalRef)
externalClause = ` AND r.external_ref = $${params.length}`
}
const refund = await client.query<{ id: number; amount_fen: number }>(
`
SELECT r.id, r.amount_fen
FROM order_refunds r
JOIN orders o ON o.id = r.order_id
WHERE o.provider = $1 AND o.platform_order_id = $2
AND r.status = 'confirmed' ${externalClause}
ORDER BY r.id DESC
LIMIT 1
`,
params,
)
const found = refund.rows[0]
if (!found) {
return { status: 'missing_refund', orderId: null, refundId: null, expected: null }
}
const expected = Number(found.amount_fen || 0)
return {
status: expected === row.amountFen ? 'matched' : 'amount_mismatch',
orderId: null,
refundId: Number(found.id),
expected,
}
}
function normalizeRow(row: ReconciliationRowInput, provider: string, index: number) {
const platformOrderId = String(row.platformOrderId ?? row.platform_order_id ?? '').trim()
if (!platformOrderId) {
throw createHttpError(`第 ${index + 1} 行缺少平台订单号`, {
statusCode: 400,
errorCode: 'finance_reconciliation_order_id_required',
})
}
const entryType = String(row.entryType ?? row.entry_type ?? 'sale').trim()
if (entryType !== 'sale' && entryType !== 'refund') {
throw createHttpError(`第 ${index + 1} 行类型只能是 sale/refund`, {
statusCode: 400,
errorCode: 'finance_reconciliation_type_invalid',
})
}
const amountFen = Math.trunc(Number(row.amountFen ?? row.amount_fen))
if (!Number.isFinite(amountFen) || amountFen <= 0) {
throw createHttpError(`第 ${index + 1} 行金额无效`, {
statusCode: 400,
errorCode: 'finance_reconciliation_amount_invalid',
})
}
const occurredAtRaw = row.occurredAt ?? row.occurred_at
const occurredAt = occurredAtRaw ? new Date(String(occurredAtRaw)) : null
if (occurredAtRaw && !Number.isFinite(occurredAt?.getTime())) {
throw createHttpError(`第 ${index + 1} 行时间无效`, {
statusCode: 400,
errorCode: 'finance_reconciliation_time_invalid',
})
}
return {
provider,
platformOrderId,
entryType,
amountFen,
externalRef: String(row.externalRef ?? row.external_ref ?? '').trim(),
occurredAt: occurredAt ? occurredAt.toISOString() : null,
}
}
function mapReconciliation(row: Record<string, unknown>) {
return {
id: Number(row.id),
provider: String(row.provider || ''),
periodFrom: row.period_from,
periodTo: row.period_to,
source: String(row.source || ''),
status: String(row.status || ''),
expectedFen: Number(row.expected_fen || 0),
actualFen: Number(row.actual_fen || 0),
diffFen: Number(row.diff_fen || 0),
summary: row.summary_json || {},
importedByAdminUserId: row.imported_by_admin_user_id
? Number(row.imported_by_admin_user_id)
: null,
importedByName: String(row.imported_by_name || ''),
resolvedAt: row.resolved_at,
createdAt: row.created_at,
}
}
function normalizeDate(value: unknown, label: string) {
const text = String(value || '').trim()
if (!/^\d{4}-\d{2}-\d{2}$/.test(text)) {
throw createHttpError(`${label}格式无效(YYYY-MM-DD)`, {
statusCode: 400,
errorCode: 'finance_reconciliation_date_invalid',
})
}
return text
}
function normalizeId(value: unknown) {
const id = Number(value)
if (!Number.isInteger(id) || id <= 0) {
throw createHttpError('对账批次 ID 无效', {
statusCode: 400,
errorCode: 'finance_reconciliation_id_invalid',
})
}
return id
}
@@ -143,3 +143,31 @@ test('迁移 111 提供订单项级人工成本唯一约束与 subject 语义约
'必须把历史 subject_id 归一并加语义约束',
)
})
test('总账/对账/关账/平台退款能力已接入', async () => {
const [migration, ledger, reconciliation, refund, manual] = await Promise.all([
readFile(new URL('../../db/migrations/112_finance_ledger.sql', import.meta.url), 'utf8'),
readFile(new URL('./finance-ledger-service.ts', import.meta.url), 'utf8'),
readFile(new URL('./finance-reconciliation-service.ts', import.meta.url), 'utf8'),
readFile(new URL('./order-refund-service.ts', import.meta.url), 'utf8'),
readFile(new URL('./manual-fulfillment-service.ts', import.meta.url), 'utf8'),
])
assert.ok(
migration.includes('finance_journal_entries') && migration.includes('finance_journal_lines'),
)
assert.ok(migration.includes('finance_periods') && migration.includes('finance_reconciliations'))
assert.ok(
ledger.includes('finance_period_closed') && ledger.includes('syncFinanceLedgerFromFacts'),
)
assert.ok(reconciliation.includes('amount_mismatch') && reconciliation.includes('missing_order'))
assert.ok(
refund.includes('approveKuaishouIndustryRefund') &&
refund.includes("source: 'platform'") &&
refund.includes('platform_refund_not_supported'),
'平台退款必须走平台 API 并落 source=platform',
)
assert.ok(
manual.includes('fundAccountId') && manual.includes('creditOwnerAdminUserId'),
'管理员/财务必须可以指定客服账户代录',
)
})
@@ -322,7 +322,7 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm
if (platformKey === 'fund_cash') {
// 备用金现金:复用唯一写入口,但把 client 传进去,和快照共用同一个事务。
const fundAccountId = await resolveFundAccountId(session, client)
const fundAccountId = await resolveFundAccountId(payload, session, client)
const { writeFundExpense } = await import('../admin/admin-fund-service.js')
const written = await writeFundExpense(client, {
agentAccountId: fundAccountId,
@@ -349,8 +349,10 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm
balanceAfter = written.balanceAfterFen
subjectSource = 'manual_expense'
} else {
// 管理员/财务可以代录到指定客服的积分账户;客服只能用自己的。
const creditOwnerAdminUserId = await resolveCreditOwnerAdminUserId(payload, session, client)
const consumed = await consumePlatformCreditWithClient(client, {
ownerAdminUserId: session.userId,
ownerAdminUserId: creditOwnerAdminUserId,
platformKey,
quantity: creditQuantity,
relatedTaskId: null,
@@ -400,6 +402,10 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm
configId: Number(row.config_id),
platformKey,
platformLabel: PLATFORM_LABELS[platformKey] || platformKey,
creditOwnerAdminUserId:
platformKey === 'fund_cash'
? null
: Number(payload.creditOwnerAdminUserId || 0) || null,
creditQuantity,
creditUnitPriceFen: unitPriceFen,
costMode: costPlan.costMode,
@@ -454,8 +460,43 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm
}
/** 客服自己的备用金账户(管理员代录时用指定账户)。 */
async function resolveFundAccountId(session: AdminSession, client?: Pick<PoolClient, 'query'>) {
const PRIVILEGED_ROLES = new Set(['admin', 'finance', 'operator'])
function canActOnBehalf(session: AdminSession) {
return PRIVILEGED_ROLES.has(String(session.role || ''))
}
/**
* 备用金账户:
* - 客服只能用本人账户;
* - 管理员/财务/运营可显式指定客服账户(fundAccountId),未指定时回退本人账户。
*/
async function resolveFundAccountId(
payload: JsonObject,
session: AdminSession,
client?: Pick<PoolClient, 'query'>,
) {
const { getOwnAgentAccountId } = await import('../admin/admin-fund-service.js')
const selectedId = Number(payload.fundAccountId ?? payload.fund_account_id ?? 0)
if (canActOnBehalf(session) && Number.isInteger(selectedId) && selectedId > 0) {
const sql = `SELECT fa.id
FROM fund_accounts fa
JOIN admin_users au ON au.id = fa.owner_admin_user_id
WHERE fa.id = $1
AND fa.account_type = 'agent'
AND fa.status = 'active'
AND au.status = 'active'
LIMIT 1`
const selected = client
? await client.query<{ id: number }>(sql, [selectedId])
: await query<{ id: number }>(sql, [selectedId])
const found = selected.rows[0]?.id
if (found) return Number(found)
throw createHttpError('指定的客服备用金账户不存在或已停用', {
statusCode: 404,
errorCode: 'fund_agent_account_not_found',
})
}
const own = await getOwnAgentAccountId(session.userId, client)
if (own) return own
throw createHttpError('当前账号还没有备用金账户', {
@@ -464,6 +505,33 @@ async function resolveFundAccountId(session: AdminSession, client?: Pick<PoolCli
})
}
/**
* 积分账户归属人:
* - 客服只能扣本人;
* - 管理员/财务/运营可指定客服(creditOwnerAdminUserId),未指定时回退本人。
*/
async function resolveCreditOwnerAdminUserId(
payload: JsonObject,
session: AdminSession,
client?: Pick<PoolClient, 'query'>,
) {
const selectedId = Number(
payload.creditOwnerAdminUserId ?? payload.credit_owner_admin_user_id ?? 0,
)
if (canActOnBehalf(session) && Number.isInteger(selectedId) && selectedId > 0) {
const sql = `SELECT id FROM admin_users WHERE id = $1 AND status = 'active' AND role = 'support' LIMIT 1`
const selected = client
? await client.query<{ id: number }>(sql, [selectedId])
: await query<{ id: number }>(sql, [selectedId])
if (selected.rows[0]?.id) return Number(selected.rows[0].id)
throw createHttpError('指定的客服账号不存在或已停用', {
statusCode: 404,
errorCode: 'platform_credit_owner_not_found',
})
}
return Number(session.userId)
}
/**
* 确认前按 SKU 匹配人工履约配置。
*
@@ -25,6 +25,15 @@ test(
await import('../cost-center/cost-snapshot-sync-service.js')
const { rematchManualFulfillments } =
await import('../cost-center/manual-fulfillment-config-service.js')
const {
closeFinancePeriod,
getTrialBalance,
postManualVoucher,
reopenFinancePeriod,
syncFinanceLedgerFromFacts,
} = await import('./finance-ledger-service.js')
const { rebuildSalesDailyFacts } = await import('./sales-facts-service.js')
const { importFinanceReconciliation } = await import('./finance-reconciliation-service.js')
const suffix = `${Date.now()}_${Math.floor(Math.random() * 1_000_000)}`
const skuKey = `IT-MANUAL-${suffix}`
@@ -221,6 +230,56 @@ test(
)
assert.equal(Number(creditExpenseCount.rows[0]?.count), 0, '积分路径不得写备用金支出')
// 5.1 管理员/财务代录:指定客服的备用金账户与积分账户
const financeAdmin = await query<{ id: number }>(
`INSERT INTO admin_users (username, password_hash, role, status, created_at, updated_at)
VALUES ($1, 'x', 'finance', 'active', NOW(), NOW()) RETURNING id`,
[`it_finance_${suffix}`],
)
const financeAdminId = Number(financeAdmin.rows[0]?.id)
adminIds.push(financeAdminId)
const financeSession = {
userId: financeAdminId,
role: 'finance',
displayName: '集成测试财务',
} as never
const delegatedCashItem = await createManualItem(query, cashSku)
orderIds.push(delegatedCashItem.orderId)
orderItemIds.push(delegatedCashItem.orderItemId)
const delegatedCash = await confirmManualFulfillment(
{ orderItemId: delegatedCashItem.orderItemId, costFen: 300, fundAccountId },
financeSession,
)
const delegatedExpense = await query<{ account_id: number }>(
`SELECT account_id FROM manual_expenses WHERE id = $1`,
[delegatedCash.fundExpenseId],
)
assert.equal(
Number(delegatedExpense.rows[0]?.account_id),
fundAccountId,
'必须扣指定客服备用金',
)
const delegatedCreditItem = await createManualItem(query, creditSku)
orderIds.push(delegatedCreditItem.orderId)
orderItemIds.push(delegatedCreditItem.orderItemId)
const delegatedCredit = await confirmManualFulfillment(
{ orderItemId: delegatedCreditItem.orderItemId, creditOwnerAdminUserId: adminId },
financeSession,
)
const delegatedCreditEntry = await query<{ owner_admin_user_id: number }>(
`SELECT pca.owner_admin_user_id
FROM platform_credit_entries pce
JOIN platform_credit_accounts pca ON pca.id = pce.account_id
WHERE pce.id = $1`,
[delegatedCredit.creditEntryId],
)
assert.equal(
Number(delegatedCreditEntry.rows[0]?.owner_admin_user_id),
adminId,
'必须扣指定客服积分',
)
// 6. 快照写入失败必须回滚已扣款:用临时触发器强制 cost_snapshots 插入失败,
// 验证「钱与成本同一事务」不是只写在注释里。
const failSku = `${skuKey}-FAIL`
@@ -320,8 +379,101 @@ test(
[pendingItem.orderItemId],
)
assert.equal(Number(resolvedManualException.rows[0]?.count), 1, '确认后异常必须自动收口')
// 7. 总账:销售事实同步、试算平衡、关账阻断、平台对账匹配
const todayDate = shanghaiDate()
await rebuildSalesDailyFacts({ dateFrom: todayDate, dateTo: todayDate })
const ledgerSync = await syncFinanceLedgerFromFacts({
dateFrom: todayDate,
dateTo: todayDate,
})
assert.ok(ledgerSync.created > 0, '销售事实必须生成总账凭证')
const trial = await getTrialBalance({ dateFrom: todayDate, dateTo: todayDate })
assert.equal(trial.balanced, true, '总账必须借贷平衡')
const voucher = await postManualVoucher(
{
occurredAt: `${todayDate}T12:00:00+08:00`,
memo: '集成测试手工凭证',
lines: [
{ accountCode: '1001', debitFen: 1 },
{ accountCode: '3001', creditFen: 1 },
],
},
session,
)
assert.ok(voucher.entryId > 0, '手工凭证必须成功落账')
await assert.rejects(
() =>
postManualVoucher(
{
occurredAt: `${todayDate}T12:00:00+08:00`,
memo: '不平衡测试',
lines: [
{ accountCode: '1001', debitFen: 2 },
{ accountCode: '3001', creditFen: 1 },
],
},
session,
),
/不平衡/,
)
const periodKey = todayDate.slice(0, 7)
await closeFinancePeriod(periodKey, session)
await assert.rejects(
() =>
postManualVoucher(
{
occurredAt: `${todayDate}T12:00:00+08:00`,
memo: '关账后写入测试',
lines: [
{ accountCode: '1001', debitFen: 1 },
{ accountCode: '3001', creditFen: 1 },
],
},
session,
),
/已关账/,
)
await reopenFinancePeriod(periodKey)
const orderInfo = await query<{ platform_order_id: string; total_amount: number }>(
`SELECT platform_order_id, total_amount FROM orders WHERE id = $1`,
[cashItem.orderId],
)
const platformOrderId = String(orderInfo.rows[0]?.platform_order_id || '')
const recon = await importFinanceReconciliation(
{
provider: '91kaquan',
periodFrom: todayDate,
periodTo: todayDate,
rows: [
{
platformOrderId,
entryType: 'sale',
amountFen: Number(orderInfo.rows[0]?.total_amount || 0),
},
{
platformOrderId: `MISSING-${suffix}`,
entryType: 'sale',
amountFen: 100,
},
],
},
session,
)
assert.equal(recon.rows, 2)
assert.equal(recon.matched, 1)
assert.equal(recon.mismatched, 1)
assert.equal(recon.status, 'differences')
} finally {
// 按依赖顺序清理,避免污染测试库。
await query(`DELETE FROM finance_reconciliations`)
await query(`DELETE FROM finance_journal_lines`)
await query(`DELETE FROM finance_journal_entries`)
await query(`DELETE FROM finance_periods`)
await query(`DELETE FROM sales_daily_facts WHERE stat_date = $1::date`, [shanghaiDate()])
if (triggerName) {
await query(`DROP TRIGGER IF EXISTS ${triggerName} ON cost_snapshots`)
}
@@ -13,6 +13,8 @@ import { createHttpError } from '../../utils/http.js'
import { generateRefundNo } from '../../utils/random.js'
import { nowIso } from '../../utils/time.js'
import { rebuildSalesDailyFacts } from './sales-facts-service.js'
import { approveKuaishouIndustryRefund } from '../platforms/kuaishou-industry/refund-service.js'
import { syncFinanceLedgerFromFacts } from './finance-ledger-service.js'
export type OrderRefundRow = {
id: number
@@ -61,6 +63,10 @@ export type CreateManualRefundInput = {
reason?: string | undefined
note?: string | undefined
refundedAt?: string | undefined
/** 默认 manual;平台接口退款写 platform,平台回调写 api。 */
source?: 'manual' | 'api' | 'platform' | 'legacy'
/** 平台退款单号/外部流水号。 */
externalRef?: string
}
export async function listOrderRefunds(orderId: number | string): Promise<OrderRefund[]> {
@@ -109,6 +115,8 @@ export async function createManualRefund(
const reason = normalizeText(input.reason, 200) || '手动退款'
const note = normalizeText(input.note, 500)
const refundedAt = normalizeTimestamp(input.refundedAt)
const source = normalizeRefundSource(input.source)
const externalRef = normalizeText(input.externalRef, 120)
const now = nowIso()
const written = await withTransaction(async (client) => {
@@ -202,12 +210,12 @@ export async function createManualRefund(
`
INSERT INTO order_refunds (
refund_no, batch_no, order_id, order_item_id, amount_fen,
reason, note, source, status, refunded_at, operator_admin_user_id,
created_at, updated_at
reason, note, source, status, refunded_at, external_ref,
operator_admin_user_id, created_at, updated_at
) VALUES (
$1, $2, $3, $4, $5,
$6, $7, 'manual', 'confirmed', $8::timestamptz, $9,
$10::timestamptz, $10::timestamptz
$6, $7, $8, 'confirmed', $9::timestamptz, $10,
$11, $12::timestamptz, $12::timestamptz
)
RETURNING id
`,
@@ -219,7 +227,9 @@ export async function createManualRefund(
row.amountFen,
reason,
note,
source,
refundedAt,
externalRef,
session?.userId || null,
now,
],
@@ -291,6 +301,7 @@ export async function createManualRefund(
const basisDate = toChinaDate(written.basisAt)
if (basisDate) {
await rebuildSalesDailyFacts({ dateFrom: basisDate, dateTo: basisDate })
await syncFinanceLedgerFromFacts({ dateFrom: basisDate, dateTo: basisDate })
}
const refunds = await listOrderRefunds(orderId)
@@ -358,6 +369,7 @@ export async function voidOrderRefund(
const rebuiltDate = toChinaDate(context.basisAt)
if (rebuiltDate) {
await rebuildSalesDailyFacts({ dateFrom: rebuiltDate, dateTo: rebuiltDate })
await syncFinanceLedgerFromFacts({ dateFrom: rebuiltDate, dateTo: rebuiltDate })
}
const refunds = await listOrderRefunds(context.orderId)
const refund = refunds.find((item) => item.id === id)
@@ -434,3 +446,109 @@ function toChinaDate(value: string): string {
function formatFen(value: number): string {
return `¥${(Number(value || 0) / 100).toFixed(2)}`
}
/**
* 平台退款:先调用平台退款接口,成功后再落本地退款记录(source=platform)。
*
* 当前只接入快手行业(91)的「同意退款」接口;抖音/拼多多暂无可用退款 API,
* 会返回 501,由后台手工登记兜底。
*/
export async function createPlatformRefund(
input: {
orderId: number | string
orderItemId?: number | string | null | undefined
amountFen?: number | string | null | undefined
platformRefundId?: string | undefined
reason?: string | undefined
note?: string | undefined
refundedAt?: string | undefined
sellerId?: string | undefined
refundHandingWay?: unknown
desc?: string | undefined
},
session: AdminSession | null,
) {
const orderId = normalizeId(input.orderId, '订单 ID')
const platformRefundId = normalizeText(input.platformRefundId, 120)
if (!platformRefundId) {
throw createHttpError('缺少平台退款单号', {
statusCode: 400,
errorCode: 'platform_refund_id_required',
})
}
// 平台退款必须在调用平台前完成本地参数校验:平台一旦同意,本地再失败会造成账实不一致。
const orderItemId = Number(input.orderItemId)
if (!Number.isInteger(orderItemId) || orderItemId <= 0) {
throw createHttpError('平台退款必须指定订单项', {
statusCode: 400,
errorCode: 'platform_refund_order_item_required',
})
}
const itemExist = await query<{ id: number }>(
`SELECT id FROM order_items WHERE id = $1 AND order_id = $2 LIMIT 1`,
[orderItemId, orderId],
)
if (!itemExist.rows[0]) {
throw createHttpError('订单项不存在或不属于该订单', {
statusCode: 404,
errorCode: 'platform_refund_order_item_not_found',
})
}
const order = await query<{ provider: string; platform_order_id: string }>(
`SELECT provider, platform_order_id FROM orders WHERE id = $1 LIMIT 1`,
[orderId],
)
const row = order.rows[0]
if (!row) {
throw createHttpError('订单不存在', {
statusCode: 404,
errorCode: 'platform_refund_order_not_found',
})
}
if (!['91kaquan', 'kuaishou-91'].includes(String(row.provider || ''))) {
throw createHttpError('该平台暂未接入退款 API,请使用手工退款登记', {
statusCode: 501,
errorCode: 'platform_refund_not_supported',
})
}
const amountFen = normalizeAmountFen(input.amountFen)
if (amountFen <= 0) {
throw createHttpError('平台退款金额必须大于 0', {
statusCode: 400,
errorCode: 'platform_refund_amount_invalid',
})
}
const approval = await approveKuaishouIndustryRefund({
refundId: platformRefundId,
refundAmount: amountFen,
desc: input.desc || input.note || '',
refundHandingWay: input.refundHandingWay,
...(input.sellerId ? { sellerId: input.sellerId } : {}),
})
const refund = await createManualRefund(
{
orderId,
orderItemId,
amountFen,
reason: input.reason || '平台退款',
note: input.note,
refundedAt: input.refundedAt,
source: 'platform',
externalRef: platformRefundId,
},
session,
)
return { approval, ...refund }
}
function normalizeRefundSource(value: unknown): 'manual' | 'api' | 'platform' | 'legacy' {
const source = String(value || 'manual').trim()
if (source === 'manual' || source === 'api' || source === 'platform' || source === 'legacy') {
return source
}
throw createHttpError('退款来源无效', {
statusCode: 400,
errorCode: 'order_refund_source_invalid',
})
}
@@ -6,6 +6,7 @@ import {
import { normalizeManualProducts } from '../cost-center/manual-product-normalization-service.js'
import { rematchManualFulfillments } from '../cost-center/manual-fulfillment-config-service.js'
import { rebuildSalesDailyFacts } from '../finance/sales-facts-service.js'
import { syncFinanceLedgerFromFacts } from '../finance/finance-ledger-service.js'
import { reconcilePendingFulfillmentModes } from '../fulfillment/fulfillment-mode-service.js'
/**
@@ -47,6 +48,11 @@ export async function runCostFinanceSyncJob(job: JsonObject = {}) {
dateFrom: shiftDays(-(days - 1)),
dateTo: today(),
})
// 影子总账按日同步销售事实(幂等;已关账期间会跳过)。
const ledger = await syncFinanceLedgerFromFacts({
dateFrom: shiftDays(-(days - 1)),
dateTo: today(),
})
return {
status: 'ok',
@@ -56,7 +62,8 @@ export async function runCostFinanceSyncJob(job: JsonObject = {}) {
`人工渠道匹配 ${manualMatches.updatedCount} 项 / ` +
`成本快照 ${estimated.createdCount} 商品预估 / ` +
`${actual.directRewardCount + actual.upgradedCount} 工单实际 / ` +
`${actual.estimatedRewardCount} 工单在途,销售事实 ${facts.insertedCount} 行`,
`${actual.estimatedRewardCount} 工单在途,销售事实 ${facts.insertedCount} 行,` +
`总账凭证 ${ledger.created} 条`,
days,
fulfillmentModeUpdated: fulfillment.updatedCount,
fulfillmentModeRemaining: fulfillment.remainingCount,
@@ -68,6 +75,8 @@ export async function runCostFinanceSyncJob(job: JsonObject = {}) {
estimatedRewardCreated: actual.estimatedRewardCount,
exceptionsCreated: estimated.exceptionCreatedCount,
factsInserted: facts.insertedCount,
ledgerEntriesCreated: ledger.created,
ledgerSkippedClosedPeriods: ledger.skipped,
dateFrom: facts.range.fromDate,
dateTo: facts.range.toDate,
}