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';