diff --git a/apps/backend/src/db/migrations/112_finance_ledger.sql b/apps/backend/src/db/migrations/112_finance_ledger.sql new file mode 100644 index 00000000..4e063384 --- /dev/null +++ b/apps/backend/src/db/migrations/112_finance_ledger.sql @@ -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'; diff --git a/apps/backend/src/routes/admin.ts b/apps/backend/src/routes/admin.ts index 0f54400b..07289822 100644 --- a/apps/backend/src/routes/admin.ts +++ b/apps/backend/src/routes/admin.ts @@ -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) diff --git a/apps/backend/src/routes/admin/finance-ledger.ts b/apps/backend/src/routes/admin/finance-ledger.ts new file mode 100644 index 00000000..cb74fd82 --- /dev/null +++ b/apps/backend/src/routes/admin/finance-ledger.ts @@ -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 + 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).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).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).periodKey || '')), + { + successMessage: '期间已重新打开', + errorMessage: '反关账失败', + scope: '[admin/finance/periods/reopen]', + audit: (req) => ({ + action: 'finance_period_reopened', + targetType: 'finance_period', + targetId: String((req.params as Record).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 diff --git a/apps/backend/src/routes/admin/orders.ts b/apps/backend/src/routes/admin/orders.ts index d52a4767..606d4848 100644 --- a/apps/backend/src/routes/admin/orders.ts +++ b/apps/backend/src/routes/admin/orders.ts @@ -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 + 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) : {}, + }), + }, + ), +) + router.post( '/orders/:orderId/refunds', requireAdminPermission('finance.config'), diff --git a/apps/backend/src/services/finance/finance-ledger-service.ts b/apps/backend/src/services/finance/finance-ledger-service.ts new file mode 100644 index 00000000..493de090 --- /dev/null +++ b/apps/backend/src/services/finance/finance-ledger-service.ts @@ -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 = {}) { + 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>( + ` + 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>( + ` + 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>>() + 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>( + ` + 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>( + `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>( + ` + 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>( + `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) { + 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 +} diff --git a/apps/backend/src/services/finance/finance-reconciliation-service.ts b/apps/backend/src/services/finance/finance-reconciliation-service.ts new file mode 100644 index 00000000..5b20f9e8 --- /dev/null +++ b/apps/backend/src/services/finance/finance-reconciliation-service.ts @@ -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() + + 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 = {}) { + 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>( + ` + 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>( + ` + 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>( + `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>( + ` + 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, +): 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) { + 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 +} diff --git a/apps/backend/src/services/finance/manual-fulfillment-integrity.test.ts b/apps/backend/src/services/finance/manual-fulfillment-integrity.test.ts index c7dfdfca..ae2cba1f 100644 --- a/apps/backend/src/services/finance/manual-fulfillment-integrity.test.ts +++ b/apps/backend/src/services/finance/manual-fulfillment-integrity.test.ts @@ -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'), + '管理员/财务必须可以指定客服账户代录', + ) +}) diff --git a/apps/backend/src/services/finance/manual-fulfillment-service.ts b/apps/backend/src/services/finance/manual-fulfillment-service.ts index 66b236d2..d8928780 100644 --- a/apps/backend/src/services/finance/manual-fulfillment-service.ts +++ b/apps/backend/src/services/finance/manual-fulfillment-service.ts @@ -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) { +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, +) { 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, +) { + 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 匹配人工履约配置。 * diff --git a/apps/backend/src/services/finance/manual-fulfillment.integration.test.ts b/apps/backend/src/services/finance/manual-fulfillment.integration.test.ts index d2e7133d..0521edbe 100644 --- a/apps/backend/src/services/finance/manual-fulfillment.integration.test.ts +++ b/apps/backend/src/services/finance/manual-fulfillment.integration.test.ts @@ -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`) } diff --git a/apps/backend/src/services/finance/order-refund-service.ts b/apps/backend/src/services/finance/order-refund-service.ts index d34507eb..a9e2da53 100644 --- a/apps/backend/src/services/finance/order-refund-service.ts +++ b/apps/backend/src/services/finance/order-refund-service.ts @@ -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 { @@ -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', + }) +} diff --git a/apps/backend/src/services/scheduler/cost-finance-sync-job.ts b/apps/backend/src/services/scheduler/cost-finance-sync-job.ts index 4a083764..5e13d0e2 100644 --- a/apps/backend/src/services/scheduler/cost-finance-sync-job.ts +++ b/apps/backend/src/services/scheduler/cost-finance-sync-job.ts @@ -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, } diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx index 2596bd49..8591e9cf 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/App.tsx @@ -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() { }> } /> + } /> } /> } /> diff --git a/apps/frontend/src/components/admin/FinanceSubNav.tsx b/apps/frontend/src/components/admin/FinanceSubNav.tsx index 1a2c96ad..17880ec0 100644 --- a/apps/frontend/src/components/admin/FinanceSubNav.tsx +++ b/apps/frontend/src/components/admin/FinanceSubNav.tsx @@ -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: , }, + { + key: '/admin/finance/ledger', + label: '总账与对账', + icon: , + }, ] as const type FinanceNavKey = (typeof FINANCE_NAV_ITEMS)[number]['key'] diff --git a/apps/frontend/src/components/admin/ManualFulfillmentConfirmModal.tsx b/apps/frontend/src/components/admin/ManualFulfillmentConfirmModal.tsx index 5dfdf92c..412bf305 100644 --- a/apps/frontend/src/components/admin/ManualFulfillmentConfirmModal.tsx +++ b/apps/frontend/src/components/admin/ManualFulfillmentConfirmModal.tsx @@ -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() const [selectedId, setSelectedId] = useState(null) + const [fundAccountId, setFundAccountId] = useState(null) + const [creditOwnerAdminUserId, setCreditOwnerAdminUserId] = useState(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: , disabled: !selected }} + okButtonProps={{ + icon: , + disabled: + !selected || + (privileged && + ((selected.isFundCash && !fundAccountId) || + (!selected.isFundCash && !creditOwnerAdminUserId))), + }} cancelText="取消" confirmLoading={confirmMutation.isPending} onOk={() => { @@ -181,6 +240,46 @@ export function ManualFulfillmentConfirmModal({ ¥{((selected?.revenueFen || 0) / 100).toFixed(2)} + {privileged && selected ? ( + selected.isFundCash ? ( +
+ 代录到客服备用金账户: + setCreditOwnerAdminUserId(value)} + options={(creditOwnersQuery.data?.data.items || []).map((item) => ({ + value: item.id, + label: item.displayName || item.username, + }))} + /> +
+ ) + ) : null} + {showCostInput ? ( ) : ( diff --git a/apps/frontend/src/layouts/AdminLayout.tsx b/apps/frontend/src/layouts/AdminLayout.tsx index b7394296..bbdbed4c 100644 --- a/apps/frontend/src/layouts/AdminLayout.tsx +++ b/apps/frontend/src/layouts/AdminLayout.tsx @@ -184,6 +184,7 @@ export default function AdminLayout() { '/admin/cost-center', '/admin/fund', '/admin/finance', + '/admin/finance/ledger', ].includes(selectedKey) const [openKeys, setOpenKeys] = useState(['sub-finance']) @@ -199,6 +200,7 @@ export default function AdminLayout() { { key: '/admin/cost-center', icon: , label: '商品与成本' }, { key: '/admin/fund', icon: , label: '钱包管理' }, { key: '/admin/finance', icon: , label: '收支明细' }, + { key: '/admin/finance/ledger', icon: , 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' diff --git a/apps/frontend/src/pages/admin/AdminFinanceLedgerPage.tsx b/apps/frontend/src/pages/admin/AdminFinanceLedgerPage.tsx new file mode 100644 index 00000000..8812d2cf --- /dev/null +++ b/apps/frontend/src/pages/admin/AdminFinanceLedgerPage.tsx @@ -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 = { + imported: { label: '待匹配', color: 'processing' }, + matched: { label: '全部匹配', color: 'green' }, + differences: { label: '有差异', color: 'orange' }, + resolved: { label: '已解决', color: 'default' }, +} + +const MATCH_STATUS_META: Record = { + 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(null) + const [voucherOpen, setVoucherOpen] = useState(false) + const [reconImportOpen, setReconImportOpen] = useState(false) + const [reconDetailId, setReconDetailId] = useState(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 = [ + { 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 = [ + { 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 = [ + { title: '期间', dataIndex: 'periodKey', width: 100 }, + { + title: '状态', + dataIndex: 'status', + width: 90, + render: (value: string) => ( + + {value === 'closed' ? '已关账' : '开放'} + + ), + }, + { + title: '关账时间', + dataIndex: 'closedAt', + width: 170, + render: (value: string | null) => (value ? formatAdminDateTime(value) : '-'), + }, + { + title: '操作', + width: 150, + render: (_, row) => + canConfig ? ( + row.status === 'closed' ? ( + + ) : ( + + ) + ) : null, + }, + ] + + const reconColumns: TableColumnsType = [ + { 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 {meta.label} + }, + }, + { + 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) => ( + + {formatMoney(value)} + + ), + }, + { + title: '操作', + width: 140, + render: (_, row) => ( + + + {canConfig && row.status !== 'resolved' ? ( + + ) : null} + + ), + }, + ] + + const reconItemColumns: TableColumnsType = [ + { 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 {meta.label} + }, + }, + ] + + return ( + <> + + + + + { + if (value?.[0] && value?.[1]) setRange([value[0], value[1]]) + }} + /> + {canConfig ? ( + + ) : null} + + + + + + + + + + + + + + + + + + + } + onClick={() => setVoucherOpen(true)} + > + 手工凭证 + + ) : null + } + > + ({ onClick: () => setEntryDetail(row) })} + pagination={{ + current: entryPage, + pageSize: entryPageSize, + total: entriesQuery.data?.data.pagination.total || 0, + showSizeChanger: true, + onChange: (page, size) => { + setEntryPage(page) + setEntryPageSize(size) + }, + }} + /> + + + ), + }, + { + key: 'periods', + label: '期间关账', + children: ( + + + + ), + }, + { + key: 'reconciliations', + label: '平台对账', + children: ( + } + onClick={() => setReconImportOpen(true)} + > + 导入账单 + + ) : null + } + > + { + setReconPage(page) + setReconPageSize(size) + }, + }} + /> + + ), + }, + ]} + /> + + + setEntryDetail(null)} + > + {entryDetail ? ( + + {entryDetail.memo} + (value ? formatMoney(value) : '-'), + }, + { + title: '贷方', + dataIndex: 'creditFen', + align: 'right', + render: (value: number) => (value ? formatMoney(value) : '-'), + }, + ]} + /> + + ) : null} + + + voucherMutation.mutate()} + onCancel={() => setVoucherOpen(false)} + > +
+ + + + + + + + + + + + +
+
+ + importReconMutation.mutate()} + onCancel={() => setReconImportOpen(false)} + > +
+ +