feat(finance): 统一履约事实并改造人工账单分类

This commit is contained in:
yml2213
2026-10-23 13:35:48 +08:00
parent fb80cad695
commit 5d1ba11523
18 changed files with 764 additions and 123 deletions
+1
View File
@@ -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",
@@ -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<string, string> = {}
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))
}
@@ -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'));
@@ -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' },
)
})
@@ -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<AdminOrderItemFulfillmentMode>([
'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<typeof mode, 'pending'> => 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,
}
}
@@ -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'
@@ -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 (
@@ -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,
@@ -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',
)
})
@@ -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<number | string>,
{ 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<number | string>,
): 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<ReconcileFulfillmentModesResult> {
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))
}
@@ -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,
@@ -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,
+17 -1
View File
@@ -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
}
@@ -33,6 +33,9 @@ export type OrderItemRow = {
quantity: number
spec_json: string | Record<string, unknown>
item_snapshot_json?: string | Record<string, unknown>
fulfillment_mode?: string | null
fulfillment_decided_at?: string | null
fulfillment_reason?: string | null
created_at?: string
updated_at?: string
}
@@ -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 (
<section className="page-stack">
@@ -254,6 +256,15 @@ export default function AdminOrderDetailPage() {
{fulfillmentMode.label}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="核销码">
{detail.order.hasVoucher && voucherLabel ? (
<Tag color="gold" style={{ margin: 0 }}>
🎫 {voucherLabel}
</Tag>
) : (
<span className="muted">无</span>
)}
</Descriptions.Item>
<Descriptions.Item label="订单状态">
<StatusTag value={detail.order.orderStatus} />
</Descriptions.Item>
@@ -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 (
<div className="cell-stack" style={{ gap: 4 }}>
@@ -278,6 +283,11 @@ export default function AdminOrdersPage() {
<Tag color={fulfillmentMode.color} style={{ margin: 0, fontSize: 11 }}>
{fulfillmentMode.label}
</Tag>
{row.hasVoucher && voucherLabel ? (
<Tag color="gold" style={{ margin: 0, fontSize: 11 }}>
🎫 {voucherLabel}
</Tag>
) : null}
<StatusTag value={row.payStatus} kind="pay" />
<StatusTag value={row.resourceStatus} kind="resource" />
<StatusTag value={row.customerStatus} kind="customer" />
@@ -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 (
<Space size={6} wrap>
+23 -2
View File
@@ -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<string, unknown>
}>
+34 -28
View File
@@ -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' }
}