diff --git a/apps/backend/package.json b/apps/backend/package.json index 5509c079..b3603fdb 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -10,6 +10,7 @@ "db:migrate:create": "tsx scripts/create-migration.ts", "db:migrate:status": "tsx scripts/migration-status.ts", "finance:sync": "tsx scripts/sync-cost-finance.ts", + "fulfillment:backfill": "tsx scripts/backfill-fulfillment-mode.ts", "storage:migrate": "tsx scripts/migrate-storage.ts", "storage:migrate:built": "node dist/storage-migrate.js", "check:sql": "tsx scripts/check-sql-guard.ts", diff --git a/apps/backend/scripts/backfill-fulfillment-mode.ts b/apps/backend/scripts/backfill-fulfillment-mode.ts new file mode 100644 index 00000000..3d7b0e21 --- /dev/null +++ b/apps/backend/scripts/backfill-fulfillment-mode.ts @@ -0,0 +1,89 @@ +import process from 'node:process' + +import { closeDb, query } from '../src/db/client.js' +import { + getFulfillmentModeCounts, + reconcilePendingFulfillmentModes, + syncOpenCostExceptionCarrierTypes, +} from '../src/services/fulfillment/fulfillment-mode-service.js' + +const options = parseOptions(process.argv.slice(2)) +const batchSize = normalizeInteger(options.batchSize, 5000, 1, 50_000) +const maxBatches = normalizeInteger(options.maxBatches, 1000, 1, 10_000) + +async function main() { + const column = await query<{ exists: boolean }>( + ` + SELECT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'order_items' + AND column_name = 'fulfillment_mode' + ) AS exists + `, + ) + + if (!column.rows[0]?.exists) { + throw new Error('order_items.fulfillment_mode 不存在,请先执行 npm run db:migrate(迁移 114)') + } + + const startedAt = Date.now() + const result = await reconcilePendingFulfillmentModes({ + batchSize, + maxBatches, + reason: 'backfill_2026_10_23', + }) + const exceptions = await syncOpenCostExceptionCarrierTypes() + const counts = await getFulfillmentModeCounts() + + console.log( + JSON.stringify( + { + ...result, + exceptionCarrierUpdated: exceptions.updatedCount, + batchSize, + maxBatches, + counts, + durationMs: Date.now() - startedAt, + }, + null, + 2, + ), + ) + + if (result.remainingCount > 0) { + console.error(`仍有 ${result.remainingCount} 个订单项未回填;请增大 --max-batches 或检查数据。`) + process.exitCode = 2 + } +} + +main() + .catch((error) => { + console.error(error instanceof Error ? error.message : error) + process.exitCode = 1 + }) + .finally(async () => { + await closeDb() + }) + +function parseOptions(args: string[]) { + const result: Record = {} + for (const arg of args) { + if (!arg.startsWith('--')) continue + const body = arg.slice(2) + const index = body.indexOf('=') + if (index === -1) { + result[body] = 'true' + } else { + result[body.slice(0, index)] = body.slice(index + 1) + } + } + return result +} + +function normalizeInteger(value: unknown, fallback: number, min: number, max: number) { + const parsed = Number(value) + if (!Number.isInteger(parsed)) return fallback + return Math.min(max, Math.max(min, parsed)) +} diff --git a/apps/backend/src/db/migrations/114_order_item_fulfillment_mode.sql b/apps/backend/src/db/migrations/114_order_item_fulfillment_mode.sql new file mode 100644 index 00000000..cfdfcb27 --- /dev/null +++ b/apps/backend/src/db/migrations/114_order_item_fulfillment_mode.sql @@ -0,0 +1,45 @@ +-- 114_order_item_fulfillment_mode.sql +-- +-- 统一履约事实:订单页、成本快照、销售事实只读 order_items.fulfillment_mode, +-- 不再各自用 provider / voucher / task 临时推断。 +-- +-- 说明: +-- 1. 本迁移只做 DDL;31 万行回填由 scripts/backfill-fulfillment-mode.ts 分批执行。 +-- 2. product_catalog / cost_items / cost_snapshots / sales_daily_facts 的 +-- carrier_type CHECK 目前都不接受 legacy_platform,必须同时放宽, +-- 否则历史平台数据写入会在运行时报错。 + +ALTER TABLE order_items + ADD COLUMN IF NOT EXISTS fulfillment_mode text, + ADD COLUMN IF NOT EXISTS fulfillment_decided_at timestamptz, + ADD COLUMN IF NOT EXISTS fulfillment_reason text; + +ALTER TABLE order_items + ADD CONSTRAINT chk_order_items_fulfillment_mode + CHECK ( + fulfillment_mode IS NULL + OR fulfillment_mode IN ('work_order', 'affiliate_dash', 'legacy_platform', 'manual') + ); + +CREATE INDEX IF NOT EXISTS idx_order_items_fulfillment_mode + ON order_items (fulfillment_mode, order_id); + +CREATE INDEX IF NOT EXISTS idx_order_items_fulfillment_pending + ON order_items (order_id) + WHERE fulfillment_mode IS NULL; + +ALTER TABLE product_catalog DROP CONSTRAINT IF EXISTS chk_product_catalog_carrier_type; +ALTER TABLE product_catalog ADD CONSTRAINT chk_product_catalog_carrier_type + CHECK (carrier_type IN ('affiliate_dash', 'work_order', 'legacy_platform', 'manual', 'shared')); + +ALTER TABLE cost_items DROP CONSTRAINT IF EXISTS chk_cost_items_carrier_type; +ALTER TABLE cost_items ADD CONSTRAINT chk_cost_items_carrier_type + CHECK (carrier_type IN ('affiliate_dash', 'work_order', 'legacy_platform', 'manual', 'shared')); + +ALTER TABLE cost_snapshots DROP CONSTRAINT IF EXISTS chk_cost_snapshots_carrier_type; +ALTER TABLE cost_snapshots ADD CONSTRAINT chk_cost_snapshots_carrier_type + CHECK (carrier_type IN ('affiliate_dash', 'work_order', 'legacy_platform', 'manual', 'shared')); + +ALTER TABLE sales_daily_facts DROP CONSTRAINT IF EXISTS chk_sales_daily_facts_carrier; +ALTER TABLE sales_daily_facts ADD CONSTRAINT chk_sales_daily_facts_carrier + CHECK (carrier_type IN ('affiliate_dash', 'work_order', 'legacy_platform', 'manual', 'shared', 'unknown')); diff --git a/apps/backend/src/services/admin/admin-order-read-helpers.test.ts b/apps/backend/src/services/admin/admin-order-read-helpers.test.ts index 1e56b95e..79479996 100644 --- a/apps/backend/src/services/admin/admin-order-read-helpers.test.ts +++ b/apps/backend/src/services/admin/admin-order-read-helpers.test.ts @@ -1,8 +1,17 @@ import test from 'node:test' import assert from 'node:assert/strict' -import { mapAdminOrderListItem, summarizeOrderItems } from './admin-order-read-helpers.js' -import type { OrderItemRow, OrderListRow } from '../../types/repository/rows.js' +import { + mapAdminOrderListItem, + resolveOrderFulfillmentSummary, + resolveVoucherSummary, + summarizeOrderItems, +} from './admin-order-read-helpers.js' +import type { + KuaishouIndustryVoucherRow, + OrderItemRow, + OrderListRow, +} from '../../types/repository/rows.js' test('mapAdminOrderListItem formats itemTitle, skuNick, and summary correctly for single item', () => { const order: OrderListRow = { @@ -62,6 +71,7 @@ test('mapAdminOrderListItem formats itemTitle, skuNick, and summary correctly fo skuNick: '安卓血宴2钻', matchSku: '安卓血宴2钻', quantity: 1, + fulfillmentMode: 'pending', }, ]) }) @@ -259,3 +269,92 @@ test('拼多多匹配 SKU 优先 sku_name,不用带地区的长 spec', () => { assert.equal(mapped.matchSku, '安卓怒嚎1钻') assert.equal(mapped.items?.[0]?.matchSku, '安卓怒嚎1钻') }) + +function buildOrderForFulfillmentTest(): OrderListRow { + return { + id: 501, + provider: '91kaquan', + platform: 'kuaishou', + shop_id: 'shop-1', + shop_name: '测试店铺', + platform_order_id: 'TEST-ORDER-501', + order_status: 'paid', + pay_status: 'paid', + buyer_id: '', + buyer_name: '', + receiver_contact: '', + total_amount: 1000, + currency: 'CNY', + raw_payload_json: {}, + paid_at: '2026-10-20T02:00:00.000Z', + created_at: '2026-10-20T02:00:00.000Z', + updated_at: '2026-10-20T02:00:00.000Z', + } +} + +function buildOrderItemForFulfillmentTest( + id: number, + fulfillmentMode: string | null, +): OrderItemRow { + return { + id, + order_id: 501, + sku_code: `SKU-${id}`, + sku_name: `商品-${id}`, + quantity: 1, + spec_json: { itemTitle: `商品-${id}`, skuNick: `规格-${id}` }, + fulfillment_mode: fulfillmentMode, + } +} + +test('人工单即使有已核销快照也仍然判为 manual,核销码单独记状态', () => { + const vouchers = [{ status: 'CONSUMED' }] as unknown as KuaishouIndustryVoucherRow[] + const mapped = mapAdminOrderListItem(buildOrderForFulfillmentTest(), { + orderItems: [buildOrderItemForFulfillmentTest(1, 'manual')], + vouchers, + }) + + assert.equal(mapped.fulfillmentMode, 'manual') + assert.equal(mapped.manualItemCount, 1) + assert.equal(mapped.hasVoucher, true) + assert.equal(mapped.voucherState, 'consumed') + assert.equal(mapped.canClaimH5, false) +}) + +test('自动发货与历史平台按 fulfillment_mode 区分,不再按 provider 猜', () => { + const affiliate = resolveOrderFulfillmentSummary({ + items: [buildOrderItemForFulfillmentTest(1, 'affiliate_dash')], + }) + const legacy = resolveOrderFulfillmentSummary({ + items: [buildOrderItemForFulfillmentTest(2, 'legacy_platform')], + }) + + assert.equal(affiliate.fulfillmentMode, 'affiliate_dash') + assert.equal(affiliate.canClaimH5, true) + assert.equal(legacy.fulfillmentMode, 'legacy_platform') + assert.equal(legacy.canClaimH5, false) +}) + +test('多商品订单出现不同履约方式时显示 mixed,并保留人工项计数', () => { + const summary = resolveOrderFulfillmentSummary({ + items: [ + buildOrderItemForFulfillmentTest(1, 'work_order'), + buildOrderItemForFulfillmentTest(2, 'manual'), + ], + }) + + assert.equal(summary.fulfillmentMode, 'mixed') + assert.equal(summary.manualItemCount, 1) + assert.equal(summary.pendingItemCount, 0) +}) + +test('核销码状态汇总:无、单一、混合', () => { + assert.deepEqual(resolveVoucherSummary([]), { hasVoucher: false, voucherState: 'none' }) + assert.deepEqual( + resolveVoucherSummary([ + { status: 'UNUSED' }, + { status: 'consumed' }, + ] as unknown as KuaishouIndustryVoucherRow[]), + { hasVoucher: true, voucherState: 'mixed' }, + ) +}) diff --git a/apps/backend/src/services/admin/admin-order-read-helpers.ts b/apps/backend/src/services/admin/admin-order-read-helpers.ts index cdee990c..b2706bb1 100644 --- a/apps/backend/src/services/admin/admin-order-read-helpers.ts +++ b/apps/backend/src/services/admin/admin-order-read-helpers.ts @@ -7,7 +7,13 @@ import { } from './admin-read-shared-helpers.js' import { buildOrderFulfillmentProgress } from './admin-task-read-helpers.js' -import type { AdminOrderItemSummary, AdminOrderListItem } from '../../types/admin/read-models.js' +import type { + AdminOrderFulfillmentMode, + AdminOrderItemSummary, + AdminOrderListItem, + AdminOrderItemFulfillmentMode, + AdminVoucherState, +} from '../../types/admin/read-models.js' import type { KuaishouIndustryVoucherRow, OrderItemRow, @@ -48,6 +54,7 @@ export function mapAdminOrderListItem( skuNick: itSkuNick, matchSku: itMatchSku, quantity: Math.max(1, Number(orderItem.quantity || 1)), + fulfillmentMode: normalizeOrderItemFulfillmentMode(orderItem.fulfillment_mode), } }) resolvedItemTitle = itemsList[0]?.itemTitle || '' @@ -64,6 +71,7 @@ export function mapAdminOrderListItem( skuNick: resolvedSkuNick, matchSku: resolvedMatchSku, quantity: 1, + fulfillmentMode: 'pending', }, ] } @@ -76,15 +84,15 @@ export function mapAdminOrderListItem( ) const hasResolvedFallback = (resolvedItemTitle || resolvedSkuNick) && rawItemCount === 0 - const { fulfillmentType, canClaimH5 } = resolveOrderFulfillmentType({ - provider: item.provider || '', + const fulfillment = resolveOrderFulfillmentSummary({ + items: normalizedItems, tasks, workOrderCount, vouchers, }) return { - orderId: item.id, + orderId: Number(item.id), provider: item.provider || '', platform: item.platform, shopId: item.shop_id || '', @@ -110,8 +118,12 @@ export function mapAdminOrderListItem( items: itemsList, taskCount: tasks.length, workOrderCount, - fulfillmentType, - canClaimH5, + fulfillmentMode: fulfillment.fulfillmentMode, + manualItemCount: fulfillment.manualItemCount, + pendingItemCount: fulfillment.pendingItemCount, + hasVoucher: fulfillment.hasVoucher, + voucherState: fulfillment.voucherState, + canClaimH5: fulfillment.canClaimH5, resourceStatus: fulfillmentProgress.resourceStatus, customerStatus: fulfillmentProgress.customerStatus, preparedTaskCount: fulfillmentProgress.preparedTaskCount, @@ -122,56 +134,118 @@ export function mapAdminOrderListItem( } } -export function resolveOrderFulfillmentType({ - provider, +const ORDER_ITEM_FULFILLMENT_MODES = new Set([ + 'work_order', + 'affiliate_dash', + 'legacy_platform', + 'manual', + 'pending', +]) + +export function normalizeOrderItemFulfillmentMode(value: unknown): AdminOrderItemFulfillmentMode { + const mode = String(value || '') + .trim() + .toLowerCase() as AdminOrderItemFulfillmentMode + return ORDER_ITEM_FULFILLMENT_MODES.has(mode) ? mode : 'pending' +} + +export function resolveVoucherSummary(vouchers: KuaishouIndustryVoucherRow[] = []): { + hasVoucher: boolean + voucherState: AdminVoucherState +} { + const statuses = new Set( + vouchers + .map((voucher) => + String(voucher.status || '') + .trim() + .toLowerCase(), + ) + .filter(Boolean), + ) + + if (statuses.size === 0) { + return { hasVoucher: false, voucherState: 'none' } + } + + if (statuses.size === 1) { + const only = Array.from(statuses)[0] + if (only === 'unused' || only === 'consumed' || only === 'destroyed') { + return { hasVoucher: true, voucherState: only } + } + } + + return { hasVoucher: true, voucherState: 'mixed' } +} + +/** + * 订单级履约摘要:只读 order_items.fulfillment_mode,不再按 provider 或核销码推断。 + * 多商品订单出现不同模式时显示 mixed;尚未判定的订单项单独计数。 + */ +export function resolveOrderFulfillmentSummary({ + items = [], tasks = [], workOrderCount = 0, vouchers = [], }: { - provider: string + items?: OrderItemRow[] tasks?: TaskRow[] workOrderCount?: number vouchers?: KuaishouIndustryVoucherRow[] }): { - fulfillmentType: 'auto_claim' | 'worker_dispatch' | 'voucher_only' | 'manual' + fulfillmentMode: AdminOrderFulfillmentMode + manualItemCount: number + pendingItemCount: number + affiliateItemCount: number + hasVoucher: boolean + voucherState: AdminVoucherState canClaimH5: boolean } { - const normProvider = String(provider || '') - .trim() - .toLowerCase() - const hasAutoClaimTask = + const normalizedItems = Array.isArray(items) ? items : [] + const itemModes = normalizedItems.map((item) => + normalizeOrderItemFulfillmentMode(item.fulfillment_mode), + ) + const knownModes = Array.from( + new Set( + itemModes.filter((mode): mode is Exclude => mode !== 'pending'), + ), + ) + + let fulfillmentMode: AdminOrderFulfillmentMode + if (knownModes.length === 1) { + fulfillmentMode = knownModes[0]! + } else if (knownModes.length > 1) { + fulfillmentMode = 'mixed' + } else if (normalizedItems.length > 0) { + fulfillmentMode = 'pending' + } else if (workOrderCount > 0) { + // 极少数历史订单没有 order_items,只能用载体表兜底;这不是按 provider 猜。 + fulfillmentMode = 'work_order' + } else if (tasks.some((task) => task.executor_key === 'affiliate_dash')) { + fulfillmentMode = 'affiliate_dash' + } else if ( tasks.some( - (t) => - t.executor_key === 'affiliate_dash' || - t.executor_key === 'kuaishou_ct_assisted' || - t.executor_key === 'kuaishou_feifei' || - Boolean(t.requires_claim), - ) || normProvider === 'douyin_mashida' - - if (hasAutoClaimTask) { - return { - fulfillmentType: 'auto_claim', - canClaimH5: true, - } + (task) => + task.executor_key === 'kuaishou_ct_assisted' || task.executor_key === 'kuaishou_feifei', + ) + ) { + fulfillmentMode = 'legacy_platform' + } else { + fulfillmentMode = 'pending' } - if (workOrderCount > 0) { - return { - fulfillmentType: 'worker_dispatch', - canClaimH5: false, - } - } - - if (vouchers.length > 0 || normProvider === '91kaquan' || normProvider === 'kuaishou_industry') { - return { - fulfillmentType: 'voucher_only', - canClaimH5: false, - } - } + const affiliateItemCount = itemModes.filter((mode) => mode === 'affiliate_dash').length + const voucher = resolveVoucherSummary(vouchers) + const canClaimH5 = + affiliateItemCount > 0 || (normalizedItems.length === 0 && fulfillmentMode === 'affiliate_dash') return { - fulfillmentType: 'manual', - canClaimH5: false, + fulfillmentMode, + manualItemCount: itemModes.filter((mode) => mode === 'manual').length, + pendingItemCount: itemModes.filter((mode) => mode === 'pending').length, + affiliateItemCount, + hasVoucher: voucher.hasVoucher, + voucherState: voucher.voucherState, + canClaimH5, } } diff --git a/apps/backend/src/services/admin/admin-read-service.ts b/apps/backend/src/services/admin/admin-read-service.ts index 40ca655e..65d17525 100644 --- a/apps/backend/src/services/admin/admin-read-service.ts +++ b/apps/backend/src/services/admin/admin-read-service.ts @@ -63,7 +63,8 @@ import { import { isRetiredFulfillmentExecutor } from '../fulfillment/executors/types.js' import { mapAdminOrderListItem, - resolveOrderFulfillmentType, + normalizeOrderItemFulfillmentMode, + resolveOrderFulfillmentSummary, summarizeOrderItems, } from './admin-order-read-helpers.js' import { @@ -190,8 +191,8 @@ export async function getAdminOrderDetail( const orderItemTitle = resolveOrderItemTitle(items[0], order, kuaishouIndustryVouchers) const orderSkuNick = resolveOrderItemSkuNick(items[0], order, kuaishouIndustryVouchers) const viewerContext = createAdminViewerContext(session) - const { fulfillmentType, canClaimH5 } = resolveOrderFulfillmentType({ - provider: order.provider || '', + const fulfillment = resolveOrderFulfillmentSummary({ + items, tasks, workOrderCount: workOrders.length, vouchers: kuaishouIndustryVouchers, @@ -227,18 +228,23 @@ export async function getAdminOrderDetail( claimStatus: order.claim_status || 'active', claimStatusReason: order.claim_status_reason || '', claimStatusUpdatedAt: order.claim_status_updated_at || null, - fulfillmentType, + fulfillmentMode: fulfillment.fulfillmentMode, + manualItemCount: fulfillment.manualItemCount, + pendingItemCount: fulfillment.pendingItemCount, + hasVoucher: fulfillment.hasVoucher, + voucherState: fulfillment.voucherState, workOrderCount: workOrders.length, - canClaimH5, + canClaimH5: fulfillment.canClaimH5, }, items: items.map((item) => ({ - orderItemId: item.id, + orderItemId: Number(item.id), skuCode: item.sku_code, skuName: item.sku_name, itemTitle: resolveOrderItemTitle(item, order, kuaishouIndustryVouchers), skuNick: resolveOrderItemSkuNick(item, order, kuaishouIndustryVouchers), matchSku: resolveOrderItemMatchSku(item, order, kuaishouIndustryVouchers), quantity: item.quantity, + fulfillmentMode: normalizeOrderItemFulfillmentMode(item.fulfillment_mode), deliveryMode: resolveOrderItemDeliveryMode(tasks, item.id), spec: item.spec_json && typeof item.spec_json === 'object' 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 a5a5c4f9..a3a58944 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 @@ -53,15 +53,7 @@ export async function syncEstimatedOrderItemCostSnapshots( COALESCE(oi.revenue_amount_fen, 0)::bigint AS revenue_fen, wo.id AS work_order_id, wo.product_rule_id AS work_product_rule_id, - CASE - WHEN wo.id IS NOT NULL THEN 'work_order' - WHEN EXISTS ( - SELECT 1 FROM fulfillment_tasks ft - WHERE ft.order_item_id = oi.id - AND ft.executor_key = 'affiliate_dash' - ) THEN 'affiliate_dash' - ELSE 'manual' - END AS carrier_type + COALESCE(oi.fulfillment_mode, 'manual') AS carrier_type FROM order_items oi JOIN orders o ON o.id = oi.order_id LEFT JOIN LATERAL ( @@ -613,14 +605,7 @@ async function insertAndResolveProductMappingExceptions( oi.canonical_product_id, COALESCE(o.paid_at, o.created_at) AS basis_at, wo.id AS work_order_id, - CASE - WHEN wo.id IS NOT NULL THEN 'work_order' - WHEN EXISTS ( - SELECT 1 FROM fulfillment_tasks ft - WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash' - ) THEN 'affiliate_dash' - ELSE 'manual' - END AS carrier_type + COALESCE(oi.fulfillment_mode, 'manual') AS carrier_type FROM order_items oi JOIN orders o ON o.id = oi.order_id LEFT JOIN LATERAL ( @@ -683,14 +668,7 @@ async function insertAndResolveCostMissingExceptions( oi.canonical_product_id, COALESCE(o.paid_at, o.created_at) AS basis_at, wo.id AS work_order_id, - CASE - WHEN wo.id IS NOT NULL THEN 'work_order' - WHEN EXISTS ( - SELECT 1 FROM fulfillment_tasks ft - WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash' - ) THEN 'affiliate_dash' - ELSE 'manual' - END AS carrier_type + COALESCE(oi.fulfillment_mode, 'manual') AS carrier_type FROM order_items oi JOIN orders o ON o.id = oi.order_id LEFT JOIN LATERAL ( diff --git a/apps/backend/src/services/finance/sales-facts-service.ts b/apps/backend/src/services/finance/sales-facts-service.ts index a8d5574a..f4f864a6 100644 --- a/apps/backend/src/services/finance/sales-facts-service.ts +++ b/apps/backend/src/services/finance/sales-facts-service.ts @@ -64,15 +64,7 @@ export async function rebuildSalesDailyFacts( o.provider, o.shop_id, oi.canonical_product_id, - CASE - WHEN wo.id IS NOT NULL THEN 'work_order' - WHEN EXISTS ( - SELECT 1 FROM fulfillment_tasks ft - WHERE ft.order_item_id = oi.id - AND ft.executor_key = 'affiliate_dash' - ) THEN 'affiliate_dash' - ELSE 'manual' - END AS carrier_type, + COALESCE(oi.fulfillment_mode, 'manual') AS carrier_type, oi.order_id, COALESCE(oi.revenue_amount_fen, 0)::bigint AS revenue_fen, CASE @@ -85,9 +77,6 @@ export async function rebuildSalesDailyFacts( COALESCE(cost.actual_count, 0)::int AS actual_count FROM order_items oi JOIN orders o ON o.id = oi.order_id - LEFT JOIN LATERAL ( - SELECT id FROM work_orders WHERE order_item_id = oi.id ORDER BY id DESC LIMIT 1 - ) wo ON TRUE LEFT JOIN LATERAL ( SELECT SUM(cs.total_amount_fen)::bigint AS cost_fen, diff --git a/apps/backend/src/services/fulfillment/fulfillment-mode-service.test.ts b/apps/backend/src/services/fulfillment/fulfillment-mode-service.test.ts new file mode 100644 index 00000000..8742b510 --- /dev/null +++ b/apps/backend/src/services/fulfillment/fulfillment-mode-service.test.ts @@ -0,0 +1,39 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { classifyFulfillmentMode } from './fulfillment-mode-service.js' + +test('履约判定优先级:工单 > 自动发货 > 历史平台 > 人工', () => { + assert.equal( + classifyFulfillmentMode({ + hasWorkOrder: true, + hasAffiliateDashTask: true, + hasLegacyPlatformTask: true, + }), + 'work_order', + ) + assert.equal( + classifyFulfillmentMode({ + hasWorkOrder: false, + hasAffiliateDashTask: true, + hasLegacyPlatformTask: true, + }), + 'affiliate_dash', + ) + assert.equal( + classifyFulfillmentMode({ + hasWorkOrder: false, + hasAffiliateDashTask: false, + hasLegacyPlatformTask: true, + }), + 'legacy_platform', + ) + assert.equal( + classifyFulfillmentMode({ + hasWorkOrder: false, + hasAffiliateDashTask: false, + hasLegacyPlatformTask: false, + }), + 'manual', + ) +}) diff --git a/apps/backend/src/services/fulfillment/fulfillment-mode-service.ts b/apps/backend/src/services/fulfillment/fulfillment-mode-service.ts new file mode 100644 index 00000000..57dc4c93 --- /dev/null +++ b/apps/backend/src/services/fulfillment/fulfillment-mode-service.ts @@ -0,0 +1,229 @@ +import { query } from '../../db/client.js' +import { nowIso } from '../../utils/time.js' + +export type OrderFulfillmentMode = 'work_order' | 'affiliate_dash' | 'legacy_platform' | 'manual' + +export type FulfillmentModeDecisionInput = { + hasWorkOrder: boolean + hasAffiliateDashTask: boolean + hasLegacyPlatformTask: boolean +} + +export type ReconcileFulfillmentModesOptions = { + batchSize?: number + maxBatches?: number + reason?: string +} + +export type ReconcileFulfillmentModesResult = { + updatedCount: number + remainingCount: number + batches: number + reason: string + finishedAt: string +} + +/** + * 统一履约判定(纯函数,供单测与文档对照)。 + * + * 优先级固定:工单 → 自动发货任务 → 历史平台任务 → 人工。 + * 核销码、provider、商品 carrier_type 都不参与判定。 + */ +export function classifyFulfillmentMode({ + hasWorkOrder, + hasAffiliateDashTask, + hasLegacyPlatformTask, +}: FulfillmentModeDecisionInput): OrderFulfillmentMode { + if (hasWorkOrder) return 'work_order' + if (hasAffiliateDashTask) return 'affiliate_dash' + if (hasLegacyPlatformTask) return 'legacy_platform' + return 'manual' +} + +const FULFILLMENT_MODE_CASE_SQL = ` + CASE + WHEN EXISTS ( + SELECT 1 FROM work_orders wo + WHERE wo.order_item_id = oi.id + ) THEN 'work_order' + WHEN EXISTS ( + SELECT 1 FROM fulfillment_tasks ft + WHERE ft.order_item_id = oi.id + AND ft.executor_key = 'affiliate_dash' + ) THEN 'affiliate_dash' + WHEN EXISTS ( + SELECT 1 FROM fulfillment_tasks ft + WHERE ft.order_item_id = oi.id + AND ft.executor_key IN ('kuaishou_ct_assisted', 'kuaishou_feifei') + ) THEN 'legacy_platform' + ELSE 'manual' + END +` + +/** + * 重新判定指定订单项的履约方式。 + * + * 只在模式发生变化时更新 fulfillment_decided_at / fulfillment_reason; + * 同一订单项重复调用是幂等的。 + */ +export async function decideOrderItemsFulfillmentModes( + orderItemIds: Array, + { reason = 'order_sync' }: { reason?: string } = {}, +): Promise<{ updatedCount: number }> { + const ids = Array.from( + new Set( + orderItemIds + .map((value) => Number(value)) + .filter((value) => Number.isInteger(value) && value > 0), + ), + ) + if (ids.length === 0) return { updatedCount: 0 } + + const result = await query<{ fulfillment_mode: OrderFulfillmentMode }>( + ` + WITH decided AS ( + SELECT oi.id, ${FULFILLMENT_MODE_CASE_SQL} AS mode + FROM order_items oi + WHERE oi.id = ANY($1::bigint[]) + ) + UPDATE order_items oi + SET + fulfillment_mode = d.mode, + fulfillment_decided_at = NOW(), + fulfillment_reason = $2 + FROM decided d + WHERE oi.id = d.id + AND ( + oi.fulfillment_mode IS NULL + OR oi.fulfillment_mode IS DISTINCT FROM d.mode + ) + RETURNING oi.fulfillment_mode + `, + [ids, String(reason || 'order_sync')], + ) + + // 已有的 open 成本异常必须跟着履约方式一起改,否则历史平台会继续显示成人工。 + if ((result.rowCount || 0) > 0) { + await syncOpenCostExceptionCarrierTypes(ids) + } + + return { updatedCount: result.rowCount || 0 } +} + +export async function syncOpenCostExceptionCarrierTypes( + orderItemIds?: Array, +): Promise<{ updatedCount: number }> { + const ids = Array.isArray(orderItemIds) + ? Array.from( + new Set( + orderItemIds + .map((value) => Number(value)) + .filter((value) => Number.isInteger(value) && value > 0), + ), + ) + : null + if (ids && ids.length === 0) return { updatedCount: 0 } + + const result = await query( + ` + UPDATE cost_exceptions ce + SET carrier_type = oi.fulfillment_mode + FROM order_items oi + WHERE ce.subject_type = 'order_item' + AND ce.order_item_id = oi.id + AND ce.status = 'open' + AND oi.fulfillment_mode IS NOT NULL + AND ce.carrier_type IS DISTINCT FROM oi.fulfillment_mode + ${ids ? 'AND oi.id = ANY($1::bigint[])' : ''} + `, + ids ? [ids] : [], + ) + + return { updatedCount: result.rowCount || 0 } +} + +/** + * 兜底归集:分批处理 fulfillment_mode 为 NULL 的订单项。 + * + * 定时任务只需处理新单;历史回填用同一个函数,把 maxBatches 调大即可。 + */ +export async function reconcilePendingFulfillmentModes({ + batchSize = 2000, + maxBatches = 10, + reason = 'reconcile', +}: ReconcileFulfillmentModesOptions = {}): Promise { + const normalizedBatchSize = clampInteger(batchSize, 2000, 1, 50_000) + const normalizedMaxBatches = clampInteger(maxBatches, 10, 1, 10_000) + const normalizedReason = String(reason || 'reconcile').trim() || 'reconcile' + let updatedCount = 0 + let batches = 0 + + for (let index = 0; index < normalizedMaxBatches; index += 1) { + const result = await query<{ fulfillment_mode: OrderFulfillmentMode }>( + ` + WITH pending AS ( + SELECT oi.id + FROM order_items oi + WHERE oi.fulfillment_mode IS NULL + ORDER BY oi.id + LIMIT $1 + ), + decided AS ( + SELECT oi.id, ${FULFILLMENT_MODE_CASE_SQL} AS mode + FROM order_items oi + JOIN pending p ON p.id = oi.id + ) + UPDATE order_items oi + SET + fulfillment_mode = d.mode, + fulfillment_decided_at = NOW(), + fulfillment_reason = $2 + FROM decided d + WHERE oi.id = d.id + AND oi.fulfillment_mode IS NULL + RETURNING oi.fulfillment_mode + `, + [normalizedBatchSize, normalizedReason], + ) + + const updated = result.rowCount || 0 + updatedCount += updated + batches += 1 + if (updated < normalizedBatchSize) break + } + + const remaining = await query<{ count: number }>( + `SELECT COUNT(*)::int AS count FROM order_items WHERE fulfillment_mode IS NULL`, + ) + + return { + updatedCount, + remainingCount: Number(remaining.rows[0]?.count || 0), + batches, + reason: normalizedReason, + finishedAt: nowIso(), + } +} + +export async function getFulfillmentModeCounts(): Promise< + Array<{ fulfillmentMode: OrderFulfillmentMode | null; count: number }> +> { + const result = await query<{ fulfillment_mode: OrderFulfillmentMode | null; count: number }>( + ` + SELECT fulfillment_mode, COUNT(*)::int AS count + FROM order_items + GROUP BY fulfillment_mode + ORDER BY COUNT(*) DESC + `, + ) + return result.rows.map((row) => ({ + fulfillmentMode: row.fulfillment_mode || null, + count: Number(row.count || 0), + })) +} + +function clampInteger(value: unknown, fallback: number, min: number, max: number) { + const parsed = Number(value) + if (!Number.isInteger(parsed)) return fallback + return Math.min(max, Math.max(min, parsed)) +} 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 0e3e7c3f..81cfc3ed 100644 --- a/apps/backend/src/services/scheduler/cost-finance-sync-job.ts +++ b/apps/backend/src/services/scheduler/cost-finance-sync-job.ts @@ -4,15 +4,22 @@ import { syncEstimatedOrderItemCostSnapshots, } from '../cost-center/cost-snapshot-sync-service.js' import { rebuildSalesDailyFacts } from '../finance/sales-facts-service.js' +import { reconcilePendingFulfillmentModes } from '../fulfillment/fulfillment-mode-service.js' /** - * 财务闭环同步:预估成本快照 -> 工单实际快照 -> 销售事实重算。 + * 财务闭环同步:履约方式兜底 -> 预估成本快照 -> 工单实际快照 -> 销售事实重算。 * 只处理最近 N 天,避免定时任务扫全量历史。 */ export async function runCostFinanceSyncJob(job: JsonObject = {}) { const config = job.config && typeof job.config === 'object' ? (job.config as JsonObject) : {} const days = clampInteger(config.days, 7, 1, 90) + const fulfillment = await reconcilePendingFulfillmentModes({ + batchSize: 2000, + maxBatches: 10, + reason: 'cost_finance_sync', + }) + const estimated = await syncEstimatedOrderItemCostSnapshots({ dateFrom: shiftDays(-(days - 1)), dateTo: today(), @@ -29,10 +36,13 @@ export async function runCostFinanceSyncJob(job: JsonObject = {}) { return { status: 'ok', message: + `履约方式 ${fulfillment.updatedCount} 项 / ` + `成本快照 ${estimated.createdCount} 商品预估 / ` + `${actual.directRewardCount + actual.upgradedCount} 工单实际 / ` + `${actual.estimatedRewardCount} 工单在途,销售事实 ${facts.insertedCount} 行`, days, + fulfillmentModeUpdated: fulfillment.updatedCount, + fulfillmentModeRemaining: fulfillment.remainingCount, estimatedCreated: estimated.createdCount, actualCreated: actual.directRewardCount + actual.upgradedCount, estimatedRewardCreated: actual.estimatedRewardCount, diff --git a/apps/backend/src/services/worker-platform/sync-worker-orders-service.ts b/apps/backend/src/services/worker-platform/sync-worker-orders-service.ts index 34b1de51..252271df 100644 --- a/apps/backend/src/services/worker-platform/sync-worker-orders-service.ts +++ b/apps/backend/src/services/worker-platform/sync-worker-orders-service.ts @@ -44,6 +44,7 @@ import { resolveWorkOrderSharingEnabled, } from './mappers.js' import { getWorkerProductMatchConfig } from './worker-product-match-config-service.js' +import { decideOrderItemsFulfillmentModes } from '../fulfillment/fulfillment-mode-service.js' export async function syncWorkerOrdersForSourceOrder( order: OrderRow, @@ -304,6 +305,21 @@ export async function syncWorkerOrdersForSourceOrder( } } + // 订单处理完成后统一落履约事实;失败不阻断下单,由财务定时任务兜底归集。 + try { + await decideOrderItemsFulfillmentModes( + orderItems.map((item) => Number(item.id)), + { reason: options.source || 'source_order' }, + ) + } catch (error) { + logWarn('[worker-order-consumer]', '履约方式归集失败,等待定时任务兜底', { + orderId: order.id, + platformOrderId: order.platform_order_id, + orderItemCount: orderItems.length, + error: error instanceof Error ? error.message : String(error), + }) + } + const summary = { source: options.source || 'source_order', orderId: order.id, diff --git a/apps/backend/src/types/admin/read-models.ts b/apps/backend/src/types/admin/read-models.ts index b152b472..6767188a 100644 --- a/apps/backend/src/types/admin/read-models.ts +++ b/apps/backend/src/types/admin/read-models.ts @@ -6,11 +6,23 @@ export type AdminOrderFulfillmentProgress = { customerStatus: string } +export type AdminOrderItemFulfillmentMode = + | 'work_order' + | 'affiliate_dash' + | 'legacy_platform' + | 'manual' + | 'pending' + +export type AdminOrderFulfillmentMode = AdminOrderItemFulfillmentMode | 'mixed' + +export type AdminVoucherState = 'none' | 'unused' | 'consumed' | 'destroyed' | 'mixed' + export type AdminOrderItemSummary = { itemTitle: string skuNick: string matchSku: string quantity: number + fulfillmentMode: AdminOrderItemFulfillmentMode } export type AdminOrderListItem = { @@ -46,7 +58,11 @@ export type AdminOrderListItem = { claimStatus?: string claimStatusReason?: string claimStatusUpdatedAt?: string | null - fulfillmentType?: 'auto_claim' | 'worker_dispatch' | 'voucher_only' | 'manual' + fulfillmentMode: AdminOrderFulfillmentMode + manualItemCount: number + pendingItemCount: number + hasVoucher: boolean + voucherState: AdminVoucherState workOrderCount?: number canClaimH5?: boolean } diff --git a/apps/backend/src/types/repository/rows.ts b/apps/backend/src/types/repository/rows.ts index 3374a05d..927b78c4 100644 --- a/apps/backend/src/types/repository/rows.ts +++ b/apps/backend/src/types/repository/rows.ts @@ -33,6 +33,9 @@ export type OrderItemRow = { quantity: number spec_json: string | Record item_snapshot_json?: string | Record + fulfillment_mode?: string | null + fulfillment_decided_at?: string | null + fulfillment_reason?: string | null created_at?: string updated_at?: string } diff --git a/apps/frontend/src/pages/admin/AdminOrderDetailPage.tsx b/apps/frontend/src/pages/admin/AdminOrderDetailPage.tsx index f2292560..12292dcd 100644 --- a/apps/frontend/src/pages/admin/AdminOrderDetailPage.tsx +++ b/apps/frontend/src/pages/admin/AdminOrderDetailPage.tsx @@ -32,7 +32,7 @@ import StatusTag from '@/components/admin/StatusTag' import { fetchAdminOrderDetail, updateAdminOrderClaimStatus } from '@/services/admin' import type { AdminOrderDetail, AdminTaskListItem } from '@/types/admin' import { formatAdminDateTime } from '@/utils/admin-time' -import { resolveOrderFulfillmentMode } from '@/utils/admin-display' +import { resolveOrderFulfillmentMode, resolveVoucherStateLabel } from '@/utils/admin-display' export default function AdminOrderDetailPage() { const navigate = useNavigate() @@ -185,9 +185,11 @@ export default function AdminOrderDetailPage() { detail.order.payStatus === 'refunded' || detail.order.orderStatus === 'cancelled' const canPauseOrClose = !isOrderFulfilled && !isOrderRefundedOrCancelled const isAutoClaimOrder = Boolean( - detail.order.canClaimH5 || detail.order.fulfillmentType === 'auto_claim', + detail.order.fulfillmentMode === 'affiliate_dash' || + (detail.order.fulfillmentMode === 'mixed' && detail.order.canClaimH5), ) const fulfillmentMode = resolveOrderFulfillmentMode(detail.order) + const voucherLabel = resolveVoucherStateLabel(detail.order.voucherState) return (
@@ -254,6 +256,15 @@ export default function AdminOrderDetailPage() { {fulfillmentMode.label} + + {detail.order.hasVoucher && voucherLabel ? ( + + 🎫 {voucherLabel} + + ) : ( + 无 + )} + diff --git a/apps/frontend/src/pages/admin/AdminOrdersPage.tsx b/apps/frontend/src/pages/admin/AdminOrdersPage.tsx index 5e87935a..18fa5726 100644 --- a/apps/frontend/src/pages/admin/AdminOrdersPage.tsx +++ b/apps/frontend/src/pages/admin/AdminOrdersPage.tsx @@ -32,7 +32,11 @@ import StatusTag from '@/components/admin/StatusTag' import dayjs from '@/lib/dayjs' import { fetchAdminOrders } from '@/services/admin' import type { AdminOrderListItem } from '@/types/admin' -import { resolveOrderFulfillmentMode, resolveOrderPlatformBadge } from '@/utils/admin-display' +import { + resolveOrderFulfillmentMode, + resolveOrderPlatformBadge, + resolveVoucherStateLabel, +} from '@/utils/admin-display' import { adminOrderExecutorOptions, adminOrderPlatformSegmentedOptions, @@ -271,6 +275,7 @@ export default function AdminOrdersPage() { minWidth: 230, render: (_, row) => { const fulfillmentMode = resolveOrderFulfillmentMode(row) + const voucherLabel = resolveVoucherStateLabel(row.voucherState) return (
@@ -278,6 +283,11 @@ export default function AdminOrdersPage() { {fulfillmentMode.label} + {row.hasVoucher && voucherLabel ? ( + + 🎫 {voucherLabel} + + ) : null} @@ -318,8 +328,7 @@ export default function AdminOrdersPage() { render: (_, row) => { const orderNo = row.platformOrderId || String(row.orderId) const h5Url = `${window.location.origin}/h5/${encodeURIComponent(orderNo)}` - const isManualOrder = - row.fulfillmentType === 'manual' || (!row.canClaimH5 && !row.workOrderCount) + const isManualOrder = Number(row.manualItemCount || 0) > 0 return ( diff --git a/apps/frontend/src/types/admin/orders.ts b/apps/frontend/src/types/admin/orders.ts index 7c3a7885..62577f80 100644 --- a/apps/frontend/src/types/admin/orders.ts +++ b/apps/frontend/src/types/admin/orders.ts @@ -8,11 +8,23 @@ export interface AdminOrderFulfillmentProgress { customerStatus: string } +export type AdminOrderItemFulfillmentMode = + | 'work_order' + | 'affiliate_dash' + | 'legacy_platform' + | 'manual' + | 'pending' + +export type AdminOrderFulfillmentMode = AdminOrderItemFulfillmentMode | 'mixed' + +export type AdminVoucherState = 'none' | 'unused' | 'consumed' | 'destroyed' | 'mixed' + export interface AdminOrderItemSummary { itemTitle: string skuNick: string matchSku: string quantity: number + fulfillmentMode: AdminOrderItemFulfillmentMode } export interface AdminOrderListItem { @@ -48,7 +60,11 @@ export interface AdminOrderListItem { claimStatus?: string claimStatusReason?: string claimStatusUpdatedAt?: string | null - fulfillmentType?: 'auto_claim' | 'worker_dispatch' | 'voucher_only' | 'manual' + fulfillmentMode: AdminOrderFulfillmentMode + manualItemCount: number + pendingItemCount: number + hasVoucher: boolean + voucherState: AdminVoucherState workOrderCount?: number canClaimH5?: boolean } @@ -66,7 +82,11 @@ export interface AdminOrderDetail { claimStatus?: string claimStatusReason?: string claimStatusUpdatedAt?: string | null - fulfillmentType?: 'auto_claim' | 'worker_dispatch' | 'voucher_only' | 'manual' + fulfillmentMode: AdminOrderFulfillmentMode + manualItemCount: number + pendingItemCount: number + hasVoucher: boolean + voucherState: AdminVoucherState workOrderCount?: number canClaimH5?: boolean buyerId: string @@ -92,6 +112,7 @@ export interface AdminOrderDetail { skuNick?: string matchSku?: string quantity: number + fulfillmentMode: AdminOrderItemFulfillmentMode deliveryMode: string spec: Record }> diff --git a/apps/frontend/src/utils/admin-display.ts b/apps/frontend/src/utils/admin-display.ts index 548cf4cb..acd913b8 100644 --- a/apps/frontend/src/utils/admin-display.ts +++ b/apps/frontend/src/utils/admin-display.ts @@ -219,35 +219,41 @@ export function resolveOrderPlatformBadge(row: { platform?: string; provider?: s } export function resolveOrderFulfillmentMode(row: { - provider?: string - platform?: string - fulfillmentType?: 'auto_claim' | 'worker_dispatch' | 'voucher_only' | 'manual' - workOrderCount?: number - canClaimH5?: boolean - taskCount?: number + fulfillmentMode?: + | 'work_order' + | 'affiliate_dash' + | 'legacy_platform' + | 'manual' + | 'pending' + | 'mixed' }): { label: string; color: string } { - const provider = String(row.provider || '').toLowerCase() + switch (row.fulfillmentMode) { + case 'affiliate_dash': + return { label: '⚡ 自动发货', color: 'blue' } + case 'work_order': + return { label: '👥 接单平台', color: 'purple' } + case 'legacy_platform': + return { label: '🗄 历史平台', color: 'default' } + case 'manual': + return { label: '🙋 人工履约', color: 'orange' } + case 'mixed': + return { label: '🔀 混合履约', color: 'geekblue' } + default: + return { label: '⏳ 待判定', color: 'default' } + } +} - if (row.canClaimH5 || row.fulfillmentType === 'auto_claim' || provider === 'douyin_mashida') { - return { label: '⚡ 自动直发', color: 'blue' } +export function resolveVoucherStateLabel(state?: string) { + switch (state) { + case 'unused': + return '核销码未用' + case 'consumed': + return '核销码已核销' + case 'destroyed': + return '核销码已销毁' + case 'mixed': + return '核销码混合状态' + default: + return '' } - - if (row.fulfillmentType === 'worker_dispatch' || Number(row.workOrderCount || 0) > 0) { - return { label: '👥 打手履约', color: 'purple' } - } - - if ( - provider === 'kuaishou_industry' || - provider === '91kaquan' || - provider === 'open_91' || - row.fulfillmentType === 'voucher_only' - ) { - return { label: '🎫 电子凭证', color: 'gold' } - } - - if (provider === 'pdd') { - return { label: '📦 拼多多发货', color: 'red' } - } - - return { label: '手动交付', color: 'default' } }