feat(finance): 统一履约事实并改造人工账单分类
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
}>
|
||||
|
||||
@@ -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' }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user