From a333c05df410d1a8f51320daec7cfb64ff3dfac1 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 6 Nov 2026 17:12:44 +0800 Subject: [PATCH] =?UTF-8?q?fix(fulfillment):=20=E6=94=B6=E5=8F=A3=E6=B8=A0?= =?UTF-8?q?=E9=81=93=E5=95=86=E5=93=81=E4=B8=8E=E7=BB=9F=E4=B8=80=E7=BB=93?= =?UTF-8?q?=E7=AE=97=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ..._fulfillment_shadow_configuration_sync.sql | 109 +++++++ ...145_fulfillment_current_attempt_latest.sql | 38 +++ apps/backend/src/routes/admin/finance.ts | 44 +++ .../cost-center/cost-exception-service.ts | 11 +- .../manual-fulfillment-config-service.ts | 20 ++ .../product-normalize-service.test.ts | 60 +++- .../cost-center/product-normalize-service.ts | 97 +++++- .../manual-fulfillment-integrity.test.ts | 9 + .../finance/manual-fulfillment-service.ts | 205 +++++++----- .../finance/on-account-bill-service.test.ts | 14 + .../finance/on-account-bill-service.ts | 306 ++++++++++++++++++ .../fulfillment/channel-assign-service.ts | 5 +- .../fulfillment-retry-guard.test.ts | 10 + .../fulfillment-shadow-model.test.ts | 16 + .../fulfillment-state-view.test.ts | 4 + .../admin/ManualFulfillmentConfirmModal.tsx | 8 +- .../admin/cost-center/CostExceptionPanel.tsx | 1 + .../src/pages/admin/fund/FundSupportView.tsx | 170 +++++++++- .../src/services/admin/cost-center.ts | 25 ++ apps/frontend/src/types/admin/cost-center.ts | 31 +- .../fund-support-wallet-on-account.test.ts | 18 ++ docs/成本中心API契约.md | 15 + docs/成本中心与履约模型.md | 3 + 23 files changed, 1113 insertions(+), 106 deletions(-) create mode 100644 apps/backend/src/db/migrations/144_fulfillment_shadow_configuration_sync.sql create mode 100644 apps/backend/src/db/migrations/145_fulfillment_current_attempt_latest.sql create mode 100644 apps/backend/src/services/finance/on-account-bill-service.test.ts create mode 100644 apps/backend/src/services/finance/on-account-bill-service.ts diff --git a/apps/backend/src/db/migrations/144_fulfillment_shadow_configuration_sync.sql b/apps/backend/src/db/migrations/144_fulfillment_shadow_configuration_sync.sql new file mode 100644 index 00000000..b365bfbc --- /dev/null +++ b/apps/backend/src/db/migrations/144_fulfillment_shadow_configuration_sync.sql @@ -0,0 +1,109 @@ +-- 144_fulfillment_shadow_configuration_sync.sql +-- +-- 统一履约视图会读取人工配置和 internal_sku_channels 的派生状态。 +-- 这两张配置表变化时也必须刷新影子模型,否则影子表会保留旧载体状态和旧异常。 + +CREATE OR REPLACE FUNCTION sync_fulfillment_shadow_for_manual_config() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +DECLARE + old_key TEXT; + new_key TEXT; +BEGIN + IF TG_OP <> 'INSERT' THEN + old_key := canonicalize_internal_sku(OLD.sku_key); + END IF; + IF TG_OP <> 'DELETE' THEN + new_key := canonicalize_internal_sku(NEW.sku_key); + END IF; + + PERFORM sync_fulfillment_shadow(item.id) + FROM order_items item + WHERE item.fulfillment_status IN ('pending', 'assigned') + AND canonicalize_internal_sku( + resolve_internal_sku(item.manual_sku_key, item.sku_name, item.sku_code) + ) IN (old_key, new_key); + + RETURN COALESCE(NEW, OLD); +END; +$$; + +DROP TRIGGER IF EXISTS trg_manual_fulfillment_config_shadow ON manual_fulfillment_configs; +CREATE TRIGGER trg_manual_fulfillment_config_shadow +AFTER INSERT OR UPDATE OR DELETE ON manual_fulfillment_configs +FOR EACH ROW +EXECUTE FUNCTION sync_fulfillment_shadow_for_manual_config(); + +CREATE OR REPLACE FUNCTION sync_fulfillment_shadow_for_internal_channel() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +DECLARE + old_key TEXT; + new_key TEXT; +BEGIN + IF TG_OP <> 'INSERT' THEN + old_key := canonicalize_internal_sku(OLD.internal_sku); + END IF; + IF TG_OP <> 'DELETE' THEN + new_key := canonicalize_internal_sku(NEW.internal_sku); + END IF; + + PERFORM sync_fulfillment_shadow(item.id) + FROM order_items item + WHERE item.fulfillment_status IN ('pending', 'assigned') + AND canonicalize_internal_sku( + resolve_internal_sku(item.manual_sku_key, item.sku_name, item.sku_code) + ) IN (old_key, new_key); + + RETURN COALESCE(NEW, OLD); +END; +$$; + +DROP TRIGGER IF EXISTS trg_internal_sku_channel_shadow ON internal_sku_channels; +CREATE TRIGGER trg_internal_sku_channel_shadow +AFTER INSERT OR UPDATE OR DELETE ON internal_sku_channels +FOR EACH ROW +EXECUTE FUNCTION sync_fulfillment_shadow_for_internal_channel(); + +CREATE OR REPLACE FUNCTION assert_manual_config_product_source() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + IF NEW.canonical_product_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 + FROM product_catalog product + WHERE product.id = NEW.canonical_product_id + AND product.status = 'active' + AND product.canonical_sku LIKE 'man:%' + ) + THEN + RAISE EXCEPTION + 'manual fulfillment config product % must be an active man: product', + NEW.canonical_product_id + USING ERRCODE = '23514'; + END IF; + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS trg_manual_config_product_source ON manual_fulfillment_configs; +CREATE TRIGGER trg_manual_config_product_source +BEFORE INSERT OR UPDATE OF canonical_product_id ON manual_fulfillment_configs +FOR EACH ROW +EXECUTE FUNCTION assert_manual_config_product_source(); + +-- 修正迁移 141 后由配置变化造成的存量陈旧影子记录。 +SELECT sync_fulfillment_shadow(item.id) +FROM order_items item +WHERE item.fulfillment_status IN ('pending', 'assigned'); + +COMMENT ON FUNCTION sync_fulfillment_shadow_for_manual_config() IS + '人工配置变更后重算同内部 SKU 的履约影子'; +COMMENT ON FUNCTION sync_fulfillment_shadow_for_internal_channel() IS + '统一渠道规则变更后重算同内部 SKU 的履约影子'; +COMMENT ON FUNCTION assert_manual_config_product_source() IS + '人工履约配置只能关联有效的 man: 标准商品'; diff --git a/apps/backend/src/db/migrations/145_fulfillment_current_attempt_latest.sql b/apps/backend/src/db/migrations/145_fulfillment_current_attempt_latest.sql new file mode 100644 index 00000000..8f89e497 --- /dev/null +++ b/apps/backend/src/db/migrations/145_fulfillment_current_attempt_latest.sql @@ -0,0 +1,38 @@ +-- 145_fulfillment_current_attempt_latest.sql +-- +-- 统一读模型按 task.id DESC 取当前尝试。为保持与 is_current 指针一致, +-- 从现在开始禁止把较旧任务重新标记为 current;新尝试仍由业务先退休旧指针。 + +CREATE OR REPLACE FUNCTION assert_fulfillment_current_is_latest() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + IF NEW.is_current = TRUE + AND EXISTS ( + SELECT 1 + FROM fulfillment_tasks newer + WHERE newer.order_item_id = NEW.order_item_id + AND newer.unit_index = NEW.unit_index + AND newer.id > COALESCE(NEW.id, 0) + ) + THEN + RAISE EXCEPTION + 'fulfillment current attempt must be the latest attempt for order_item % unit %', + NEW.order_item_id, + NEW.unit_index + USING ERRCODE = '23514'; + END IF; + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS trg_fulfillment_current_is_latest ON fulfillment_tasks; +CREATE TRIGGER trg_fulfillment_current_is_latest +BEFORE INSERT OR UPDATE OF is_current, order_item_id, unit_index +ON fulfillment_tasks +FOR EACH ROW +EXECUTE FUNCTION assert_fulfillment_current_is_latest(); + +COMMENT ON FUNCTION assert_fulfillment_current_is_latest() IS + '当前履约尝试必须是同订单项单元中 id 最大的尝试,保证统一视图 id DESC 与 is_current 一致'; diff --git a/apps/backend/src/routes/admin/finance.ts b/apps/backend/src/routes/admin/finance.ts index 31e9bf58..0904a338 100644 --- a/apps/backend/src/routes/admin/finance.ts +++ b/apps/backend/src/routes/admin/finance.ts @@ -13,6 +13,10 @@ import { confirmManualFulfillment, listManualFulfillmentPending, } from '../../services/finance/manual-fulfillment-service.js' +import { + listOnAccountBills, + reconcileOnAccountBill, +} from '../../services/finance/on-account-bill-service.js' import { createJsonHandler, requireAdminPermission, @@ -152,4 +156,44 @@ router.post( }), ) +router.get( + '/finance/on-account-bills', + requireAdminRoles(['admin', 'operator', 'support', 'finance']), + createJsonHandler((req) => listOnAccountBills(req.query as Record), { + successMessage: 'ok', + errorMessage: '读取统一结算账单失败', + scope: '[admin/finance/on-account-bills]', + }), +) + +router.post( + '/finance/on-account-bills/:billId/reconcile', + requireAdminPermission('finance.reconcile'), + createJsonHandler( + (req) => reconcileOnAccountBill(req.params.billId, bodyOf(req), sessionOf(req)), + { + successMessage: '统一结算账单已对账', + errorMessage: '统一结算账单对账失败', + scope: '[admin/finance/on-account-bills/:billId/reconcile]', + audit: (req, data) => { + const result = data as { + bill?: { id?: number; billNo?: string } + amountFen?: number + snapshotId?: number + } + return { + action: 'on_account_bill_reconciled', + targetType: 'on_account_bill', + targetId: String(result.bill?.id || req.params.billId || ''), + data: { + billNo: result.bill?.billNo || '', + amountFen: result.amountFen || 0, + costSnapshotId: result.snapshotId || null, + }, + } + }, + }, + ), +) + export default router diff --git a/apps/backend/src/services/cost-center/cost-exception-service.ts b/apps/backend/src/services/cost-center/cost-exception-service.ts index 27c84d37..6166f9e8 100644 --- a/apps/backend/src/services/cost-center/cost-exception-service.ts +++ b/apps/backend/src/services/cost-center/cost-exception-service.ts @@ -1,3 +1,5 @@ +import type { PoolClient } from 'pg' + import { query } from '../../db/client.js' import { nowIso } from '../../utils/time.js' @@ -19,6 +21,7 @@ export const COST_EXCEPTION_REASONS = [ 'cost_missing', 'manual_config_missing', 'manual_cost_missing', + 'on_account_cost_pending', 'carrier_missing', ] as const @@ -29,14 +32,16 @@ export type UpsertExceptionInput = { scopeKey: string orderItemId: number detail?: Record + client?: Pick } /** 幂等上浮一条聚合问题:已存在则累加明细并刷新 last_seen。 */ export async function upsertAggregatedException(input: UpsertExceptionInput): Promise { const now = nowIso() const detail = JSON.stringify(input.detail || {}) + const run = input.client ? input.client.query.bind(input.client) : query - const result = await query<{ id: number }>( + const result = await run<{ id: number }>( ` INSERT INTO cost_exceptions ( subject_type, subject_id, order_item_id, reason, status, detail_json, @@ -55,7 +60,7 @@ export async function upsertAggregatedException(input: UpsertExceptionInput): Pr const exceptionId = result.rows[0]?.id if (!exceptionId) return - await query( + await run( ` INSERT INTO cost_exception_items (exception_id, order_item_id, created_at) VALUES ($1, $2, $3) @@ -64,7 +69,7 @@ export async function upsertAggregatedException(input: UpsertExceptionInput): Pr [exceptionId, input.orderItemId, now], ) - await query( + await run( ` UPDATE cost_exceptions ce SET item_count = ( 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 ba7b95c7..dcfab3f0 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 @@ -468,6 +468,26 @@ export async function saveManualFulfillmentConfig(payload: JsonObject) { const existingId = Number(existing.rows[0]?.id || 0) let resolvedProductId = canonicalProductId + if (resolvedProductId) { + const product = await client.query<{ canonical_sku: string; status: string }>( + `SELECT canonical_sku, status + FROM product_catalog + WHERE id = $1 + LIMIT 1`, + [resolvedProductId], + ) + if ( + !product.rows[0] || + product.rows[0].status !== 'active' || + !String(product.rows[0].canonical_sku || '').startsWith('man:') + ) { + throw createHttpError('人工履约配置只能关联有效的 man: 标准商品', { + statusCode: 400, + errorCode: 'manual_config_product_source_invalid', + }) + } + } + // 人工配置本身就是该 SKU 的标准商品声明。没有显式关联时自动创建/复用 // man:<内部 SKU>,避免运营再去「标准商品」手工建一次。 if (!resolvedProductId) { diff --git a/apps/backend/src/services/cost-center/product-normalize-service.test.ts b/apps/backend/src/services/cost-center/product-normalize-service.test.ts index 0ab25723..4ac1cd97 100644 --- a/apps/backend/src/services/cost-center/product-normalize-service.test.ts +++ b/apps/backend/src/services/cost-center/product-normalize-service.test.ts @@ -1,4 +1,5 @@ import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' import test from 'node:test' import { @@ -63,9 +64,11 @@ function item(patch: Record = {}) { sku_name: '任意', normalized_sku_name: null as string | null, normalized_sku_series: null as string | null, - fulfillment_channel: 'work_order' as string | null, + fulfillment_channel: null as string | null, + canonical_product_id: null as number | null, work_rule_key: null as string | null, manual_config_product_id: null as number | null, + manual_config_product_sku: null as string | null, ...patch, } } @@ -123,6 +126,7 @@ test('商品归一:人工渠道直接使用配置里指定的商品', () => { fulfillment_channel: 'manual', sku_code: 'unknown', manual_config_product_id: 44, + manual_config_product_sku: 'man:配置商品', }), index, ), @@ -157,11 +161,52 @@ test('商品归一:第三方卡券缺少 tpv 商品时不能串到同名人工 ) }) +test('商品归一:其他已确定渠道也不能借用同名其他来源商品', () => { + for (const fulfillmentChannel of ['manual', 'on_account', 'affiliate_dash', 'work_order']) { + assert.equal( + resolveProductId( + item({ + fulfillment_channel: fulfillmentChannel, + sku_code: 'unknown', + internal_sku: '套装-测试', + }), + index, + ), + null, + ) + } +}) + +test('商品归一:人工配置也只能挂载 man: 商品', () => { + assert.equal( + resolveProductId( + item({ + fulfillment_channel: 'manual', + manual_config_product_id: 99, + manual_config_product_sku: 'aff:错误商品', + }), + index, + ), + null, + ) + assert.equal( + resolveProductId( + item({ + fulfillment_channel: 'on_account', + manual_config_product_id: 99, + manual_config_product_sku: 'man:正确商品', + }), + index, + ), + 99, + ) +}) + test('商品归一:中文内部 SKU 命中标准商品,不依赖平台 SKU', () => { assert.equal( resolveProductId( item({ - fulfillment_channel: 'affiliate_dash', + fulfillment_channel: null, sku_code: 'unknown', internal_sku: '套装-测试', }), @@ -191,3 +236,14 @@ test('三方卡券自动建商品时优先使用内部中文 SKU', () => { assert.equal(voucherCanonicalSku({ manual_sku_key: '', sku_name: '', sku_code: 'x' }), null) assert.equal(voucherCanonicalSku({}), null) }) + +test('商品归一扫描会纠正所有渠道的错误来源商品', async () => { + const source = await readFile(new URL('./product-normalize-service.ts', import.meta.url), 'utf8') + + assert.ok(source.includes("current_product.canonical_sku NOT LIKE 'aff:%'")) + assert.ok(source.includes("current_product.canonical_sku NOT LIKE 'man:%'")) + assert.ok(source.includes("current_product.canonical_sku NOT LIKE 'tpl:%'")) + assert.ok(source.includes("current_product.canonical_sku NOT LIKE 'tpv:%'")) + assert.ok(source.includes('canonicalProductId: null')) + assert.ok(source.includes('onlyWhenCanonicalProductDifferent: true')) +}) diff --git a/apps/backend/src/services/cost-center/product-normalize-service.ts b/apps/backend/src/services/cost-center/product-normalize-service.ts index 552bb87c..050ab17b 100644 --- a/apps/backend/src/services/cost-center/product-normalize-service.ts +++ b/apps/backend/src/services/cost-center/product-normalize-service.ts @@ -3,7 +3,10 @@ import { resolveExceptionsForItems, upsertAggregatedException } from './cost-exc import { readWatermarkItemId } from '../watermark-store.js' import { upsertInternalSkuChannel } from '../fulfillment/internal-sku-channel-service.js' import { canonicalizeInternalSku, containsCjk } from '../fulfillment/internal-sku-resolver.js' -import { attachFulfillmentConfigurations } from '../fulfillment/fulfillment-state-service.js' +import { + attachFulfillmentConfigurations, + transitionFulfillmentState, +} from '../fulfillment/fulfillment-state-service.js' /** * 管道 B:商品归一。 @@ -51,8 +54,10 @@ type ItemRow = { normalized_sku_name: string | null normalized_sku_series: string | null fulfillment_channel: string | null + canonical_product_id?: number | null work_rule_key: string | null manual_config_product_id: number | null + manual_config_product_sku?: string | null } function key(...parts: string[]): string { @@ -145,6 +150,17 @@ export function voucherCanonicalSku(item: { return internal ? `tpv:${internal}` : null } +async function clearMismatchedProduct(item: ItemRow) { + if (!item.fulfillment_channel || !item.canonical_product_id) return + await transitionFulfillmentState({ + orderItemId: Number(item.id), + expected: { status: 'assigned' }, + patch: { canonicalProductId: null }, + eventType: 'product_source_cleared', + reason: `渠道 ${item.fulfillment_channel} 与当前标准商品来源不一致`, + }) +} + /** * 三方卡券没有预先建库时,按内部 SKU 自动建 `tpv:` 商品并补渠道规则。 * 建完后该商品会出现在「商品与成本中心 → 第三方卡券」,运营直接设采购价。 @@ -225,20 +241,23 @@ export function resolveProductId(item: ItemRow, index: MappingIndex): number | n const seriesKey = String(item.normalized_sku_series || '').trim() const internalSku = canonicalizeInternalSku(item.internal_sku || item.manual_sku_key) - // 人工渠道:配置里已经指定商品,直接用,不需要再建映射。 - if (item.fulfillment_channel === 'manual' && item.manual_config_product_id) { - return Number(item.manual_config_product_id) + // 已确定渠道必须命中本渠道商品,不能借用同名的其他来源商品。 + if (item.fulfillment_channel === 'manual' || item.fulfillment_channel === 'on_account') { + return item.manual_config_product_id && + String(item.manual_config_product_sku || '').startsWith('man:') + ? Number(item.manual_config_product_id) + : null } // 接单平台:模板即商品来源(tpl:),无需渠道映射。 - if (item.fulfillment_channel === 'work_order' && item.work_rule_key) { + if (item.fulfillment_channel === 'work_order') { + if (!item.work_rule_key) return null const templateProduct = index.templateRules.get(String(item.work_rule_key).trim()) - if (templateProduct) return templateProduct + return templateProduct || null } - if (item.fulfillment_channel === 'affiliate_dash' && skuCode) { - const aff = index.affSkus.get(skuCode) - if (aff) return aff + if (item.fulfillment_channel === 'affiliate_dash') { + return skuCode ? index.affSkus.get(skuCode) || null : null } // 第三方卡券:与自动发货同构,商品编码 tpv:。 @@ -325,13 +344,16 @@ export async function normalizeOrderItemProducts( oi.normalized_sku_name, oi.normalized_sku_series, oi.fulfillment_channel, + oi.canonical_product_id, wpr.rule_key AS work_rule_key, - cfg.canonical_product_id AS manual_config_product_id + cfg.canonical_product_id AS manual_config_product_id, + cfg_product.canonical_sku AS manual_config_product_sku FROM order_items oi JOIN orders o ON o.id = oi.order_id LEFT JOIN work_orders wo ON wo.order_item_id = oi.id AND wo.status <> 'cancelled' LEFT JOIN work_product_rules wpr ON wpr.id = wo.product_rule_id LEFT JOIN manual_fulfillment_configs cfg ON cfg.id = oi.fulfillment_config_id + LEFT JOIN product_catalog cfg_product ON cfg_product.id = cfg.canonical_product_id LEFT JOIN product_catalog current_product ON current_product.id = oi.canonical_product_id WHERE oi.fulfillment_status = 'assigned' AND ( @@ -343,6 +365,30 @@ export async function normalizeOrderItemProducts( OR current_product.canonical_sku NOT LIKE 'tpv:%' ) ) + OR ( + oi.fulfillment_channel = 'affiliate_dash' + AND ( + current_product.id IS NULL + OR current_product.canonical_sku NOT LIKE 'aff:%' + ) + ) + OR ( + oi.fulfillment_channel IN ('manual', 'on_account') + AND ( + current_product.id IS NULL + OR current_product.canonical_sku NOT LIKE 'man:%' + ) + ) + OR ( + oi.fulfillment_channel = 'work_order' + AND ( + current_product.id IS NULL + OR ( + current_product.canonical_sku NOT LIKE 'tpl:%' + AND current_product.canonical_sku NOT LIKE 'wo:%' + ) + ) + ) ) AND oi.id > $1 ORDER BY oi.id @@ -369,6 +415,7 @@ export async function normalizeOrderItemProducts( } if (!productId) { result.unmatched += 1 + await clearMismatchedProduct(item) await upsertAggregatedException({ reason: 'product_mapping_missing', scopeKey: `product_mapping_missing|-|-|${canonicalizeInternalSku(item.internal_sku || item.manual_sku_key || item.sku_code)}|${item.fulfillment_channel || '-'}`, @@ -487,13 +534,16 @@ export async function normalizeProductsForItems(itemIds: number[]): Promise<{ oi.normalized_sku_name, oi.normalized_sku_series, oi.fulfillment_channel, + oi.canonical_product_id, wpr.rule_key AS work_rule_key, - cfg.canonical_product_id AS manual_config_product_id + cfg.canonical_product_id AS manual_config_product_id, + cfg_product.canonical_sku AS manual_config_product_sku FROM order_items oi JOIN orders o ON o.id = oi.order_id LEFT JOIN work_orders wo ON wo.order_item_id = oi.id AND wo.status <> 'cancelled' LEFT JOIN work_product_rules wpr ON wpr.id = wo.product_rule_id LEFT JOIN manual_fulfillment_configs cfg ON cfg.id = oi.fulfillment_config_id + LEFT JOIN product_catalog cfg_product ON cfg_product.id = cfg.canonical_product_id LEFT JOIN product_catalog current_product ON current_product.id = oi.canonical_product_id WHERE oi.fulfillment_status = 'assigned' AND ( @@ -505,6 +555,30 @@ export async function normalizeProductsForItems(itemIds: number[]): Promise<{ OR current_product.canonical_sku NOT LIKE 'tpv:%' ) ) + OR ( + oi.fulfillment_channel = 'affiliate_dash' + AND ( + current_product.id IS NULL + OR current_product.canonical_sku NOT LIKE 'aff:%' + ) + ) + OR ( + oi.fulfillment_channel IN ('manual', 'on_account') + AND ( + current_product.id IS NULL + OR current_product.canonical_sku NOT LIKE 'man:%' + ) + ) + OR ( + oi.fulfillment_channel = 'work_order' + AND ( + current_product.id IS NULL + OR ( + current_product.canonical_sku NOT LIKE 'tpl:%' + AND current_product.canonical_sku NOT LIKE 'wo:%' + ) + ) + ) ) AND oi.id = ANY($1::bigint[]) `, @@ -519,6 +593,7 @@ export async function normalizeProductsForItems(itemIds: number[]): Promise<{ } if (!productId) { unmatched += 1 + await clearMismatchedProduct(item) await upsertAggregatedException({ reason: 'product_mapping_missing', scopeKey: `product_mapping_missing|-|-|${canonicalizeInternalSku(item.internal_sku || item.manual_sku_key || item.sku_code)}|${item.fulfillment_channel || '-'}`, diff --git a/apps/backend/src/services/finance/manual-fulfillment-integrity.test.ts b/apps/backend/src/services/finance/manual-fulfillment-integrity.test.ts index 92f86362..ea0c20cc 100644 --- a/apps/backend/src/services/finance/manual-fulfillment-integrity.test.ts +++ b/apps/backend/src/services/finance/manual-fulfillment-integrity.test.ts @@ -165,6 +165,15 @@ test('人工确认前必须自动匹配履约配置,配置保存/定时任务 ) }) +test('统一结算金额未知时只生成待对账账单,不写 0 元实际成本', async () => { + const source = await readFile(new URL('./manual-fulfillment-service.ts', import.meta.url), 'utf8') + + assert.ok(source.includes("platformKey === 'on_account' && costFen <= 0")) + assert.ok(source.includes('if (!onAccountCostPending)')) + assert.ok(source.includes("reason: 'on_account_cost_pending'")) + assert.ok(source.includes('costPending: onAccountCostPending')) +}) + test('人工成本缺失由管道按配置与快照生成并收口', async () => { const [pipelineSource, manualSource] = await Promise.all([ readFile(new URL('../scheduler/finance-pipeline-jobs.ts', import.meta.url), 'utf8'), diff --git a/apps/backend/src/services/finance/manual-fulfillment-service.ts b/apps/backend/src/services/finance/manual-fulfillment-service.ts index 315bbeaa..091d6841 100644 --- a/apps/backend/src/services/finance/manual-fulfillment-service.ts +++ b/apps/backend/src/services/finance/manual-fulfillment-service.ts @@ -21,6 +21,7 @@ import { attachFulfillmentConfigurationsWithExecutor, transitionFulfillmentStateWithExecutor, } from '../fulfillment/fulfillment-state-service.js' +import { upsertAggregatedException } from '../cost-center/cost-exception-service.js' import { calculateManualCostFen, normalizeManualCreditQuantity, @@ -411,6 +412,20 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm errorCode: 'manual_fulfillment_platform_required', }) } + if (selectedChannel.platformKey === 'on_account') { + const existingBill = await client.query<{ id: number }>( + `SELECT id FROM on_account_bills + WHERE order_item_id = $1 AND status IN ('pending', 'reconciled') + LIMIT 1`, + [orderItemId], + ) + if (existingBill.rows[0]) { + throw createHttpError('该订单项已生成统一结算账单,不能重复操作', { + statusCode: 409, + errorCode: 'manual_fulfillment_on_account_already_exists', + }) + } + } // 锁内复查:并发/双击的第二个请求会等锁,醒来后看到快照直接拒绝,不会重复扣款。 const existing = await client.query<{ id: number }>( @@ -447,6 +462,7 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm providedCostFen, }) const costFen = costPlan.costFen + const onAccountCostPending = platformKey === 'on_account' && costFen <= 0 const payeeName = optionalText(payload.payeeName ?? payload.payee_name, 120) const note = optionalText(payload.note, 300) const fulfillmentReference = buildManualFulfillmentReference({ @@ -455,16 +471,8 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm 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', - }) - } - + // 扣款前先确定最终成本模式。统一结算未填金额时只生成待对账账单, + // 不写 0 元 actual 成本快照,避免利润被虚增。 let fundExpenseId: number | null = null let creditEntryId: number | null = null let balanceAfter: number | null = null @@ -522,11 +530,22 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm subjectSource = 'platform_credit' } - // subject_id 固定为 order_item_id:不再与 manual_expenses.id / - // platform_credit_entries.id 混用,避免两张表序列撞车。 - // subject_type 仍为 manual_case,资金流水只放在 metadata 里。 - const snapshot = await client.query<{ id: number }>( - ` + let snapshotId: number | null = null + if (!onAccountCostPending) { + 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', + }) + } + + // subject_id 固定为 order_item_id:不再与 manual_expenses.id / + // platform_credit_entries.id 混用,资金流水只放在 metadata 里。 + // subject_type 仍为 manual_case。 + 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, @@ -540,50 +559,51 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm ) 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, - creditOwnerAdminUserId: - platformKey === 'fund_cash' - ? null - : Number(payload.creditOwnerAdminUserId || 0) || null, - creditQuantity, - creditUnitPriceFen: unitPriceFen, - costMode: costPlan.costMode, + `, + [ + orderItemId, + row.order_id_resolved, + row.work_order_id, + costItemId, + versionId, + row.canonical_product_id, 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', - }) + JSON.stringify({ + source: 'manual_fulfillment_confirm', + subjectSource, + configId: Number(row.config_id), + platformKey, + platformLabel: PLATFORM_LABELS[platformKey] || platformKey, + creditOwnerAdminUserId: + platformKey === 'fund_cash' + ? null + : Number(payload.creditOwnerAdminUserId || 0) || null, + creditQuantity, + creditUnitPriceFen: unitPriceFen, + costMode: costPlan.costMode, + costFen, + fundExpenseId, + creditEntryId, + balanceAfter, + payeeName, + note, + fulfilledBy: { + userId: Number(session.userId || 0), + displayName: String(session.displayName || ''), + role: String(session.role || ''), + }, + }), + now, + ], + ) + snapshotId = snapshot.rows[0]?.id ?? null + if (!snapshotId) { + // 订单项唯一索引兜底:任何竞态都会走到这里,抛错回滚本次扣款。 + throw createHttpError('该订单项已有人工成本快照,不能重复录入', { + statusCode: 409, + errorCode: 'manual_fulfillment_snapshot_conflict', + }) + } } if (platformKey === 'on_account') { @@ -641,30 +661,46 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm reason: platformKey, }) - // 人工缺成本异常立即收口,不等下一轮定时任务。 - 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], - ) + if (onAccountCostPending) { + await upsertAggregatedException({ + reason: 'on_account_cost_pending', + scopeKey: `on_account_cost_pending|-|-|${row.internal_sku}|on_account`, + orderItemId, + detail: { + billId: onAccountBillId, + internalSku: row.internal_sku, + platformOrderId: row.platform_order_id, + }, + client, + }) + } else { + // 人工缺成本异常立即收口,不等下一轮定时任务。 + 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], + ) + } - // v2 聚合异常:删除本条明细,没人再引用的异常头自动关闭。 - await client.query(`DELETE FROM cost_exception_items WHERE order_item_id = $1`, [orderItemId]) - await client.query( - ` - UPDATE cost_exceptions ce - SET status = 'resolved', resolved_at = $1, item_count = 0, last_seen_at = $1 - WHERE ce.status <> 'resolved' - AND NOT EXISTS ( - SELECT 1 FROM cost_exception_items i WHERE i.exception_id = ce.id - ) - `, - [now], - ) + if (!onAccountCostPending) { + // v2 聚合异常:成本已经落账后,删除本条明细并自动关闭空异常头。 + await client.query(`DELETE FROM cost_exception_items WHERE order_item_id = $1`, [orderItemId]) + await client.query( + ` + UPDATE cost_exceptions ce + SET status = 'resolved', resolved_at = $1, item_count = 0, last_seen_at = $1 + WHERE ce.status <> 'resolved' + AND NOT EXISTS ( + SELECT 1 FROM cost_exception_items i WHERE i.exception_id = ce.id + ) + `, + [now], + ) + } return { orderItemId, @@ -678,6 +714,7 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm creditEntryId, balanceAfter, onAccountBillId, + costPending: onAccountCostPending, } }) @@ -693,7 +730,7 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm * 事实任务默认只回看最近几天,确认一笔较早的订单时不会自动覆盖, * 所以这里按该订单自己的业务日期精确重算。 */ -async function refreshFactsForOrderItem(orderItemId: number): Promise { +export async function refreshFactsForOrderItem(orderItemId: number): Promise { try { const basis = await query<{ stat_date: string | null }>( ` @@ -841,7 +878,7 @@ async function matchManualFulfillmentConfigWithClient(client: PoolClient, orderI return true } -async function resolveManualCostItemIdWithClient(client: PoolClient) { +export 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`, ) @@ -855,7 +892,7 @@ async function resolveManualCostItemIdWithClient(client: PoolClient) { return id } -async function resolveCostVersionIdWithClient( +export async function resolveCostVersionIdWithClient( client: PoolClient, costItemId: number, row: { diff --git a/apps/backend/src/services/finance/on-account-bill-service.test.ts b/apps/backend/src/services/finance/on-account-bill-service.test.ts new file mode 100644 index 00000000..9c7196b7 --- /dev/null +++ b/apps/backend/src/services/finance/on-account-bill-service.test.ts @@ -0,0 +1,14 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import test from 'node:test' + +test('统一结算对账用实际金额替代成本快照并重算事实', async () => { + const source = await readFile(new URL('./on-account-bill-service.ts', import.meta.url), 'utf8') + + assert.ok(source.includes('withTransaction(')) + assert.ok(source.includes("status = 'reconciled'")) + assert.ok(source.includes('SET superseded_at = $2')) + assert.ok(source.includes("source: 'on_account_reconcile'")) + assert.ok(source.includes("'on_account_cost_pending'")) + assert.ok(source.includes('refreshFactsForOrderItem')) +}) diff --git a/apps/backend/src/services/finance/on-account-bill-service.ts b/apps/backend/src/services/finance/on-account-bill-service.ts new file mode 100644 index 00000000..73827352 --- /dev/null +++ b/apps/backend/src/services/finance/on-account-bill-service.ts @@ -0,0 +1,306 @@ +import { query, withTransaction } from '../../db/client.js' +import type { JsonObject } from '../../types/json.js' +import { createHttpError } from '../../utils/http.js' +import { nowIso } from '../../utils/time.js' +import type { AdminSession } from '../admin/admin-auth-service.js' +import { resolveExceptionsForItemsByReason } from '../cost-center/cost-exception-service.js' +import { + refreshFactsForOrderItem, + resolveCostVersionIdWithClient, + resolveManualCostItemIdWithClient, +} from './manual-fulfillment-service.js' + +type OnAccountBillRow = { + id: number + bill_no: string + order_id: number + order_item_id: number + sku_key: string + canonical_product_id: number | null + platform_order_id: string + amount_fen: string | number + status: 'pending' | 'reconciled' | 'void' + payee_name: string + note: string + metadata_json: Record + created_by_admin_user_id: number | null + created_at: string + updated_at: string + reconciled_at: string | null + provider?: string + shop_id?: string + sku_name?: string + revenue_amount_fen?: string | number +} + +function mapBill(row: OnAccountBillRow) { + return { + id: Number(row.id), + billNo: row.bill_no, + orderId: Number(row.order_id), + orderItemId: Number(row.order_item_id), + skuKey: row.sku_key, + skuName: row.sku_name || row.sku_key, + canonicalProductId: row.canonical_product_id ? Number(row.canonical_product_id) : null, + platformOrderId: row.platform_order_id, + provider: row.provider || '', + shopId: row.shop_id || '', + revenueFen: Number(row.revenue_amount_fen || 0), + amountFen: Number(row.amount_fen || 0), + status: row.status, + payeeName: row.payee_name || '', + note: row.note || '', + metadata: row.metadata_json || {}, + createdByAdminUserId: row.created_by_admin_user_id + ? Number(row.created_by_admin_user_id) + : null, + createdAt: row.created_at, + updatedAt: row.updated_at, + reconciledAt: row.reconciled_at, + } +} + +const BILL_SELECT = ` + SELECT + bill.*, + order_row.provider, + order_row.shop_id, + item.sku_name, + item.revenue_amount_fen + FROM on_account_bills bill + JOIN orders order_row ON order_row.id = bill.order_id + JOIN order_items item ON item.id = bill.order_item_id +` + +export async function listOnAccountBills(input: JsonObject = {}) { + const page = Math.max(1, Math.trunc(Number(input.page) || 1)) + const pageSize = Math.min(100, Math.max(1, Math.trunc(Number(input.pageSize) || 20))) + const keyword = String(input.keyword || '').trim() + const status = String(input.status || '').trim() + const where: string[] = [] + const params: unknown[] = [] + + if (keyword) { + params.push(`%${keyword}%`) + where.push(`( + bill.bill_no ILIKE $${params.length} + OR bill.platform_order_id ILIKE $${params.length} + OR bill.sku_key ILIKE $${params.length} + OR item.sku_name ILIKE $${params.length} + )`) + } + if (['pending', 'reconciled', 'void'].includes(status)) { + params.push(status) + where.push(`bill.status = $${params.length}`) + } + + const whereSql = where.length > 0 ? `WHERE ${where.join(' AND ')}` : '' + const total = await query<{ total: number }>( + `SELECT COUNT(*)::int AS total + FROM on_account_bills bill + JOIN order_items item ON item.id = bill.order_item_id + ${whereSql}`, + params, + ) + const listParams = [...params, pageSize, (page - 1) * pageSize] + const result = await query( + `${BILL_SELECT} + ${whereSql} + ORDER BY + CASE bill.status WHEN 'pending' THEN 0 WHEN 'reconciled' THEN 1 ELSE 2 END, + bill.created_at DESC, + bill.id DESC + LIMIT $${params.length + 1} OFFSET $${params.length + 2}`, + listParams, + ) + + return { + items: result.rows.map(mapBill), + pagination: { + page, + pageSize, + total: Number(total.rows[0]?.total || 0), + }, + } +} + +export async function reconcileOnAccountBill( + billIdInput: unknown, + payload: JsonObject, + session: AdminSession, +) { + const billId = Number(billIdInput) + if (!Number.isInteger(billId) || billId <= 0) { + throw createHttpError('统一结算账单 ID 无效', { statusCode: 400 }) + } + const amountFen = Number(payload.amountFen ?? payload.amount_fen) + if (!Number.isFinite(amountFen) || amountFen <= 0 || !Number.isInteger(amountFen)) { + throw createHttpError('实际结算金额必须是大于 0 的整数分', { + statusCode: 400, + errorCode: 'on_account_amount_invalid', + }) + } + + const now = nowIso() + const result = await withTransaction(async (client) => { + const billResult = await client.query( + `${BILL_SELECT} + WHERE bill.id = $1 + LIMIT 1 + FOR UPDATE OF bill, item`, + [billId], + ) + const bill = billResult.rows[0] + if (!bill) { + throw createHttpError('统一结算账单不存在', { statusCode: 404 }) + } + if (bill.status !== 'pending') { + throw createHttpError('只有待对账账单可以确认结算', { + statusCode: 409, + errorCode: 'on_account_bill_not_pending', + }) + } + + const orderResult = await client.query<{ + provider: string + shop_id: string + paid_at: string | null + created_at: string + }>( + `SELECT provider, shop_id, paid_at, created_at + FROM orders WHERE id = $1`, + [bill.order_id], + ) + const order = orderResult.rows[0] + if (!order) { + throw createHttpError('统一结算账单关联订单不存在', { statusCode: 409 }) + } + const basisAt = order.paid_at || order.created_at + const period = await client.query<{ status: string }>( + `SELECT status FROM finance_periods + WHERE period_key = to_char($1::timestamptz AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM') + LIMIT 1`, + [basisAt], + ) + if (period.rows[0]?.status === 'closed') { + throw createHttpError('该订单所属期间已关账,请先反关账后再对账', { + statusCode: 409, + errorCode: 'on_account_period_closed', + }) + } + + const workOrder = await client.query<{ product_rule_id: number | null }>( + `SELECT product_rule_id FROM work_orders + WHERE order_item_id = $1 AND status <> 'cancelled' + ORDER BY id DESC LIMIT 1`, + [bill.order_item_id], + ) + const costItemId = await resolveManualCostItemIdWithClient(client) + const versionId = await resolveCostVersionIdWithClient( + client, + costItemId, + { + order_id_resolved: Number(bill.order_id), + provider: order.provider, + shop_id: order.shop_id, + canonical_product_id: bill.canonical_product_id, + work_product_rule_id: workOrder.rows[0]?.product_rule_id || null, + credit_unit_price_fen: '1', + }, + basisAt, + ) + if (!versionId) { + throw createHttpError('人工履约成本版本不可用,无法完成对账', { + statusCode: 409, + errorCode: 'on_account_cost_version_missing', + }) + } + + await client.query( + `UPDATE cost_snapshots + SET superseded_at = $2 + WHERE order_item_id = $1 + AND subject_type = 'manual_case' + AND superseded_at IS NULL + AND reversed_at IS NULL`, + [bill.order_item_id, now], + ) + 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, NULL, + $3, $4, $5, + 1, $6, $6, 'actual', 'manual_input', + $7::jsonb, $8, $9, $9 + ) + RETURNING id`, + [ + bill.order_item_id, + bill.order_id, + costItemId, + versionId, + bill.canonical_product_id, + amountFen, + JSON.stringify({ + source: 'on_account_reconcile', + billId: bill.id, + billNo: bill.bill_no, + platformKey: 'on_account', + previousAmountFen: Number(bill.amount_fen || 0), + reconciledBy: { + userId: Number(session.userId || 0), + displayName: String(session.displayName || ''), + }, + }), + basisAt, + now, + ], + ) + const snapshotId = Number(snapshot.rows[0]?.id || 0) + if (!snapshotId) { + throw createHttpError('统一结算成本快照保存失败', { statusCode: 500 }) + } + + const updated = await client.query( + `UPDATE on_account_bills + SET amount_fen = $1, + status = 'reconciled', + payee_name = $2, + note = $3, + metadata_json = metadata_json || $4::jsonb, + reconciled_at = $5, + updated_at = $5 + WHERE id = $6 + RETURNING *`, + [ + amountFen, + String(payload.payeeName ?? payload.payee_name ?? bill.payee_name ?? '').slice(0, 120), + String(payload.note ?? bill.note ?? '').slice(0, 300), + JSON.stringify({ + reconciledByAdminUserId: Number(session.userId || 0) || null, + costSnapshotId: snapshotId, + }), + now, + bill.id, + ], + ) + return { + bill: mapBill({ ...bill, ...updated.rows[0]!, amount_fen: amountFen }), + orderItemId: Number(bill.order_item_id), + snapshotId, + amountFen, + } + }) + + await Promise.all([ + resolveExceptionsForItemsByReason([result.orderItemId], 'on_account_cost_pending'), + resolveExceptionsForItemsByReason([result.orderItemId], 'manual_cost_missing'), + resolveExceptionsForItemsByReason([result.orderItemId], 'cost_missing'), + ]).catch(() => undefined) + await refreshFactsForOrderItem(result.orderItemId).catch(() => undefined) + return result +} diff --git a/apps/backend/src/services/fulfillment/channel-assign-service.ts b/apps/backend/src/services/fulfillment/channel-assign-service.ts index 97533ad0..1452e9ae 100644 --- a/apps/backend/src/services/fulfillment/channel-assign-service.ts +++ b/apps/backend/src/services/fulfillment/channel-assign-service.ts @@ -434,7 +434,10 @@ export async function assignPendingOrderItemChannels( .filter((id) => id > 0) .slice(0, 2000) result.assigned += assignedItemIds.length - result.assignedItemIds = assignedItemIds + result.assignedItemIds = [...new Set([...result.assignedItemIds, ...assignedItemIds])].slice( + 0, + 2000, + ) await scheduleVoucherConsumeForOrderItems(assignedItemIds, 'order_received').catch( (error) => { logWarn('[channel-assign]', '订单收到后安排自动核销失败', { diff --git a/apps/backend/src/services/fulfillment/fulfillment-retry-guard.test.ts b/apps/backend/src/services/fulfillment/fulfillment-retry-guard.test.ts index 7b5709e7..1c9c1568 100644 --- a/apps/backend/src/services/fulfillment/fulfillment-retry-guard.test.ts +++ b/apps/backend/src/services/fulfillment/fulfillment-retry-guard.test.ts @@ -16,6 +16,10 @@ const currentAttemptMigration = readFileSync( new URL('../../db/migrations/138_fulfillment_current_attempt.sql', import.meta.url), 'utf8', ) +const currentLatestMigration = readFileSync( + new URL('../../db/migrations/145_fulfillment_current_attempt_latest.sql', import.meta.url), + 'utf8', +) test('自动发货历史非活跃任务默认阻止后台自动重开', () => { assert.ok(carrierCreation.includes('INACTIVE_CARRIER_TASK_STATUSES.includes')) @@ -55,3 +59,9 @@ test('创建新履约尝试时自动退休旧当前尝试', () => { assert.ok(taskRepo.includes('$26, TRUE')) assert.ok(taskRepo.includes('SELECT id FROM order_items WHERE id = $1 FOR UPDATE')) }) + +test('当前尝试必须是同单元最新任务,统一视图不能读到已退休尝试', () => { + assert.ok(currentLatestMigration.includes('assert_fulfillment_current_is_latest')) + assert.ok(currentLatestMigration.includes('newer.id > COALESCE(NEW.id, 0)')) + assert.ok(currentLatestMigration.includes("ERRCODE = '23514'")) +}) diff --git a/apps/backend/src/services/fulfillment/fulfillment-shadow-model.test.ts b/apps/backend/src/services/fulfillment/fulfillment-shadow-model.test.ts index a0484cb8..02940f7a 100644 --- a/apps/backend/src/services/fulfillment/fulfillment-shadow-model.test.ts +++ b/apps/backend/src/services/fulfillment/fulfillment-shadow-model.test.ts @@ -10,6 +10,10 @@ const auditScript = readFileSync( new URL('../../../scripts/audit-fulfillment-state.ts', import.meta.url), 'utf8', ) +const configSyncMigration = readFileSync( + new URL('../../db/migrations/144_fulfillment_shadow_configuration_sync.sql', import.meta.url), + 'utf8', +) test('正式履约影子模型包含履约、尝试和异常三张表', () => { assert.ok(migration.includes('CREATE TABLE IF NOT EXISTS fulfillments')) @@ -27,6 +31,18 @@ test('影子模型从现有真源回填并持续双写', () => { assert.ok(migration.includes('assign_fulfillment_event_shadow')) }) +test('人工配置和统一渠道规则变化会刷新履约影子', () => { + assert.ok(configSyncMigration.includes('trg_manual_fulfillment_config_shadow')) + assert.ok(configSyncMigration.includes('trg_internal_sku_channel_shadow')) + assert.ok(configSyncMigration.includes('sync_fulfillment_shadow(item.id)')) +}) + +test('人工履约配置不能关联其他来源商品', () => { + assert.ok(configSyncMigration.includes('trg_manual_config_product_source')) + assert.ok(configSyncMigration.includes("product.canonical_sku LIKE 'man:%'")) + assert.ok(configSyncMigration.includes("ERRCODE = '23514'")) +}) + test('明确渠道即视为已路由,不用渠道配置缺失覆盖已选渠道', () => { assert.ok(migration.includes('v_fulfillment_item_state_raw')) assert.ok(migration.includes("WHEN raw.intended_channel IS NOT NULL THEN 'configured'")) diff --git a/apps/backend/src/services/fulfillment/fulfillment-state-view.test.ts b/apps/backend/src/services/fulfillment/fulfillment-state-view.test.ts index 48cae4bc..552f7109 100644 --- a/apps/backend/src/services/fulfillment/fulfillment-state-view.test.ts +++ b/apps/backend/src/services/fulfillment/fulfillment-state-view.test.ts @@ -43,6 +43,10 @@ test('待分类查询使用统一读模型并返回异常数量', () => { assert.ok(channelAssign.includes('issueCategoryInput')) }) +test('批量判渠保留所有批次的即时成本同步订单项', () => { + assert.ok(channelAssign.includes('...new Set([...result.assignedItemIds, ...assignedItemIds])')) +}) + test('重试必须按可重试 issueCode 限定,不能按整个 SKU 处理售后和复核项', () => { assert.ok(channelAssign.includes('RETRYABLE_CARRIER_ISSUES')) assert.ok(channelAssign.includes('issueCodes?: string[]')) diff --git a/apps/frontend/src/components/admin/ManualFulfillmentConfirmModal.tsx b/apps/frontend/src/components/admin/ManualFulfillmentConfirmModal.tsx index cf3413a5..019f3e51 100644 --- a/apps/frontend/src/components/admin/ManualFulfillmentConfirmModal.tsx +++ b/apps/frontend/src/components/admin/ManualFulfillmentConfirmModal.tsx @@ -183,9 +183,11 @@ export function ManualFulfillmentConfirmModal({ onSuccess: (response) => { const result = response.data message.success( - result.onAccountBillId - ? `履约已确认,已生成统一结算虚拟账单 ¥${(result.costFen / 100).toFixed(2)}` - : `履约已确认,扣 ${result.creditQuantity} 积分,成本 ¥${(result.costFen / 100).toFixed(2)}`, + result.costPending + ? '履约已确认,已生成待对账的统一结算账单;实际成本待对账后入账' + : result.onAccountBillId + ? `履约已确认,已生成统一结算虚拟账单 ¥${(result.costFen / 100).toFixed(2)}` + : `履约已确认,扣 ${result.creditQuantity} 积分,成本 ¥${(result.costFen / 100).toFixed(2)}`, ) void queryClient.invalidateQueries({ queryKey: ['admin-manual-fulfillment-pending'] }) void queryClient.invalidateQueries({ queryKey: ['admin-order-detail'] }) diff --git a/apps/frontend/src/pages/admin/cost-center/CostExceptionPanel.tsx b/apps/frontend/src/pages/admin/cost-center/CostExceptionPanel.tsx index cd3bad59..31c5897e 100644 --- a/apps/frontend/src/pages/admin/cost-center/CostExceptionPanel.tsx +++ b/apps/frontend/src/pages/admin/cost-center/CostExceptionPanel.tsx @@ -60,6 +60,7 @@ const REASON_LABELS: Record = { cost_missing: '成本缺失', manual_config_missing: '人工 SKU 未配置', manual_cost_missing: '人工成本未记录', + on_account_cost_pending: '统一结算待对账', carrier_missing: '缺少履约载体', } diff --git a/apps/frontend/src/pages/admin/fund/FundSupportView.tsx b/apps/frontend/src/pages/admin/fund/FundSupportView.tsx index fe148b24..a0104dc8 100644 --- a/apps/frontend/src/pages/admin/fund/FundSupportView.tsx +++ b/apps/frontend/src/pages/admin/fund/FundSupportView.tsx @@ -1,5 +1,5 @@ import { PlusOutlined, ReloadOutlined, RollbackOutlined } from '@ant-design/icons' -import { useQuery, useQueryClient } from '@tanstack/react-query' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { Alert, App, @@ -34,18 +34,22 @@ import { createAdminFundExpense, createAdminFundReturn, fetchAdminFundMyAccount, + fetchAdminOnAccountBills, fetchAdminPlatformCreditAccounts, fetchAdminPlatformCreditEntries, + reconcileAdminOnAccountBill, } from '@/services/admin' import type { AdminFundEntry, AdminFundExpense, AdminFundExpenseCategory, AdminFundPayeeType, + OnAccountBill, PlatformCreditEntry, } from '@/types/admin' import type { UploadedFile } from '@/types/worker-platform' import { formatAdminDateTime } from '@/utils/admin-time' +import { hasAdminPermission } from '@/utils/admin-auth' import { formatCreditUnitPriceLabel } from '@/utils/credit-unit-price' import { FUND_ACCOUNT_STATUS_LABELS, @@ -79,14 +83,23 @@ type ReturnFormValues = { note?: string } +type OnAccountReconcileFormValues = { + amountYuan: number + payeeName?: string + note?: string +} + export default function FundSupportView() { const { message } = App.useApp() const queryClient = useQueryClient() const [expenseOpen, setExpenseOpen] = useState(false) const [returnOpen, setReturnOpen] = useState(false) + const [reconcileBill, setReconcileBill] = useState(null) const [submitting, setSubmitting] = useState(false) const [expenseForm] = Form.useForm() const [returnForm] = Form.useForm() + const [reconcileForm] = Form.useForm() + const canReconcileOnAccount = hasAdminPermission('finance.reconcile') const myQuery = useQuery({ queryKey: ['admin-fund-my'], @@ -110,9 +123,35 @@ export default function FundSupportView() { refetchOnMount: 'always', refetchInterval: 15_000, }) + const onAccountBillsQuery = useQuery({ + queryKey: ['admin-on-account-bills', 'pending'], + queryFn: () => fetchAdminOnAccountBills({ status: 'pending', pageSize: 100 }), + refetchOnMount: 'always', + }) const creditAccounts = creditAccountsQuery.data?.data.items || [] const creditEntries = creditEntriesQuery.data?.data.items || [] const creditPlatforms = creditAccountsQuery.data?.data.platforms || [] + const onAccountBills = onAccountBillsQuery.data?.data.items || [] + + const reconcileMutation = useMutation({ + mutationFn: (values: OnAccountReconcileFormValues) => + reconcileAdminOnAccountBill(reconcileBill!.id, { + amountFen: toFen(values.amountYuan), + payeeName: values.payeeName || '', + note: values.note || '', + }), + onSuccess: () => { + message.success('统一结算账单已对账,实际成本已入账') + setReconcileBill(null) + reconcileForm.resetFields() + void queryClient.invalidateQueries({ queryKey: ['admin-on-account-bills'] }) + void queryClient.invalidateQueries({ queryKey: ['admin-sales-profit'] }) + void queryClient.invalidateQueries({ queryKey: ['admin-finance'] }) + void queryClient.invalidateQueries({ queryKey: ['admin-orders'] }) + void queryClient.invalidateQueries({ queryKey: ['admin-order-detail'] }) + }, + onError: (error) => message.error(error instanceof Error ? error.message : '统一结算对账失败'), + }) /** 客服自己的三个积分钱包,按平台顺序排好。 */ const myCreditWallets = creditPlatforms.map((platform) => { @@ -132,6 +171,7 @@ export default function FundSupportView() { void queryClient.invalidateQueries({ queryKey: ['admin-fund-expenses'] }) void queryClient.invalidateQueries({ queryKey: ['admin-platform-credit-accounts'] }) void queryClient.invalidateQueries({ queryKey: ['admin-platform-credit-entries'] }) + void queryClient.invalidateQueries({ queryKey: ['admin-on-account-bills'] }) } function openExpense() { @@ -155,6 +195,15 @@ export default function FundSupportView() { setReturnOpen(true) } + function openReconcileBill(bill: OnAccountBill) { + setReconcileBill(bill) + reconcileForm.setFieldsValue({ + amountYuan: bill.amountFen > 0 ? bill.amountFen / 100 : undefined, + payeeName: bill.payeeName || '', + note: bill.note || '', + }) + } + async function submitExpense(values: ExpenseFormValues) { if (!account) return setSubmitting(true) @@ -384,6 +433,56 @@ export default function FundSupportView() { { title: '说明', dataIndex: 'note', render: (value: string) => value || '-' }, ] + const onAccountBillColumns: TableColumnsType = [ + { title: '账单号', dataIndex: 'billNo', width: 180 }, + { + title: '订单 / SKU', + width: 260, + render: (_, row) => ( + + + {row.platformOrderId || `#${row.orderId}`} + + + {row.skuName || row.skuKey} + + + ), + }, + { + title: '订单收入', + dataIndex: 'revenueFen', + width: 120, + render: (value: number) => formatFundAmount(value), + }, + { + title: '待结算金额', + dataIndex: 'amountFen', + width: 130, + render: (value: number) => (value > 0 ? formatFundAmount(value) : '待填写'), + }, + { + title: '创建时间', + dataIndex: 'createdAt', + width: 170, + render: (value: string) => formatAdminDateTime(value), + }, + { + title: '操作', + width: 110, + render: (_, row) => ( + + ), + }, + ] + return (
+ } + loading={onAccountBillsQuery.isFetching} + onClick={() => void onAccountBillsQuery.refetch()} + > + 刷新 + + } + > + + + rowKey="id" + size="small" + columns={onAccountBillColumns} + dataSource={onAccountBills} + loading={onAccountBillsQuery.isLoading} + pagination={false} + scroll={{ x: 980 }} + locale={{ emptyText: '没有待对账的统一结算账单' }} + /> + + {account ? ( @@ -699,6 +830,43 @@ export default function FundSupportView() { + + setReconcileBill(null)} + onOk={() => reconcileForm.submit()} + confirmLoading={reconcileMutation.isPending} + okText="确认对账" + destroyOnHidden + > + + + form={reconcileForm} + layout="vertical" + onFinish={(values) => reconcileMutation.mutate(values)} + > + + + + + + + + + + +
) } diff --git a/apps/frontend/src/services/admin/cost-center.ts b/apps/frontend/src/services/admin/cost-center.ts index 68be723e..0136ec42 100644 --- a/apps/frontend/src/services/admin/cost-center.ts +++ b/apps/frontend/src/services/admin/cost-center.ts @@ -24,6 +24,8 @@ import type { ManualSkuListResult, ManualSkuOverview, ManualSkuScanResult, + OnAccountBill, + OnAccountBillListResult, ProductCatalogItem, ProductCatalogListResult, TemplateProductBootstrapPreview, @@ -555,3 +557,26 @@ export function deleteAdminVoucherProduct(productId: number) { } }>(`/api/v1/admin/cost-center/voucher-products/${productId}`) } + +export function fetchAdminOnAccountBills( + params: { + page?: number + pageSize?: number + keyword?: string + status?: string + } = {}, +) { + return apiGet('/api/v1/admin/finance/on-account-bills', params) +} + +export function reconcileAdminOnAccountBill( + billId: number, + payload: { amountFen: number; payeeName?: string; note?: string }, +) { + return apiPost<{ + bill: OnAccountBill + orderItemId: number + snapshotId: number + amountFen: number + }>(`/api/v1/admin/finance/on-account-bills/${billId}/reconcile`, payload) +} diff --git a/apps/frontend/src/types/admin/cost-center.ts b/apps/frontend/src/types/admin/cost-center.ts index 2d9482df..4b6de907 100644 --- a/apps/frontend/src/types/admin/cost-center.ts +++ b/apps/frontend/src/types/admin/cost-center.ts @@ -564,7 +564,7 @@ export interface ManualFulfillmentPendingListResult { export interface ManualFulfillmentConfirmResult { orderItemId: number orderId: number - costSnapshotId: number + costSnapshotId: number | null platformKey: string platformLabel: string creditQuantity: number @@ -573,4 +573,33 @@ export interface ManualFulfillmentConfirmResult { creditEntryId: number | null balanceAfter: number | null onAccountBillId: number | null + costPending: boolean +} + +export interface OnAccountBill { + id: number + billNo: string + orderId: number + orderItemId: number + skuKey: string + skuName: string + canonicalProductId: number | null + platformOrderId: string + provider: string + shopId: string + revenueFen: number + amountFen: number + status: 'pending' | 'reconciled' | 'void' + payeeName: string + note: string + metadata: Record + createdByAdminUserId: number | null + createdAt: string + updatedAt: string + reconciledAt: string | null +} + +export interface OnAccountBillListResult { + items: OnAccountBill[] + pagination: { page: number; pageSize: number; total: number } } diff --git a/apps/frontend/tests/fund-support-wallet-on-account.test.ts b/apps/frontend/tests/fund-support-wallet-on-account.test.ts index 5b7e40f1..5012d22e 100644 --- a/apps/frontend/tests/fund-support-wallet-on-account.test.ts +++ b/apps/frontend/tests/fund-support-wallet-on-account.test.ts @@ -11,3 +11,21 @@ test('客服我的钱包展示统一结算虚拟账单入口', async () => { assert.ok(source.includes('生成虚拟账单,不扣积分或备用金')) assert.ok(source.includes('support-wallet-grid')) }) + +test('客服钱包提供统一结算待对账列表和实际金额确认', async () => { + const [source, service, confirmModal] = await Promise.all([ + readFile(new URL('../src/pages/admin/fund/FundSupportView.tsx', import.meta.url), 'utf8'), + readFile(new URL('../src/services/admin/cost-center.ts', import.meta.url), 'utf8'), + readFile( + new URL('../src/components/admin/ManualFulfillmentConfirmModal.tsx', import.meta.url), + 'utf8', + ), + ]) + + assert.ok(source.includes('统一结算待对账')) + assert.ok(source.includes('实际结算金额(元)')) + assert.ok(source.includes('确认对账')) + assert.ok(confirmModal.includes('costPending')) + assert.ok(service.includes('/api/v1/admin/finance/on-account-bills')) + assert.ok(service.includes('/reconcile')) +}) diff --git a/docs/成本中心API契约.md b/docs/成本中心API契约.md index 03469fc6..fe4974e1 100644 --- a/docs/成本中心API契约.md +++ b/docs/成本中心API契约.md @@ -195,6 +195,21 @@ POST /api/v1/admin/cost-center/voucher-products 通用 `POST /api/v1/admin/cost-center/products` 不再接受 `tpv:` 和 `man:`。 迁移 `143` 负责修复历史卡券商品归属。 +### 4.5 商品来源隔离(现行) + +订单项一旦已确定履约渠道,商品归一只能命中该渠道来源: + +```text +affiliate_dash → aff: +work_order → tpl: / wo: +third_party_voucher → tpv: +manual → man: +on_account → man: +``` + +缺少渠道专属商品时进入 `product_mapping_missing`,不得继续按同名商品借用其他来源。 +商品归一任务会清除已有错误来源关联并重新挂载正确商品。 + ## 五、成本版本接口 路径不变,字段不变: diff --git a/docs/成本中心与履约模型.md b/docs/成本中心与履约模型.md index 751ec0a1..8350f70f 100644 --- a/docs/成本中心与履约模型.md +++ b/docs/成本中心与履约模型.md @@ -91,6 +91,9 @@ orders ──┬── order_items ──── fulfillment_channel + fulfillmen 新增或保存配置时,系统在同一业务流程内创建或复用标准商品,并同步人工渠道规则。 商品目录中的 `man:` 商品因此是人工配置的派生结果。 +已确定履约渠道的订单项必须使用对应来源商品,不能按同名商品跨渠道兜底。统一结算金额 +未对账前只生成 `pending` 虚拟账单,不生成 0 元 actual 成本快照;确认对账后才写入实际成本。 + --- ## 二、商品主数据