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,152 @@
-- 112_finance_ledger.sql
-- 财务总账(影子账):科目、凭证、期间关账、平台对账。
--
-- 设计边界:
-- * 影子账不接管现有钱包余额;现有 worker_wallets / fund_accounts 仍是运行时权威。
-- * 凭证按业务/日期幂等(event_type + ref_type + ref_id 唯一),可重复同步。
-- * 销售事实同步按日生成收入/退款/成本凭证;人工确认履约时补记「付款冲应付」凭证。
-- * 期间关账只阻断该期间的新凭证,不修改历史凭证。
--
-- 全部建表/种子幂等。
CREATE TABLE IF NOT EXISTS finance_accounts (
account_code TEXT PRIMARY KEY,
account_name TEXT NOT NULL,
account_type TEXT NOT NULL
CHECK (account_type IN ('asset', 'liability', 'equity', 'revenue', 'expense')),
normal_side TEXT NOT NULL CHECK (normal_side IN ('debit', 'credit')),
parent_code TEXT REFERENCES finance_accounts(account_code) ON DELETE RESTRICT,
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'archived')),
description TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE IF NOT EXISTS finance_journal_entries (
id BIGSERIAL PRIMARY KEY,
entry_no TEXT NOT NULL UNIQUE,
event_type TEXT NOT NULL,
ref_type TEXT NOT NULL,
ref_id TEXT NOT NULL,
occurred_at TIMESTAMPTZ NOT NULL,
period_key TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'posted' CHECK (status IN ('posted', 'reversed')),
memo TEXT NOT NULL DEFAULT '',
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
UNIQUE (event_type, ref_type, ref_id)
);
CREATE INDEX IF NOT EXISTS idx_finance_entries_period
ON finance_journal_entries (period_key, occurred_at DESC);
CREATE INDEX IF NOT EXISTS idx_finance_entries_ref
ON finance_journal_entries (ref_type, ref_id);
CREATE TABLE IF NOT EXISTS finance_journal_lines (
id BIGSERIAL PRIMARY KEY,
entry_id BIGINT NOT NULL REFERENCES finance_journal_entries(id) ON DELETE CASCADE,
line_no INTEGER NOT NULL,
account_code TEXT NOT NULL REFERENCES finance_accounts(account_code) ON DELETE RESTRICT,
debit_fen BIGINT NOT NULL DEFAULT 0,
credit_fen BIGINT NOT NULL DEFAULT 0,
memo TEXT NOT NULL DEFAULT '',
dimensions_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL,
CHECK (debit_fen >= 0 AND credit_fen >= 0),
CHECK ((debit_fen > 0) <> (credit_fen > 0)),
UNIQUE (entry_id, line_no)
);
CREATE INDEX IF NOT EXISTS idx_finance_lines_account
ON finance_journal_lines (account_code);
CREATE TABLE IF NOT EXISTS finance_periods (
period_key TEXT PRIMARY KEY,
starts_at TIMESTAMPTZ NOT NULL,
ends_at TIMESTAMPTZ NOT NULL,
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'closed')),
summary_json JSONB NOT NULL DEFAULT '{}'::jsonb,
closed_at TIMESTAMPTZ,
closed_by_admin_user_id BIGINT REFERENCES admin_users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE IF NOT EXISTS finance_reconciliations (
id BIGSERIAL PRIMARY KEY,
provider TEXT NOT NULL,
period_from DATE NOT NULL,
period_to DATE NOT NULL,
source TEXT NOT NULL DEFAULT 'manual_import',
status TEXT NOT NULL DEFAULT 'imported'
CHECK (status IN ('imported', 'matched', 'differences', 'resolved')),
expected_fen BIGINT NOT NULL DEFAULT 0,
actual_fen BIGINT NOT NULL DEFAULT 0,
diff_fen BIGINT NOT NULL DEFAULT 0,
summary_json JSONB NOT NULL DEFAULT '{}'::jsonb,
imported_by_admin_user_id BIGINT REFERENCES admin_users(id) ON DELETE SET NULL,
resolved_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_finance_reconciliations_period
ON finance_reconciliations (provider, period_from DESC, id DESC);
CREATE TABLE IF NOT EXISTS finance_reconciliation_items (
id BIGSERIAL PRIMARY KEY,
reconciliation_id BIGINT NOT NULL
REFERENCES finance_reconciliations(id) ON DELETE CASCADE,
line_no INTEGER NOT NULL,
provider TEXT NOT NULL,
platform_order_id TEXT NOT NULL DEFAULT '',
external_ref TEXT NOT NULL DEFAULT '',
entry_type TEXT NOT NULL CHECK (entry_type IN ('sale', 'refund')),
amount_fen BIGINT NOT NULL,
occurred_at TIMESTAMPTZ,
matched_order_id BIGINT,
matched_refund_id BIGINT,
expected_fen BIGINT,
diff_fen BIGINT,
match_status TEXT NOT NULL
CHECK (match_status IN (
'matched', 'amount_mismatch', 'missing_order', 'missing_refund', 'duplicate'
)),
detail_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL,
UNIQUE (reconciliation_id, line_no)
);
CREATE INDEX IF NOT EXISTS idx_finance_reconciliation_items_status
ON finance_reconciliation_items (reconciliation_id, match_status);
-- 科目表(可按业务裁剪;借贷方向先定死)
INSERT INTO finance_accounts (
account_code, account_name, account_type, normal_side, description, created_at, updated_at
)
VALUES
('1001', '银行存款', 'asset', 'debit', '公司银行/支付通道资金', NOW(), NOW()),
('1002', '客服备用金', 'asset', 'debit', '已拨付给客服的备用金', NOW(), NOW()),
('1101', '应收账款-平台', 'asset', 'debit', '平台待结算/待收款', NOW(), NOW()),
('1201', '预付账款-客服积分', 'asset', 'debit', '已充值到平台的客服积分', NOW(), NOW()),
('2001', '应付履约款', 'liability', 'credit', '应付打手/人工履约成本', NOW(), NOW()),
('2005', '其他应付-垫付人', 'liability', 'credit', '客服垫付待核销', NOW(), NOW()),
('3001', '投入资金/权益', 'equity', 'credit', '股东投入与期初权益', NOW(), NOW()),
('4001', '商品销售收入', 'revenue', 'credit', '按用户实付确认的收入', NOW(), NOW()),
('4101', '退款冲减', 'revenue', 'debit', '退款冲减收入(借方)', NOW(), NOW()),
('5001', '履约成本', 'expense', 'debit', '打手报酬/人工/自动发货成本', NOW(), NOW()),
('5101', '人工履约成本', 'expense', 'debit', '人工客服实际垫付成本', NOW(), NOW())
ON CONFLICT (account_code) DO UPDATE
SET account_name = EXCLUDED.account_name,
account_type = EXCLUDED.account_type,
normal_side = EXCLUDED.normal_side,
description = EXCLUDED.description,
updated_at = NOW();
COMMENT ON TABLE finance_accounts IS '会计科目表;影子总账使用,不接管业务钱包余额';
COMMENT ON TABLE finance_journal_entries IS '凭证头;event_type + ref_type + ref_id 保证业务幂等';
COMMENT ON TABLE finance_journal_lines IS '凭证行;每行只能借或贷,单张凭证借贷必须平衡';
COMMENT ON TABLE finance_periods IS '会计期间;closed 期间禁止新增凭证';
COMMENT ON TABLE finance_reconciliations IS '平台对账批次:导入平台账单后与订单/退款匹配';
COMMENT ON TABLE finance_reconciliation_items IS '对账明细:matched / amount_mismatch / missing_* / duplicate';
+2
View File
@@ -7,6 +7,7 @@ import dashboardRouter from './admin/dashboard.js'
import devMockRouter from './admin/dev-mock.js'
import douyinMashidaRouter from './admin/douyin-mashida.js'
import financeRouter from './admin/finance.js'
import financeLedgerRouter from './admin/finance-ledger.js'
import fundRouter from './admin/fund.js'
import filesRouter from './admin/files.js'
import kuaishouIndustryRouter from './admin/kuaishou-industry.js'
@@ -28,6 +29,7 @@ router.use(requireAdminSession)
router.use(filesRouter)
router.use(dashboardRouter)
router.use(financeRouter)
router.use(financeLedgerRouter)
router.use(costCenterRouter)
router.use(fundRouter)
router.use(usersRouter)
@@ -0,0 +1,224 @@
/**
* 财务总账 / 期间关账 / 平台对账路由。
*
* 总账是影子账:只读写 finance_*,不接管钱包余额。
*/
import { Router } from 'express'
import {
closeFinancePeriod,
getTrialBalance,
listFinanceAccounts,
listFinancePeriods,
listJournalEntries,
postManualVoucher,
reopenFinancePeriod,
syncFinanceLedgerFromFacts,
} from '../../services/finance/finance-ledger-service.js'
import {
getFinanceReconciliation,
importFinanceReconciliation,
listFinanceReconciliations,
resolveFinanceReconciliation,
} from '../../services/finance/finance-reconciliation-service.js'
import type { JsonObject } from '../../types/json.js'
import { bodyOf, idOf, sessionOf } from './request-helpers.js'
import { createJsonHandler, requireAdminPermission, requireAdminRoles } from './session.js'
const router = Router()
router.use('/finance', requireAdminRoles(['admin', 'operator', 'finance']))
router.get(
'/finance/ledger/accounts',
requireAdminPermission('finance.view'),
createJsonHandler(() => listFinanceAccounts(), {
successMessage: 'ok',
errorMessage: '读取科目表失败',
scope: '[admin/finance/ledger/accounts]',
}),
)
router.get(
'/finance/ledger/trial-balance',
requireAdminPermission('finance.view'),
createJsonHandler(
(req) => {
const query = req.query as Record<string, unknown>
const today = new Date().toISOString().slice(0, 10)
return getTrialBalance({
dateFrom: String(query.dateFrom || query.date_from || `${today.slice(0, 7)}-01`),
dateTo: String(query.dateTo || query.date_to || today),
})
},
{
successMessage: 'ok',
errorMessage: '读取试算平衡失败',
scope: '[admin/finance/ledger/trial-balance]',
},
),
)
router.get(
'/finance/ledger/entries',
requireAdminPermission('finance.view'),
createJsonHandler((req) => listJournalEntries(req.query as JsonObject), {
successMessage: 'ok',
errorMessage: '读取总账凭证失败',
scope: '[admin/finance/ledger/entries]',
}),
)
router.post(
'/finance/ledger/sync',
requireAdminPermission('finance.config'),
createJsonHandler(
(req) => {
const body = bodyOf(req)
const today = new Date().toISOString().slice(0, 10)
return syncFinanceLedgerFromFacts({
dateFrom: String(body.dateFrom || body.date_from || `${today.slice(0, 7)}-01`),
dateTo: String(body.dateTo || body.date_to || today),
})
},
{
successMessage: '总账已同步',
errorMessage: '同步总账失败',
scope: '[admin/finance/ledger/sync]',
audit: (req, data) => ({
action: 'finance_ledger_synced',
targetType: 'finance_ledger',
targetId: 'sales_fact',
data: data && typeof data === 'object' ? (data as JsonObject) : {},
}),
},
),
)
router.post(
'/finance/ledger/manual-voucher',
requireAdminPermission('finance.config'),
createJsonHandler((req) => postManualVoucher(bodyOf(req) as never, sessionOf(req)), {
successMessage: '凭证已创建',
errorMessage: '创建凭证失败',
scope: '[admin/finance/ledger/manual-voucher]',
audit: (req, data) => ({
action: 'finance_manual_voucher_created',
targetType: 'finance_journal_entry',
targetId: String((data as { entryId?: number })?.entryId || ''),
data: data && typeof data === 'object' ? (data as JsonObject) : {},
}),
}),
)
router.get(
'/finance/periods',
requireAdminPermission('finance.view'),
createJsonHandler(() => listFinancePeriods(), {
successMessage: 'ok',
errorMessage: '读取会计期间失败',
scope: '[admin/finance/periods]',
}),
)
router.post(
'/finance/periods/:periodKey/close',
requireAdminPermission('finance.config'),
createJsonHandler(
(req) =>
closeFinancePeriod(
String((req.params as Record<string, string>).periodKey || ''),
sessionOf(req),
),
{
successMessage: '期间已关账',
errorMessage: '关账失败',
scope: '[admin/finance/periods/close]',
audit: (req, data) => ({
action: 'finance_period_closed',
targetType: 'finance_period',
targetId: String((req.params as Record<string, string>).periodKey || ''),
data: data && typeof data === 'object' ? (data as JsonObject) : {},
}),
},
),
)
router.post(
'/finance/periods/:periodKey/reopen',
requireAdminPermission('finance.config'),
createJsonHandler(
(req) => reopenFinancePeriod(String((req.params as Record<string, string>).periodKey || '')),
{
successMessage: '期间已重新打开',
errorMessage: '反关账失败',
scope: '[admin/finance/periods/reopen]',
audit: (req) => ({
action: 'finance_period_reopened',
targetType: 'finance_period',
targetId: String((req.params as Record<string, string>).periodKey || ''),
data: {},
}),
},
),
)
router.get(
'/finance/reconciliations',
requireAdminPermission('finance.view'),
createJsonHandler((req) => listFinanceReconciliations(req.query as JsonObject), {
successMessage: 'ok',
errorMessage: '读取对账批次失败',
scope: '[admin/finance/reconciliations]',
}),
)
router.post(
'/finance/reconciliations/import',
requireAdminPermission('finance.config'),
createJsonHandler((req) => importFinanceReconciliation(bodyOf(req), sessionOf(req)), {
successMessage: '账单已导入',
errorMessage: '导入账单失败',
scope: '[admin/finance/reconciliations/import]',
audit: (req, data) => ({
action: 'finance_reconciliation_imported',
targetType: 'finance_reconciliation',
targetId: String((data as { reconciliationId?: number })?.reconciliationId || ''),
data: data && typeof data === 'object' ? (data as JsonObject) : {},
}),
}),
)
router.get(
'/finance/reconciliations/:id',
requireAdminPermission('finance.view'),
createJsonHandler((req) => getFinanceReconciliation(idOf(req, 'id')), {
successMessage: 'ok',
errorMessage: '读取对账明细失败',
scope: '[admin/finance/reconciliations/detail]',
}),
)
router.post(
'/finance/reconciliations/:id/resolve',
requireAdminPermission('finance.config'),
createJsonHandler(
(req) => {
const body = bodyOf(req)
return resolveFinanceReconciliation(idOf(req, 'id'), body.note, sessionOf(req))
},
{
successMessage: '对账批次已标记解决',
errorMessage: '标记对账解决失败',
scope: '[admin/finance/reconciliations/resolve]',
audit: (req) => ({
action: 'finance_reconciliation_resolved',
targetType: 'finance_reconciliation',
targetId: String(idOf(req, 'id') || ''),
data: {},
}),
},
),
)
export default router
+37
View File
@@ -8,6 +8,7 @@ import {
} from '../../services/admin/write/order-actions.js'
import {
createManualRefund,
createPlatformRefund,
listOrderRefunds,
voidOrderRefund,
} from '../../services/finance/order-refund-service.js'
@@ -103,6 +104,42 @@ router.get(
),
)
router.post(
'/orders/:orderId/refunds/platform',
requireAdminPermission('finance.config'),
createJsonHandler(
(req) => {
const body = req.body as Record<string, unknown>
return createPlatformRefund(
{
orderId: String((req.params as AdminOrderRouteParams).orderId || ''),
orderItemId: body?.orderItemId as string | undefined,
amountFen: body?.amountFen as string | undefined,
platformRefundId: String(body?.platformRefundId || ''),
reason: body?.reason as string | undefined,
note: body?.note as string | undefined,
refundedAt: body?.refundedAt as string | undefined,
sellerId: body?.sellerId as string | undefined,
refundHandingWay: body?.refundHandingWay,
desc: body?.desc as string | undefined,
},
req.adminSession || null,
)
},
{
successMessage: '平台退款已提交并登记',
errorMessage: '平台退款失败',
scope: '[admin/orders/:orderId/refunds/platform]',
audit: (req, data) => ({
action: 'order_platform_refund_created',
targetType: 'order',
targetId: String((req.params as AdminOrderRouteParams).orderId || ''),
data: data && typeof data === 'object' ? (data as Record<string, unknown>) : {},
}),
},
),
)
router.post(
'/orders/:orderId/refunds',
requireAdminPermission('finance.config'),
@@ -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,
}
+2
View File
@@ -21,6 +21,7 @@ import WorkerLayout from '@/layouts/WorkerLayout'
const AdminDashboardPage = lazy(() => import('@/pages/admin/AdminDashboardPage'))
const AdminFinancePage = lazy(() => import('@/pages/admin/AdminFinancePage'))
const AdminFinanceLedgerPage = lazy(() => import('@/pages/admin/AdminFinanceLedgerPage'))
const AdminSalesProfitPage = lazy(() => import('@/pages/admin/AdminSalesProfitPage'))
const AdminCostCenterPage = lazy(() => import('@/pages/admin/AdminCostCenterPage'))
const AdminFundPage = lazy(() => import('@/pages/admin/AdminFundPage'))
@@ -147,6 +148,7 @@ function AdminApplication() {
</Route>
<Route element={<RequireRole roles={['admin', 'operator', 'finance']} />}>
<Route path="finance" element={<AdminFinancePage />} />
<Route path="finance/ledger" element={<AdminFinanceLedgerPage />} />
<Route path="sales-profit" element={<AdminSalesProfitPage />} />
<Route path="cost-center" element={<AdminCostCenterPage />} />
</Route>
@@ -1,6 +1,7 @@
import {
AccountBookOutlined,
ApartmentOutlined,
AuditOutlined,
LineChartOutlined,
WalletOutlined,
} from '@ant-design/icons'
@@ -30,6 +31,11 @@ export const FINANCE_NAV_ITEMS = [
label: '收支明细',
icon: <AccountBookOutlined />,
},
{
key: '/admin/finance/ledger',
label: '总账与对账',
icon: <AuditOutlined />,
},
] as const
type FinanceNavKey = (typeof FINANCE_NAV_ITEMS)[number]['key']
@@ -26,8 +26,14 @@ import {
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { confirmAdminManualFulfillment, fetchAdminManualFulfillmentPending } from '@/services/admin'
import {
confirmAdminManualFulfillment,
fetchAdminFundAccounts,
fetchAdminManualFulfillmentPending,
fetchAdminPlatformCreditOwners,
} from '@/services/admin'
import type { ManualFulfillmentPendingItem } from '@/types/admin'
import { getAdminRole, getAdminUserId } from '@/utils/admin-auth'
type ConfirmFormValues = {
costYuan?: number
@@ -61,6 +67,11 @@ export function ManualFulfillmentConfirmModal({
const queryClient = useQueryClient()
const [form] = Form.useForm<ConfirmFormValues>()
const [selectedId, setSelectedId] = useState<number | null>(null)
const [fundAccountId, setFundAccountId] = useState<number | null>(null)
const [creditOwnerAdminUserId, setCreditOwnerAdminUserId] = useState<number | null>(null)
const role = getAdminRole()
const privileged = role !== 'support'
const currentUserId = getAdminUserId()
const pendingQuery = useQuery({
queryKey: ['admin-manual-fulfillment-pending', 'order', orderId],
@@ -81,12 +92,49 @@ export function ManualFulfillmentConfirmModal({
items[0] ||
null
const fundAccountsQuery = useQuery({
queryKey: ['admin-manual-confirm-fund-accounts'],
queryFn: () => fetchAdminFundAccounts({ page: 1, pageSize: 100 }),
enabled: open && privileged && Boolean(selected?.isFundCash),
retry: false,
})
const creditOwnersQuery = useQuery({
queryKey: ['admin-manual-confirm-credit-owners'],
queryFn: () => fetchAdminPlatformCreditOwners(),
enabled: open && privileged && Boolean(selected) && !selected?.isFundCash,
retry: false,
})
useEffect(() => {
if (!open) return
setSelectedId(initialOrderItemId || null)
form.resetFields()
}, [open, initialOrderItemId, form])
// 代录时默认选本人账户;客服没有代录权限,不展示选择。
useEffect(() => {
if (!open || !privileged) return
if (selected?.isFundCash) {
const accounts = (fundAccountsQuery.data?.data.items || []).filter(
(item) => item.accountType === 'agent' && item.status === 'active',
)
const own = accounts.find((item) => item.ownerAdminUserId === currentUserId)
setFundAccountId(own?.id ?? null)
return
}
const owners = creditOwnersQuery.data?.data.items || []
const own = owners.find((item) => item.id === currentUserId)
setCreditOwnerAdminUserId(own?.id ?? null)
}, [
open,
privileged,
selected?.orderItemId,
selected?.isFundCash,
currentUserId,
fundAccountsQuery.data,
creditOwnersQuery.data,
])
const confirmMutation = useMutation({
mutationFn: (values: ConfirmFormValues) =>
confirmAdminManualFulfillment({
@@ -94,6 +142,10 @@ export function ManualFulfillmentConfirmModal({
costFen: values.costYuan === undefined ? undefined : Math.round(values.costYuan * 100),
payeeName: values.payeeName || '',
note: values.note || '',
...(privileged && selected?.isFundCash && fundAccountId ? { fundAccountId } : {}),
...(privileged && selected && !selected.isFundCash && creditOwnerAdminUserId
? { creditOwnerAdminUserId }
: {}),
}),
onSuccess: (response) => {
const result = response.data
@@ -122,7 +174,14 @@ export function ManualFulfillmentConfirmModal({
open={open}
title="确认人工履约"
okText="确认履约"
okButtonProps={{ icon: <CheckOutlined />, disabled: !selected }}
okButtonProps={{
icon: <CheckOutlined />,
disabled:
!selected ||
(privileged &&
((selected.isFundCash && !fundAccountId) ||
(!selected.isFundCash && !creditOwnerAdminUserId))),
}}
cancelText="取消"
confirmLoading={confirmMutation.isPending}
onOk={() => {
@@ -181,6 +240,46 @@ export function ManualFulfillmentConfirmModal({
<Typography.Text>¥{((selected?.revenueFen || 0) / 100).toFixed(2)}</Typography.Text>
</div>
{privileged && selected ? (
selected.isFundCash ? (
<div>
<Typography.Text type="secondary">代录到客服备用金账户:</Typography.Text>
<Select
showSearch
optionFilterProp="label"
value={fundAccountId ?? undefined}
placeholder="请选择客服备用金账户"
style={{ width: '100%', marginTop: 6 }}
loading={fundAccountsQuery.isLoading}
onChange={(value) => setFundAccountId(value)}
options={(fundAccountsQuery.data?.data.items || [])
.filter((item) => item.accountType === 'agent' && item.status === 'active')
.map((item) => ({
value: item.id,
label: `${item.ownerName || item.accountNo} · 余额 ¥${(item.balanceFen / 100).toFixed(2)}`,
}))}
/>
</div>
) : (
<div>
<Typography.Text type="secondary">代录到客服积分账户:</Typography.Text>
<Select
showSearch
optionFilterProp="label"
value={creditOwnerAdminUserId ?? undefined}
placeholder="请选择客服账号"
style={{ width: '100%', marginTop: 6 }}
loading={creditOwnersQuery.isLoading}
onChange={(value) => setCreditOwnerAdminUserId(value)}
options={(creditOwnersQuery.data?.data.items || []).map((item) => ({
value: item.id,
label: item.displayName || item.username,
}))}
/>
</div>
)
) : null}
{showCostInput ? (
<Alert type="warning" showIcon message="该商品成本不固定,请填写本次实际成本" />
) : (
@@ -184,6 +184,7 @@ export default function AdminLayout() {
'/admin/cost-center',
'/admin/fund',
'/admin/finance',
'/admin/finance/ledger',
].includes(selectedKey)
const [openKeys, setOpenKeys] = useState<string[]>(['sub-finance'])
@@ -199,6 +200,7 @@ export default function AdminLayout() {
{ key: '/admin/cost-center', icon: <ApartmentOutlined />, label: '商品与成本' },
{ key: '/admin/fund', icon: <WalletOutlined />, label: '钱包管理' },
{ key: '/admin/finance', icon: <AccountBookOutlined />, label: '收支明细' },
{ key: '/admin/finance/ledger', icon: <AuditOutlined />, label: '总账与对账' },
]
const financeSubMenu = {
@@ -844,6 +846,7 @@ function resolveSelectedKey(pathname: string) {
if (pathname.startsWith('/admin/sales-profit')) return '/admin/sales-profit'
if (pathname.startsWith('/admin/cost-center')) return '/admin/cost-center'
if (pathname.startsWith('/admin/fund')) return '/admin/fund'
if (pathname.startsWith('/admin/finance/ledger')) return '/admin/finance/ledger'
if (pathname.startsWith('/admin/finance')) return '/admin/finance'
if (pathname.startsWith('/admin/tasks')) return '/admin/tasks'
if (pathname.startsWith('/admin/worker-platform')) return '/admin/worker-platform'
@@ -0,0 +1,687 @@
import { AuditOutlined, PlusOutlined, ReloadOutlined, SyncOutlined } from '@ant-design/icons'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
App,
Button,
Card,
Col,
DatePicker,
Drawer,
Form,
Input,
InputNumber,
Modal,
Row,
Select,
Space,
Statistic,
Tabs,
Tag,
Typography,
} from 'antd'
import type { TableColumnsType } from 'antd'
import { useState } from 'react'
import type { Dayjs } from 'dayjs'
import { AdminRangePicker } from '@/components/admin/AdminDatePicker'
import FinanceSubNav from '@/components/admin/FinanceSubNav'
import PageHeader from '@/components/admin/PageHeader'
import PercentTable from '@/components/common/PercentTable'
import dayjs from '@/lib/dayjs'
import {
closeFinancePeriod,
createFinanceManualVoucher,
fetchFinanceAccounts,
fetchFinanceJournalEntries,
fetchFinancePeriods,
fetchFinanceReconciliation,
fetchFinanceReconciliations,
fetchFinanceTrialBalance,
importFinanceReconciliation,
reopenFinancePeriod,
resolveFinanceReconciliation,
syncFinanceLedger,
} from '@/services/admin'
import type {
FinanceAccount,
FinanceJournalEntry,
FinancePeriod,
FinanceReconciliation,
FinanceReconciliationItem,
FinanceTrialBalanceItem,
} from '@/types/admin/finance-ledger'
import { hasAdminPermission } from '@/utils/admin-auth'
import { formatAdminDateTime } from '@/utils/admin-time'
import { formatMoney } from './panels/shared'
const PROVIDER_OPTIONS = [
{ value: '91kaquan', label: '91 卡券 / 快手' },
{ value: 'pdd', label: '拼多多' },
{ value: 'douyin_mashida', label: '抖音码速达' },
]
const RECON_STATUS_META: Record<string, { label: string; color: string }> = {
imported: { label: '待匹配', color: 'processing' },
matched: { label: '全部匹配', color: 'green' },
differences: { label: '有差异', color: 'orange' },
resolved: { label: '已解决', color: 'default' },
}
const MATCH_STATUS_META: Record<string, { label: string; color: string }> = {
matched: { label: '匹配', color: 'green' },
amount_mismatch: { label: '金额不一致', color: 'orange' },
missing_order: { label: '系统无订单', color: 'red' },
missing_refund: { label: '系统无退款', color: 'red' },
duplicate: { label: '重复行', color: 'default' },
}
export default function AdminFinanceLedgerPage() {
const { message } = App.useApp()
const queryClient = useQueryClient()
const canConfig = hasAdminPermission('finance.config')
const [range, setRange] = useState<[Dayjs, Dayjs]>([dayjs().startOf('month'), dayjs()])
const [entryPage, setEntryPage] = useState(1)
const [entryPageSize, setEntryPageSize] = useState(20)
const [entryDetail, setEntryDetail] = useState<FinanceJournalEntry | null>(null)
const [voucherOpen, setVoucherOpen] = useState(false)
const [reconImportOpen, setReconImportOpen] = useState(false)
const [reconDetailId, setReconDetailId] = useState<number | null>(null)
const [reconPage, setReconPage] = useState(1)
const [reconPageSize, setReconPageSize] = useState(20)
const [voucherForm] = Form.useForm()
const [reconForm] = Form.useForm()
const dateFrom = range[0].format('YYYY-MM-DD')
const dateTo = range[1].format('YYYY-MM-DD')
const accountsQuery = useQuery({
queryKey: ['admin-finance-ledger-accounts'],
queryFn: () => fetchFinanceAccounts(),
})
const trialQuery = useQuery({
queryKey: ['admin-finance-ledger-trial', dateFrom, dateTo],
queryFn: () => fetchFinanceTrialBalance({ dateFrom, dateTo }),
})
const entriesQuery = useQuery({
queryKey: ['admin-finance-ledger-entries', dateFrom, dateTo, entryPage, entryPageSize],
queryFn: () =>
fetchFinanceJournalEntries({
dateFrom,
dateTo,
page: entryPage,
pageSize: entryPageSize,
}),
})
const periodsQuery = useQuery({
queryKey: ['admin-finance-periods'],
queryFn: () => fetchFinancePeriods(),
})
const reconciliationsQuery = useQuery({
queryKey: ['admin-finance-reconciliations', reconPage, reconPageSize],
queryFn: () => fetchFinanceReconciliations({ page: reconPage, pageSize: reconPageSize }),
})
const reconDetailQuery = useQuery({
queryKey: ['admin-finance-reconciliation-detail', reconDetailId],
queryFn: () => fetchFinanceReconciliation(Number(reconDetailId)),
enabled: Boolean(reconDetailId),
})
const syncMutation = useMutation({
mutationFn: () => syncFinanceLedger({ dateFrom, dateTo }),
onSuccess: (response) => {
message.success(`总账同步完成:新增 ${response.data.created} 条凭证`)
void queryClient.invalidateQueries({ queryKey: ['admin-finance-ledger'] })
void queryClient.invalidateQueries({ queryKey: ['admin-finance-periods'] })
},
onError: (error) => message.error(error instanceof Error ? error.message : '同步总账失败'),
})
const voucherMutation = useMutation({
mutationFn: () => {
const values = voucherForm.getFieldsValue() as {
debitAccount: string
creditAccount: string
amountYuan: number
occurredAt?: Dayjs
memo?: string
}
const amountFen = Math.round(Number(values.amountYuan || 0) * 100)
return createFinanceManualVoucher({
occurredAt: values.occurredAt?.toISOString(),
memo: values.memo || '',
lines: [
{ accountCode: values.debitAccount, debitFen: amountFen, memo: values.memo || '' },
{ accountCode: values.creditAccount, creditFen: amountFen, memo: values.memo || '' },
],
})
},
onSuccess: () => {
message.success('手工凭证已创建')
setVoucherOpen(false)
voucherForm.resetFields()
void queryClient.invalidateQueries({ queryKey: ['admin-finance-ledger'] })
},
onError: (error) => message.error(error instanceof Error ? error.message : '创建凭证失败'),
})
const closeMutation = useMutation({
mutationFn: (periodKey: string) => closeFinancePeriod(periodKey),
onSuccess: () => {
message.success('期间已关账')
void queryClient.invalidateQueries({ queryKey: ['admin-finance-periods'] })
},
onError: (error) => message.error(error instanceof Error ? error.message : '关账失败'),
})
const reopenMutation = useMutation({
mutationFn: (periodKey: string) => reopenFinancePeriod(periodKey),
onSuccess: () => {
message.success('期间已重新打开')
void queryClient.invalidateQueries({ queryKey: ['admin-finance-periods'] })
},
onError: (error) => message.error(error instanceof Error ? error.message : '反关账失败'),
})
const importReconMutation = useMutation({
mutationFn: () => {
const values = reconForm.getFieldsValue() as {
provider: string
periodRange?: [Dayjs, Dayjs]
rowsText: string
}
const parsed = JSON.parse(values.rowsText || '[]')
if (!Array.isArray(parsed) || parsed.length === 0) {
throw new Error('请粘贴至少一行 JSON 数组')
}
const periodRange = values.periodRange || range
return importFinanceReconciliation({
provider: values.provider,
periodFrom: periodRange[0].format('YYYY-MM-DD'),
periodTo: periodRange[1].format('YYYY-MM-DD'),
rows: parsed,
})
},
onSuccess: (response) => {
message.success(
`导入完成:${response.data.rows} 行,匹配 ${response.data.matched},差异 ${response.data.mismatched}`,
)
setReconImportOpen(false)
reconForm.resetFields()
void queryClient.invalidateQueries({ queryKey: ['admin-finance-reconciliations'] })
},
onError: (error) => message.error(error instanceof Error ? error.message : '导入账单失败'),
})
const resolveReconMutation = useMutation({
mutationFn: (id: number) => resolveFinanceReconciliation(id, '后台确认差异已处理'),
onSuccess: () => {
message.success('已标记解决')
void queryClient.invalidateQueries({ queryKey: ['admin-finance-reconciliations'] })
void queryClient.invalidateQueries({ queryKey: ['admin-finance-reconciliation-detail'] })
},
onError: (error) => message.error(error instanceof Error ? error.message : '标记失败'),
})
const accountOptions = (accountsQuery.data?.data.items || []).map((item: FinanceAccount) => ({
value: item.accountCode,
label: `${item.accountCode} ${item.accountName}`,
}))
const trialColumns: TableColumnsType<FinanceTrialBalanceItem> = [
{ title: '科目', dataIndex: 'accountCode', width: 90 },
{ title: '名称', dataIndex: 'accountName', width: 160 },
{ title: '类型', dataIndex: 'accountType', width: 90 },
{
title: '借方',
dataIndex: 'debitFen',
align: 'right',
width: 120,
render: (value: number) => formatMoney(value),
},
{
title: '贷方',
dataIndex: 'creditFen',
align: 'right',
width: 120,
render: (value: number) => formatMoney(value),
},
{
title: '余额',
dataIndex: 'balanceFen',
align: 'right',
width: 120,
render: (value: number) => formatMoney(value),
},
]
const entryColumns: TableColumnsType<FinanceJournalEntry> = [
{ title: '凭证号', dataIndex: 'entryNo', width: 200 },
{
title: '日期',
dataIndex: 'occurredAt',
width: 160,
render: (value: string) => formatAdminDateTime(value),
},
{ title: '事件', dataIndex: 'eventType', width: 140 },
{ title: '摘要', dataIndex: 'memo' },
{
title: '借方合计',
dataIndex: 'debitTotalFen',
align: 'right',
width: 120,
render: (value: number) => formatMoney(value),
},
]
const periodColumns: TableColumnsType<FinancePeriod> = [
{ title: '期间', dataIndex: 'periodKey', width: 100 },
{
title: '状态',
dataIndex: 'status',
width: 90,
render: (value: string) => (
<Tag color={value === 'closed' ? 'default' : 'processing'}>
{value === 'closed' ? '已关账' : '开放'}
</Tag>
),
},
{
title: '关账时间',
dataIndex: 'closedAt',
width: 170,
render: (value: string | null) => (value ? formatAdminDateTime(value) : '-'),
},
{
title: '操作',
width: 150,
render: (_, row) =>
canConfig ? (
row.status === 'closed' ? (
<Button size="small" onClick={() => reopenMutation.mutate(row.periodKey)}>
反关账
</Button>
) : (
<Button size="small" type="primary" onClick={() => closeMutation.mutate(row.periodKey)}>
关账
</Button>
)
) : null,
},
]
const reconColumns: TableColumnsType<FinanceReconciliation> = [
{ title: '批次', dataIndex: 'id', width: 80 },
{ title: '平台', dataIndex: 'provider', width: 120 },
{
title: '期间',
width: 190,
render: (_, row) => `${row.periodFrom} ~ ${row.periodTo}`,
},
{
title: '状态',
dataIndex: 'status',
width: 110,
render: (value: string) => {
const meta = RECON_STATUS_META[value] || { label: value, color: 'default' }
return <Tag color={meta.color}>{meta.label}</Tag>
},
},
{
title: '系统金额',
dataIndex: 'expectedFen',
align: 'right',
width: 120,
render: (value: number) => formatMoney(value),
},
{
title: '账单金额',
dataIndex: 'actualFen',
align: 'right',
width: 120,
render: (value: number) => formatMoney(value),
},
{
title: '差异',
dataIndex: 'diffFen',
align: 'right',
width: 120,
render: (value: number) => (
<Typography.Text type={value === 0 ? 'secondary' : 'danger'}>
{formatMoney(value)}
</Typography.Text>
),
},
{
title: '操作',
width: 140,
render: (_, row) => (
<Space>
<Button size="small" onClick={() => setReconDetailId(row.id)}>
明细
</Button>
{canConfig && row.status !== 'resolved' ? (
<Button size="small" onClick={() => resolveReconMutation.mutate(row.id)}>
标记解决
</Button>
) : null}
</Space>
),
},
]
const reconItemColumns: TableColumnsType<FinanceReconciliationItem> = [
{ title: '行', dataIndex: 'lineNo', width: 60 },
{ title: '平台单号', dataIndex: 'platformOrderId', width: 190 },
{ title: '类型', dataIndex: 'entryType', width: 80 },
{
title: '账单金额',
dataIndex: 'amountFen',
align: 'right',
width: 110,
render: (value: number) => formatMoney(value),
},
{
title: '系统金额',
dataIndex: 'expectedFen',
align: 'right',
width: 110,
render: (value: number | null) => (value === null ? '-' : formatMoney(value)),
},
{
title: '结果',
dataIndex: 'matchStatus',
width: 120,
render: (value: string) => {
const meta = MATCH_STATUS_META[value] || { label: value, color: 'default' }
return <Tag color={meta.color}>{meta.label}</Tag>
},
},
]
return (
<>
<PageHeader title="总账与对账" description="影子总账、期间关账与平台账单对账" />
<FinanceSubNav current="/admin/finance/ledger" />
<Card bordered={false}>
<Space wrap style={{ marginBottom: 16 }}>
<AdminRangePicker
value={range}
onChange={(value) => {
if (value?.[0] && value?.[1]) setRange([value[0], value[1]])
}}
/>
{canConfig ? (
<Button
type="primary"
icon={<SyncOutlined />}
loading={syncMutation.isPending}
onClick={() => syncMutation.mutate()}
>
同步销售事实到总账
</Button>
) : null}
<Button
icon={<ReloadOutlined />}
onClick={() => {
void trialQuery.refetch()
void entriesQuery.refetch()
}}
>
刷新
</Button>
</Space>
<Tabs
items={[
{
key: 'ledger',
label: '总账',
children: (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Row gutter={16}>
<Col xs={24} md={8}>
<Statistic
title="借方合计"
value={formatMoney(trialQuery.data?.data.totals.debitFen || 0)}
/>
</Col>
<Col xs={24} md={8}>
<Statistic
title="贷方合计"
value={formatMoney(trialQuery.data?.data.totals.creditFen || 0)}
/>
</Col>
<Col xs={24} md={8}>
<Statistic
title="借贷平衡"
value={trialQuery.data?.data.balanced ? '是' : '否'}
valueStyle={{
color: trialQuery.data?.data.balanced ? '#3f8600' : '#cf1322',
}}
/>
</Col>
</Row>
<Card size="small" title="试算平衡">
<PercentTable
size="small"
rowKey="accountCode"
loading={trialQuery.isLoading}
dataSource={trialQuery.data?.data.items || []}
columns={trialColumns}
pagination={false}
/>
</Card>
<Card
size="small"
title="凭证"
extra={
canConfig ? (
<Button
size="small"
icon={<PlusOutlined />}
onClick={() => setVoucherOpen(true)}
>
手工凭证
</Button>
) : null
}
>
<PercentTable
size="small"
rowKey="id"
loading={entriesQuery.isLoading}
dataSource={entriesQuery.data?.data.items || []}
columns={entryColumns}
onRow={(row) => ({ onClick: () => setEntryDetail(row) })}
pagination={{
current: entryPage,
pageSize: entryPageSize,
total: entriesQuery.data?.data.pagination.total || 0,
showSizeChanger: true,
onChange: (page, size) => {
setEntryPage(page)
setEntryPageSize(size)
},
}}
/>
</Card>
</Space>
),
},
{
key: 'periods',
label: '期间关账',
children: (
<Card size="small" title="会计期间">
<PercentTable
size="small"
rowKey="periodKey"
loading={periodsQuery.isLoading}
dataSource={periodsQuery.data?.data.items || []}
columns={periodColumns}
pagination={false}
/>
</Card>
),
},
{
key: 'reconciliations',
label: '平台对账',
children: (
<Card
size="small"
title="对账批次"
extra={
canConfig ? (
<Button
size="small"
type="primary"
icon={<AuditOutlined />}
onClick={() => setReconImportOpen(true)}
>
导入账单
</Button>
) : null
}
>
<PercentTable
size="small"
rowKey="id"
loading={reconciliationsQuery.isLoading}
dataSource={reconciliationsQuery.data?.data.items || []}
columns={reconColumns}
pagination={{
current: reconPage,
pageSize: reconPageSize,
total: reconciliationsQuery.data?.data.pagination.total || 0,
showSizeChanger: true,
onChange: (page, size) => {
setReconPage(page)
setReconPageSize(size)
},
}}
/>
</Card>
),
},
]}
/>
</Card>
<Drawer
open={Boolean(entryDetail)}
title={entryDetail?.entryNo || '凭证明细'}
width={640}
onClose={() => setEntryDetail(null)}
>
{entryDetail ? (
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Typography.Text type="secondary">{entryDetail.memo}</Typography.Text>
<PercentTable
size="small"
rowKey="lineNo"
pagination={false}
dataSource={entryDetail.lines}
columns={[
{ title: '科目', dataIndex: 'accountName' },
{
title: '借方',
dataIndex: 'debitFen',
align: 'right',
render: (value: number) => (value ? formatMoney(value) : '-'),
},
{
title: '贷方',
dataIndex: 'creditFen',
align: 'right',
render: (value: number) => (value ? formatMoney(value) : '-'),
},
]}
/>
</Space>
) : null}
</Drawer>
<Modal
open={voucherOpen}
title="新增手工凭证"
okText="创建"
confirmLoading={voucherMutation.isPending}
onOk={() => voucherMutation.mutate()}
onCancel={() => setVoucherOpen(false)}
>
<Form form={voucherForm} layout="vertical">
<Form.Item name="debitAccount" label="借方科目" rules={[{ required: true }]}>
<Select options={accountOptions} showSearch optionFilterProp="label" />
</Form.Item>
<Form.Item name="creditAccount" label="贷方科目" rules={[{ required: true }]}>
<Select options={accountOptions} showSearch optionFilterProp="label" />
</Form.Item>
<Form.Item name="amountYuan" label="金额(元)" rules={[{ required: true }]}>
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="occurredAt" label="发生时间">
<DatePicker showTime style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="memo" label="摘要">
<Input maxLength={200} />
</Form.Item>
</Form>
</Modal>
<Modal
open={reconImportOpen}
title="导入平台账单"
width={720}
okText="导入并匹配"
confirmLoading={importReconMutation.isPending}
onOk={() => importReconMutation.mutate()}
onCancel={() => setReconImportOpen(false)}
>
<Form form={reconForm} layout="vertical" initialValues={{ provider: '91kaquan' }}>
<Form.Item name="provider" label="平台" rules={[{ required: true }]}>
<Select options={PROVIDER_OPTIONS} />
</Form.Item>
<Form.Item name="periodRange" label="账单期间">
<AdminRangePicker />
</Form.Item>
<Form.Item
name="rowsText"
label="账单明细 JSON"
rules={[{ required: true }]}
extra='示例:[{"platformOrderId":"...","entryType":"sale","amountFen":100}];refund 行可带 externalRef。'
>
<Input.TextArea
rows={8}
placeholder='[{"platformOrderId":"123","entryType":"sale","amountFen":100}]'
/>
</Form.Item>
</Form>
</Modal>
<Drawer
open={Boolean(reconDetailId)}
title={`对账批次 #${reconDetailId || ''}`}
width={860}
onClose={() => setReconDetailId(null)}
>
{reconDetailQuery.data ? (
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Typography.Text type="secondary">
{reconDetailQuery.data.data.provider} · {reconDetailQuery.data.data.periodFrom} ~{' '}
{reconDetailQuery.data.data.periodTo} · 差异{' '}
{formatMoney(reconDetailQuery.data.data.diffFen)}
</Typography.Text>
<PercentTable
size="small"
rowKey="id"
pagination={false}
dataSource={reconDetailQuery.data.data.items}
columns={reconItemColumns}
/>
</Space>
) : null}
</Drawer>
</>
)
}
@@ -458,6 +458,10 @@ export function confirmAdminManualFulfillment(payload: {
costFen?: number
payeeName?: string
note?: string
/** 管理员/财务代录:指定客服备用金账户 */
fundAccountId?: number
/** 管理员/财务代录:指定客服积分账户归属人 */
creditOwnerAdminUserId?: number
}) {
return apiPost<ManualFulfillmentConfirmResult>(
'/api/v1/admin/finance/manual-fulfillment/confirm',
@@ -0,0 +1,115 @@
import { apiGet, apiPost } from '@/lib/http'
import type {
FinanceAccount,
FinanceJournalEntry,
FinancePeriod,
FinanceReconciliation,
FinanceReconciliationItem,
FinanceTrialBalance,
} from '@/types/admin/finance-ledger'
export function fetchFinanceAccounts() {
return apiGet<{ items: FinanceAccount[] }>('/api/v1/admin/finance/ledger/accounts')
}
export function fetchFinanceTrialBalance(params: { dateFrom: string; dateTo: string }) {
return apiGet<FinanceTrialBalance>('/api/v1/admin/finance/ledger/trial-balance', params)
}
export function fetchFinanceJournalEntries(params: {
dateFrom?: string
dateTo?: string
page?: number
pageSize?: number
}) {
return apiGet<{
items: FinanceJournalEntry[]
pagination: { page: number; pageSize: number; total: number }
}>('/api/v1/admin/finance/ledger/entries', params)
}
export function syncFinanceLedger(payload: { dateFrom?: string; dateTo?: string }) {
return apiPost<{ created: number; skipped: number }>('/api/v1/admin/finance/ledger/sync', payload)
}
export function createFinanceManualVoucher(payload: {
occurredAt?: string
memo?: string
reference?: string
lines: Array<{
accountCode: string
debitFen?: number
creditFen?: number
memo?: string
}>
}) {
return apiPost<{ entryId: number; entryNo: string }>(
'/api/v1/admin/finance/ledger/manual-voucher',
payload,
)
}
export function fetchFinancePeriods() {
return apiGet<{ items: FinancePeriod[] }>('/api/v1/admin/finance/periods')
}
export function closeFinancePeriod(periodKey: string) {
return apiPost<{ periodKey: string }>(
`/api/v1/admin/finance/periods/${encodeURIComponent(periodKey)}/close`,
{},
)
}
export function reopenFinancePeriod(periodKey: string) {
return apiPost<FinancePeriod>(
`/api/v1/admin/finance/periods/${encodeURIComponent(periodKey)}/reopen`,
{},
)
}
export function fetchFinanceReconciliations(params?: {
page?: number
pageSize?: number
provider?: string
}) {
return apiGet<{
items: FinanceReconciliation[]
pagination: { page: number; pageSize: number; total: number }
}>('/api/v1/admin/finance/reconciliations', params)
}
export function fetchFinanceReconciliation(id: number) {
return apiGet<FinanceReconciliation & { items: FinanceReconciliationItem[] }>(
`/api/v1/admin/finance/reconciliations/${id}`,
)
}
export function importFinanceReconciliation(payload: {
provider: string
periodFrom: string
periodTo: string
rows: Array<{
platformOrderId: string
entryType: 'sale' | 'refund'
amountFen: number
externalRef?: string
occurredAt?: string
}>
}) {
return apiPost<{
reconciliationId: number
status: string
rows: number
matched: number
mismatched: number
expectedFen: number
actualFen: number
diffFen: number
}>('/api/v1/admin/finance/reconciliations/import', payload)
}
export function resolveFinanceReconciliation(id: number, note: string) {
return apiPost<FinanceReconciliation>(`/api/v1/admin/finance/reconciliations/${id}/resolve`, {
note,
})
}
@@ -1,6 +1,7 @@
export * from './auth'
export * from './dashboard'
export * from './finance'
export * from './finance-ledger'
export * from './cost-center'
export * from './fund'
export * from './users'
@@ -0,0 +1,91 @@
export type FinanceAccount = {
accountCode: string
accountName: string
accountType: 'asset' | 'liability' | 'equity' | 'revenue' | 'expense'
normalSide: 'debit' | 'credit'
description: string
}
export type FinanceTrialBalanceItem = {
accountCode: string
accountName: string
accountType: string
normalSide: string
debitFen: number
creditFen: number
balanceFen: number
}
export type FinanceTrialBalance = {
range: { dateFrom: string; dateTo: string }
items: FinanceTrialBalanceItem[]
totals: { debitFen: number; creditFen: number; balanceFen: number }
balanced: boolean
}
export type FinanceJournalLine = {
lineNo: number
accountCode: string
accountName: string
debitFen: number
creditFen: number
memo: string
}
export type FinanceJournalEntry = {
id: number
entryNo: string
eventType: string
refType: string
refId: string
occurredAt: string
periodKey: string
status: string
memo: string
metadata: Record<string, unknown>
debitTotalFen: number
lines: FinanceJournalLine[]
}
export type FinancePeriod = {
periodKey: string
startsAt: string
endsAt: string
status: 'open' | 'closed'
summary: Record<string, unknown>
closedAt: string | null
closedByAdminUserId: number | null
}
export type FinanceReconciliation = {
id: number
provider: string
periodFrom: string
periodTo: string
source: string
status: 'imported' | 'matched' | 'differences' | 'resolved'
expectedFen: number
actualFen: number
diffFen: number
summary: Record<string, unknown>
importedByAdminUserId: number | null
importedByName: string
resolvedAt: string | null
createdAt: string
}
export type FinanceReconciliationItem = {
id: number
lineNo: number
platformOrderId: string
externalRef: string
entryType: 'sale' | 'refund'
amountFen: number
expectedFen: number | null
diffFen: number | null
occurredAt: string | null
matchedOrderId: number | null
matchedRefundId: number | null
matchStatus: 'matched' | 'amount_mismatch' | 'missing_order' | 'missing_refund' | 'duplicate'
detail: Record<string, unknown>
}
+12 -11
View File
@@ -1,6 +1,6 @@
# order_site 文档索引
> 最后校准:2026-10-26,代码基线 `dev`(合并 `dev1023` + 人工成本完整性修复,迁移到 `111`;**未推送、未部署生产**)。
> 最后校准:2026-10-26,代码基线 `dev`(合并 `dev1023` + 人工成本完整性修复,迁移到 `112`;**未推送、未部署生产**)。
> 数据基线:生产 dump `backups/postgres/order_site-20261022-092601.dump`(数据 2026-07-10 ~ 2026-10-22);本地生产副本已升级到 111,只跑了近 30 天增量。历史全量数字仅作基线,**上线验收以 [财务上线初始化与验收-2026-10-26.md](财务上线初始化与验收-2026-10-26.md) 为准**。
> 本文是 `docs/` 的唯一入口。**财务与履约口径以 [财务与履约口径总纲-2026-10-23.md](财务与履约口径总纲-2026-10-23.md) 为准**;数据模型看 [成本中心与履约模型.md](成本中心与履约模型.md),接口看 [成本中心API契约.md](成本中心API契约.md)。
> 新增文档必须在下表登记状态;过期文档不再直接改写结论,统一移入 `docs/archive/`。
@@ -86,11 +86,12 @@
| 人工缺成本监控 | **已实施(2026-10-26)** | `manual_cost_missing`:首次人工配置之后的订单项未确认即入队,确认后自动收口 |
| 履约方式统一 | **已实施 P0** | 迁移 `105`;375,447 项全部回填:接单平台 153,148、人工 115,194、历史平台 53,901、自动发货 53,204 |
| 人工商品归一 | **已实施 P1(本次快照恢复执行)** | 人工 115,194 项已归一为 1,195 个 `man:<内部SKU>` 商品;历史人工支出仍不补录 |
| 影子总账 | 未实施 | 现有财务页仍是 `UNION ALL` 业务事件拼装,不是复式总账 |
| 影子总账 | **已实施 P0(2026-10-26)** | `finance_accounts/journal_entries/journal_lines/periods`;销售事实按日凭证 + 手工凭证 + 试算平衡;不接管钱包余额 |
| 成本异常归档 | **已实施** | 迁移 `108`;已处理异常超 60 天搬入 `cost_exceptions_archive`,open 行不归档;`COST_EXCEPTION_RETENTION_DAYS` 可调 |
| 退款冲减 / 平台对账 / 期间关账 | 未实施 | 仅方案 |
| 平台对账 / 期间关账 | **已实施 P0** | 平台账单 JSON 导入匹配、差异明细、标记解决;关账/反关账;见 `/admin/finance/ledger` |
| 平台退款 API | **部分实施** | 快手行业(91)同意退款已接入;抖音/拼多多暂未接 API,仍手工登记 |
> 分支状态(2026-10-26):`dev` 已合并 `dev1023` 与人工成本完整性修复,迁移到 `111`;本地生产副本已验证 101 → 111(109/110 索引在生产规模数据上实测 < 0.3s),临时库全量执行与重跑幂等通过。**未推送、未部署生产**;生产口径仍以 `main` 为准。上线前按 [财务上线初始化与验收-2026-10-26.md](财务上线初始化与验收-2026-10-26.md) 初始化资金与人工 SKU。
> 分支状态(2026-10-26):`dev` 已合并 `dev1023` 与人工成本完整性修复,迁移到 `112`;本地生产副本已验证 101 → 112(109/110 索引在生产规模数据上实测 < 0.3s),临时库全量执行与重跑幂等通过。**未推送、未部署生产**;生产口径仍以 `main` 为准。上线前按 [财务上线初始化与验收-2026-10-26.md](财务上线初始化与验收-2026-10-26.md) 初始化资金与人工 SKU。
> 财务与履约口径以 [财务与履约口径总纲-2026-10-23.md](财务与履约口径总纲-2026-10-23.md) 为准。
## 三、统一实施顺序
@@ -99,11 +100,11 @@
0. 统一履约事实(fulfillment_mode + 成本模型正交) 已完成 P0(迁移 105)
1. 商品主数据 + 渠道 SKU 映射 已完成 P0(仅自动发货/接单平台)
2. 成本中心 + 销售事实 已完成 P0
2.5 人工成本链路收敛(确认履约 → 扣积分/备用金 → 写快照) 已完成 P0(迁移 104/107)
2.5 人工成本链路收敛(确认履约 → 扣积分/备用金 → 写快照) 已完成 P0(迁移 104/107/111)
3. 人工商品归一与上线后人工成本全覆盖 已完成代码;生产数据需上线后配置 SKU(历史不补录)
4. 退款冲减与异常队列 已完成手工退款;平台退款 API 未实施;人工缺成本已入队
5. 影子总账(订单、工单、押金、提现、寄卖、售后) 未实施
6. 平台对账、报表、期间关账 未实施
4. 退款冲减与异常队列 已完成手工退款 + 快手平台退款;人工缺成本已入队
5. 影子总账(销售事实 + 手工凭证) 已完成 P0(迁移 112)
6. 平台对账、期间关账 已完成 P0(手工账单导入);平台自动拉单未实施
```
**第 0 步已完成。** 迁移、回填、代码改造与验收结果见 [成本中心与履约模型](成本中心与履约模型.md) §六、§八。下一步只做第 3 步人工商品归一,不要同时改退款或总账。
@@ -128,12 +129,12 @@
| 文档 | 状态 | 用途 |
| --- | --- | --- |
| [财务与履约口径总纲-2026-10-23.md](财务与履约口径总纲-2026-10-23.md) | **现行唯一口径** | 履约方式、成本模型、人工全覆盖、历史策略、实施顺序、运营日检与验收 SQL |
| [成本中心与履约模型.md](成本中心与履约模型.md) | 现行 | 商品、成本版本、快照、异常、销售事实的数据模型;迁移 `103`–`111`;履约回填;迁移基线与本地库同步;索引与性能 |
| [成本中心与履约模型.md](成本中心与履约模型.md) | 现行 | 商品、成本版本、快照、异常、销售事实的数据模型;迁移 `103`–`112`;履约回填;迁移基线与本地库同步;索引与性能 |
| [成本中心API契约.md](成本中心API契约.md) | 现行 | 成本中心接口字段替换总表、枚举、模块边界(后端 5 模块 + 前端 4 个固定 Tab) |
| [人工支出与备用金.md](人工支出与备用金.md) | 现行(P0 已实施) | 确认履约流程/权限、客服备用金台账、本人账户与编号规则 |
| [财务上线初始化与验收-2026-10-26.md](财务上线初始化与验收-2026-10-26.md) | **现行上线手册** | 迁移后资金/人工 SKU 初始化、验收 SQL、日常监控 |
| [财务系统集成方案.md](财务系统集成方案.md) | 现行方案(总账未实施) | 复式记账、总账表、对账、关账 |
| [影子总账说明.md](影子总账说明.md) | 现行说明(未实施) | 影子总账是什么、和钱包流水的区别、对账与转正标准 |
| [财务系统集成方案.md](财务系统集成方案.md) | 现行方案(影子总账 P0 已实施) | 复式记账、总账表、对账、关账;P1/P2 平台自动对账待接 |
| [影子总账说明.md](影子总账说明.md) | 现行说明(P0 已实施) | 影子总账是什么、和钱包流水的区别、对账与转正标准 |
| [项目全量分析报告-2026-10-17.md](项目全量分析报告-2026-10-17.md) | 历史参考 | 2026-10-17 架构与风险;财务口径以总纲为准 |
| [接单模板匹配引擎重构设计.md](接单模板匹配引擎重构设计.md) | 现行(P0/P1 已实施) | `kuaishou-91 / pdd / douyin-mashida` 三来源匹配引擎 |
| [履约配置/多发货平台与电子凭证整体链路.md](履约配置/多发货平台与电子凭证整体链路.md) | 现行 | 进单、履约、领取、电子凭证的整体链路 |
+2 -2
View File
@@ -101,7 +101,7 @@
- 接口:`apps/backend/src/routes/admin/finance.ts` 的 `POST /finance/manual-fulfillment/confirm`;待办 `GET /finance/manual-fulfillment/pending`
- 组件:`apps/frontend/src/components/admin/ManualFulfillmentConfirmModal.tsx`
- 订单入口:订单列表/详情「确认履约」
- 权限:`admin / operator / support`;客服只能使用本人备用金/积分账户
- 权限:`admin / operator / support`;客服只能使用本人备用金/积分账户;管理员/财务/运营可在确认时通过 `fundAccountId` / `creditOwnerAdminUserId` 指定客服账户代录
- 配置前置:商品与成本 → 人工 SKU 配置(`manual_fulfillment_configs`)
### 3.2 资金流与成本流(同一事务)
@@ -137,7 +137,7 @@ payeeName -- 收款方(选填,默认「人工履约」)
note -- 备注(选填)
```
平台、消耗积分与固定成本都来自人工 SKU 配置,客服不选账户、不改平台。
平台、消耗积分与固定成本都来自人工 SKU 配置;客服不选账户、不改平台。管理员/财务/运营代录时额外传 `fundAccountId`(现金)或 `creditOwnerAdminUserId`(积分),未指定则回退本人账户。
### 3.5 配置与监控
@@ -1,6 +1,6 @@
# 多发货平台与电子凭证整体链路
> 状态:现行。最后校准:2026-10-23(代码基线 `dev`(合并 dev1023 后)+ 迁移 `103`–`111`;履约方式已统一落库)。
> 状态:现行。最后校准:2026-10-23(代码基线 `dev`(合并 dev1023 后)+ 迁移 `103`–`112`;履约方式已统一落库)。
> 财务与履约总口径见 [财务与履约口径总纲-2026-10-23.md](../财务与履约口径总纲-2026-10-23.md)。
> 来源口径:本系统只有三个订单来源——`kuaishou-91`、`pdd`、`douyin-mashida`。**91 回调与快手 send-code 是同一个 `kuaishou-91` 来源的两条数据通道**(91=骨架权威源,快手 send-code=补全/兜底源),不是一个来源加另一个来源。
+4 -4
View File
@@ -1,8 +1,8 @@
# 影子总账到底是什么意思
> 状态:**现行说明(未实施)**
> 最后校准:2026-10-23
> 代码基线:`dev`(合并 dev1023 后)+ 迁移 `103`–`111`(已合并到 `dev`,**未推送、未部署生产**)
> 状态:**现行说明(P0 已实施,2026-10-26)**
> 最后校准:2026-10-26
> 代码基线:`dev`(合并 dev1023 后)+ 迁移 `103`–`112`(已合并到 `dev`,**未推送、未部署生产**)
> 配套方案:[财务系统集成方案.md](财务系统集成方案.md)
> **总口径以 [财务与履约口径总纲-2026-10-23.md](财务与履约口径总纲-2026-10-23.md) 为准。**
> 前置:统一履约事实、商品主数据、渠道映射、成本中心与销售事实;实施顺序以总纲第六节为准。
@@ -10,7 +10,7 @@
>
> 边界:影子总账解决“钱怎么记、怎么对”,不负责“商品是什么、成本是多少、人工谁处理”。商品与成本见 [成本中心与履约模型.md](成本中心与履约模型.md),人工支出见 [人工支出与备用金.md](人工支出与备用金.md)。
> 当前进度:客服备用金、商品与成本中心、成本快照、销售事实、人工录单已实施;`finance_*` 表、分录、对账、转正均未实施。人工成本现在只有一条链路:人工录单 → 扣客服备用金 → 写成本快照,无登记单/审批/报销;历史人工支出找不回来、直接放弃,上线后必须全覆盖。
> 当前进度:客服备用金、商品与成本中心、成本快照、销售事实、人工确认已实施;`finance_*` 科目/凭证/期间/对账表已建,销售事实按日生成凭证、手工凭证、试算平衡、期间关账与手工账单对账已上线 P0。**尚未接管钱包余额**,平台自动拉单与正式转正未实施。人工成本现在只有一条链路:确认履约 → 扣客服积分/备用金 → 写成本快照,无登记单/审批/报销;历史人工支出找不回来、直接放弃,上线后必须全覆盖。
---
+32 -1
View File
@@ -2,7 +2,7 @@
> 状态:**现行**。后端服务与前端页面都必须严格按此实现,不得各自发挥。
> 最后校准:2026-10-25
> 代码基线:`dev`(合并 dev1023 后)+ 迁移 `103`–`111`(已合并到 `dev`,**未推送、未部署生产**)
> 代码基线:`dev`(合并 dev1023 后)+ 迁移 `103`–`112`(已合并到 `dev`,**未推送、未部署生产**)
> 详细业务口径见 [财务与履约口径总纲-2026-10-23.md](财务与履约口径总纲-2026-10-23.md);数据模型见 [成本中心与履约模型.md](成本中心与履约模型.md)。
## 一、字段替换总表
@@ -306,3 +306,34 @@ export type CostModel =
```
`CostCarrierType` 已删除。
## 八、总账 / 期间 / 对账接口(2026-10-26)
| 方法 | 路径 | 权限 | 说明 |
| --- | --- | --- | --- |
| GET | `/api/v1/admin/finance/ledger/accounts` | finance.view | 科目表 |
| GET | `/api/v1/admin/finance/ledger/trial-balance?dateFrom&dateTo` | finance.view | 试算平衡 |
| GET | `/api/v1/admin/finance/ledger/entries?dateFrom&dateTo&page&pageSize` | finance.view | 凭证列表(含分录) |
| POST | `/api/v1/admin/finance/ledger/sync` | finance.config | 按日期区间同步销售事实到总账 |
| POST | `/api/v1/admin/finance/ledger/manual-voucher` | finance.config | 手工凭证(借贷必须平衡) |
| GET | `/api/v1/admin/finance/periods` | finance.view | 会计期间列表 |
| POST | `/api/v1/admin/finance/periods/:periodKey/close` | finance.config | 关账(先补凭证) |
| POST | `/api/v1/admin/finance/periods/:periodKey/reopen` | finance.config | 反关账 |
| GET | `/api/v1/admin/finance/reconciliations` | finance.view | 对账批次 |
| POST | `/api/v1/admin/finance/reconciliations/import` | finance.config | 导入平台账单 JSON 并匹配 |
| GET | `/api/v1/admin/finance/reconciliations/:id` | finance.view | 对账明细 |
| POST | `/api/v1/admin/finance/reconciliations/:id/resolve` | finance.config | 标记差异已解决 |
| POST | `/api/v1/admin/orders/:orderId/refunds/platform` | finance.config | 平台退款(当前仅快手 91) |
确认履约接口新增代录字段:
```jsonc
{
"orderItemId": 123,
"costFen": 100,
"fundAccountId": 45, // 现金路径:管理员/财务指定客服备用金账户
"creditOwnerAdminUserId": 67 // 积分路径:管理员/财务指定客服账号
}
```
客服角色只能使用本人账户;传了也会被服务端忽略。
+4 -3
View File
@@ -2,7 +2,7 @@
> 状态:**现行**。商品主数据、成本中心、成本快照、销售事实、履约事实均已落地。
> 最后校准:2026-10-26
> 代码基线:`dev`(合并 dev1023 + 人工成本完整性修复,迁移到 `111`)
> 代码基线:`dev`(合并 dev1023 + 人工成本完整性修复,迁移到 `112`)
> 数据基线:生产 dump `order_site-20261022-092601.dump`(2026-07-10 ~ 2026-10-22);本地只跑了近 30 天增量,历史数字仅作基线。
> 上线初始化/验收见 [财务上线初始化与验收-2026-10-26.md](财务上线初始化与验收-2026-10-26.md)。
> **业务口径以 [财务与履约口径总纲-2026-10-23.md](财务与履约口径总纲-2026-10-23.md) 为准**;本文只负责数据模型、迁移与运维。
@@ -35,7 +35,7 @@ orders ──┬── order_items ──── fulfillment_mode (唯一履约
## 一、迁移基线:10 个收敛为 5 个
原 `dev` 分支上,商品、成本中心、人工履约用 10 个迁移(`103`–`112`,1,275 行)分批落地,字段反复摇摆。其中:
原 `dev` 分支上,商品、成本中心、人工履约用 10 个**历史编号**迁移(`103`–`112`,1,275 行)分批落地,字段反复摇摆。其中:
- 原 `108`(下线人工支出登记)与原 `109`(隔离历史快照主体)在「生产 102 → 当前」的唯一升级路径上是**空操作**;
- 原 `111` 是给 `104` 自身种子打的补丁(draft 占位改 active 占位);
@@ -59,6 +59,7 @@ orders ──┬── order_items ──── fulfillment_mode (唯一履约
| `109_work_order_share_participation.sql` | 拼单多次参与份额模型(与本模块无关,同批上线) |
| `110_work_order_hall_and_shop_indexes.sql` | 大厅置顶/店铺筛选索引(与本模块无关) |
| `111_manual_cost_integrity.sql` | 人工成本单事务兜底:订单项级唯一、`subject_id=order_item_id`、人工 SKU 匹配索引、`manual_cost_missing` 监控所需索引 |
| `112_finance_ledger.sql` | 财务总账(科目/凭证/期间/对账),与本模块无直接依赖,同批上线 |
**合并原则**:同一批上线的结构性 DDL 才合并;数据回填与业务口径注释跟着所属表走。`103` 保持独立(商品主数据是成本与销售事实的共同上游),`106` 保持独立(退款是独立业务线)。
@@ -267,7 +268,7 @@ resolved_at, superseded_at, created_at
- 匹配不到成本时写 `cost_missing` 异常,**不能默认 0**;
- 履约方式不存快照里,需要时 join `order_items.fulfillment_mode`;
- 人工单的快照主体是 `subject_type='manual_case'` + `subject_id=order_item_id`;资金流水 id 只写 `metadata_json`,**没有独立的登记单表**;
- 负数 `subject_id` 表示历史孤儿快照(迁移 `104` 约定),迁移 `111` 保留不动,不参与新链路;
- 负数 `subject_id` 表示历史孤儿快照(迁移 `104` 约定),迁移 `112` 保留不动,不参与新链路;
- 一个订单项最多一条有效人工快照(`uq_cost_snapshots_manual_order_item`)。
### 3.5 `cost_exceptions`
+1 -1
View File
@@ -1,6 +1,6 @@
# 接单模板匹配引擎重构设计
> 目标读者:后端 / 后台运营。状态:**P0 + P1 已实施;P2 / P3 待办**。最后校准:2026-10-23(代码基线 `dev`(合并 dev1023 后)+ 迁移 `103`–`111`;匹配业务逻辑未变)。
> 目标读者:后端 / 后台运营。状态:**P0 + P1 已实施;P2 / P3 待办**。最后校准:2026-10-23(代码基线 `dev`(合并 dev1023 后)+ 迁移 `103`–`112`;匹配业务逻辑未变)。
>
> 一句话:把「订单信息 → 接单模板(价格/拼单/要求)」的匹配,从散落的多个消费点、平台判断和"店铺必填"的绑定表里,收敛为**一个平台无关的引擎**:匹配条件完全由**订单信息**表达,**店铺只是可选条件之一**,新增来源只加一个适配器 + 一组配置。
>
@@ -1,14 +1,14 @@
# 财务/成本上线初始化与验收(2026-10-26)
> 状态:现行操作手册。适用于 `dev` 合并后的财务/成本模块上线;**历史人工成本不补录**。
> 代码基线:迁移 `103`–`111`(本手册写于人工成本完整性与配置自动匹配修复之后)。
> 代码基线:迁移 `103`–`112`(本手册写于人工成本完整性与配置自动匹配修复之后)。
> 数据基线:生产 dump `2026-10-22`(迁移 `101`)。历史人工支出找不回来,只保证**首次配置人工履约之后**的新单可记录、可监控。
---
## 〇、上线范围与边界
本次上线的是「备用金 + 人工成本 + 成本中心 + 销售事实」模块,不是复式总账:
本次上线的是「备用金 + 人工成本 + 成本中心 + 销售事实 + 影子总账 P0」模块:
| 能力 | 本次是否上线 |
| --- | --- |
@@ -16,11 +16,13 @@
| 人工 SKU 配置 + 确认履约(扣积分/备用金 + 写 actual 快照) | 是(配置需上线后设置) |
| 商品目录、渠道映射、成本版本、成本快照、销售事实 | 是 |
| 手工退款登记(冲减收入、关闭成本异常) | 是 |
| 平台退款 API、影子总账、对账中心、期间关账 | **否**(后续 P3) |
| 影子总账(科目/凭证/试算平衡)、期间关账、平台对账 | 是(迁移 `112`;对账当前为手工账单导入) |
| 平台退款 API | **部分**:快手行业(91)已接入;抖音/拼多多仍手工登记 |
明确不做:
- 历史人工支出不补录、不按均价估算;
- 历史人工单的 `cost_missing` 不进入上线后监控口径;
- 平台自动拉单、全平台退款 API、总账接管钱包余额暂不做;
- 人工成本监控从**第一次创建人工履约配置的时间**开始计算(`manual_fulfillment_configs.created_at`)。
---
@@ -209,6 +211,22 @@ SELECT
SELECT reason, status, COUNT(*) FROM cost_exceptions
WHERE created_at >= :launch_at
GROUP BY 1,2 ORDER BY 3 DESC;
-- 8. 总账借贷平衡(应为差额 0)
SELECT
COALESCE(SUM(debit_fen), 0) AS debit_fen,
COALESCE(SUM(credit_fen), 0) AS credit_fen,
COALESCE(SUM(debit_fen), 0) - COALESCE(SUM(credit_fen), 0) AS diff_fen
FROM finance_journal_lines l
JOIN finance_journal_entries e ON e.id = l.entry_id AND e.status = 'posted'
WHERE e.occurred_at >= :launch_at;
-- 9. 已关账期间不应再有新凭证
SELECT e.period_key, COUNT(*)
FROM finance_journal_entries e
JOIN finance_periods p ON p.period_key = e.period_key AND p.status = 'closed'
WHERE e.created_at >= :launch_at
GROUP BY 1;
```
---
@@ -225,10 +243,51 @@ GROUP BY 1,2 ORDER BY 3 DESC;
---
## 四、已知边界(不要对外承诺)
## 四、总账、期间关账与平台对账(迁移 112)
1. 没有复式总账、平台对账、期间关账;现有「收支明细」是业务事件拼装,不是会计账。
2. 平台退款 API 未接入,退款只支持后台手工登记(整单/指定商品,可撤销)。
入口:后台「财务 → 总账与对账」(`/admin/finance/ledger`)。
### 4.1 影子总账
- 销售事实按日同步:收入(借 1101 / 贷 4001)、退款(借 4101 / 贷 1101)、成本(借 5001 / 贷 2001);
- 财务可手工录入调整凭证(借贷科目 + 金额 + 摘要),必须借贷平衡;
- 试算平衡按日期区间展示;总账不接管 `worker_wallets` / `fund_accounts` 余额;
- 定时任务 `cost-finance-sync` 每 5 分钟同步最近窗口;也可手动「同步销售事实到总账」。
### 4.2 期间关账
- 期间按 `YYYY-MM`;关账前会先补齐该期间凭证,并校验试算平衡;
- 关账后该期间禁止新增凭证;反关账需 `finance.config` 权限并写审计;
- 关账不修改历史凭证,只是阻断新写入。
### 4.3 平台对账
- 支持粘贴 JSON 账单导入(销售/退款行),按 `provider + platform_order_id` 匹配订单/退款;
- 明细状态:`matched / amount_mismatch / missing_order / missing_refund / duplicate`;
- 差异批次可标记解决(记录备注);平台自动拉单后续复用同一匹配器。
账单行示例:
```json
[
{ "platformOrderId": "123456", "entryType": "sale", "amountFen": 990 },
{ "platformOrderId": "123456", "entryType": "refund", "amountFen": 100, "externalRef": "RF-1" }
]
```
### 4.4 平台退款 API
- 快手行业(91):`POST /api/v1/admin/orders/:orderId/refunds/platform`,传 `platformRefundId`、`orderItemId`、`amountFen`;
先调用快手「同意退款」,成功后再落 `order_refunds.source='platform'` 并冲减销售事实;
- 抖音/拼多多:暂无退款 API,返回 501,仍用「手工退款登记」;
- 平台退款必须传订单项;参数校验在调用平台之前完成,避免平台已同意、本地记账失败。
---
## 五、已知边界(不要对外承诺)
1. 总账是**影子账**:销售事实按日凭证 + 手工凭证;尚未覆盖每一笔钱包/寄卖/提现业务,也没有接管余额;「收支明细」仍是业务事件拼装。
2. 平台对账当前是**手工账单 JSON 导入**,未做平台自动拉单;平台退款 API 仅接入快手(91),抖音/拼多多仍手工登记。
3. 平台服务费/支付通道费/寄卖货款成本确认时点仍待定;相关成本项配置为空时不参与毛利。
4. 历史人工成本、历史平台成本、历史未归一商品不补齐;历史区间毛利只能作为参考。
5. 管理员/财务代录目前仍使用**本人**备用金/积分账户,不能指定客服账户。
5. 管理员/财务代录已支持指定客服备用金/积分账户;客服只能使用本人账户。
@@ -2,7 +2,7 @@
> 状态:**现行唯一口径**。本文是财务、成本、履约分类的总入口;其他文档与本文件冲突时,以本文为准。
> 最后校准:2026-10-26
> 代码基线:`dev`(合并 dev1023 + 人工成本完整性修复,迁移到 `111`)
> 代码基线:`dev`(合并 dev1023 + 人工成本完整性修复,迁移到 `112`)
> 数据基线:§二/§七 的数字来自 2026-10-22 生产 dump 的**历史快照**,只用于理解历史缺口,**不再作为上线验收依据**;
> 上线初始化与验收以 [财务上线初始化与验收-2026-10-26.md](财务上线初始化与验收-2026-10-26.md) 为准。
> 适用范围:订单接入、履约判定、商品归一、成本快照、销售事实、人工录单、备用金、后续总账与对账。
@@ -147,8 +147,8 @@
| P0 | 统一履约事实(`fulfillment_mode`)+ 成本模型正交 | **已完成(本地,未部署生产)** |
| P0.5 | 人工成本链路收敛为「人工录单 → 扣备用金 → 写快照」(下线登记/审批/报销) | **已完成(本地,未部署生产)** |
| P1 | 人工商品归一(`man:<skuId>`)+ 上线后人工成本全覆盖 | **已完成(本地,未部署生产)** |
| P2 | 退款冲减与异常闭环 | **已完成(手动登记先行,平台退款 API 待接入)** |
| P3 | 影子总账与对账 | 未实施 |
| P2 | 退款冲减与异常闭环 | **已完成(手动登记 + 快手平台退款;抖音/拼多多仍手工)** |
| P3 | 影子总账与对账 | **已完成 P0(2026-10-26)**:总账/试算平衡/期间关账/手工账单对账;平台自动拉单待接 |
P0 的具体迁移、回填、代码清单与验收 SQL 见 [成本中心与履约模型](成本中心与履约模型.md);接口契约见 [成本中心API契约](成本中心API契约.md)。
@@ -170,18 +170,18 @@ P0 的具体迁移、回填、代码清单与验收 SQL 见 [成本中心与履
| 本地 316,585 项回填 | **已实施** | `fulfillment:backfill`,四项计数与 §二 一致 |
| 成本模型与履约方式正交(去 `carrier_type`) | **已实施(本地库已改造)** | 迁移 `103`–`108`;`cost_items.cost_model` |
| 10 个迁移收敛为 5 个 | **已实施** | `103_product_catalog` ~ `107_manual_fulfillment`(后续新增 `108`–`111` 增量) |
| 人工确认原子性/防重复 | **已实施(2026-10-26)** | 单事务 + 订单项行锁 + `uq_cost_snapshots_manual_order_item`(迁移 `111`);集成测试覆盖并发/回滚 |
| 人工确认原子性/防重复 | **已实施(2026-10-26)** | 单事务 + 订单项行锁 + `uq_cost_snapshots_manual_order_item`(迁移 `112`);集成测试覆盖并发/回滚 |
| 人工 SKU 配置自动匹配 | **已实施(2026-10-26)** | 保存配置立即回填 + 定时任务 rematch + 确认时按 SKU 兜底匹配 |
| 人工缺成本监控 | **已实施(2026-10-26)** | `manual_cost_missing`,从首次人工配置时间起算,确认后自动收口 |
| 人工成本链路收敛为「人工录单 → 扣备用金 → 写快照」 | **已实施(本地库已改造)** | 迁移 `104`;登记单与审批报销已下线,口径固化在成本中心迁移 |
| 人工商品归一 `man:<skuId>` | **已实施(本地库已归一)** | `manual-product-normalization-service` + `POST /cost-center/manual-products/normalize`;1,116 个 `man:` 商品、91,606 个人工订单项归一,人工未归一 = 0 |
| 人工商品归一后的销售事实 | **已实施** | 94 天分 30 天窗口重算:26,507 行;人工 ¥1,219.20 万收入全部按 `man:` 商品归集 |
| 退款冲减、异常闭环 | **已实施(手动登记先行)** | 迁移 `106_order_refunds`;订单页「退款登记」(整单/指定商品、可撤销);销售事实按有效退款冲减;全额退完后自动关闭该订单项成本异常并写 `resolution_note` |
| 平台退款 API 接入 | **未实施** | 预留 `order_refunds.source='api'/'platform'` 与 `external_ref` |
| 影子总账、对账、关账 | **未实施** | §六 P3 |
| 平台退款 API 接入 | **部分实施** | 快手行业(91)同意退款已接入,落 `source='platform'` + `external_ref`;抖音/拼多多仍手工 |
| 影子总账、对账、关账 | **已实施 P0** | 迁移 `112`;销售事实按日凭证 + 手工凭证 + 试算平衡 + 账单导入对账 + 关账/反关账 |
> 历史平台仍有 53,901 个订单项(¥389.12 万)未归一,不属于 P1 范围;人工历史成本按既定口径不补录,因此人工毛利当前是“收入已归商品、成本仅新单有”的状态。
> 分支事实:以上实现已合并到 `dev`;迁移 `103`–`111` **未推送远端、未部署生产**。生产口径仍以 `main` 为准;上线前按 [财务上线初始化与验收-2026-10-26.md](财务上线初始化与验收-2026-10-26.md) 初始化资金与人工 SKU。
> 分支事实:以上实现已合并到 `dev`;迁移 `103`–`112` **未推送远端、未部署生产**。生产口径仍以 `main` 为准;上线前按 [财务上线初始化与验收-2026-10-26.md](财务上线初始化与验收-2026-10-26.md) 初始化资金与人工 SKU。
---
@@ -307,7 +307,7 @@ WHERE oi.fulfillment_mode = 'manual'
| [成本中心与履约模型.md](成本中心与履约模型.md) | 商品、成本版本、快照、销售事实的数据模型与迁移 | 现行 |
| [成本中心API契约.md](成本中心API契约.md) | 成本中心后端/前端接口字段与模块边界 | 现行 |
| [人工支出与备用金.md](人工支出与备用金.md) | 人工录单、客服备用金台账与权限 | 现行(P0 已实施) |
| [财务系统集成方案.md](财务系统集成方案.md) | `finance_*` 复式总账层(未实施) | 方案 |
| [财务系统集成方案.md](财务系统集成方案.md) | `finance_*` 复式总账层(P0 已实施) | 现行方案 |
| [README.md](README.md) | 文档索引、分支与进度 | 现行入口 |
维护规则:
+7 -5
View File
@@ -1,9 +1,9 @@
# 财务系统集成方案
> 状态:**现行方案(总账层未实施)**。本文只描述 `finance_*` 总账层;当前总账表尚未建立,现有财务页仍是 `UNION ALL` 业务事件拼装。
> 状态:**现行方案(影子总账 P0 已实施,2026-10-26)**。`finance_accounts / journal_entries / journal_lines / periods / reconciliations` 已建;总账不接管钱包余额;对账当前为手工账单导入,平台自动拉单待接。
> 最后校准:2026-10-26
> 代码基线:`dev`(迁移到 `111`,**未推送、未部署生产**)
> 说明:本文是后续 P3 总账/对账/关账方案,**尚未实施**;本次上线范围(备用金、人工成本、成本中心、销售事实)与初始化步骤见 [财务上线初始化与验收-2026-10-26.md](财务上线初始化与验收-2026-10-26.md)。
> 代码基线:`dev`(迁移到 `112`,**未推送、未部署生产**)
> 说明:本文是总账/对账/关账的目标架构与分期;当前已实施 P0(销售事实凭证、手工凭证、试算平衡、期间关账、手工账单对账)与快手平台退款。初始化步骤见 [财务上线初始化与验收-2026-10-26.md](财务上线初始化与验收-2026-10-26.md)。
> **履约方式、成本模型、人工全覆盖口径以 [财务与履约口径总纲-2026-10-23.md](财务与履约口径总纲-2026-10-23.md) 为准**;本文不重复分类设计。
> 目标:在**不破坏现有抢单、押金、结算、寄卖、提现链路**的前提下,增加可对账、可关账、可追溯的复式记账底座。
> 结论先说:**不要直接在 `worker_wallets` / `worker_wallet_ledgers` 上继续加字段。** 现有表是业务钱包与流水,不是会计总账;正确做法是新增复式记账底座,让现有钱包成为它的“余额投影”。
@@ -21,8 +21,10 @@
| `sales_daily_facts` / 订单级毛利 | **已实施 P0(`dev` 分支)**,迁移 `105`;94 天收入 ¥2,650.87 万、成本 ¥950.27 万 |
| 人工成本录入(确认履约 → 扣积分/备用金 → 写快照) | **已实施 P0(`dev` 分支)**,迁移 `104/107`;2026-10-24 人工履约页下线、入口收敛到订单页;**无登记单、无审批、无报销** |
| 履约方式统一 `fulfillment_mode` | **已实施 P0(`dev` 分支)**;迁移 `105`,316,585 项已回填;订单页、成本快照、销售事实统一读该字段 |
| `finance_accounts` / `finance_business_events` / `finance_journal_*` | 未实施 |
| `finance_settlements` / `finance_reconciliations` | 未实施 |
| `finance_accounts` / `finance_journal_entries` / `finance_journal_lines` | **已实施 P0(迁移 112)** |
| `finance_periods` / `finance_reconciliations` / `finance_reconciliation_items` | **已实施 P0(迁移 112)** |
| `finance_business_events` / `finance_settlements` | 未实施(当前用条目唯一键幂等) |
| 现有 `admin-finance-service.ts` | 仍是业务事件 `UNION ALL`,不是总账 |
**人工成本只有一条链路**:订单列表/详情点「确认履约」→ 扣客服积分或备用金 → 写 `cost_snapshots`(`subject_type='manual_case'`)。没有独立的人工支出登记表,也没有审批与报销环节。
+3 -2
View File
@@ -219,7 +219,7 @@ docker compose ps
docker compose logs -f backend
```
## 财务/成本上线后的初始化(迁移 102–111)
## 财务/成本上线后的初始化(迁移 102–112)
代码与迁移只负责建表;**备用金账户、积分余额、人工 SKU 配置不会自动创建**。
首次上线后按 [财务上线初始化与验收-2026-10-26.md](财务上线初始化与验收-2026-10-26.md) 执行:
@@ -227,7 +227,8 @@ docker compose logs -f backend
1. 钱包管理 → 补建客服备用金账户 → 按需拨付;
2. 商品与成本 → 积分钱包 → 给客服发放三平台积分;
3. 商品与成本 → 人工 SKU 配置 → 扫描并逐项配置(保存后自动回填订单项);
4. 用手册 §二 的验收 SQL 检查:配置覆盖、人工成本快照、`manual_cost_missing`、销售事实对平。
4. 打开「财务 → 总账与对账」,同步总账、检查试算平衡;按需关账/导入平台账单;
5. 用手册 §二 的验收 SQL 检查:配置覆盖、人工成本快照、`manual_cost_missing`、销售事实与总账对平。
> 历史人工成本不补录;人工成本监控从第一次创建人工履约配置的时间开始。
> 定时任务 `cost-finance-sync` 每 5 分钟运行,负责履约归集、人工 SKU 回填、成本快照与销售事实。