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