From a3e2bdb7c4fc3af798d7248cab8193e157c9b252 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Mon, 26 Oct 2026 13:04:51 +0800 Subject: [PATCH] =?UTF-8?q?feat(finance):=20=E4=BA=BA=E5=B7=A5=E6=88=90?= =?UTF-8?q?=E6=9C=AC=E5=8D=95=E4=BA=8B=E5=8A=A1/=E5=B9=82=E7=AD=89=20+=20?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E8=87=AA=E5=8A=A8=E5=8C=B9=E9=85=8D=20+=20?= =?UTF-8?q?=E7=BC=BA=E6=88=90=E6=9C=AC=E7=9B=91=E6=8E=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 确认履约改为单事务:锁订单项 → 复查快照 → 扣积分/备用金 → 写成本快照; 快照失败整体回滚,不再出现「钱扣了、成本没记」 - manual_case 的 subject_id 统一为 order_item_id,新增订单项级唯一索引(迁移 111), 消除 manual_expenses.id / platform_credit_entries.id 跨表撞车 - 人工 SKU 匹配增加 sku_code / sku_name 兜底;保存配置立即回填最近 30 天, 定时任务按窗口 rematch,确认时再按 SKU 兜底匹配 - 新增 manual_cost_missing 异常队列:从首次人工配置起算,确认履约立即收口 - 补单元测试与真实 PostgreSQL 集成测试(并发、重复、强制快照失败回滚、双资金链路) - 校准财务文档并新增上线初始化/验收手册(历史人工不补录,只保证配置后的新单) 验证:backend typecheck/lint/format/check:sql/test 843(839 pass / 4 skip); frontend typecheck/lint/test 172;迁移 111 在 scratch 库全量执行 + 重跑幂等。 --- .../migrations/111_manual_cost_integrity.sql | 85 +++ .../src/services/admin/admin-fund-service.ts | 14 +- .../cost-center/cost-snapshot-sync-service.ts | 74 ++- .../manual-fulfillment-config-service.ts | 45 +- .../cost-center/platform-credit-service.ts | 106 ++-- .../manual-fulfillment-integrity.test.ts | 145 +++++ .../finance/manual-fulfillment-service.ts | 590 +++++++++++------- .../manual-fulfillment.integration.test.ts | 406 ++++++++++++ .../src/services/scheduler/config-service.ts | 4 +- .../scheduler/cost-finance-sync-job.ts | 10 + .../admin/cost-center/CostExceptionPanel.tsx | 1 + docs/README.md | 19 +- docs/人工支出与备用金.md | 114 ++-- docs/履约配置/多发货平台与电子凭证整体链路.md | 2 +- docs/影子总账说明.md | 2 +- docs/成本中心API契约.md | 7 +- docs/成本中心与履约模型.md | 40 +- docs/接单模板匹配引擎重构设计.md | 2 +- docs/财务上线初始化与验收-2026-10-26.md | 233 +++++++ docs/财务与履约口径总纲-2026-10-23.md | 46 +- docs/财务系统集成方案.md | 5 +- docs/部署启动与数据库重建.md | 15 + 22 files changed, 1575 insertions(+), 390 deletions(-) create mode 100644 apps/backend/src/db/migrations/111_manual_cost_integrity.sql create mode 100644 apps/backend/src/services/finance/manual-fulfillment-integrity.test.ts create mode 100644 apps/backend/src/services/finance/manual-fulfillment.integration.test.ts create mode 100644 docs/财务上线初始化与验收-2026-10-26.md diff --git a/apps/backend/src/db/migrations/111_manual_cost_integrity.sql b/apps/backend/src/db/migrations/111_manual_cost_integrity.sql new file mode 100644 index 00000000..726f9556 --- /dev/null +++ b/apps/backend/src/db/migrations/111_manual_cost_integrity.sql @@ -0,0 +1,85 @@ +-- 111_manual_cost_integrity.sql +-- 人工成本完整性:一个订单项只允许一条有效的人工成本快照。 +-- +-- 背景: +-- 1. 旧实现把 cost_snapshots.subject_id 写成 manual_expenses.id 或 +-- platform_credit_entries.id(两张表各自 BIGSERIAL),与唯一索引 +-- (subject_type, subject_id, cost_item_id) 叠加后,两条链路会互相撞 id: +-- 第二次合法确认被当成「已有人工成本快照」拒绝,而钱已经扣了。 +-- 2. 人工确认原先是「先扣款、后写快照」两个事务,快照失败会留下已扣款未记成本。 +-- 现在改为同一事务,并由本迁移提供数据库级兜底: +-- 一个 order_item 只允许一条有效 (superseded_at IS NULL) 的 manual_case 快照。 +-- +-- 语义调整: +-- * subject_type = 'manual_case' 的 subject_id 统一为 order_item_id; +-- 资金来源(manual_expenses.id / platform_credit_entries.id)只写 metadata_json, +-- 不再作为 subject_id。 +-- * 正数 subject_id 的历史行(旧实现写入的支出单/积分流水 id)修正为 order_item_id; +-- 负数 subject_id 的历史孤儿快照按迁移 104 的约定保留,不参与新链路。 +-- * 重复正数行保留最新一条,其余标记 superseded,避免唯一索引创建失败。 +-- +-- 全部语句幂等,可安全重跑。 + +-- 1. 历史人工快照的 subject_id 归一为 order_item_id。 +UPDATE cost_snapshots +SET subject_id = order_item_id +WHERE subject_type = 'manual_case' + AND order_item_id IS NOT NULL + AND subject_id > 0 + AND subject_id <> order_item_id; + +-- 2. 同一订单项存在重复有效人工快照时,只保留最新一条(历史脏数据兜底)。 +UPDATE cost_snapshots c +SET superseded_at = NOW() +WHERE c.subject_type = 'manual_case' + AND c.superseded_at IS NULL + AND c.subject_id > 0 + AND EXISTS ( + SELECT 1 + FROM cost_snapshots newer + WHERE newer.subject_type = 'manual_case' + AND newer.order_item_id = c.order_item_id + AND newer.superseded_at IS NULL + AND newer.id > c.id + ); + +-- 3. 数据库级唯一:一个订单项只有一条有效人工成本快照。 +CREATE UNIQUE INDEX IF NOT EXISTS uq_cost_snapshots_manual_order_item + ON cost_snapshots (order_item_id) + WHERE subject_type = 'manual_case' AND superseded_at IS NULL AND subject_id > 0; + +-- 4. 语义约束:新写入的 manual_case 必须 subject_id = order_item_id。 +-- NOT VALID 只约束新写入,负数历史孤儿快照(迁移 104 约定)保持原样。 +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'chk_cost_snapshots_manual_subject' + ) THEN + ALTER TABLE cost_snapshots + ADD CONSTRAINT chk_cost_snapshots_manual_subject + CHECK (subject_type <> 'manual_case' OR subject_id = order_item_id) + NOT VALID; + END IF; +END $$; + +-- 5. 人工成本监控 / 对账按订单项反查备用金支出。 +CREATE INDEX IF NOT EXISTS idx_manual_expenses_order_item + ON manual_expenses (order_item_id) + WHERE order_item_id IS NOT NULL; + +-- 6. 人工 SKU 匹配键索引:spec_json.skuNick -> outSkuSn -> sku_code -> sku_name。 +-- 旧索引只覆盖前两级,缺 SKU 键的人工单会走顺序扫描;这里替换为同一套兜底表达式。 +DROP INDEX IF EXISTS idx_order_items_manual_internal_sku; +CREATE INDEX IF NOT EXISTS idx_order_items_manual_sku_key + ON order_items ( + COALESCE( + NULLIF(spec_json->>'skuNick', ''), + NULLIF(spec_json->>'outSkuSn', ''), + NULLIF(sku_code, ''), + NULLIF(sku_name, '') + ) + ) + WHERE fulfillment_mode = 'manual'; + +COMMENT ON INDEX uq_cost_snapshots_manual_order_item IS + '人工成本完整性:一个订单项最多一条有效 manual_case 快照(并发重复确认的数据库兜底)'; diff --git a/apps/backend/src/services/admin/admin-fund-service.ts b/apps/backend/src/services/admin/admin-fund-service.ts index 7b94f6b4..e1393370 100644 --- a/apps/backend/src/services/admin/admin-fund-service.ts +++ b/apps/backend/src/services/admin/admin-fund-service.ts @@ -1538,11 +1538,15 @@ export async function createAdminFundReturn(payload: JsonObject, session: AdminS }) } -export async function getOwnAgentAccountId(userId: number): Promise { - const own = await query( - "SELECT * FROM fund_accounts WHERE owner_admin_user_id = $1 AND account_type = 'agent' AND status <> 'closed' ORDER BY id LIMIT 1", - [userId], - ) +export async function getOwnAgentAccountId( + userId: number, + client?: Pick, +): Promise { + const sql = + "SELECT * FROM fund_accounts WHERE owner_admin_user_id = $1 AND account_type = 'agent' AND status <> 'closed' ORDER BY id LIMIT 1" + const own = client + ? await client.query(sql, [userId]) + : await query(sql, [userId]) return own.rows[0] ? Number(own.rows[0].id) : 0 } diff --git a/apps/backend/src/services/cost-center/cost-snapshot-sync-service.ts b/apps/backend/src/services/cost-center/cost-snapshot-sync-service.ts index 52a8aff5..87da6cfc 100644 --- a/apps/backend/src/services/cost-center/cost-snapshot-sync-service.ts +++ b/apps/backend/src/services/cost-center/cost-snapshot-sync-service.ts @@ -672,6 +672,7 @@ async function insertAndResolveCostMissingExceptions( SELECT oi.id AS order_item_id, oi.canonical_product_id, + oi.fulfillment_config_id, COALESCE(o.paid_at, o.created_at) AS basis_at, wo.id AS work_order_id, COALESCE(oi.fulfillment_mode, 'manual') AS fulfillment_mode @@ -714,6 +715,58 @@ async function insertAndResolveCostMissingExceptions( [fromDate, toDate], ) + const manualInserted = await client.query( + ` + WITH cutoff AS ( + SELECT MIN(created_at) AS enforce_from + FROM manual_fulfillment_configs + ), + item_scope AS ( + SELECT + oi.id AS order_item_id, + oi.fulfillment_config_id, + COALESCE(o.paid_at, o.created_at) AS basis_at + FROM order_items oi + JOIN orders o ON o.id = oi.order_id + WHERE o.pay_status IN ('paid', 'refunded') + AND (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date + BETWEEN $1::date AND $2::date + AND oi.fulfillment_mode = 'manual' + ) + INSERT INTO cost_exceptions ( + subject_type, subject_id, order_item_id, work_order_id, + reason, status, detail_json, created_at + ) + SELECT + 'order_item', s.order_item_id, s.order_item_id, NULL, + 'manual_cost_missing', 'open', + jsonb_build_object( + 'fulfillmentMode', 'manual', + 'basisAt', s.basis_at, + 'configMissing', s.fulfillment_config_id IS NULL, + 'message', CASE + WHEN s.fulfillment_config_id IS NULL + THEN '人工订单项未配置履约渠道,请先在成本中心配置人工 SKU' + ELSE '人工订单项尚未确认履约并记录成本' + END + ), + NOW() + FROM item_scope s + CROSS JOIN cutoff c + WHERE c.enforce_from IS NOT NULL + -- 只监控「第一次配置人工履约之后」的订单项:历史人工单按既定口径不补录。 + AND s.basis_at >= c.enforce_from + AND NOT EXISTS ( + SELECT 1 FROM cost_snapshots cs + WHERE cs.subject_type = 'manual_case' + AND cs.order_item_id = s.order_item_id + AND cs.superseded_at IS NULL + ) + ON CONFLICT (subject_type, subject_id, reason) DO NOTHING + `, + [fromDate, toDate], + ) + const resolved = await client.query( ` UPDATE cost_exceptions ce @@ -730,5 +783,24 @@ async function insertAndResolveCostMissingExceptions( `, ) - return { created: inserted.rowCount || 0, resolved: resolved.rowCount || 0 } + const manualResolved = await client.query( + ` + UPDATE cost_exceptions ce + SET status = 'resolved', resolved_at = NOW() + WHERE ce.status = 'open' + AND ce.subject_type = 'order_item' + AND ce.reason = 'manual_cost_missing' + AND EXISTS ( + SELECT 1 FROM cost_snapshots cs + WHERE cs.subject_type = 'manual_case' + AND cs.order_item_id = ce.subject_id + AND cs.superseded_at IS NULL + ) + `, + ) + + return { + created: (inserted.rowCount || 0) + (manualInserted.rowCount || 0), + resolved: (resolved.rowCount || 0) + (manualResolved.rowCount || 0), + } } diff --git a/apps/backend/src/services/cost-center/manual-fulfillment-config-service.ts b/apps/backend/src/services/cost-center/manual-fulfillment-config-service.ts index 7177c884..f21108fe 100644 --- a/apps/backend/src/services/cost-center/manual-fulfillment-config-service.ts +++ b/apps/backend/src/services/cost-center/manual-fulfillment-config-service.ts @@ -1,14 +1,15 @@ 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 { nowIso } from '../../utils/time.js' /** * 人工履约 SKU 配置服务。 * * 口径(2026-10-24 确认): - * * 配置主键是**内部 SKU**(快手 spec_json.skuNick / 拼多多 spec_json.outSkuSn), - * 不是商品标题;同名 SKU 算同一个商品,跨渠道共用一份配置; + * * 配置主键优先取内部 SKU(快手 spec_json.skuNick / 拼多多 spec_json.outSkuSn), + * 缺失时按 sku_code → sku_name 兜底;同名/同码算同一个商品,跨渠道共用一份配置; * * 配置项:履约渠道(南航/乐玩/ROCK/备用金现金)+ 消耗积分 + 成本模式; * * 换算率固定:1 积分 = 1 分钱; * * 消耗积分 0 = 成本不固定,由客服在确认履约时手填实际成本; @@ -125,7 +126,9 @@ export async function scanManualSkus(options: ScanOptions) { (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date AS stat_date, COALESCE( NULLIF(oi.spec_json->>'skuNick', ''), - NULLIF(oi.spec_json->>'outSkuSn', '') + NULLIF(oi.spec_json->>'outSkuSn', ''), + NULLIF(oi.sku_code, ''), + NULLIF(oi.sku_name, '') ) AS sku_key FROM order_items oi JOIN orders o ON o.id = oi.order_id @@ -380,6 +383,15 @@ export async function saveManualFulfillmentConfig(payload: JsonObject) { const config = requiredRow(result.rows[0], '保存人工履约配置失败') await syncRegistryConfigId(config.sku_key, config.id) + // 配置保存后立刻回填该 SKU 最近 30 天的订单项,避免必须再点一次「重新匹配」。 + // 历史更早的订单项不自动回填(历史人工成本不补录);需要时再显式点「重新匹配」。 + const today = formatShanghaiDate(new Date()) + await rematchManualFulfillments({ + skuKey: config.sku_key, + onlyUnmatched: true, + dateFrom: shiftDate(today, -29), + dateTo: today, + }) return mapManualConfig(config, unitPrices) } @@ -408,6 +420,9 @@ export async function deleteManualFulfillmentConfig(skuKey: string) { export async function rematchManualFulfillments(input: Record = {}) { const skuKey = String(input.skuKey ?? input.sku_key ?? '').trim() const onlyUnmatched = input.onlyUnmatched !== false + // 定时任务只回填最近窗口,避免一次性把 10 万历史人工单变成「待履约」。 + const dateFrom = normalizeOptionalDate(input.dateFrom ?? input.date_from, '开始日期') + const dateTo = normalizeOptionalDate(input.dateTo ?? input.date_to, '结束日期') const now = nowIso() const result = await query<{ updated_count: number }>( @@ -422,22 +437,32 @@ export async function rematchManualFulfillments(input: Record = JOIN manual_fulfillment_configs cfg ON cfg.sku_key = COALESCE( NULLIF(oi.spec_json->>'skuNick', ''), - NULLIF(oi.spec_json->>'outSkuSn', '') + NULLIF(oi.spec_json->>'outSkuSn', ''), + NULLIF(oi.sku_code, ''), + NULLIF(oi.sku_name, '') ) WHERE oi.fulfillment_mode = 'manual' AND cfg.enabled = TRUE AND ($1 = '' OR cfg.sku_key = $1) AND ($2 = FALSE OR oi.fulfillment_config_id IS NULL) + AND ( + $3::date IS NULL + OR (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date >= $3::date + ) + AND ( + $4::date IS NULL + OR (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date <= $4::date + ) ) UPDATE order_items oi SET fulfillment_config_id = target.config_id, canonical_product_id = COALESCE(target.canonical_product_id, oi.canonical_product_id), - updated_at = $3 + updated_at = $5 FROM target WHERE oi.id = target.order_item_id RETURNING 1 `, - [skuKey, onlyUnmatched, now], + [skuKey, onlyUnmatched, dateFrom, dateTo, now], ) // 配置生效后,把 SKU 对应的成本版本挂上(若已配置商品)。 @@ -446,6 +471,8 @@ export async function rematchManualFulfillments(input: Record = return { updatedCount: result.rowCount || 0, skuKey, + dateFrom, + dateTo, } } @@ -629,6 +656,12 @@ function normalizeCreditQuantity(value: unknown) { return parsed } +function normalizeOptionalDate(value: unknown, label: string) { + const text = String(value || '').trim() + if (!text) return null + return normalizeDate(text, label) +} + function normalizeDate(value: unknown, label: string) { const text = String(value || '').trim() if (!/^\d{4}-\d{2}-\d{2}$/.test(text)) { diff --git a/apps/backend/src/services/cost-center/platform-credit-service.ts b/apps/backend/src/services/cost-center/platform-credit-service.ts index c26e54ba..689c1dde 100644 --- a/apps/backend/src/services/cost-center/platform-credit-service.ts +++ b/apps/backend/src/services/cost-center/platform-credit-service.ts @@ -290,55 +290,73 @@ export async function consumePlatformCredit(input: { platformRef?: string note?: string }): Promise<{ entryId: number; balanceAfter: number; amountFen: number }> { + return withTransaction((client) => consumePlatformCreditWithClient(client, input)) +} + +/** + * 在调用方事务内消耗积分。 + * + * 人工履约的「扣积分 + 写成本快照」必须在同一事务里完成, + * 否则快照失败会留下已扣积分、未记成本的资金洞。 + */ +export async function consumePlatformCreditWithClient( + client: PoolClient, + input: { + ownerAdminUserId: number + platformKey: PlatformCreditKey + quantity: number + relatedTaskId?: number | null + platformRef?: string + note?: string + }, +): Promise<{ entryId: number; balanceAfter: number; amountFen: number }> { const unitPrices = await loadUnitPrices() const unitPriceFen = unitPrices[input.platformKey] const now = nowIso() - return withTransaction(async (client) => { - await ensureAccountRow(client, input.ownerAdminUserId, input.platformKey, unitPriceFen, now) - const account = await lockAccount(client, input.ownerAdminUserId, input.platformKey) - const balance = Number(account.balance_quantity) - if (balance < input.quantity) { - throw createHttpError( - `${PLATFORM_LABELS[input.platformKey]}积分不足(当前 ${balance},需要 ${input.quantity}),请联系主管充值`, - { - statusCode: 409, - errorCode: 'platform_credit_balance_insufficient', - }, - ) - } - const nextBalance = balance - input.quantity - await client.query( - `UPDATE platform_credit_accounts - SET balance_quantity = $2, updated_at = $3 - WHERE id = $1`, - [account.id, nextBalance, now], + await ensureAccountRow(client, input.ownerAdminUserId, input.platformKey, unitPriceFen, now) + const account = await lockAccount(client, input.ownerAdminUserId, input.platformKey) + const balance = Number(account.balance_quantity) + if (balance < input.quantity) { + throw createHttpError( + `${PLATFORM_LABELS[input.platformKey]}积分不足(当前 ${balance},需要 ${input.quantity}),请联系主管充值`, + { + statusCode: 409, + errorCode: 'platform_credit_balance_insufficient', + }, ) - const entryResult = await client.query( - ` - INSERT INTO platform_credit_entries ( - account_id, entry_type, quantity_delta, balance_after, amount_fen, - platform_ref, related_task_id, note, created_at - ) VALUES ($1, 'consume', $2, $3, $4, $5, $6, $7, $8) - RETURNING * - `, - [ - account.id, - -input.quantity, - nextBalance, - input.quantity * unitPriceFen, - input.platformRef || '', - input.relatedTaskId ?? null, - input.note || '', - now, - ], - ) - return { - entryId: Number(entryResult.rows[0]?.id || 0), - balanceAfter: nextBalance, - amountFen: input.quantity * unitPriceFen, - } - }) + } + const nextBalance = balance - input.quantity + await client.query( + `UPDATE platform_credit_accounts + SET balance_quantity = $2, updated_at = $3 + WHERE id = $1`, + [account.id, nextBalance, now], + ) + const entryResult = await client.query( + ` + INSERT INTO platform_credit_entries ( + account_id, entry_type, quantity_delta, balance_after, amount_fen, + platform_ref, related_task_id, note, created_at + ) VALUES ($1, 'consume', $2, $3, $4, $5, $6, $7, $8) + RETURNING * + `, + [ + account.id, + -input.quantity, + nextBalance, + input.quantity * unitPriceFen, + input.platformRef || '', + input.relatedTaskId ?? null, + input.note || '', + now, + ], + ) + return { + entryId: Number(entryResult.rows[0]?.id || 0), + balanceAfter: nextBalance, + amountFen: input.quantity * unitPriceFen, + } } /** 积分流水列表。 */ diff --git a/apps/backend/src/services/finance/manual-fulfillment-integrity.test.ts b/apps/backend/src/services/finance/manual-fulfillment-integrity.test.ts new file mode 100644 index 00000000..c7dfdfca --- /dev/null +++ b/apps/backend/src/services/finance/manual-fulfillment-integrity.test.ts @@ -0,0 +1,145 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import test from 'node:test' + +import { resolveManualFulfillmentCost } from './manual-fulfillment-service.js' + +test('人工成本:固定成本按配置积分 × 平台换算率', () => { + assert.deepEqual( + resolveManualFulfillmentCost({ + creditQuantity: 4, + unitPriceFen: 100, + providedCostFen: undefined, + }), + { costFen: 400, costMode: 'fixed' }, + ) + // 固定成本时手填金额不生效(客服零输入)。 + assert.equal( + resolveManualFulfillmentCost({ creditQuantity: 2, unitPriceFen: 100, providedCostFen: 999 }) + .costFen, + 200, + ) +}) + +test('人工成本:成本不固定必须手填正数金额', () => { + assert.deepEqual( + resolveManualFulfillmentCost({ + creditQuantity: 0, + unitPriceFen: 1, + providedCostFen: 1234, + }), + { costFen: 1234, costMode: 'variable' }, + ) + assert.throws( + () => resolveManualFulfillmentCost({ creditQuantity: 0, unitPriceFen: 1 }), + /成本不固定/, + ) + assert.throws( + () => resolveManualFulfillmentCost({ creditQuantity: 0, unitPriceFen: 1, providedCostFen: 0 }), + /成本不固定/, + ) + assert.throws( + () => + resolveManualFulfillmentCost({ + creditQuantity: 0, + unitPriceFen: 1, + providedCostFen: -1, + }), + /金额无效/, + ) +}) + +test('人工确认必须单事务:扣款与快照同事务,且使用 with-client 版本', async () => { + const source = await readFile(new URL('./manual-fulfillment-service.ts', import.meta.url), 'utf8') + const start = source.indexOf('export async function confirmManualFulfillment') + const end = source.indexOf('async function resolveFundAccountId') + const body = source.slice(start, end) + + assert.ok(body.includes('withTransaction('), '人工确认必须包在事务里') + assert.ok( + body.indexOf('withTransaction(') < body.indexOf('writeFundExpense(client'), + '备用金扣款必须在同一事务内执行', + ) + assert.ok( + body.includes('consumePlatformCreditWithClient(client'), + '积分扣减必须复用调用方事务(不能再用自带事务的 consumePlatformCredit)', + ) + assert.ok( + !body.includes('consumePlatformCredit('), + 'confirm 内不得调用自带事务的 consumePlatformCredit', + ) + assert.ok( + body.includes('FOR UPDATE OF oi') && body.includes("subject_type = 'manual_case'"), + '必须先锁订单项并复查快照,避免并发重复扣款', + ) + assert.ok( + body.includes("'manual_case', $1") && body.includes('subjectSource'), + 'manual_case 的 subject_id 必须是 order_item_id,资金来源只写 metadata', + ) +}) + +test('人工确认前必须自动匹配履约配置,配置保存/定时任务也要回填', async () => { + const [manualSource, configSource, jobSource] = await Promise.all([ + readFile(new URL('./manual-fulfillment-service.ts', import.meta.url), 'utf8'), + readFile( + new URL('../cost-center/manual-fulfillment-config-service.ts', import.meta.url), + 'utf8', + ), + readFile(new URL('../scheduler/cost-finance-sync-job.ts', import.meta.url), 'utf8'), + ]) + assert.ok( + manualSource.includes('matchManualFulfillmentConfigWithClient(client, orderItemId, now)'), + '确认时必须按 SKU 自动挂渠道', + ) + assert.ok( + configSource.includes('rematchManualFulfillments({') && + configSource.includes('skuKey: config.sku_key') && + configSource.includes('dateFrom: shiftDate(today, -29)'), + '保存人工 SKU 配置后必须立即回填最近 30 天订单项', + ) + assert.ok( + jobSource.includes('rematchManualFulfillments({') && + jobSource.includes('onlyUnmatched: true') && + jobSource.includes('dateFrom:'), + '定时同步必须自动回填未匹配的人工订单项,并限制在最近窗口', + ) + assert.ok( + manualSource.includes('NULLIF(oi.sku_code') && configSource.includes('NULLIF(oi.sku_code'), + 'SKU 匹配必须有 sku_code / sku_name 兜底,不能只认 spec_json', + ) +}) + +test('人工成本缺失必须进入异常队列,且只监控首次配置之后的订单项', async () => { + const source = await readFile( + new URL('../cost-center/cost-snapshot-sync-service.ts', import.meta.url), + 'utf8', + ) + assert.ok(source.includes("'manual_cost_missing'"), '缺少人工成本缺失异常') + assert.ok( + source.includes('FROM manual_fulfillment_configs') && + source.includes('s.basis_at >= c.enforce_from'), + '人工异常必须以首次配置时间为起点,避免历史人工单刷屏', + ) + assert.ok( + source.includes("cs.subject_type = 'manual_case'") && + source.includes('cs.order_item_id = s.order_item_id'), + '人工异常回填/收口必须按订单项关联人工快照', + ) +}) + +test('迁移 111 提供订单项级人工成本唯一约束与 subject 语义约束', async () => { + const source = await readFile( + new URL('../../db/migrations/111_manual_cost_integrity.sql', import.meta.url), + 'utf8', + ) + assert.ok(source.includes('uq_cost_snapshots_manual_order_item'), '缺少订单项级人工快照唯一索引') + assert.ok( + source.includes("WHERE subject_type = 'manual_case' AND superseded_at IS NULL"), + '唯一索引必须只约束有效人工快照', + ) + assert.ok( + source.includes('subject_id <> order_item_id') && + source.includes('chk_cost_snapshots_manual_subject'), + '必须把历史 subject_id 归一并加语义约束', + ) +}) diff --git a/apps/backend/src/services/finance/manual-fulfillment-service.ts b/apps/backend/src/services/finance/manual-fulfillment-service.ts index eb015ebf..66b236d2 100644 --- a/apps/backend/src/services/finance/manual-fulfillment-service.ts +++ b/apps/backend/src/services/finance/manual-fulfillment-service.ts @@ -1,3 +1,5 @@ +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' @@ -8,7 +10,8 @@ import { buildManualFulfillmentReference, } from './manual-fulfillment-note.js' import { - consumePlatformCredit, + consumePlatformCreditWithClient, + PLATFORM_CREDIT_KEYS, type PlatformCreditKey, } from '../cost-center/platform-credit-service.js' @@ -52,6 +55,28 @@ type PendingRow = { fulfilled_at: string | null } +type ConfirmContextRow = { + order_item_id: number + order_id: number + order_id_resolved: number + platform_order_id: string + provider: string + shop_id: string + sku_code: string + sku_name: string + revenue_fen: string + canonical_product_id: number | null + fulfillment_mode: string + config_id: number | null + platform_key: string + credit_quantity: string + cost_mode: string + credit_unit_price_fen: string + work_order_id: number | null + work_order_no: string | null + work_product_rule_id: number | null +} + /** * 列出客服的人工履约待办。 * @@ -128,7 +153,12 @@ export async function listManualFulfillmentPending( o.shop_id, oi.sku_code, oi.sku_name, - COALESCE(NULLIF(oi.spec_json->>'skuNick',''), NULLIF(oi.spec_json->>'outSkuSn','')) AS internal_sku, + COALESCE( + NULLIF(oi.spec_json->>'skuNick',''), + NULLIF(oi.spec_json->>'outSkuSn',''), + NULLIF(oi.sku_code,''), + NULLIF(oi.sku_name,'') + ) AS internal_sku, COALESCE(oi.revenue_amount_fen, 0)::bigint AS revenue_fen, COALESCE(o.paid_at, o.created_at) AS paid_at, cfg.id AS config_id, @@ -167,135 +197,134 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm ) const now = nowIso() - const context = await query<{ - order_item_id: number - order_id: number - order_id_resolved: number - platform_order_id: string - provider: string - shop_id: string - sku_code: string - sku_name: string - revenue_fen: string - canonical_product_id: number | null - fulfillment_mode: string - config_id: number | null - platform_key: string - credit_quantity: string - cost_mode: string - credit_unit_price_fen: string - work_order_id: number | null - work_order_no: string | null - work_product_rule_id: number | null - fulfilled_at: string | null - }>( - ` - WITH fulfilled AS ( - SELECT cs.order_item_id, MAX(cs.created_at) AS fulfilled_at - FROM cost_snapshots cs - WHERE cs.subject_type = 'manual_case' AND cs.superseded_at IS NULL - GROUP BY cs.order_item_id - ) - SELECT - oi.id AS order_item_id, - oi.order_id, - o.id AS order_id_resolved, - o.platform_order_id, - o.provider, - o.shop_id, - oi.sku_code, - oi.sku_name, - COALESCE(oi.revenue_amount_fen, 0)::bigint AS revenue_fen, - oi.canonical_product_id, - oi.fulfillment_mode, - oi.fulfillment_config_id AS config_id, - COALESCE(cfg.platform_key, '') AS platform_key, - COALESCE(cfg.credit_quantity, 0)::bigint AS credit_quantity, - COALESCE(cfg.cost_mode, 'variable') AS cost_mode, - COALESCE((fp.config_json->>'rechargeUnitPriceFen')::bigint, 1) AS credit_unit_price_fen, - wo.id AS work_order_id, - wo.work_order_no, - wo.product_rule_id AS work_product_rule_id, - fulfilled.fulfilled_at - FROM order_items oi - JOIN orders o ON o.id = oi.order_id - LEFT JOIN manual_fulfillment_configs cfg ON cfg.id = oi.fulfillment_config_id - LEFT JOIN fulfillment_profiles fp ON fp.profile_key = 'manual_' || cfg.platform_key - LEFT JOIN LATERAL ( - SELECT id, product_rule_id, work_order_no FROM work_orders - WHERE order_item_id = oi.id ORDER BY id DESC LIMIT 1 - ) wo ON TRUE - LEFT JOIN fulfilled ON fulfilled.order_item_id = oi.id - WHERE oi.id = $1 - LIMIT 1 - `, - [orderItemId], - ) + // 钱与成本必须同一事务:先锁订单项、复查未履约,再扣款、最后写快照。 + // 任何一步失败(余额不足、成本版本缺失、重复快照)都整体回滚,不会留下「扣了钱没记成本」。 + return withTransaction(async (client) => { + // 配置可能晚于订单创建:确认前按 SKU 再匹配一次(与定时 rematch 同口径), + // 保证「配置好之后的新单」不需要人工再点一次「重新匹配」。 + await matchManualFulfillmentConfigWithClient(client, orderItemId, now) - const row = context.rows[0] - if (!row) { - throw createHttpError('订单项不存在', { - statusCode: 404, - errorCode: 'manual_fulfillment_order_item_not_found', - }) - } - if (row.fulfillment_mode !== 'manual') { - throw createHttpError('该订单项不是人工履约', { - statusCode: 409, - errorCode: 'manual_fulfillment_not_manual', - }) - } - if (row.fulfilled_at) { - throw createHttpError('该订单项已确认过履约,不能重复操作', { - statusCode: 409, - errorCode: 'manual_fulfillment_already_done', - }) - } - if (!row.config_id) { - throw createHttpError('该商品还没有配置履约渠道,请先在成本中心配置', { - statusCode: 409, - errorCode: 'manual_fulfillment_config_missing', - }) - } + const context = await client.query( + ` + SELECT + oi.id AS order_item_id, + oi.order_id, + o.id AS order_id_resolved, + o.platform_order_id, + o.provider, + o.shop_id, + oi.sku_code, + oi.sku_name, + COALESCE(oi.revenue_amount_fen, 0)::bigint AS revenue_fen, + oi.canonical_product_id, + oi.fulfillment_mode, + oi.fulfillment_config_id AS config_id, + COALESCE(cfg.platform_key, '') AS platform_key, + COALESCE(cfg.credit_quantity, 0)::bigint AS credit_quantity, + COALESCE(cfg.cost_mode, 'variable') AS cost_mode, + COALESCE((fp.config_json->>'rechargeUnitPriceFen')::bigint, 1) AS credit_unit_price_fen, + wo.id AS work_order_id, + wo.work_order_no, + wo.product_rule_id AS work_product_rule_id + FROM order_items oi + JOIN orders o ON o.id = oi.order_id + LEFT JOIN manual_fulfillment_configs cfg ON cfg.id = oi.fulfillment_config_id + LEFT JOIN fulfillment_profiles fp ON fp.profile_key = 'manual_' || cfg.platform_key + LEFT JOIN LATERAL ( + SELECT id, product_rule_id, work_order_no FROM work_orders + WHERE order_item_id = oi.id ORDER BY id DESC LIMIT 1 + ) wo ON TRUE + WHERE oi.id = $1 + LIMIT 1 + FOR UPDATE OF oi + `, + [orderItemId], + ) - const platformKey = row.platform_key as PlatformCreditKey | 'fund_cash' - const creditQuantity = Number(row.credit_quantity || 0) - const unitPriceFen = Number(row.credit_unit_price_fen || 1) - - // 成本:固定成本用配置值;成本不固定必须客服手填。 - let costFen: number - if (creditQuantity > 0) { - costFen = creditQuantity * unitPriceFen - } else { - const provided = payload.costFen ?? payload.cost_fen ?? payload.amountFen - costFen = normalizeCostFen(provided) - if (costFen === 0) { - throw createHttpError('该商品成本不固定,请填写本次实际成本', { - statusCode: 400, - errorCode: 'manual_fulfillment_cost_required', + const row = context.rows[0] + if (!row) { + throw createHttpError('订单项不存在', { + statusCode: 404, + errorCode: 'manual_fulfillment_order_item_not_found', + }) + } + if (row.fulfillment_mode !== 'manual') { + throw createHttpError('该订单项不是人工履约', { + statusCode: 409, + errorCode: 'manual_fulfillment_not_manual', + }) + } + if (!row.config_id) { + throw createHttpError('该商品还没有配置履约渠道,请先在成本中心配置', { + statusCode: 409, + errorCode: 'manual_fulfillment_config_missing', + }) + } + const platformKey = row.platform_key as PlatformCreditKey | 'fund_cash' + if (platformKey !== 'fund_cash' && !PLATFORM_CREDIT_KEYS.includes(platformKey)) { + throw createHttpError('该商品的人工履约平台配置无效,请重新配置', { + statusCode: 409, + errorCode: 'manual_fulfillment_config_invalid', }) } - } - const note = optionalText(payload.note, 300) - const payeeName = optionalText(payload.payeeName ?? payload.payee_name, 120) - const fulfillmentReference = buildManualFulfillmentReference({ - workOrderNo: row.work_order_no, - platformOrderId: row.platform_order_id, - orderItemId, - }) + // 锁内复查:并发/双击的第二个请求会等锁,醒来后看到快照直接拒绝,不会重复扣款。 + const existing = await client.query<{ id: number }>( + `SELECT id FROM cost_snapshots + WHERE subject_type = 'manual_case' AND order_item_id = $1 AND superseded_at IS NULL + LIMIT 1`, + [orderItemId], + ) + if (existing.rows[0]) { + throw createHttpError('该订单项已确认过履约,不能重复操作', { + statusCode: 409, + errorCode: 'manual_fulfillment_already_done', + }) + } - // 1. 先扣额度(积分平台走积分钱包;备用金现金走现有 fund_* 链路)。 - let creditEntryId: number | null = null - let fundExpenseId: number | null = null - let balanceAfter: number | null = null + const creditQuantity = Number(row.credit_quantity || 0) + const unitPriceFen = Number(row.credit_unit_price_fen || 1) + if (platformKey !== 'fund_cash' && creditQuantity <= 0) { + throw createHttpError('该人工 SKU 的积分配置无效,请先在成本中心重新配置', { + statusCode: 409, + errorCode: 'manual_fulfillment_config_invalid', + }) + } - if (platformKey === 'fund_cash') { - // 备用金现金:复用现有客服备用金支出链路(扣余额 + 写 fund_entries + manual_expenses)。 - const { writeFundExpense } = await import('../admin/admin-fund-service.js') - const fundAccountId = await resolveFundAccountId(session) - const written = await withTransaction(async (client) => { - return writeFundExpense(client, { + const costPlan = resolveManualFulfillmentCost({ + creditQuantity, + unitPriceFen, + providedCostFen: payload.costFen ?? payload.cost_fen ?? payload.amountFen, + }) + const costFen = costPlan.costFen + const payeeName = optionalText(payload.payeeName ?? payload.payee_name, 120) + const note = optionalText(payload.note, 300) + const fulfillmentReference = buildManualFulfillmentReference({ + workOrderNo: row.work_order_no, + platformOrderId: row.platform_order_id, + orderItemId, + }) + + // 先把成本项/版本解析完再扣钱:版本缺失不能发生在扣款之后。 + const costItemId = await resolveManualCostItemIdWithClient(client) + const versionId = await resolveCostVersionIdWithClient(client, costItemId, row, now) + if (!versionId) { + throw createHttpError('该成本项没有可用的成本版本,请先在成本中心配置后重试', { + statusCode: 409, + errorCode: 'manual_fulfillment_cost_version_missing', + }) + } + + let fundExpenseId: number | null = null + let creditEntryId: number | null = null + let balanceAfter: number | null = null + let subjectSource: 'manual_expense' | 'platform_credit' + + if (platformKey === 'fund_cash') { + // 备用金现金:复用唯一写入口,但把 client 传进去,和快照共用同一个事务。 + const fundAccountId = await resolveFundAccountId(session, client) + const { writeFundExpense } = await import('../admin/admin-fund-service.js') + const written = await writeFundExpense(client, { agentAccountId: fundAccountId, amountFen: costFen, category: 'other', @@ -312,115 +341,122 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm maxLength: 500, }), actorUserId: session.userId, + // 同一订单可能有多个同价人工项:幂等由订单项锁 + 快照唯一索引保证, + // 这里放开 24 小时同金额同收款方的通用防重,避免误拦第二个订单项。 + allowDuplicate: true, }) - }) - fundExpenseId = Number(written.expense.id) - balanceAfter = written.balanceAfterFen - } else if (creditQuantity > 0) { - const consumed = await consumePlatformCredit({ - ownerAdminUserId: session.userId, - platformKey: platformKey as PlatformCreditKey, - quantity: creditQuantity, - relatedTaskId: null, - platformRef: row.work_order_no || row.platform_order_id, - note: buildManualFulfillmentNote({ - skuName: row.sku_name, - reference: fulfillmentReference, - note, - maxLength: 300, - }), - }) - creditEntryId = consumed.entryId - balanceAfter = consumed.balanceAfter - } - - // 2. 写 actual 成本快照(主体 = 备用金支出单 / 积分流水)。 - const subjectId = fundExpenseId ?? creditEntryId ?? orderItemId - const costItemId = await resolveManualCostItemId() - const versionId = await resolveCostVersionId(costItemId, row, now) - - if (!versionId) { - throw createHttpError('该成本项没有可用的成本版本,请先在成本中心配置后重试', { - statusCode: 409, - errorCode: 'manual_fulfillment_cost_version_missing', - }) - } - - const snapshot = await query<{ id: number }>( - ` - INSERT INTO cost_snapshots ( - subject_type, subject_id, order_id, order_item_id, work_order_id, - cost_item_id, cost_version_id, canonical_product_id, - quantity, unit_amount_fen, total_amount_fen, snapshot_stage, source, - metadata_json, cost_basis_at, resolved_at, created_at - ) VALUES ( - 'manual_case', $1, $2, $3, $4, - $5, $6, $7, - 1, $8, $8, 'actual', 'manual_input', - $9::jsonb, $10, $11, $11 - ) - ON CONFLICT (subject_type, subject_id, cost_item_id) - WHERE superseded_at IS NULL - DO NOTHING - RETURNING id - `, - [ - subjectId, - row.order_id_resolved, - orderItemId, - row.work_order_id, - costItemId, - versionId, - row.canonical_product_id, - costFen, - JSON.stringify({ - source: 'manual_fulfillment_confirm', + fundExpenseId = Number(written.expense.id) + balanceAfter = written.balanceAfterFen + subjectSource = 'manual_expense' + } else { + const consumed = await consumePlatformCreditWithClient(client, { + ownerAdminUserId: session.userId, platformKey, - platformLabel: PLATFORM_LABELS[platformKey] || platformKey, - creditQuantity, - creditUnitPriceFen: unitPriceFen, - costMode: creditQuantity > 0 ? 'fixed' : 'variable', - costFen, - fundExpenseId, - creditEntryId, - payeeName, - note, - fulfilledBy: { - userId: Number(session.userId || 0), - displayName: String(session.displayName || ''), - role: String(session.role || ''), - }, - }), - now, - nowIso(), - ], - ) - const snapshotId = snapshot.rows[0]?.id ?? null - if (!snapshotId) { - throw createHttpError('该订单项已有人工成本快照,不能重复录入', { - statusCode: 409, - errorCode: 'manual_fulfillment_snapshot_conflict', - }) - } + quantity: creditQuantity, + relatedTaskId: null, + platformRef: row.work_order_no || row.platform_order_id, + note: buildManualFulfillmentNote({ + skuName: row.sku_name, + reference: fulfillmentReference, + note, + maxLength: 300, + }), + }) + creditEntryId = consumed.entryId + balanceAfter = consumed.balanceAfter + subjectSource = 'platform_credit' + } - return { - orderItemId, - orderId: Number(row.order_id_resolved), - costSnapshotId: snapshotId, - platformKey, - platformLabel: PLATFORM_LABELS[platformKey] || platformKey, - creditQuantity, - costFen, - fundExpenseId, - creditEntryId, - balanceAfter, - } + // subject_id 固定为 order_item_id:不再与 manual_expenses.id / + // platform_credit_entries.id 混用,避免两张表序列撞车。 + // subject_type 仍为 manual_case,资金流水只放在 metadata 里。 + const snapshot = await client.query<{ id: number }>( + ` + INSERT INTO cost_snapshots ( + subject_type, subject_id, order_id, order_item_id, work_order_id, + cost_item_id, cost_version_id, canonical_product_id, + quantity, unit_amount_fen, total_amount_fen, snapshot_stage, source, + metadata_json, cost_basis_at, resolved_at, created_at + ) VALUES ( + 'manual_case', $1, $2, $1, $3, + $4, $5, $6, + 1, $7, $7, 'actual', 'manual_input', + $8::jsonb, $9, $9, $9 + ) + ON CONFLICT DO NOTHING + RETURNING id + `, + [ + orderItemId, + row.order_id_resolved, + row.work_order_id, + costItemId, + versionId, + row.canonical_product_id, + costFen, + JSON.stringify({ + source: 'manual_fulfillment_confirm', + subjectSource, + configId: Number(row.config_id), + platformKey, + platformLabel: PLATFORM_LABELS[platformKey] || platformKey, + creditQuantity, + creditUnitPriceFen: unitPriceFen, + costMode: costPlan.costMode, + costFen, + fundExpenseId, + creditEntryId, + balanceAfter, + payeeName, + note, + fulfilledBy: { + userId: Number(session.userId || 0), + displayName: String(session.displayName || ''), + role: String(session.role || ''), + }, + }), + now, + ], + ) + const snapshotId = snapshot.rows[0]?.id ?? null + if (!snapshotId) { + // 订单项唯一索引兜底:任何竞态都会走到这里,抛错回滚本次扣款。 + throw createHttpError('该订单项已有人工成本快照,不能重复录入', { + statusCode: 409, + errorCode: 'manual_fulfillment_snapshot_conflict', + }) + } + + // 人工缺成本异常立即收口,不等下一轮定时任务。 + await client.query( + `UPDATE cost_exceptions + SET status = 'resolved', resolved_at = $2 + WHERE subject_type = 'order_item' + AND subject_id = $1 + AND reason = 'manual_cost_missing' + AND status = 'open'`, + [orderItemId, now], + ) + + return { + orderItemId, + orderId: Number(row.order_id_resolved), + costSnapshotId: snapshotId, + platformKey, + platformLabel: PLATFORM_LABELS[platformKey] || platformKey, + creditQuantity, + costFen, + fundExpenseId, + creditEntryId, + balanceAfter, + } + }) } /** 客服自己的备用金账户(管理员代录时用指定账户)。 */ -async function resolveFundAccountId(session: AdminSession) { +async function resolveFundAccountId(session: AdminSession, client?: Pick) { const { getOwnAgentAccountId } = await import('../admin/admin-fund-service.js') - const own = await getOwnAgentAccountId(session.userId) + const own = await getOwnAgentAccountId(session.userId, client) if (own) return own throw createHttpError('当前账号还没有备用金账户', { statusCode: 404, @@ -428,8 +464,67 @@ async function resolveFundAccountId(session: AdminSession) { }) } -async function resolveManualCostItemId() { - const result = await query<{ id: number }>( +/** + * 确认前按 SKU 匹配人工履约配置。 + * + * 与 `rematchManualFulfillments` 使用同一套键: + * spec_json.skuNick -> spec_json.outSkuSn -> sku_code -> sku_name。 + * 这样即使配置晚于订单创建,新单在确认时也能自动挂上渠道。 + */ +async function matchManualFulfillmentConfigWithClient( + client: PoolClient, + orderItemId: number, + now: string, +) { + const matched = await client.query<{ + id: number + canonical_product_id: number | null + }>( + ` + SELECT cfg.id, cfg.canonical_product_id + FROM order_items oi + JOIN manual_fulfillment_configs cfg ON cfg.enabled = TRUE + AND ( + cfg.sku_key = COALESCE( + NULLIF(oi.spec_json->>'skuNick', ''), + NULLIF(oi.spec_json->>'outSkuSn', ''), + NULLIF(oi.sku_code, ''), + NULLIF(oi.sku_name, '') + ) + OR ( + cfg.canonical_product_id IS NOT NULL + AND cfg.canonical_product_id = oi.canonical_product_id + ) + ) + WHERE oi.id = $1 + AND oi.fulfillment_mode = 'manual' + AND oi.fulfillment_config_id IS NULL + ORDER BY + (cfg.canonical_product_id IS NOT NULL + AND cfg.canonical_product_id = oi.canonical_product_id) DESC, + cfg.id ASC + LIMIT 1 + `, + [orderItemId], + ) + const match = matched.rows[0] + if (!match) return false + await client.query( + ` + UPDATE order_items + SET fulfillment_config_id = $2, + canonical_product_id = COALESCE($3, canonical_product_id), + updated_at = $4 + WHERE id = $1 + AND fulfillment_config_id IS NULL + `, + [orderItemId, match.id, match.canonical_product_id, now], + ) + return true +} + +async function resolveManualCostItemIdWithClient(client: PoolClient) { + const result = await client.query<{ id: number }>( `SELECT id FROM cost_items WHERE code = 'sys_manual_service' AND status = 'active' LIMIT 1`, ) const id = Number(result.rows[0]?.id || 0) @@ -442,7 +537,8 @@ async function resolveManualCostItemId() { return id } -async function resolveCostVersionId( +async function resolveCostVersionIdWithClient( + client: PoolClient, costItemId: number, row: { order_id_resolved: number @@ -454,7 +550,7 @@ async function resolveCostVersionId( }, basisAt: string, ) { - const result = await query<{ id: number }>( + const result = await client.query<{ id: number }>( ` SELECT v.id FROM cost_versions v @@ -499,6 +595,40 @@ async function resolveCostVersionId( return result.rows[0]?.id ?? null } +/** + * 人工履约的本次成本:固定成本按「配置积分 × 平台换算率」,成本不固定必须客服手填。 + * 抽成纯函数,便于单测覆盖固定/手填/非法金额三种分支。 + */ +export function resolveManualFulfillmentCost(input: { + creditQuantity: number + unitPriceFen: number + providedCostFen?: unknown +}): { costFen: number; costMode: 'fixed' | 'variable' } { + const creditQuantity = Math.max(0, Math.trunc(Number(input.creditQuantity) || 0)) + if (creditQuantity > 0) { + const unitPriceFen = Math.max(0, Math.trunc(Number(input.unitPriceFen) || 0)) + return { costFen: creditQuantity * unitPriceFen, costMode: 'fixed' } + } + if ( + input.providedCostFen === undefined || + input.providedCostFen === null || + input.providedCostFen === '' + ) { + throw createHttpError('该商品成本不固定,请填写本次实际成本', { + statusCode: 400, + errorCode: 'manual_fulfillment_cost_required', + }) + } + const costFen = normalizeCostFen(input.providedCostFen) + if (costFen <= 0) { + throw createHttpError('该商品成本不固定,请填写本次实际成本', { + statusCode: 400, + errorCode: 'manual_fulfillment_cost_required', + }) + } + return { costFen, costMode: 'variable' } +} + function mapPending(row: PendingRow) { const creditQuantity = Number(row.credit_quantity || 0) const unitPriceFen = Number(row.credit_unit_price_fen || 1) diff --git a/apps/backend/src/services/finance/manual-fulfillment.integration.test.ts b/apps/backend/src/services/finance/manual-fulfillment.integration.test.ts new file mode 100644 index 00000000..d2e7133d --- /dev/null +++ b/apps/backend/src/services/finance/manual-fulfillment.integration.test.ts @@ -0,0 +1,406 @@ +/** + * 人工履约资金链路集成测试(需要真实 PostgreSQL)。 + * + * 运行方式: + * MANUAL_FULFILLMENT_TEST_DATABASE_URL=postgres://.../scratch npm test -- --test-name-pattern ... + * 或直接: + * MANUAL_FULFILLMENT_TEST_DATABASE_URL=... npx tsx --test src/services/finance/manual-fulfillment.integration.test.ts + * + * 约定:目标库必须已执行到迁移 111;测试只创建带唯一前缀的临时数据,结束后按依赖顺序清理。 + * 未设置环境变量时整个文件跳过,因此普通 `npm test` 不依赖数据库。 + */ +import assert from 'node:assert/strict' +import test from 'node:test' + +const TEST_DATABASE_URL = String(process.env.MANUAL_FULFILLMENT_TEST_DATABASE_URL || '').trim() + +test( + '人工履约:单事务、防重复扣款、subject 不再跨表撞车、缺成本入异常队列', + { skip: !TEST_DATABASE_URL ? '未设置 MANUAL_FULFILLMENT_TEST_DATABASE_URL' : false }, + async () => { + process.env.DATABASE_URL = TEST_DATABASE_URL + const { query, closeDb } = await import('../../db/client.js') + const { confirmManualFulfillment } = await import('./manual-fulfillment-service.js') + const { syncEstimatedOrderItemCostSnapshots } = + await import('../cost-center/cost-snapshot-sync-service.js') + const { rematchManualFulfillments } = + await import('../cost-center/manual-fulfillment-config-service.js') + + const suffix = `${Date.now()}_${Math.floor(Math.random() * 1_000_000)}` + const skuKey = `IT-MANUAL-${suffix}` + const creditSkuKey = `IT-CREDIT-${suffix}` + const unconfirmedSkuKey = `IT-PENDING-${suffix}` + const orderIds: number[] = [] + const orderItemIds: number[] = [] + const fundAccountIds: number[] = [] + const adminIds: number[] = [] + const creditAccountIds: number[] = [] + const configIds: number[] = [] + let triggerName = '' + let fnName = '' + + try { + // 1. 客服 + 备用金账户(余额 100 元) + const admin = await query<{ id: number }>( + `INSERT INTO admin_users (username, password_hash, role, status, created_at, updated_at) + VALUES ($1, 'x', 'support', 'active', NOW(), NOW()) RETURNING id`, + [`it_mf_${suffix}`], + ) + const adminId = Number(admin.rows[0]?.id) + adminIds.push(adminId) + const fundAccount = await query<{ id: number }>( + `INSERT INTO fund_accounts ( + account_no, account_type, owner_admin_user_id, balance_fen, status, created_at, updated_at + ) VALUES ($1, 'agent', $2, 10000, 'active', NOW(), NOW()) RETURNING id`, + [`FA-IT-${suffix}`, adminId], + ) + const fundAccountId = Number(fundAccount.rows[0]?.id) + fundAccountIds.push(fundAccountId) + + const session = { + userId: adminId, + role: 'support', + displayName: '集成测试客服', + } as never + + // 2. 现金人工项:配置在 1 小时前创建,订单项尚未挂 config(验证确认时自动匹配) + const cashSku = `${skuKey}-CASH` + const cashConfig = await query<{ id: number }>( + `INSERT INTO manual_fulfillment_configs ( + sku_key, platform_key, credit_quantity, cost_mode, enabled, note, created_at, updated_at + ) VALUES ($1, 'fund_cash', 0, 'variable', TRUE, '', NOW() - interval '1 hour', NOW()) + RETURNING id`, + [cashSku], + ) + configIds.push(Number(cashConfig.rows[0]?.id)) + const cashItem = await createManualItem(query, cashSku) + orderIds.push(cashItem.orderId) + orderItemIds.push(cashItem.orderItemId) + + // 配置晚于下单:rematch(最近窗口)应把订单项挂上配置 + const today = shanghaiDate() + const rematched = await rematchManualFulfillments({ + onlyUnmatched: true, + dateFrom: today, + dateTo: today, + }) + assert.ok(rematched.updatedCount >= 1) + const matchedConfig = await query<{ fulfillment_config_id: number | null }>( + `SELECT fulfillment_config_id FROM order_items WHERE id = $1`, + [cashItem.orderItemId], + ) + assert.ok(Number(matchedConfig.rows[0]?.fulfillment_config_id) > 0, 'rematch 必须挂上配置') + + const cashResult = await confirmManualFulfillment( + { orderItemId: cashItem.orderItemId, costFen: 1234 }, + session, + ) + assert.ok(cashResult.costSnapshotId > 0) + assert.equal(cashResult.costFen, 1234) + + const cashSnapshot = await query<{ + subject_id: number + order_item_id: number + total_amount_fen: number + metadata_json: Record + }>( + `SELECT subject_id, order_item_id, total_amount_fen, metadata_json + FROM cost_snapshots WHERE id = $1`, + [cashResult.costSnapshotId], + ) + assert.equal( + Number(cashSnapshot.rows[0]?.subject_id), + cashItem.orderItemId, + 'manual_case 的 subject_id 必须是 order_item_id', + ) + assert.equal(Number(cashSnapshot.rows[0]?.order_item_id), cashItem.orderItemId) + assert.equal(Number(cashSnapshot.rows[0]?.total_amount_fen), 1234) + assert.equal(cashSnapshot.rows[0]?.metadata_json?.fundExpenseId, cashResult.fundExpenseId) + + const cashExpense = await query<{ amount_fen: number; order_item_id: number }>( + `SELECT amount_fen, order_item_id FROM manual_expenses WHERE id = $1`, + [cashResult.fundExpenseId], + ) + assert.equal(Number(cashExpense.rows[0]?.amount_fen), 1234) + assert.equal(Number(cashExpense.rows[0]?.order_item_id), cashItem.orderItemId) + + const balanceAfterCash = await query<{ balance_fen: number }>( + `SELECT balance_fen FROM fund_accounts WHERE id = $1`, + [fundAccountId], + ) + assert.equal(Number(balanceAfterCash.rows[0]?.balance_fen), 10000 - 1234) + + // 3. 重复确认:必须拒绝,且不再扣款 + await assert.rejects( + () => + confirmManualFulfillment({ orderItemId: cashItem.orderItemId, costFen: 1234 }, session), + /已确认过履约/, + ) + const cashExpenseCount = await query<{ count: number }>( + `SELECT COUNT(*)::int AS count FROM manual_expenses WHERE order_item_id = $1`, + [cashItem.orderItemId], + ) + assert.equal(Number(cashExpenseCount.rows[0]?.count), 1, '重复确认不得产生第二条支出') + const balanceStill = await query<{ balance_fen: number }>( + `SELECT balance_fen FROM fund_accounts WHERE id = $1`, + [fundAccountId], + ) + assert.equal(Number(balanceStill.rows[0]?.balance_fen), 10000 - 1234) + + // 4. 并发确认:只有一个成功,扣款也只发生一次 + const concurrentItem = await createManualItem(query, cashSku) + orderIds.push(concurrentItem.orderId) + orderItemIds.push(concurrentItem.orderItemId) + const concurrent = await Promise.allSettled([ + confirmManualFulfillment( + { orderItemId: concurrentItem.orderItemId, costFen: 500 }, + session, + ), + confirmManualFulfillment( + { orderItemId: concurrentItem.orderItemId, costFen: 500 }, + session, + ), + ]) + assert.equal(concurrent.filter((item) => item.status === 'fulfilled').length, 1) + assert.equal(concurrent.filter((item) => item.status === 'rejected').length, 1) + const concurrentExpense = await query<{ count: number }>( + `SELECT COUNT(*)::int AS count FROM manual_expenses WHERE order_item_id = $1`, + [concurrentItem.orderItemId], + ) + assert.equal(Number(concurrentExpense.rows[0]?.count), 1, '并发确认只能扣一次') + const balanceAfterConcurrent = await query<{ balance_fen: number }>( + `SELECT balance_fen FROM fund_accounts WHERE id = $1`, + [fundAccountId], + ) + assert.equal(Number(balanceAfterConcurrent.rows[0]?.balance_fen), 10000 - 1234 - 500) + + // 5. 积分人工项:与现金项同时存在,subject_id 都是 order_item_id,不撞唯一键 + const creditAccount = await query<{ id: number }>( + `INSERT INTO platform_credit_accounts ( + platform_key, owner_admin_user_id, balance_quantity, unit_name, + recharge_unit_price_fen, status, created_at, updated_at + ) VALUES ('nanhang', $1, 10, '积分', 1, 'active', NOW(), NOW()) RETURNING id`, + [adminId], + ) + creditAccountIds.push(Number(creditAccount.rows[0]?.id)) + const creditSku = `${creditSkuKey}-NANHANG` + const creditConfig = await query<{ id: number }>( + `INSERT INTO manual_fulfillment_configs ( + sku_key, platform_key, credit_quantity, cost_mode, enabled, note, created_at, updated_at + ) VALUES ($1, 'nanhang', 2, 'fixed', TRUE, '', NOW() - interval '1 hour', NOW()) + RETURNING id`, + [creditSku], + ) + configIds.push(Number(creditConfig.rows[0]?.id)) + const creditItem = await createManualItem(query, creditSku) + orderIds.push(creditItem.orderId) + orderItemIds.push(creditItem.orderItemId) + + const creditResult = await confirmManualFulfillment( + { orderItemId: creditItem.orderItemId }, + session, + ) + assert.equal(creditResult.costFen, 2) + assert.ok(creditResult.creditEntryId) + const creditSnapshot = await query<{ + subject_id: number + metadata_json: Record + }>(`SELECT subject_id, metadata_json FROM cost_snapshots WHERE id = $1`, [ + creditResult.costSnapshotId, + ]) + assert.equal(Number(creditSnapshot.rows[0]?.subject_id), creditItem.orderItemId) + assert.equal(creditSnapshot.rows[0]?.metadata_json?.subjectSource, 'platform_credit') + const creditBalance = await query<{ balance_quantity: number }>( + `SELECT balance_quantity FROM platform_credit_accounts WHERE id = $1`, + [creditAccountIds[0]], + ) + assert.equal(Number(creditBalance.rows[0]?.balance_quantity), 8) + const creditExpenseCount = await query<{ count: number }>( + `SELECT COUNT(*)::int AS count FROM manual_expenses WHERE order_item_id = $1`, + [creditItem.orderItemId], + ) + assert.equal(Number(creditExpenseCount.rows[0]?.count), 0, '积分路径不得写备用金支出') + + // 6. 快照写入失败必须回滚已扣款:用临时触发器强制 cost_snapshots 插入失败, + // 验证「钱与成本同一事务」不是只写在注释里。 + const failSku = `${skuKey}-FAIL` + const failConfig = await query<{ id: number }>( + `INSERT INTO manual_fulfillment_configs ( + sku_key, platform_key, credit_quantity, cost_mode, enabled, note, created_at, updated_at + ) VALUES ($1, 'fund_cash', 0, 'variable', TRUE, '', NOW() - interval '1 hour', NOW()) + RETURNING id`, + [failSku], + ) + configIds.push(Number(failConfig.rows[0]?.id)) + const failItem = await createManualItem(query, failSku) + orderIds.push(failItem.orderId) + orderItemIds.push(failItem.orderItemId) + triggerName = `it_mf_fail_${suffix}` + fnName = `it_mf_fail_fn_${suffix}` + await query( + `CREATE OR REPLACE FUNCTION ${fnName}() RETURNS trigger AS $$ + BEGIN + IF NEW.order_item_id = ${Number(failItem.orderItemId)} THEN + RAISE EXCEPTION 'forced snapshot failure for test'; + END IF; + RETURN NEW; + END $$ LANGUAGE plpgsql`, + ) + await query( + `CREATE TRIGGER ${triggerName} BEFORE INSERT ON cost_snapshots + FOR EACH ROW EXECUTE FUNCTION ${fnName}()`, + ) + const balanceBeforeFail = await query<{ balance_fen: number }>( + `SELECT balance_fen FROM fund_accounts WHERE id = $1`, + [fundAccountId], + ) + await assert.rejects( + () => + confirmManualFulfillment({ orderItemId: failItem.orderItemId, costFen: 700 }, session), + /forced snapshot failure/, + ) + const balanceAfterFail = await query<{ balance_fen: number }>( + `SELECT balance_fen FROM fund_accounts WHERE id = $1`, + [fundAccountId], + ) + assert.equal( + Number(balanceAfterFail.rows[0]?.balance_fen), + Number(balanceBeforeFail.rows[0]?.balance_fen), + '快照失败必须回滚扣款', + ) + const failExpense = await query<{ count: number }>( + `SELECT COUNT(*)::int AS count FROM manual_expenses WHERE order_item_id = $1`, + [failItem.orderItemId], + ) + assert.equal(Number(failExpense.rows[0]?.count), 0, '快照失败不得留下支出') + const failSnapshot = await query<{ count: number }>( + `SELECT COUNT(*)::int AS count FROM cost_snapshots WHERE order_item_id = $1`, + [failItem.orderItemId], + ) + assert.equal(Number(failSnapshot.rows[0]?.count), 0, '快照失败不得留下成本') + await query(`DROP TRIGGER ${triggerName} ON cost_snapshots`) + await query(`DROP FUNCTION ${fnName}()`) + + // 7. 人工缺成本:首次配置之后、尚未确认的订单项进入 manual_cost_missing;确认后自动收口 + const pendingSku = `${unconfirmedSkuKey}-CASH` + const pendingConfig = await query<{ id: number }>( + `INSERT INTO manual_fulfillment_configs ( + sku_key, platform_key, credit_quantity, cost_mode, enabled, note, created_at, updated_at + ) VALUES ($1, 'fund_cash', 0, 'variable', TRUE, '', NOW() - interval '1 hour', NOW()) + RETURNING id`, + [pendingSku], + ) + configIds.push(Number(pendingConfig.rows[0]?.id)) + const pendingItem = await createManualItem(query, pendingSku) + orderIds.push(pendingItem.orderId) + orderItemIds.push(pendingItem.orderItemId) + + await syncEstimatedOrderItemCostSnapshots({ dateFrom: today, dateTo: today }) + const openManualException = await query<{ count: number }>( + `SELECT COUNT(*)::int AS count FROM cost_exceptions + WHERE reason = 'manual_cost_missing' AND status = 'open' AND subject_id = $1`, + [pendingItem.orderItemId], + ) + assert.equal(Number(openManualException.rows[0]?.count), 1, '人工缺成本必须进异常队列') + + await confirmManualFulfillment( + { orderItemId: pendingItem.orderItemId, costFen: 100 }, + session, + ) + const openAfterConfirm = await query<{ count: number }>( + `SELECT COUNT(*)::int AS count FROM cost_exceptions + WHERE reason = 'manual_cost_missing' AND status = 'open' AND subject_id = $1`, + [pendingItem.orderItemId], + ) + assert.equal(Number(openAfterConfirm.rows[0]?.count), 0, '确认后应立即收口人工异常') + await syncEstimatedOrderItemCostSnapshots({ dateFrom: today, dateTo: today }) + const resolvedManualException = await query<{ count: number }>( + `SELECT COUNT(*)::int AS count FROM cost_exceptions + WHERE reason = 'manual_cost_missing' AND status = 'resolved' AND subject_id = $1`, + [pendingItem.orderItemId], + ) + assert.equal(Number(resolvedManualException.rows[0]?.count), 1, '确认后异常必须自动收口') + } finally { + // 按依赖顺序清理,避免污染测试库。 + if (triggerName) { + await query(`DROP TRIGGER IF EXISTS ${triggerName} ON cost_snapshots`) + } + if (fnName) { + await query(`DROP FUNCTION IF EXISTS ${fnName}()`) + } + if (orderItemIds.length > 0) { + await query(`DELETE FROM cost_exceptions WHERE subject_id = ANY($1::bigint[])`, [ + orderItemIds, + ]) + await query(`DELETE FROM cost_snapshots WHERE order_item_id = ANY($1::bigint[])`, [ + orderItemIds, + ]) + await query(`DELETE FROM manual_expenses WHERE order_item_id = ANY($1::bigint[])`, [ + orderItemIds, + ]) + } + if (fundAccountIds.length > 0) { + await query(`DELETE FROM fund_entries WHERE account_id = ANY($1::bigint[])`, [ + fundAccountIds, + ]) + await query(`DELETE FROM fund_accounts WHERE id = ANY($1::bigint[])`, [fundAccountIds]) + } + if (creditAccountIds.length > 0) { + await query(`DELETE FROM platform_credit_entries WHERE account_id = ANY($1::bigint[])`, [ + creditAccountIds, + ]) + await query(`DELETE FROM platform_credit_accounts WHERE id = ANY($1::bigint[])`, [ + creditAccountIds, + ]) + } + if (orderItemIds.length > 0) { + await query(`DELETE FROM order_items WHERE id = ANY($1::bigint[])`, [orderItemIds]) + } + if (orderIds.length > 0) { + await query(`DELETE FROM orders WHERE id = ANY($1::bigint[])`, [orderIds]) + } + if (configIds.length > 0) { + await query(`DELETE FROM manual_fulfillment_configs WHERE id = ANY($1::bigint[])`, [ + configIds, + ]) + } + if (adminIds.length > 0) { + await query(`DELETE FROM admin_users WHERE id = ANY($1::bigint[])`, [adminIds]) + } + await closeDb() + } + }, +) + +async function createManualItem( + query: (sql: string, params?: unknown[]) => Promise<{ rows: Array> }>, + skuNick: string, +) { + const order = await query( + `INSERT INTO orders ( + provider, platform, platform_order_id, pay_status, total_amount, paid_at, + created_at, updated_at + ) VALUES ('91kaquan', 'kuaishou-91', $1, 'paid', 50, NOW(), NOW(), NOW()) + RETURNING id`, + [`IT-MF-${Date.now()}-${Math.floor(Math.random() * 1_000_000)}`], + ) + const orderId = Number(order.rows[0]?.id) + const item = await query( + `INSERT INTO order_items ( + order_id, sku_code, sku_name, fulfillment_mode, spec_json, + revenue_amount_fen, revenue_basis, created_at, updated_at + ) VALUES ($1, $2, $3, 'manual', $4::jsonb, 5000, 'order_total', NOW(), NOW()) + RETURNING id`, + [orderId, skuNick, `集成测试SKU-${skuNick}`, JSON.stringify({ skuNick })], + ) + return { orderId, orderItemId: Number(item.rows[0]?.id) } +} + +function shanghaiDate(date = new Date()) { + return new Intl.DateTimeFormat('en-CA', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).format(date) +} diff --git a/apps/backend/src/services/scheduler/config-service.ts b/apps/backend/src/services/scheduler/config-service.ts index ad34e1bb..72cb0d6c 100644 --- a/apps/backend/src/services/scheduler/config-service.ts +++ b/apps/backend/src/services/scheduler/config-service.ts @@ -152,7 +152,7 @@ function normalizeCostFinanceSyncJob(rawValue: JsonObject) { enabled: rawValue.enabled !== false, intervalSeconds: normalizeRangeInteger(rawValue.intervalSeconds, 300, 60, 3600), config: { - days: normalizeRangeInteger(config.days, 7, 1, 90), + days: normalizeRangeInteger(config.days, 30, 1, 90), }, } } @@ -227,7 +227,7 @@ function createDefaultCostFinanceSyncJob() { enabled: true, intervalSeconds: 300, config: { - days: 7, + days: 30, }, } } 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 510d5d2b..4a083764 100644 --- a/apps/backend/src/services/scheduler/cost-finance-sync-job.ts +++ b/apps/backend/src/services/scheduler/cost-finance-sync-job.ts @@ -4,6 +4,7 @@ import { syncEstimatedOrderItemCostSnapshots, } from '../cost-center/cost-snapshot-sync-service.js' 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 { reconcilePendingFulfillmentModes } from '../fulfillment/fulfillment-mode-service.js' @@ -26,6 +27,13 @@ export async function runCostFinanceSyncJob(job: JsonObject = {}) { dateTo: today(), resolveExceptions: false, }) + // 人工 SKU 配置可能在订单之后才补:每次同步都按配置回填未匹配订单项, + // 保证「配置好人工之后的新单」自动挂上渠道,不依赖管理员手动点「重新匹配」。 + const manualMatches = await rematchManualFulfillments({ + onlyUnmatched: true, + dateFrom: shiftDays(-(days - 1)), + dateTo: today(), + }) const estimated = await syncEstimatedOrderItemCostSnapshots({ dateFrom: shiftDays(-(days - 1)), @@ -45,6 +53,7 @@ export async function runCostFinanceSyncJob(job: JsonObject = {}) { message: `履约方式 ${fulfillment.updatedCount} 项 / ` + `人工商品 ${manualProducts.updatedItemCount} 项 / ` + + `人工渠道匹配 ${manualMatches.updatedCount} 项 / ` + `成本快照 ${estimated.createdCount} 商品预估 / ` + `${actual.directRewardCount + actual.upgradedCount} 工单实际 / ` + `${actual.estimatedRewardCount} 工单在途,销售事实 ${facts.insertedCount} 行`, @@ -53,6 +62,7 @@ export async function runCostFinanceSyncJob(job: JsonObject = {}) { fulfillmentModeRemaining: fulfillment.remainingCount, manualItemsNormalized: manualProducts.updatedItemCount, manualProductsCreated: manualProducts.createdProductCount, + manualItemsMatched: manualMatches.updatedCount, estimatedCreated: estimated.createdCount, actualCreated: actual.directRewardCount + actual.upgradedCount, estimatedRewardCreated: actual.estimatedRewardCount, diff --git a/apps/frontend/src/pages/admin/cost-center/CostExceptionPanel.tsx b/apps/frontend/src/pages/admin/cost-center/CostExceptionPanel.tsx index 4a4c30d1..c3e0f8be 100644 --- a/apps/frontend/src/pages/admin/cost-center/CostExceptionPanel.tsx +++ b/apps/frontend/src/pages/admin/cost-center/CostExceptionPanel.tsx @@ -54,6 +54,7 @@ const FULFILLMENT_MODE_LABELS: Record = { const REASON_LABELS: Record = { product_mapping_missing: '商品未归一', cost_missing: '成本缺失', + manual_cost_missing: '人工成本未记录', } const RANGE_OPTIONS = [ diff --git a/docs/README.md b/docs/README.md index 932af312..38316335 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,7 +1,7 @@ # order_site 文档索引 -> 最后校准:2026-10-24,代码基线 `dev @ 790de676 + 本地未提交修复`(**未推送、未部署生产**)。 -> 数据基线:生产 dump `backups/postgres/order_site-20261022-092601.dump`(数据 2026-07-10 ~ 2026-10-22);本地已按真实升级路径从生产迁移 101 升到 107,并完成商品、成本快照、销售事实初始化。 +> 最后校准:2026-10-26,代码基线 `dev`(合并 `dev1023` + 人工成本完整性修复,迁移到 `111`;**未推送、未部署生产**)。 +> 数据基线:生产 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/`。 @@ -53,7 +53,7 @@ | 名称 | 业务含义 | 当前状态 | | --- | --- | --- | | `manual_expenses` | **客服备用金支出明细**:客服从自己的备用金里充值/采购/赔偿,减少客服余额 | 已实施(迁移 100);确认履约的现金支出也写这里 | -| `cost_snapshots` | **人工成本的唯一落点**:`subject_type='manual_case'`,`subject_id=manual_expenses.id` | 已实施(迁移 104) | +| `cost_snapshots` | **人工成本的唯一落点**:`subject_type='manual_case'`,`subject_id=order_item_id`(资金来源写 metadata) | 已实施(迁移 104 + 111) | **没有独立的登记单,也没有审批与报销环节。** `manual_expense_records` / `manual_expense_events` 已随迁移 `104` 归并下线(这两张表在生产 102 上从未创建)。后续做财务系统时,人工成本的钱与账都从这条链路走,不要再另造一张人工支出表。 @@ -81,14 +81,16 @@ | 商品主数据 + 渠道 SKU 映射 | **已实施 P0/P1** | 2,017 个标准商品、4,897 条映射:`tpl:` 710、`man:` 1,195、`aff:` 49、`wo:` 63;订单项已按履约证据/人工内部 SKU 归一 | | 成本中心 | **已实施 P0** | 迁移 `104`;710 个规则级版本 + 2 个全局占位版本;工单成本快照 151,939 条,其中 actual 129,430、estimated 22,509 | | 销售事实 / 订单毛利 | **已实施 P0** | 迁移 `105`;375,447 项、收入 ¥3,143.61 万、成本 ¥572.95 万、毛利 ¥2,570.66 万;`sales_daily_facts.fulfillment_mode` | -| 人工成本录入(确认履约) | **已实施 P0(2026-10-24 入口收敛到订单页)** | 人工履约页已下线;确认即扣积分/备用金并写 `actual` 快照;无登记单/审批/报销 | +| 人工成本录入(确认履约) | **已实施 P0(2026-10-26 单事务 + 防重复)** | 确认即扣积分/备用金并写 `actual` 快照;钱与成本同事务、订单项级唯一;无登记单/审批/报销 | +| 人工 SKU 配置自动匹配 | **已实施(2026-10-26)** | 保存配置立即回填 + 定时 rematch + 确认时按 SKU 兜底匹配 | +| 人工缺成本监控 | **已实施(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` 业务事件拼装,不是复式总账 | | 成本异常归档 | **已实施** | 迁移 `108`;已处理异常超 60 天搬入 `cost_exceptions_archive`,open 行不归档;`COST_EXCEPTION_RETENTION_DAYS` 可调 | | 退款冲减 / 平台对账 / 期间关账 | 未实施 | 仅方案 | -> 分支状态(2026-10-25):`dev @ 465a9f65` 之上完成迁移收敛(10 → 5,`103`–`107`),并已与 `main` 的 worker-hall 改动合并。本地库按「清 `schema_migrations` 103–112 后重跑」对齐,升级结果与全新构建的 schema 完全一致;生产 101 → 107 的路径已验证。**未推送、未部署生产**;生产口径仍以 `main` 为准。 +> 分支状态(2026-10-26):`dev` 已合并 `dev1023` 与人工成本完整性修复,迁移到 `111`;本地生产副本已验证 101 → 111(109/110 索引在生产规模数据上实测 < 0.3s),临时库全量执行与重跑幂等通过。**未推送、未部署生产**;生产口径仍以 `main` 为准。上线前按 [财务上线初始化与验收-2026-10-26.md](财务上线初始化与验收-2026-10-26.md) 初始化资金与人工 SKU。 > 财务与履约口径以 [财务与履约口径总纲-2026-10-23.md](财务与履约口径总纲-2026-10-23.md) 为准。 ## 三、统一实施顺序 @@ -98,8 +100,8 @@ 1. 商品主数据 + 渠道 SKU 映射 已完成 P0(仅自动发货/接单平台) 2. 成本中心 + 销售事实 已完成 P0 2.5 人工成本链路收敛(确认履约 → 扣积分/备用金 → 写快照) 已完成 P0(迁移 104/107) -3. 人工商品归一与上线后人工成本全覆盖 未实施(下一阶段) -4. 退款冲减与异常队列 未实施 +3. 人工商品归一与上线后人工成本全覆盖 已完成代码;生产数据需上线后配置 SKU(历史不补录) +4. 退款冲减与异常队列 已完成手工退款;平台退款 API 未实施;人工缺成本已入队 5. 影子总账(订单、工单、押金、提现、寄卖、售后) 未实施 6. 平台对账、报表、期间关账 未实施 ``` @@ -126,9 +128,10 @@ | 文档 | 状态 | 用途 | | --- | --- | --- | | [财务与履约口径总纲-2026-10-23.md](财务与履约口径总纲-2026-10-23.md) | **现行唯一口径** | 履约方式、成本模型、人工全覆盖、历史策略、实施顺序、运营日检与验收 SQL | -| [成本中心与履约模型.md](成本中心与履约模型.md) | 现行 | 商品、成本版本、快照、异常、销售事实的数据模型;迁移 `103`–`107`;履约回填;迁移基线与本地库同步;索引与性能 | +| [成本中心与履约模型.md](成本中心与履约模型.md) | 现行 | 商品、成本版本、快照、异常、销售事实的数据模型;迁移 `103`–`111`;履约回填;迁移基线与本地库同步;索引与性能 | | [成本中心API契约.md](成本中心API契约.md) | 现行 | 成本中心接口字段替换总表、枚举、模块边界(后端 5 模块 + 前端 4 个固定 Tab) | | [人工支出与备用金.md](人工支出与备用金.md) | 现行(P0 已实施) | 确认履约流程/权限、客服备用金台账、本人账户与编号规则 | +| [财务上线初始化与验收-2026-10-26.md](财务上线初始化与验收-2026-10-26.md) | **现行上线手册** | 迁移后资金/人工 SKU 初始化、验收 SQL、日常监控 | | [财务系统集成方案.md](财务系统集成方案.md) | 现行方案(总账未实施) | 复式记账、总账表、对账、关账 | | [影子总账说明.md](影子总账说明.md) | 现行说明(未实施) | 影子总账是什么、和钱包流水的区别、对账与转正标准 | | [项目全量分析报告-2026-10-17.md](项目全量分析报告-2026-10-17.md) | 历史参考 | 2026-10-17 架构与风险;财务口径以总纲为准 | diff --git a/docs/人工支出与备用金.md b/docs/人工支出与备用金.md index 6e0eae8a..b7e4f1d5 100644 --- a/docs/人工支出与备用金.md +++ b/docs/人工支出与备用金.md @@ -1,7 +1,7 @@ # 人工支出与客服备用金 -> 状态:**现行(P0 已实施,未部署生产)**。 -> 最后校准:2026-10-24 +> 状态:**现行(P0 已实施,待部署生产)**。 +> 最后校准:2026-10-26(人工确认改单事务、subject_id 归一、配置自动匹配、人工缺成本入队列) > **2026-10-24 变更**:独立「人工履约」页面已下线,订单列表/详情里的「人工录单」改为「确认履约」;`/finance/manual-order-cost*` 接口已删除。资金链路不变(扣积分/备用金 → 写 `manual_case` 快照)。 > 代码基线:`dev @ 43f402cf + 迁移 100/101/104/107`(已合并到 `dev`,**未推送、未部署生产**) > **业务口径以 [财务与履约口径总纲-2026-10-23.md](财务与履约口径总纲-2026-10-23.md) 为准**;本文只负责人工录单的字段、流程、权限与备用金台账。 @@ -16,13 +16,13 @@ ```text 订单列表/详情「确认履约」 → 扣客服积分或备用金余额(写 fund_entries / platform_credit_entries) - → 写 cost_snapshots(subject_type='manual_case',subject_id=manual_expenses.id) + → 写 cost_snapshots(subject_type='manual_case',subject_id=order_item_id) ``` | 表 | 业务含义 | 状态 | 关键字段 | | --- | --- | --- | --- | | `manual_expenses` | **客服备用金支出**:客服从自己的备用金账户里充值/采购/赔偿 | 已实施(迁移 `100`) | `account_id` / `fund_entry_id` / `category` / `payee_*` / `order_item_id` / `evidence_json` | -| `cost_snapshots` | **人工成本的唯一落点** | 已实施(迁移 `104`) | `subject_type='manual_case'` / `subject_id=manual_expenses.id` / `total_amount_fen` / `snapshot_stage='actual'` | +| `cost_snapshots` | **人工成本的唯一落点** | 已实施(迁移 `104` + 完整性兜底 `111`) | `subject_type='manual_case'` / `subject_id=order_item_id` / `total_amount_fen` / `snapshot_stage='actual'`;资金来源写 `metadata_json.fundExpenseId / creditEntryId` | 结论: @@ -40,7 +40,7 @@ 1. **人工成本逐单、按实际金额。** 不按小时、不按固定单量、不按固定单价。 2. **人工支出从客服备用金账户扣款。** 余额不足直接拒绝,不允许负余额。 3. **客服角色只能使用自己的备用金账户。** 后端越权直接 403;管理员/财务可代录到指定客服账户。 -4. **历史人工支出找不回来,直接放弃。** 不补录、不做样本校准;历史缺口标记 `historical_missing`,不阻塞上线。 +4. **历史人工支出找不回来,直接放弃。** 不补录、不做样本校准;历史缺口只计入 `cost_missing_count`,不阻塞上线。 5. **上线后每一笔人工单必须 100% 有成本记录**,否则不允许关账。 6. **录入必须"发生在垫付当天"才有监管价值**;事后补记只能算账,不能算监管。 7. **客服备用金是周转金台账,不是报销系统。** P0 只做"账户 + 流水 + 支出 + 对账",不做复杂审批和总账。 @@ -89,75 +89,71 @@ 批量发放/对账、导出、低余额提醒、大额支出主管确认;支出凭证目前只校验数组非空,未校验对象存储 key 是否真实存在。 -> 原「异常队列」指人工支出登记单的审批异常队列,随登记链下线而取消。现在的人工异常只有一类:人工录单匹配不到有效成本版本时写 `manual_expense_cost_missing`,由成本中心的异常面板统一处理。 +> 原「异常队列」指人工支出登记单的审批异常队列,随登记链下线而取消。现在的人工异常统一为 `manual_cost_missing`:**首次创建人工履约配置之后**的订单项,只要还没有人工成本快照就进入队列;确认履约写入快照后自动 `resolved`。历史人工单不进入该队列。 --- -## 三、人工录单 +## 三、确认履约 ### 3.1 落点 -- 服务:`apps/backend/src/services/finance/manual-order-cost-service.ts`;备用金扣款唯一写入口 `admin-fund-service.ts` 的 `writeFundExpense` -- 接口:`apps/backend/src/routes/admin/finance.ts` 的 `POST /finance/manual-order-costs`;成本项选项 `GET /finance/manual-order-cost-items` -- 组件:`apps/frontend/src/components/admin/ManualOrderCostModal.tsx` -- 订单入口:订单列表/详情「人工录单」打开弹窗,锁定订单号、选择订单项与备用金账户、填写本单实际金额;支持深链 `/admin/orders/:orderId?manualCost=1` -- 权限:登记 `fund.expense` / `finance.config`;客服角色自动锁定本人账户 -- **已删除**:迁移 `106_manual_expense_records.sql` 建的登记单表(迁移 `104` 归并下线)、`manual-expense-service.ts`、`AdminManualExpensePage.tsx`、`/admin/manual-expenses` 页面与菜单 +- 服务:`apps/backend/src/services/finance/manual-fulfillment-service.ts` +- 接口:`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`;客服只能使用本人备用金/积分账户 +- 配置前置:商品与成本 → 人工 SKU 配置(`manual_fulfillment_configs`) ### 3.2 资金流与成本流(同一事务) ```text -锁定 fund_accounts 行 - → 校验余额 - → 扣减余额 - → 写 fund_entries - → 写 manual_expenses(order_id / order_item_id 已带上) - → 解析成本版本 - → 写 cost_snapshots(actual) +BEGIN + 锁定 order_items 行(FOR UPDATE OF oi) + → 复查是否已有 manual_case 快照(有则 409,不扣款) + → 按 SKU 自动匹配人工履约配置(配置晚于下单也能挂上) + → 解析成本项/成本版本(版本缺失在扣款前失败) + → 扣积分(platform_credit_entries)或扣备用金(fund_entries + manual_expenses) + → 写 cost_snapshots(actual,subject_id = order_item_id) +COMMIT -- 任一步失败整体回滚 ``` -余额不足直接拒绝,**不允许负余额**。扣钱与记成本在同一个事务里,要么都成功要么都回滚。 +- 余额不足直接拒绝,**不允许负余额**; +- 重复点击/并发请求:订单项行锁 + `uq_cost_snapshots_manual_order_item` 唯一索引双重兜底,只有一个请求能成功扣款; +- 同一个订单项最多一条有效人工成本快照;不同订单项(包括现金与积分两条链路)不会因为 id 撞车互相覆盖。 ### 3.3 成本快照 -以 `subject_type='manual_case'`、`subject_id=manual_expenses.id` 写 `actual` 快照,金额 = 录入的实际金额。 - -同一订单项多笔支出可累加,不会互相覆盖(唯一键是 `subject_type + subject_id + cost_item_id`,每笔支出单一个主体)。快照 `metadata_json` 里记录处理人、收款方、付款方式、费用类型、凭证、履约方式。 - -没有匹配到有效成本版本时,写 `manual_expense_cost_missing` 异常(`status='open'`),**不允许静默记 0**;此时备用金已经扣了,界面会提示"已扣备用金,但未匹配到有效成本版本,已进入异常队列"。 +- `subject_type='manual_case'`、`subject_id=order_item_id`、`order_item_id` 同值; +- 金额:固定成本 = `credit_quantity × 平台换算率`;成本不固定 = 客服填写的实际金额; +- `metadata_json` 记录 `fundExpenseId / creditEntryId / subjectSource / platformKey / creditQuantity / costFen / paidBy` 等; +- `uq_cost_snapshots_manual_order_item` 保证一个订单项只有一条有效快照(迁移 `111`)。 ### 3.4 录入字段 ```text -order_id, order_item_id -- 订单与订单项(锁定订单号,不允许改) -fund_account_id -- 备用金账户;客服只能本人 -amount_fen -- 实际金额(分),> 0 -expense_category -- goods_purchase / compensation / refund_advance / - -- service_fee / freight / recharge / other -payee_type, payee_name, payee_account -payment_method -- wechat / alipay / bank / cash / platform_balance / other -note -- 备注兼凭证(无附件时) -evidenceJson -- 凭证(对象存储 key / 流水号 / 聊天记录 key) +order_item_id -- 订单项(必填;弹窗内锁定) +costFen -- 仅成本不固定的 SKU 必填,> 0(分) +payeeName -- 收款方(选填,默认「人工履约」) +note -- 备注(选填) ``` -费用类型到备用金分类的映射:`goods_purchase→purchase`、`compensation→compensation`、`freight→freight`、`recharge→platform_recharge`,其余 → `other`。 +平台、消耗积分与固定成本都来自人工 SKU 配置,客服不选账户、不改平台。 -### 3.5 凭证 +### 3.5 配置与监控 -凭证**至少留一项**可追溯信息(对象存储 key、流水号、聊天记录 key,或备注文字),否则后端拒绝。 +- 人工 SKU 的匹配键:`spec_json.skuNick → spec_json.outSkuSn → sku_code → sku_name`;没有前两级的历史单也能按后两级配置。 +- 保存配置后立即回填该 SKU 最近 30 天的订单项;定时任务每 5 分钟对最近窗口内未匹配项兜底;更早历史需显式「重新匹配」。 +- 未配置 SKU 的订单项进入 `manual_cost_missing`(`detail.configMissing=true`),配置后自动匹配;确认履约后异常自动收口。 --- ## 四、订单页「确认履约」 -订单里的数据足够预填 90%:店铺、商品、规格、数量、用户实付、支付时间、平台单号、履约标签、状态,后台订单列表/详情已经全部具备。客服实际只需要补 **4 件事**:渠道/项目、金额、收款方、凭证(外加默认带出的「谁、什么时候」)。 - -弹窗规则: - -- 锁定订单号,不允许改; -- 必须选择备用金账户(客服角色自动带入本人账户且不可改); -- 金额按本单实际金额填写,**没有小时单价或固定单量**; -- 录入即生效:扣备用金与写成本快照一次完成,不再有"提交审核"步骤。 +- 弹窗只展示已配置履约渠道的人工项;固定成本零输入,成本不固定才要求填金额; +- 锁定订单与订单项,不允许改平台/账户; +- 确认即扣钱并写成本,没有审核/报销步骤; +- 若 SKU 还没配置,弹窗会提示先去「商品与成本 → 人工 SKU 配置」补配置; +- 重复确认会被拒绝,不会重复扣款。 --- @@ -165,16 +161,16 @@ evidenceJson -- 凭证(对象存储 key / 流水号 / 聊天记 | 指标 | 目标 | | --- | ---: | -| 人工订单项无备用金支出单 | 0 | -| 人工订单项无成本快照 | 0 | -| 人工支出金额与备用金流水不一致 | 0 | -| 成本异常 open 中人工项 | 0(历史标记除外) | +| 人工订单项无资金流水(现金备用金支出 / 积分消耗流水) | 0 | +| 人工订单项无有效成本快照 | 0 | +| 现金人工成本金额与 manual_expenses 不一致 | 0 | +| `manual_cost_missing` open(上线后) | 0(历史人工单不计入) | 关账门槛: -- 存在人工单无成本记录:**不允许关账**; -- 存在人工单未扣备用金:**不允许关账**; -- 存在人工支出与备用金流水金额不一致:**不允许关账**。 +- 存在上线后人工单无成本记录:**不允许关账**; +- 存在上线后人工单没有对应资金流水(现金或积分):**不允许关账**; +- 存在现金人工支出与备用金流水金额不一致:**不允许关账**。 核对 SQL 见 [财务与履约口径总纲-2026-10-23.md](财务与履约口径总纲-2026-10-23.md) §八。 @@ -182,11 +178,11 @@ evidenceJson -- 凭证(对象存储 key / 流水号 / 聊天记 ## 六、异常处理 -- 发现人工单无成本:当天补录;补录走人工录单,仍然从备用金扣款,不允许直接改余额。 -- 备用金余额不足:拒绝录入,由主管补拨后再录;不允许负余额。 -- 人工录单匹配不到成本版本:备用金已扣,成本异常进队列;补齐 `sys_manual_service` 的有效版本后重新解析。 -- 同一订单项多笔支出:允许累加(渠道费、加急费、补差价),但每笔都必须有独立备用金流水。 -- 历史人工单:标记 `historical_missing`,找不回来、**直接放弃**,不补录、不计入上线后缺口。 +- 发现人工单无成本:确认履约;扣款与写成本同事务,失败整体回滚,不会出现「扣了钱没记成本」。 +- 备用金/积分不足:拒绝确认,由主管补拨/充值后再确认;不允许负余额。 +- 人工 SKU 未配置:进入 `manual_cost_missing` 队列,配置后自动回填订单项,再确认即可。 +- 确认后需要更正:当前不提供反审核;只能通过后续冲销/调整处理(P1),不要直接改库。 +- 历史人工单:找不回来、**直接放弃**,不补录、不计入上线后监控缺口。 --- @@ -196,7 +192,7 @@ evidenceJson -- 凭证(对象存储 key / 流水号 / 聊天记 - 不补录、不回填 `cost_snapshots`; - 不做样本抽取与均价估算; -- 不阻塞上线,历史缺口在销售事实里标记 `historical_missing`; +- 不阻塞上线,历史缺口计入销售事实的 `cost_missing_count`; - 只保证**上线后**的新单不丢:人工单必须有备用金支出与成本快照。 历史平台 53,901 项同理,只有拿到旧平台结算数据才允许按历史成本入账。 diff --git a/docs/履约配置/多发货平台与电子凭证整体链路.md b/docs/履约配置/多发货平台与电子凭证整体链路.md index 9884fe2b..7351ffce 100644 --- a/docs/履约配置/多发货平台与电子凭证整体链路.md +++ b/docs/履约配置/多发货平台与电子凭证整体链路.md @@ -1,6 +1,6 @@ # 多发货平台与电子凭证整体链路 -> 状态:现行。最后校准:2026-10-23(代码基线 `dev @ 5d1ba115 + 迁移 `103`–`107`;履约方式已统一落库)。 +> 状态:现行。最后校准:2026-10-23(代码基线 `dev`(合并 dev1023 后)+ 迁移 `103`–`111`;履约方式已统一落库)。 > 财务与履约总口径见 [财务与履约口径总纲-2026-10-23.md](../财务与履约口径总纲-2026-10-23.md)。 > 来源口径:本系统只有三个订单来源——`kuaishou-91`、`pdd`、`douyin-mashida`。**91 回调与快手 send-code 是同一个 `kuaishou-91` 来源的两条数据通道**(91=骨架权威源,快手 send-code=补全/兜底源),不是一个来源加另一个来源。 diff --git a/docs/影子总账说明.md b/docs/影子总账说明.md index 91362dd5..a6577541 100644 --- a/docs/影子总账说明.md +++ b/docs/影子总账说明.md @@ -2,7 +2,7 @@ > 状态:**现行说明(未实施)** > 最后校准:2026-10-23 -> 代码基线:`dev @ 43f402cf + 迁移 `103`–`107`(已合并到 `dev`,**未推送、未部署生产**) +> 代码基线:`dev`(合并 dev1023 后)+ 迁移 `103`–`111`(已合并到 `dev`,**未推送、未部署生产**) > 配套方案:[财务系统集成方案.md](财务系统集成方案.md) > **总口径以 [财务与履约口径总纲-2026-10-23.md](财务与履约口径总纲-2026-10-23.md) 为准。** > 前置:统一履约事实、商品主数据、渠道映射、成本中心与销售事实;实施顺序以总纲第六节为准。 diff --git a/docs/成本中心API契约.md b/docs/成本中心API契约.md index 8176731e..d5c04b00 100644 --- a/docs/成本中心API契约.md +++ b/docs/成本中心API契约.md @@ -2,7 +2,7 @@ > 状态:**现行**。后端服务与前端页面都必须严格按此实现,不得各自发挥。 > 最后校准:2026-10-25 -> 代码基线:`dev @ 465a9f65` + 迁移 `103`–`107`(已合并到 `dev`,**未推送、未部署生产**) +> 代码基线:`dev`(合并 dev1023 后)+ 迁移 `103`–`111`(已合并到 `dev`,**未推送、未部署生产**) > 详细业务口径见 [财务与履约口径总纲-2026-10-23.md](财务与履约口径总纲-2026-10-23.md);数据模型见 [成本中心与履约模型.md](成本中心与履约模型.md)。 ## 一、字段替换总表 @@ -204,6 +204,11 @@ AND ( } ``` +`reason` 取值:`product_mapping_missing`(商品未归一)、`cost_missing`(非人工项缺成本版本)、 +`manual_cost_missing`(人工项尚未确认履约/未配置渠道)。`manual_cost_missing` 只监控 +「第一次创建人工履约配置之后」的订单项;确认履约写入 `manual_case` 快照后自动 `resolved`。 +人工成本快照的 `subjectId` / `subject_id` 统一为 `order_item_id`。 + ## 七、销售事实接口 `GET /api/v1/admin/sales-profit` 的分组维度字段名从 `carrierType` 改为 `fulfillmentMode`: diff --git a/docs/成本中心与履约模型.md b/docs/成本中心与履约模型.md index c99f9f2d..a391601e 100644 --- a/docs/成本中心与履约模型.md +++ b/docs/成本中心与履约模型.md @@ -1,9 +1,10 @@ # 成本中心与履约模型 > 状态:**现行**。商品主数据、成本中心、成本快照、销售事实、履约事实均已落地。 -> 最后校准:2026-10-25 -> 代码基线:`dev @ 465a9f65 + 迁移合并`(**未推送、未部署生产**) -> 数据基线:生产 dump `order_site-20261022-092601.dump`(2026-07-10 ~ 2026-10-22);本地已升级到迁移 107,并完成商品、映射、成本快照与销售事实初始化 +> 最后校准:2026-10-26 +> 代码基线:`dev`(合并 dev1023 + 人工成本完整性修复,迁移到 `111`) +> 数据基线:生产 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) 为准**;本文只负责数据模型、迁移与运维。 > 接口字段见 [成本中心API契约.md](成本中心API契约.md)。 @@ -36,9 +37,9 @@ orders ──┬── order_items ──── fulfillment_mode (唯一履约 原 `dev` 分支上,商品、成本中心、人工履约用 10 个迁移(`103`–`112`,1,275 行)分批落地,字段反复摇摆。其中: -- `108`(下线人工支出登记)与 `109`(隔离历史快照主体)在「生产 102 → 当前」的唯一升级路径上是**空操作**; -- `111` 是给 `104` 自身种子打的补丁(draft 占位改 active 占位); -- `112` 的 `resolution_note` 列本就属于异常表。 +- 原 `108`(下线人工支出登记)与原 `109`(隔离历史快照主体)在「生产 102 → 当前」的唯一升级路径上是**空操作**; +- 原 `111` 是给 `104` 自身种子打的补丁(draft 占位改 active 占位); +- 原 `112` 的 `resolution_note` 列本就属于异常表。 2026-10-25 收敛为 5 个(合计 1,151 行): @@ -50,9 +51,18 @@ orders ──┬── order_items ──── fulfillment_mode (唯一履约 | `106_order_refunds.sql` | `order_refunds` 退款表 | 38 | | `107_manual_fulfillment.sql` | 人工 SKU 登记/配置 + 三平台积分钱包 + 备用金账户编号可读化 | 292 | +后续增量(不参与「10 合 5」的收敛): + +| 迁移 | 内容 | +| --- | --- | +| `108_cost_exception_archive.sql` | 成本异常 60 天归档 | +| `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` 监控所需索引 | + **合并原则**:同一批上线的结构性 DDL 才合并;数据回填与业务口径注释跟着所属表走。`103` 保持独立(商品主数据是成本与销售事实的共同上游),`106` 保持独立(退款是独立业务线)。 -**关于历史人工快照的负数 `subject_id`(重要坑,已固化在 `104` 的列注释里)**:人工成本快照的主体从「人工支出登记单 id」改成「客服备用金支出单 id」(`manual_expenses.id`),两套 id 空间各自从 1 开始会撞车。撞上唯一索引 `uq_cost_snapshots_active_subject_item` 后,`INSERT ... ON CONFLICT DO NOTHING` 会静默跳过——**钱从备用金扣了、成本快照却没写成**。历史孤儿快照的主体一律用负数,与正数的 `manual_expenses.id` 彻底隔离;新建链路不再产生负数主体。 +**人工成本快照主体(2026-10-26 定稿)**:`manual_case` 的 `subject_id` 统一为 `order_item_id`,不再使用 `manual_expenses.id` / `platform_credit_entries.id`(两张表各自 BIGSERIAL,必然撞 id)。资金来源只放 `metadata_json.fundExpenseId / creditEntryId`。迁移 `111` 增加订单项级唯一索引 `uq_cost_snapshots_manual_order_item`,一个订单项最多一条有效人工快照;`subject_id` 为正数的历史行归一到订单项,负数历史孤儿快照按迁移 `104` 约定保留、不参与新链路。 ### 1.1 字段替换对照 @@ -256,8 +266,9 @@ resolved_at, superseded_at, created_at - 预估快照代表支付时点的成本,**不因后续改价重写**;只有实际成本允许替代当前有效行; - 匹配不到成本时写 `cost_missing` 异常,**不能默认 0**; - 履约方式不存快照里,需要时 join `order_items.fulfillment_mode`; -- 人工单的快照主体是 `subject_type='manual_case'` + `subject_id=manual_expenses.id`(客服备用金支出单),**没有独立的登记单表**; -- 负数 `subject_id` 表示历史孤儿快照(约定固化在迁移 `104` 的列注释里),金额保留供毛利参考,不与新链路争主体 id。 +- 人工单的快照主体是 `subject_type='manual_case'` + `subject_id=order_item_id`;资金流水 id 只写 `metadata_json`,**没有独立的登记单表**; +- 负数 `subject_id` 表示历史孤儿快照(迁移 `104` 约定),迁移 `111` 保留不动,不参与新链路; +- 一个订单项最多一条有效人工快照(`uq_cost_snapshots_manual_order_item`)。 ### 3.5 `cost_exceptions` @@ -265,7 +276,7 @@ resolved_at, superseded_at, created_at id subject_type, subject_id order_item_id, work_order_id -reason -- product_mapping_missing / cost_missing / manual_expense_cost_missing +reason -- product_mapping_missing / cost_missing / manual_cost_missing status -- open / resolved / ignored detail_json created_at, resolved_at, resolved_by @@ -386,7 +397,8 @@ WHERE oi.id = d.id AND oi.fulfillment_mode IS NULL; ``` -本地验收(2026-10-23 重写后重跑):`work_order` 118,053 / `manual` 102,955 / `legacy_platform` 53,901 / `affiliate_dash` 41,676,四项相加 = 316,585,`NULL = 0`。 +> 上述计数是 2026-10-23 历史回填的结果,仅作迁移正确性参照;当前本地生产副本只跑了近 30 天增量,**不要用这些数字判断上线是否完成**。 +> 本地验收(2026-10-23 重写后重跑):`work_order` 118,053 / `manual` 102,955 / `legacy_platform` 53,901 / `affiliate_dash` 41,676,四项相加 = 316,585,`NULL = 0`。 ### 6.3 决策边界(写进代码注释与测试) @@ -419,11 +431,11 @@ WHERE oi.id = d.id 生产尚未执行过 `103` 及之后的迁移。本地库与代码对齐有两条路径: -1. **全新升级**:从生产 dump 恢复到 `101` / `102`,直接 `npm run db:migrate` 升到 `107`; +1. **全新升级**:从生产 dump 恢复到 `101` / `102`,直接 `npm run db:migrate` 升到 `111`; 2. **已有本地库**:清掉 `schema_migrations` 里 `103`–`112` 的记录后重跑。所有迁移都是 `IF NOT EXISTS`,数据步骤幂等: ```sql -DELETE FROM schema_migrations WHERE name ~ '^(10[3-9]|11[0-2])_'; +DELETE FROM schema_migrations WHERE name ~ '^(10[3-9]|11[0-1])_'; ``` 2026-10-25 实测:按第 2 条路径升级后的库,与全新构建的库 **schema 完全一致**(`pg_dump --schema-only` 仅随机 token 不同),种子数据(3 个系统成本项、2 个全局占位版本、4 个人工履约档案)也一致。 @@ -486,7 +498,7 @@ npm run revenue:backfill -- --batch-size=2000 --max-batches=100 归档不删除审计痕迹,但热表查询与页面上不会再出现这些历史行;需要追溯时查归档表。 -> **2026-10-25 已执行一次性清理**:`--days=0` 口径归档 151,939 条 resolved 异常(152 批,3.9 秒)。 +> **2026-10-25 已执行一次性清理(历史库)**:`--days=0` 口径归档 151,939 条 resolved 异常(152 批,3.9 秒)。该数字只作历史记录。 > 热表 260,253 → 108,314 行(全部为 open),`VACUUM FULL` 后体积 155 MB → 51 MB(heap 38 / idx 13), > 归档表 61 MB;主键重叠校验 0 条。生产环境不要在业务时段跑 `VACUUM FULL`,日常由 autovacuum 复用空间即可。 diff --git a/docs/接单模板匹配引擎重构设计.md b/docs/接单模板匹配引擎重构设计.md index 0d56cdf7..cf58b649 100644 --- a/docs/接单模板匹配引擎重构设计.md +++ b/docs/接单模板匹配引擎重构设计.md @@ -1,6 +1,6 @@ # 接单模板匹配引擎重构设计 -> 目标读者:后端 / 后台运营。状态:**P0 + P1 已实施;P2 / P3 待办**。最后校准:2026-10-23(代码基线 `dev @ 43f402cf + 迁移 `103`–`107`;匹配业务逻辑未变)。 +> 目标读者:后端 / 后台运营。状态:**P0 + P1 已实施;P2 / P3 待办**。最后校准:2026-10-23(代码基线 `dev`(合并 dev1023 后)+ 迁移 `103`–`111`;匹配业务逻辑未变)。 > > 一句话:把「订单信息 → 接单模板(价格/拼单/要求)」的匹配,从散落的多个消费点、平台判断和"店铺必填"的绑定表里,收敛为**一个平台无关的引擎**:匹配条件完全由**订单信息**表达,**店铺只是可选条件之一**,新增来源只加一个适配器 + 一组配置。 > diff --git a/docs/财务上线初始化与验收-2026-10-26.md b/docs/财务上线初始化与验收-2026-10-26.md new file mode 100644 index 00000000..2435e863 --- /dev/null +++ b/docs/财务上线初始化与验收-2026-10-26.md @@ -0,0 +1,233 @@ +# 财务/成本上线初始化与验收(2026-10-26) + +> 状态:现行操作手册。适用于 `dev` 合并后的财务/成本模块上线;**历史人工成本不补录**。 +> 代码基线:迁移 `103`–`111`(本手册写于人工成本完整性与配置自动匹配修复之后)。 +> 数据基线:生产 dump `2026-10-22`(迁移 `101`)。历史人工支出找不回来,只保证**首次配置人工履约之后**的新单可记录、可监控。 + +--- + +## 〇、上线范围与边界 + +本次上线的是「备用金 + 人工成本 + 成本中心 + 销售事实」模块,不是复式总账: + +| 能力 | 本次是否上线 | +| --- | --- | +| 客服备用金账户/流水/支出/对账 | 是(数据需上线后初始化) | +| 人工 SKU 配置 + 确认履约(扣积分/备用金 + 写 actual 快照) | 是(配置需上线后设置) | +| 商品目录、渠道映射、成本版本、成本快照、销售事实 | 是 | +| 手工退款登记(冲减收入、关闭成本异常) | 是 | +| 平台退款 API、影子总账、对账中心、期间关账 | **否**(后续 P3) | + +明确不做: +- 历史人工支出不补录、不按均价估算; +- 历史人工单的 `cost_missing` 不进入上线后监控口径; +- 人工成本监控从**第一次创建人工履约配置的时间**开始计算(`manual_fulfillment_configs.created_at`)。 + +--- + +## 一、上线顺序(必须按序) + +### 1. 备份 + 迁移 + +```bash +# 生产机 +cd /home/order_site +bash deploy/ubuntu-deploy.sh --backup --dry-run # 先看计划 +bash deploy/ubuntu-deploy.sh --backup # 迁移前 pg_dump + OSS,再重建/重启 +``` + +后端启动会自动执行 `102`–`111`。确认: + +```sql +SELECT name FROM schema_migrations ORDER BY id DESC LIMIT 5; +-- 期望包含:111_manual_cost_integrity / 110_work_order_hall_and_shop_indexes / 109_work_order_share_participation +``` + +> 迁移 `109`/`110`/`111` 都是增量、幂等;`111` 会给 `orders(375k)` 建表达式索引,实测写阻塞约 200–300ms。 + +### 2. 初始化客服资金(备用金 + 积分) + +上线后由财务/主管在后台完成: + +1. **备用金账户**:钱包管理 → 客服账户 → 「补建客服账户」(或调用 `POST /api/v1/admin/fund/accounts/ensure-agents`)。 +2. **拨付备用金**:钱包管理 → 对应账户 → 「发放」,把周转金拨给客服;余额不足时人工确认会直接拒绝。 +3. **积分充值**:商品与成本 → 积分钱包 → 选择客服/平台 → 「发放」(南航/乐玩 1 积分 = 1 分,ROCK 1 积分 = 1 元,以 `fulfillment_profiles.config_json.rechargeUnitPriceFen` 为准)。 +4. 要求:客服只能使用**本人**备用金账户和积分账户;管理员代录也使用自己的账户(当前实现如此)。 + +校验: + +```sql +-- 每个客服都应有 agent 账户;余额按实际拨付为准 +SELECT au.username, fa.account_no, fa.balance_fen, fa.status +FROM admin_users au +LEFT JOIN fund_accounts fa + ON fa.owner_admin_user_id = au.id AND fa.account_type = 'agent' +WHERE au.role = 'support' +ORDER BY au.username; + +-- 三平台积分账户余额 +SELECT pca.platform_key, au.username, pca.balance_quantity +FROM platform_credit_accounts pca +JOIN admin_users au ON au.id = pca.owner_admin_user_id +ORDER BY pca.platform_key, au.username; +``` + +### 3. 扫描并配置人工 SKU + +入口:商品与成本 → 人工 SKU 配置。 + +1. 选择日期范围(建议上线前 30 天)→ 「扫描」;没有 `spec_json.skuNick/outSkuSn` 的单会按 `sku_code → sku_name` 兜底,不再漏扫。 +2. 逐项配置:平台(南航/乐玩/ROCK/备用金现金)+ 消耗积分(0 = 成本不固定,客服确认时手填)+ 启用。 +3. **保存后会自动重新匹配该 SKU 最近 30 天的订单项**,不需要再点「重新匹配」;定时任务每 5 分钟会对最近窗口内未匹配人工项做一次兜底回填;更早历史不自动回填。 +4. 未配置的 SKU 会进入成本异常队列(`manual_cost_missing`,`detail.configMissing=true`),按队列把配置补齐即可。 + +批量检查缺口: + +```sql +-- 已配置 / 未配置的人工 SKU(按首次配置之后的订单项) +SELECT + COUNT(*) FILTER (WHERE oi.fulfillment_config_id IS NULL) AS unconfigured_items, + COUNT(*) AS manual_items +FROM order_items oi +WHERE oi.fulfillment_mode = 'manual' + AND oi.created_at >= (SELECT MIN(created_at) FROM manual_fulfillment_configs); + +-- 需要补配置的 SKU(按订单量排序) +SELECT + COALESCE(NULLIF(oi.spec_json->>'skuNick',''), NULLIF(oi.spec_json->>'outSkuSn',''), + NULLIF(oi.sku_code,''), NULLIF(oi.sku_name,'')) AS sku_key, + MAX(oi.sku_name) AS sku_name, + COUNT(*) AS items +FROM order_items oi +WHERE oi.fulfillment_mode = 'manual' + AND oi.fulfillment_config_id IS NULL + AND oi.created_at >= (SELECT MIN(created_at) FROM manual_fulfillment_configs) +GROUP BY 1 +ORDER BY items DESC; +``` + +> 配置是全量生效的:保存后历史未匹配项也会被回填(只填空值),但历史成本仍不会补录。 + +### 4. 确认人工履约(日常) + +订单列表/详情 → 「确认履约」: + +- 固定成本 SKU:零输入,直接扣对应积分/备用金; +- 成本不固定 SKU:填写本次实际成本(分); +- 扣款、写 `manual_expenses`/积分流水、写 `manual_case` 成本快照在**同一个事务**里; +- 重复点击/并发请求只能成功一次(订单项行锁 + 唯一索引兜底),失败不会重复扣款; +- 成功后可重复确认会被拒绝。 + +监控: + +```sql +-- 上线后人工项:已配置但未确认(客服待办) +SELECT COUNT(*) AS pending_confirmed +FROM order_items oi +WHERE oi.fulfillment_mode = 'manual' + AND oi.fulfillment_config_id IS NOT NULL + AND oi.created_at >= :launch_at + AND NOT EXISTS ( + SELECT 1 FROM cost_snapshots cs + WHERE cs.subject_type = 'manual_case' + AND cs.order_item_id = oi.id + AND cs.superseded_at IS NULL + ); + +-- 人工成本缺失异常(队列) +SELECT status, COUNT(*) FROM cost_exceptions +WHERE reason = 'manual_cost_missing' +GROUP BY status; +``` + +`manual_cost_missing` 只在「首次配置之后」生成;确认履约写入快照后自动置为 `resolved`。 + +### 5. 历史回填(可选,不阻塞上线) + +本次上线不要求补历史人工成本;如需历史商品归一/成本快照/销售事实用于报表,另跑: + +```bash +npm run revenue:backfill -- --dry-run # 应为 unknown=0 +npm run fulfillment:backfill # 只填 NULL +npm run finance:sync -- --mode=all --from=2026-07-10 --to=2026-10-22 +``` + +长区间按 30 天窗口分段执行(默认 `DATABASE_STATEMENT_TIMEOUT_MS=15000`)。 +注意:人工历史成本按既定口径**不补录**,历史人工毛利仍不可用。 + +--- + +## 二、上线验收 SQL(替换 `:launch_at` 为实际上线时间) + +```sql +-- 1. 履约方式覆盖:四项相加 = order_items 总数,NULL = 0 +SELECT COALESCE(fulfillment_mode, '(null)') AS mode, COUNT(*) +FROM order_items GROUP BY 1 ORDER BY 2 DESC; + +-- 2. 上线后人工项必须有履约配置(允许配置跟进期) +SELECT COUNT(*) FROM order_items +WHERE fulfillment_mode = 'manual' + AND created_at >= :launch_at + AND fulfillment_config_id IS NULL; + +-- 3. 上线后人工项必须有成本快照(关账门槛) +SELECT COUNT(*) FROM order_items oi +WHERE oi.fulfillment_mode = 'manual' + AND oi.created_at >= :launch_at + AND NOT EXISTS ( + SELECT 1 FROM cost_snapshots cs + WHERE cs.subject_type = 'manual_case' + AND cs.order_item_id = oi.id + AND cs.superseded_at IS NULL + ); + +-- 4. 人工成本快照金额 = 对应备用金支出(仅现金路径) +SELECT cs.id, me.expense_no, me.amount_fen, cs.total_amount_fen +FROM cost_snapshots cs +JOIN manual_expenses me ON me.order_item_id = cs.order_item_id +WHERE cs.subject_type = 'manual_case' + AND cs.superseded_at IS NULL + AND cs.created_at >= :launch_at + AND cs.total_amount_fen <> me.amount_fen; + +-- 5. 一个订单项最多一条有效人工快照(应为 0) +SELECT order_item_id, COUNT(*) +FROM cost_snapshots +WHERE subject_type = 'manual_case' AND superseded_at IS NULL +GROUP BY 1 HAVING COUNT(*) > 1; + +-- 6. 销售事实与实时聚合对平(同一日期区间) +-- 净收入 = Σ订单项收入 − 已确认退款;成本 = Σ有效快照 +WITH r AS (SELECT MIN(stat_date) a, MAX(stat_date) b FROM sales_daily_facts) +SELECT + (SELECT SUM(net_revenue_fen) FROM sales_daily_facts) AS facts_revenue, + (SELECT SUM(cost_amount_fen) FROM sales_daily_facts) AS facts_cost; +-- 与独立聚合对比,允许 0 差异。 + +-- 7. 人工成本异常:上线后应能被确认动作收口 +SELECT reason, status, COUNT(*) FROM cost_exceptions +WHERE created_at >= :launch_at +GROUP BY 1,2 ORDER BY 3 DESC; +``` + +--- + +## 三、常见问题 + +| 现象 | 原因 | 处理 | +| --- | --- | --- | +| 确认时报「还没有配置履约渠道」 | SKU 未配置,或配置的 `sku_key` 与订单项不匹配 | 在人工 SKU 配置里按兜底键配置;保存后自动回填 | +| 确认时报「当前账号还没有备用金账户」 | 该客服未补建账户 | 钱包管理 → 补建客服账户 | +| 确认时报「积分不足」 | 未充值/充值到别的客服 | 积分钱包 → 给本人账户发放 | +| 确认成功但列表仍有「待确认」 | 前端缓存 | 刷新;后端以快照为准 | +| `manual_cost_missing` 不减少 | 未确认履约,或确认失败回滚 | 看订单详情确认;异常只能由快照自动收口 | + +--- + +## 四、已知边界(不要对外承诺) + +1. 没有复式总账、平台对账、期间关账;现有「收支明细」是业务事件拼装,不是会计账。 +2. 平台退款 API 未接入,退款只支持后台手工登记(整单/指定商品,可撤销)。 +3. 平台服务费/支付通道费/寄卖货款成本确认时点仍待定;相关成本项配置为空时不参与毛利。 +4. 历史人工成本、历史平台成本、历史未归一商品不补齐;历史区间毛利只能作为参考。 +5. 管理员/财务代录目前仍使用**本人**备用金/积分账户,不能指定客服账户。 diff --git a/docs/财务与履约口径总纲-2026-10-23.md b/docs/财务与履约口径总纲-2026-10-23.md index d878f39d..1f4cc18a 100644 --- a/docs/财务与履约口径总纲-2026-10-23.md +++ b/docs/财务与履约口径总纲-2026-10-23.md @@ -1,9 +1,10 @@ # 财务与履约口径总纲(2026-10-23 重写版) > 状态:**现行唯一口径**。本文是财务、成本、履约分类的总入口;其他文档与本文件冲突时,以本文为准。 -> 最后校准:2026-10-24 -> 代码基线:`dev @ 790de676 + 本地未提交修复`(**未推送、未部署生产**) -> 数据基线:生产 dump `order_site-20261022-092601.dump`(2026-07-10 ~ 2026-10-22);本地已升级到迁移 107,并完成履约方式、商品、成本快照与销售事实初始化 +> 最后校准:2026-10-26 +> 代码基线:`dev`(合并 dev1023 + 人工成本完整性修复,迁移到 `111`) +> 数据基线:§二/§七 的数字来自 2026-10-22 生产 dump 的**历史快照**,只用于理解历史缺口,**不再作为上线验收依据**; +> 上线初始化与验收以 [财务上线初始化与验收-2026-10-26.md](财务上线初始化与验收-2026-10-26.md) 为准。 > 适用范围:订单接入、履约判定、商品归一、成本快照、销售事实、人工录单、备用金、后续总账与对账。 --- @@ -41,7 +42,9 @@ --- -## 二、当前真实履约构成(本地库,2026-07-10 ~ 2026-10-22) +## 二、历史履约构成(2026-10-22 dump,仅作历史基线) + +> 本节数字是 2026-10-22 的历史快照。当前库只跑了近 30 天增量,**不要拿本节数字做上线验收**;上线验收用新手册的 SQL。 | 履约方式 | 订单项 | 收入 | 当前成本情况 | | --- | ---: | ---: | --- | @@ -131,8 +134,8 @@ ## 五、历史数据处理 - 历史人工支出**找不回来,直接放弃**:不补录、不做样本回填、不做均价估算; -- 历史人工单在销售事实中保留收入,成本标记为 `historical_missing`,不计入"上线后监控缺口"; -- 历史平台 `legacy_platform` 单独成行,成本未采集的标记 `legacy_missing`; +- 历史人工单在销售事实里保留收入,成本缺口计入 `cost_missing_count`;**不做特殊 stage、不补录**,不计入「上线后监控缺口」; +- 历史平台 `legacy_platform` 单独成行,成本缺口同样计入 `cost_missing_count`;`cost_stage` 由快照覆盖情况决定(actual / estimated / partial / missing); - 自动发货、接单平台历史成本不回写、不重算版本,沿用现有快照。 --- @@ -165,8 +168,11 @@ P0 的具体迁移、回填、代码清单与验收 SQL 见 [成本中心与履 | `order_items.fulfillment_mode` 持久化 | **已实施** | 迁移 `105` + 统一决策服务 | | 订单页 / 成本快照 / 销售事实统一读字段 | **已实施** | `admin-order-read-helpers`、`sales-facts-service`、`cost-snapshot-sync-service` | | 本地 316,585 项回填 | **已实施** | `fulfillment:backfill`,四项计数与 §二 一致 | -| 成本模型与履约方式正交(去 `carrier_type`) | **已实施(本地库已改造)** | 迁移 `103`–`107` 重写;`cost_items.cost_model` | -| 10 个迁移收敛为 5 个 | **已实施** | `103_product_catalog` ~ `107_manual_fulfillment` | +| 成本模型与履约方式正交(去 `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`);集成测试覆盖并发/回滚 | +| 人工 SKU 配置自动匹配 | **已实施(2026-10-26)** | 保存配置立即回填 + 定时任务 rematch + 确认时按 SKU 兜底匹配 | +| 人工缺成本监控 | **已实施(2026-10-26)** | `manual_cost_missing`,从首次人工配置时间起算,确认后自动收口 | | 人工成本链路收敛为「人工录单 → 扣备用金 → 写快照」 | **已实施(本地库已改造)** | 迁移 `104`;登记单与审批报销已下线,口径固化在成本中心迁移 | | 人工商品归一 `man:` | **已实施(本地库已归一)** | `manual-product-normalization-service` + `POST /cost-center/manual-products/normalize`;1,116 个 `man:` 商品、91,606 个人工订单项归一,人工未归一 = 0 | | 人工商品归一后的销售事实 | **已实施** | 94 天分 30 天窗口重算:26,507 行;人工 ¥1,219.20 万收入全部按 `man:` 商品归集 | @@ -175,7 +181,7 @@ P0 的具体迁移、回填、代码清单与验收 SQL 见 [成本中心与履 | 影子总账、对账、关账 | **未实施** | §六 P3 | > 历史平台仍有 53,901 个订单项(¥389.12 万)未归一,不属于 P1 范围;人工历史成本按既定口径不补录,因此人工毛利当前是“收入已归商品、成本仅新单有”的状态。 -> 分支事实:以上实现已快进合并到 `dev`;迁移 `103`–`107` **未推送远端、未部署生产**。生产口径仍以 `main` 为准。 +> 分支事实:以上实现已合并到 `dev`;迁移 `103`–`111` **未推送远端、未部署生产**。生产口径仍以 `main` 为准;上线前按 [财务上线初始化与验收-2026-10-26.md](财务上线初始化与验收-2026-10-26.md) 初始化资金与人工 SKU。 --- @@ -209,20 +215,25 @@ WHERE oi.fulfillment_mode = 'manual' WHERE cs.order_item_id = oi.id AND cs.superseded_at IS NULL ); --- 4. 人工订单项没有备用金支出单:上线后应为 0 +-- 4. 人工订单项没有资金流水(现金 manual_expenses 或积分 creditEntryId):上线后应为 0 SELECT COUNT(*) FROM order_items oi WHERE oi.fulfillment_mode = 'manual' AND oi.created_at >= DATE '2026-11-01' + AND NOT EXISTS (SELECT 1 FROM manual_expenses me WHERE me.order_item_id = oi.id) AND NOT EXISTS ( - SELECT 1 FROM manual_expenses me - WHERE me.order_item_id = oi.id + SELECT 1 FROM cost_snapshots cs + WHERE cs.subject_type = 'manual_case' + AND cs.order_item_id = oi.id + AND cs.superseded_at IS NULL + AND cs.metadata_json ? 'creditEntryId' ); --- 5. 人工成本快照金额与备用金支出金额不一致:应为 0 +-- 5. 现金人工成本快照金额与备用金支出金额不一致:应为 0 +-- subject_id 已统一为 order_item_id,资金来源按 order_item_id 关联。 SELECT cs.id, me.expense_no, me.amount_fen, cs.total_amount_fen FROM cost_snapshots cs -JOIN manual_expenses me ON me.id = cs.subject_id +JOIN manual_expenses me ON me.order_item_id = cs.order_item_id WHERE cs.subject_type = 'manual_case' AND cs.superseded_at IS NULL AND cs.total_amount_fen <> me.amount_fen @@ -256,7 +267,12 @@ WHERE oi.fulfillment_mode = 'manual' AND ft.executor_key IN ('kuaishou_ct_assisted', 'kuaishou_feifei') ); --- 9. 人工项误挂接单模板商品:P1 完成后应为 0 +-- 9. 人工成本缺失异常:上线后应随确认履约自动收口 +SELECT status, COUNT(*) FROM cost_exceptions +WHERE reason = 'manual_cost_missing' +GROUP BY status; + +-- 10. 人工项误挂接单模板商品:P1 完成后应为 0 SELECT COUNT(*) FROM order_items oi JOIN product_catalog pc ON pc.id = oi.canonical_product_id diff --git a/docs/财务系统集成方案.md b/docs/财务系统集成方案.md index 9b33d44f..9b4a50d1 100644 --- a/docs/财务系统集成方案.md +++ b/docs/财务系统集成方案.md @@ -1,8 +1,9 @@ # 财务系统集成方案 > 状态:**现行方案(总账层未实施)**。本文只描述 `finance_*` 总账层;当前总账表尚未建立,现有财务页仍是 `UNION ALL` 业务事件拼装。 -> 最后校准:2026-10-23 -> 代码基线:`dev @ 43f402cf + 迁移 `103`–`107`(已合并到 `dev`,**未推送、未部署生产**) +> 最后校准:2026-10-26 +> 代码基线:`dev`(迁移到 `111`,**未推送、未部署生产**) +> 说明:本文是后续 P3 总账/对账/关账方案,**尚未实施**;本次上线范围(备用金、人工成本、成本中心、销售事实)与初始化步骤见 [财务上线初始化与验收-2026-10-26.md](财务上线初始化与验收-2026-10-26.md)。 > **履约方式、成本模型、人工全覆盖口径以 [财务与履约口径总纲-2026-10-23.md](财务与履约口径总纲-2026-10-23.md) 为准**;本文不重复分类设计。 > 目标:在**不破坏现有抢单、押金、结算、寄卖、提现链路**的前提下,增加可对账、可关账、可追溯的复式记账底座。 > 结论先说:**不要直接在 `worker_wallets` / `worker_wallet_ledgers` 上继续加字段。** 现有表是业务钱包与流水,不是会计总账;正确做法是新增复式记账底座,让现有钱包成为它的“余额投影”。 diff --git a/docs/部署启动与数据库重建.md b/docs/部署启动与数据库重建.md index 83ac9b80..bc1ed870 100644 --- a/docs/部署启动与数据库重建.md +++ b/docs/部署启动与数据库重建.md @@ -219,6 +219,21 @@ docker compose ps docker compose logs -f backend ``` +## 财务/成本上线后的初始化(迁移 102–111) + +代码与迁移只负责建表;**备用金账户、积分余额、人工 SKU 配置不会自动创建**。 +首次上线后按 [财务上线初始化与验收-2026-10-26.md](财务上线初始化与验收-2026-10-26.md) 执行: + +1. 钱包管理 → 补建客服备用金账户 → 按需拨付; +2. 商品与成本 → 积分钱包 → 给客服发放三平台积分; +3. 商品与成本 → 人工 SKU 配置 → 扫描并逐项配置(保存后自动回填订单项); +4. 用手册 §二 的验收 SQL 检查:配置覆盖、人工成本快照、`manual_cost_missing`、销售事实对平。 + +> 历史人工成本不补录;人工成本监控从第一次创建人工履约配置的时间开始。 +> 定时任务 `cost-finance-sync` 每 5 分钟运行,负责履约归集、人工 SKU 回填、成本快照与销售事实。 + +--- + ## 服务器日常更新 ```bash