fix(fulfillment): 封堵重复载体并完善统一SKU映射
This commit is contained in:
@@ -10,6 +10,7 @@
|
|||||||
"db:migrate:create": "tsx scripts/create-migration.ts",
|
"db:migrate:create": "tsx scripts/create-migration.ts",
|
||||||
"db:migrate:status": "tsx scripts/migration-status.ts",
|
"db:migrate:status": "tsx scripts/migration-status.ts",
|
||||||
"finance:sync": "tsx scripts/sync-cost-finance.ts",
|
"finance:sync": "tsx scripts/sync-cost-finance.ts",
|
||||||
|
"fulfillment:normalize-sku-configs": "tsx scripts/normalize-internal-sku-configs.ts",
|
||||||
"finance:rebuild-cutover": "tsx scripts/rebuild-after-cutover.ts",
|
"finance:rebuild-cutover": "tsx scripts/rebuild-after-cutover.ts",
|
||||||
"revenue:backfill": "tsx scripts/backfill-revenue-basis.ts",
|
"revenue:backfill": "tsx scripts/backfill-revenue-basis.ts",
|
||||||
"cost-exceptions:archive": "tsx scripts/archive-cost-exceptions.ts",
|
"cost-exceptions:archive": "tsx scripts/archive-cost-exceptions.ts",
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ async function main() {
|
|||||||
), '')) ~ '^[0-9]+$')::int AS numeric_only,
|
), '')) ~ '^[0-9]+$')::int AS numeric_only,
|
||||||
count(*) FILTER (WHERE btrim(COALESCE(resolve_internal_sku(
|
count(*) FILTER (WHERE btrim(COALESCE(resolve_internal_sku(
|
||||||
manual_sku_key, sku_name, sku_code
|
manual_sku_key, sku_name, sku_code
|
||||||
), '')) ~ '[一-龥]')::int AS with_cjk
|
), '')) ~ '[㐀-䶿一-鿿豈-𠀀-𰀀-𲎯]')::int AS with_cjk
|
||||||
FROM order_items
|
FROM order_items
|
||||||
WHERE fulfillment_status = 'assigned'
|
WHERE fulfillment_status = 'assigned'
|
||||||
`,
|
`,
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ async function main() {
|
|||||||
SELECT o.provider, o.shop_id, oi.sku_code, MIN(oi.sku_name) AS sku_name, count(*)::int AS total
|
SELECT o.provider, o.shop_id, oi.sku_code, MIN(oi.sku_name) AS sku_name, count(*)::int AS total
|
||||||
FROM order_items oi
|
FROM order_items oi
|
||||||
JOIN orders o ON o.id = oi.order_id
|
JOIN orders o ON o.id = oi.order_id
|
||||||
WHERE COALESCE(canonicalize_internal_sku(oi.manual_sku_key), '') !~ '[一-龥]'
|
WHERE COALESCE(canonicalize_internal_sku(oi.manual_sku_key), '') !~ '[㐀-䶿一-鿿豈-𠀀-𰀀-𲎯]'
|
||||||
AND btrim(COALESCE(oi.sku_name, '')) ~ '[一-龥]'
|
AND btrim(COALESCE(oi.sku_name, '')) ~ '[㐀-䶿一-鿿豈-𠀀-𰀀-𲎯]'
|
||||||
GROUP BY 1, 2, 3
|
GROUP BY 1, 2, 3
|
||||||
ORDER BY 5 DESC
|
ORDER BY 5 DESC
|
||||||
`,
|
`,
|
||||||
@@ -37,8 +37,8 @@ async function main() {
|
|||||||
SELECT o.provider, o.shop_id, oi.sku_code, count(*)::int AS total
|
SELECT o.provider, o.shop_id, oi.sku_code, count(*)::int AS total
|
||||||
FROM order_items oi
|
FROM order_items oi
|
||||||
JOIN orders o ON o.id = oi.order_id
|
JOIN orders o ON o.id = oi.order_id
|
||||||
WHERE COALESCE(canonicalize_internal_sku(oi.manual_sku_key), '') !~ '[一-龥]'
|
WHERE COALESCE(canonicalize_internal_sku(oi.manual_sku_key), '') !~ '[㐀-䶿一-鿿豈-𠀀-𰀀-𲎯]'
|
||||||
AND btrim(COALESCE(oi.sku_name, '')) !~ '[一-龥]'
|
AND btrim(COALESCE(oi.sku_name, '')) !~ '[㐀-䶿一-鿿豈-𠀀-𰀀-𲎯]'
|
||||||
GROUP BY 1, 2, 3
|
GROUP BY 1, 2, 3
|
||||||
ORDER BY 4 DESC
|
ORDER BY 4 DESC
|
||||||
`,
|
`,
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ async function resolveInternalSku(rule: LegacyRule): Promise<string> {
|
|||||||
FROM order_items oi
|
FROM order_items oi
|
||||||
JOIN orders o ON o.id = oi.order_id
|
JOIN orders o ON o.id = oi.order_id
|
||||||
WHERE oi.sku_code = $1
|
WHERE oi.sku_code = $1
|
||||||
AND btrim(COALESCE(oi.manual_sku_key, '')) ~ '[一-龥]'
|
AND btrim(COALESCE(oi.manual_sku_key, '')) ~ '[㐀-䶿一-鿿豈-𠀀-𰀀-𲎯]'
|
||||||
${scope}
|
${scope}
|
||||||
LIMIT 2
|
LIMIT 2
|
||||||
`,
|
`,
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
/**
|
||||||
|
* 合并规范化后重复的内部 SKU 渠道配置。
|
||||||
|
*
|
||||||
|
* 默认只读;确认后加 --apply。
|
||||||
|
* 用法:npx tsx scripts/normalize-internal-sku-configs.ts [--apply]
|
||||||
|
*/
|
||||||
|
import { closeDb, query, withTransaction } from '../src/db/client.js'
|
||||||
|
import { canonicalizeInternalSku } from '../src/services/fulfillment/internal-sku-resolver.js'
|
||||||
|
|
||||||
|
type ChannelRow = {
|
||||||
|
id: number
|
||||||
|
internal_sku: string
|
||||||
|
channel: string
|
||||||
|
priority: number
|
||||||
|
is_fallback: boolean
|
||||||
|
enabled: boolean
|
||||||
|
canonical_product_id: number | null
|
||||||
|
config_json: Record<string, unknown>
|
||||||
|
source: string
|
||||||
|
note: string
|
||||||
|
created_by_admin_user_id: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type DuplicateGroup = {
|
||||||
|
internalSku: string
|
||||||
|
channel: string
|
||||||
|
rows: ChannelRow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type MappingRow = {
|
||||||
|
id: number
|
||||||
|
provider: string
|
||||||
|
shop_id: string
|
||||||
|
match_type: string
|
||||||
|
match_value: string
|
||||||
|
internal_sku: string
|
||||||
|
priority: number
|
||||||
|
enabled: boolean
|
||||||
|
source: string
|
||||||
|
note: string
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const apply = process.argv.includes('--apply')
|
||||||
|
const result = await query<ChannelRow>(
|
||||||
|
`SELECT id, internal_sku, channel, priority, is_fallback, enabled,
|
||||||
|
canonical_product_id, config_json, source, note, created_by_admin_user_id
|
||||||
|
FROM internal_sku_channels
|
||||||
|
ORDER BY canonicalize_internal_sku(internal_sku), channel, is_fallback, priority, id`,
|
||||||
|
)
|
||||||
|
|
||||||
|
const groups = new Map<string, DuplicateGroup>()
|
||||||
|
for (const row of result.rows) {
|
||||||
|
const internalSku = canonicalizeInternalSku(row.internal_sku)
|
||||||
|
const key = `${internalSku}|${row.channel}`
|
||||||
|
const group = groups.get(key) || { internalSku, channel: row.channel, rows: [] }
|
||||||
|
group.rows.push({ ...row, id: Number(row.id), priority: Number(row.priority) })
|
||||||
|
groups.set(key, group)
|
||||||
|
}
|
||||||
|
|
||||||
|
const duplicates = [...groups.values()].filter((group) => group.rows.length > 1)
|
||||||
|
const mappingRows = await query<MappingRow>(
|
||||||
|
`SELECT id, provider, shop_id, match_type, match_value, internal_sku,
|
||||||
|
priority, enabled, source, note
|
||||||
|
FROM internal_sku_mappings
|
||||||
|
ORDER BY provider, shop_id, match_type, canonicalize_internal_sku(match_value), enabled DESC, priority, id`,
|
||||||
|
)
|
||||||
|
const duplicateMappings = new Map<string, MappingRow[]>()
|
||||||
|
for (const row of mappingRows.rows) {
|
||||||
|
const key = [
|
||||||
|
row.provider,
|
||||||
|
row.shop_id,
|
||||||
|
row.match_type,
|
||||||
|
canonicalizeInternalSku(row.match_value),
|
||||||
|
].join('|')
|
||||||
|
const list = duplicateMappings.get(key) || []
|
||||||
|
list.push({ ...row, id: Number(row.id), priority: Number(row.priority) })
|
||||||
|
duplicateMappings.set(key, list)
|
||||||
|
}
|
||||||
|
const mappingDuplicates = [...duplicateMappings.values()].filter((rows) => rows.length > 1)
|
||||||
|
if (apply) {
|
||||||
|
for (const group of duplicates) {
|
||||||
|
await withTransaction(async (client) => {
|
||||||
|
const rows = (
|
||||||
|
await client.query<ChannelRow>(
|
||||||
|
`SELECT id, internal_sku, channel, priority, is_fallback, enabled,
|
||||||
|
canonical_product_id, config_json, source, note, created_by_admin_user_id
|
||||||
|
FROM internal_sku_channels
|
||||||
|
WHERE id = ANY($1::bigint[])
|
||||||
|
ORDER BY is_fallback, priority, id
|
||||||
|
FOR UPDATE`,
|
||||||
|
[group.rows.map((row) => row.id)],
|
||||||
|
)
|
||||||
|
).rows.map((row) => ({ ...row, id: Number(row.id), priority: Number(row.priority) }))
|
||||||
|
if (rows.length <= 1) return
|
||||||
|
|
||||||
|
const keeper = rows[0]!
|
||||||
|
const primaryRows = rows.filter((row) => !row.is_fallback)
|
||||||
|
const configJson = rows.reduce<Record<string, unknown>>(
|
||||||
|
(merged, row) => ({ ...merged, ...(row.config_json || {}) }),
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
const source = rows.find((row) => row.source === 'operator')?.source || keeper.source
|
||||||
|
const note = [
|
||||||
|
...new Set(rows.map((row) => String(row.note || '').trim()).filter(Boolean)),
|
||||||
|
].join(' / ')
|
||||||
|
await client.query(`DELETE FROM internal_sku_channels WHERE id = ANY($1::bigint[])`, [
|
||||||
|
rows.slice(1).map((row) => row.id),
|
||||||
|
])
|
||||||
|
await client.query(
|
||||||
|
`UPDATE internal_sku_channels
|
||||||
|
SET internal_sku = $2,
|
||||||
|
priority = $3,
|
||||||
|
is_fallback = $4,
|
||||||
|
enabled = $5,
|
||||||
|
canonical_product_id = $6,
|
||||||
|
config_json = $7::jsonb,
|
||||||
|
source = $8,
|
||||||
|
note = $9,
|
||||||
|
created_by_admin_user_id = $10,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1`,
|
||||||
|
[
|
||||||
|
keeper.id,
|
||||||
|
group.internalSku,
|
||||||
|
Math.min(...(primaryRows.length > 0 ? primaryRows : rows).map((row) => row.priority)),
|
||||||
|
primaryRows.length === 0,
|
||||||
|
rows.some((row) => row.enabled),
|
||||||
|
rows.find((row) => row.canonical_product_id != null)?.canonical_product_id || null,
|
||||||
|
JSON.stringify(configJson),
|
||||||
|
source,
|
||||||
|
note,
|
||||||
|
rows.find((row) => row.created_by_admin_user_id != null)?.created_by_admin_user_id ||
|
||||||
|
null,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for (const rows of mappingDuplicates) {
|
||||||
|
await withTransaction(async (client) => {
|
||||||
|
const keeper = rows[0]!
|
||||||
|
await client.query(`DELETE FROM internal_sku_mappings WHERE id = ANY($1::bigint[])`, [
|
||||||
|
rows.slice(1).map((row) => row.id),
|
||||||
|
])
|
||||||
|
await client.query(
|
||||||
|
`UPDATE internal_sku_mappings
|
||||||
|
SET match_value = $2, internal_sku = $3, priority = $4,
|
||||||
|
enabled = $5, source = $6, note = $7, updated_at = NOW()
|
||||||
|
WHERE id = $1`,
|
||||||
|
[
|
||||||
|
keeper.id,
|
||||||
|
canonicalizeInternalSku(keeper.match_value),
|
||||||
|
canonicalizeInternalSku(keeper.internal_sku),
|
||||||
|
Math.min(...rows.map((row) => row.priority)),
|
||||||
|
rows.some((row) => row.enabled),
|
||||||
|
keeper.source,
|
||||||
|
[...new Set(rows.map((row) => String(row.note || '').trim()).filter(Boolean))].join(
|
||||||
|
' / ',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const duplicateManualConfigs = await query<{ label: string; total: number }>(
|
||||||
|
`SELECT canonicalize_internal_sku(sku_key) AS label, count(*)::int AS total
|
||||||
|
FROM manual_fulfillment_configs
|
||||||
|
GROUP BY canonicalize_internal_sku(sku_key)
|
||||||
|
HAVING count(*) > 1
|
||||||
|
ORDER BY count(*) DESC, label
|
||||||
|
LIMIT 20`,
|
||||||
|
)
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
apply,
|
||||||
|
duplicateGroups: duplicates.length,
|
||||||
|
duplicateRows: duplicates.reduce((total, group) => total + group.rows.length, 0),
|
||||||
|
duplicateMappingGroups: mappingDuplicates.length,
|
||||||
|
duplicateMappingRows: mappingDuplicates.reduce((total, rows) => total + rows.length, 0),
|
||||||
|
sample: duplicates.slice(0, 20).map((group) => ({
|
||||||
|
internalSku: group.internalSku,
|
||||||
|
channel: group.channel,
|
||||||
|
ids: group.rows.map((row) => row.id),
|
||||||
|
})),
|
||||||
|
duplicateManualConfigs: duplicateManualConfigs.rows,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(error)
|
||||||
|
process.exitCode = 1
|
||||||
|
})
|
||||||
|
.finally(() => closeDb())
|
||||||
@@ -160,6 +160,54 @@ async function main() {
|
|||||||
)
|
)
|
||||||
).rows
|
).rows
|
||||||
|
|
||||||
|
checks.ambiguousCarrierConfigs = (
|
||||||
|
await query<CountRow>(
|
||||||
|
`SELECT canonicalize_internal_sku(canonical_name) || '/' || channel AS label,
|
||||||
|
count(DISTINCT canonical_sku)::int AS total
|
||||||
|
FROM (
|
||||||
|
SELECT canonical_name, canonical_sku,
|
||||||
|
CASE
|
||||||
|
WHEN canonical_sku LIKE 'aff:%' THEN 'affiliate_dash'
|
||||||
|
WHEN canonical_sku LIKE 'tpl:%' OR canonical_sku LIKE 'wo:%' THEN 'work_order'
|
||||||
|
WHEN canonical_sku LIKE 'tpv:%' THEN 'third_party_voucher'
|
||||||
|
END AS channel
|
||||||
|
FROM product_catalog
|
||||||
|
WHERE status = 'active'
|
||||||
|
AND (canonical_sku LIKE 'aff:%' OR canonical_sku LIKE 'tpl:%'
|
||||||
|
OR canonical_sku LIKE 'wo:%' OR canonical_sku LIKE 'tpv:%')
|
||||||
|
) carrier
|
||||||
|
WHERE channel IS NOT NULL
|
||||||
|
GROUP BY canonicalize_internal_sku(canonical_name), channel
|
||||||
|
HAVING count(DISTINCT canonical_sku) > 1
|
||||||
|
ORDER BY count(DISTINCT canonical_sku) DESC, label`,
|
||||||
|
)
|
||||||
|
).rows
|
||||||
|
|
||||||
|
checks.ambiguousManualConfigs = (
|
||||||
|
await query<CountRow>(
|
||||||
|
`SELECT canonicalize_internal_sku(sku_key) AS label, count(*)::int AS total
|
||||||
|
FROM manual_fulfillment_configs
|
||||||
|
WHERE enabled = TRUE
|
||||||
|
GROUP BY canonicalize_internal_sku(sku_key)
|
||||||
|
HAVING count(*) > 1
|
||||||
|
ORDER BY count(*) DESC, label`,
|
||||||
|
)
|
||||||
|
).rows
|
||||||
|
|
||||||
|
checks.ambiguousInternalSkuMappings = (
|
||||||
|
await query<CountRow>(
|
||||||
|
`SELECT provider || '/' || shop_id || '/' || match_type || '/' ||
|
||||||
|
canonicalize_internal_sku(match_value) AS label,
|
||||||
|
count(*)::int AS total
|
||||||
|
FROM internal_sku_mappings
|
||||||
|
WHERE enabled = TRUE
|
||||||
|
GROUP BY provider, shop_id, match_type, canonicalize_internal_sku(match_value)
|
||||||
|
HAVING count(*) > 1
|
||||||
|
ORDER BY count(*) DESC, label
|
||||||
|
LIMIT 200`,
|
||||||
|
)
|
||||||
|
).rows
|
||||||
|
|
||||||
checks.pendingWithoutChineseInternalSku = (
|
checks.pendingWithoutChineseInternalSku = (
|
||||||
await query<CountRow>(
|
await query<CountRow>(
|
||||||
`SELECT 'pending_without_chinese_internal_sku' AS label, count(*)::int AS total
|
`SELECT 'pending_without_chinese_internal_sku' AS label, count(*)::int AS total
|
||||||
@@ -262,6 +310,12 @@ async function main() {
|
|||||||
failed.push('存在规范化后重复的内部 SKU 渠道配置')
|
failed.push('存在规范化后重复的内部 SKU 渠道配置')
|
||||||
if ((checks.incompleteCarrierConfigs as CountRow[]).length > 0)
|
if ((checks.incompleteCarrierConfigs as CountRow[]).length > 0)
|
||||||
failed.push('存在缺少 workRuleKey 或人工履约配置的启用渠道')
|
failed.push('存在缺少 workRuleKey 或人工履约配置的启用渠道')
|
||||||
|
if ((checks.ambiguousCarrierConfigs as CountRow[]).length > 0)
|
||||||
|
failed.push('存在同一中文内部 SKU 的多个载体配置')
|
||||||
|
if ((checks.ambiguousManualConfigs as CountRow[]).length > 0)
|
||||||
|
failed.push('存在规范化后重复的人工履约配置')
|
||||||
|
if ((checks.ambiguousInternalSkuMappings as CountRow[]).length > 0)
|
||||||
|
failed.push('存在规范化后重复的内部 SKU 映射')
|
||||||
if ((checks.pendingWithoutChineseInternalSku as CountRow).total > 0)
|
if ((checks.pendingWithoutChineseInternalSku as CountRow).total > 0)
|
||||||
failed.push('存在无法归一到中文内部 SKU 的 pending 订单项')
|
failed.push('存在无法归一到中文内部 SKU 的 pending 订单项')
|
||||||
if ((checks.enabledLegacyRules as CountRow[]).length > 0)
|
if ((checks.enabledLegacyRules as CountRow[]).length > 0)
|
||||||
|
|||||||
@@ -36,11 +36,11 @@ PARALLEL SAFE
|
|||||||
AS $$
|
AS $$
|
||||||
SELECT COALESCE(
|
SELECT COALESCE(
|
||||||
CASE
|
CASE
|
||||||
WHEN canonicalize_internal_sku(p_manual_sku_key) ~ '[一-龥]'
|
WHEN canonicalize_internal_sku(p_manual_sku_key) ~ '[㐀-䶿一-鿿豈-𠀀-𰀀-𲎯]'
|
||||||
THEN canonicalize_internal_sku(p_manual_sku_key)
|
THEN canonicalize_internal_sku(p_manual_sku_key)
|
||||||
END,
|
END,
|
||||||
CASE
|
CASE
|
||||||
WHEN canonicalize_internal_sku(p_sku_name) ~ '[一-龥]'
|
WHEN canonicalize_internal_sku(p_sku_name) ~ '[㐀-䶿一-鿿豈-𠀀-𰀀-𲎯]'
|
||||||
THEN canonicalize_internal_sku(p_sku_name)
|
THEN canonicalize_internal_sku(p_sku_name)
|
||||||
END
|
END
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
|
|
||||||
import { resolveOrderItemSyncPlan } from './order-item-repo.js'
|
import { preserveAssignedOrderItemState, resolveOrderItemSyncPlan } from './order-item-repo.js'
|
||||||
|
|
||||||
test('resolveOrderItemSyncPlan preserves matching order item ids across repeated source syncs', () => {
|
test('resolveOrderItemSyncPlan preserves matching order item ids across repeated source syncs', () => {
|
||||||
const existingItems = [
|
const existingItems = [
|
||||||
@@ -98,3 +98,52 @@ test('resolveOrderItemSyncPlan keeps surplus existing ids for conditional cleanu
|
|||||||
assert.deepEqual(plan.creates, [])
|
assert.deepEqual(plan.creates, [])
|
||||||
assert.deepEqual(plan.deletes, [32])
|
assert.deepEqual(plan.deletes, [32])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('已分配订单重推时冻结原渠道决策与中文内部 SKU', () => {
|
||||||
|
const existing = {
|
||||||
|
id: 41,
|
||||||
|
fulfillment_status: 'assigned',
|
||||||
|
fulfillment_channel: 'affiliate_dash',
|
||||||
|
sku_code: 'lucky_coin_x2',
|
||||||
|
sku_name: '幸运币2个',
|
||||||
|
quantity: 1,
|
||||||
|
spec_json: { skuNick: '幸运币2个', sourceSpec: 'old' },
|
||||||
|
item_snapshot_json: {
|
||||||
|
internalSku: '幸运币2个',
|
||||||
|
internalSkuChannel: 'affiliate_dash',
|
||||||
|
internalSkuChannelSource: 'operator',
|
||||||
|
isConfigured: true,
|
||||||
|
fulfillmentRoute: { selectedExecutorKey: 'affiliate_dash' },
|
||||||
|
affiliateDash: { sku: 'lucky_coin_x2' },
|
||||||
|
externalSkuName: 'old title',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const incoming = {
|
||||||
|
skuCode: 'platform-new',
|
||||||
|
skuName: '幸运币2个',
|
||||||
|
quantity: 1,
|
||||||
|
specJson: { skuNick: '新映射', sourceSpec: 'new' },
|
||||||
|
itemSnapshotJson: {
|
||||||
|
internalSku: '新映射',
|
||||||
|
internalSkuChannel: 'work_order',
|
||||||
|
isConfigured: false,
|
||||||
|
fulfillmentRoute: null,
|
||||||
|
externalSkuName: 'new title',
|
||||||
|
},
|
||||||
|
createdAt: '2026-11-03T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-11-03T00:00:00.000Z',
|
||||||
|
}
|
||||||
|
|
||||||
|
const stabilized = preserveAssignedOrderItemState(existing as never, incoming)
|
||||||
|
|
||||||
|
assert.deepEqual(stabilized.specJson, { skuNick: '幸运币2个', sourceSpec: 'new' })
|
||||||
|
assert.deepEqual(stabilized.itemSnapshotJson, {
|
||||||
|
externalSkuName: 'new title',
|
||||||
|
internalSku: '幸运币2个',
|
||||||
|
internalSkuChannel: 'affiliate_dash',
|
||||||
|
internalSkuChannelSource: 'operator',
|
||||||
|
isConfigured: true,
|
||||||
|
fulfillmentRoute: { selectedExecutorKey: 'affiliate_dash' },
|
||||||
|
affiliateDash: { sku: 'lucky_coin_x2' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -87,8 +87,13 @@ export async function replaceOrderItemsWithExecutor(
|
|||||||
): Promise<OrderItemRow[]> {
|
): Promise<OrderItemRow[]> {
|
||||||
const existingItems = await listOrderItemsByOrderIdWithExecutor(executor, orderId)
|
const existingItems = await listOrderItemsByOrderIdWithExecutor(executor, orderId)
|
||||||
const plan = resolveOrderItemSyncPlan(existingItems, items)
|
const plan = resolveOrderItemSyncPlan(existingItems, items)
|
||||||
|
const existingById = new Map(existingItems.map((item) => [Number(item.id), item]))
|
||||||
|
|
||||||
for (const update of plan.updates) {
|
for (const update of plan.updates) {
|
||||||
|
const effectiveItem = preserveAssignedOrderItemState(
|
||||||
|
existingById.get(update.orderItemId),
|
||||||
|
update.item,
|
||||||
|
)
|
||||||
await executor(
|
await executor(
|
||||||
`
|
`
|
||||||
UPDATE order_items
|
UPDATE order_items
|
||||||
@@ -105,9 +110,9 @@ export async function replaceOrderItemsWithExecutor(
|
|||||||
update.item.skuCode,
|
update.item.skuCode,
|
||||||
update.item.skuName,
|
update.item.skuName,
|
||||||
update.item.quantity,
|
update.item.quantity,
|
||||||
safeJsonStringify(update.item.specJson || '{}'),
|
safeJsonStringify(effectiveItem.specJson || '{}'),
|
||||||
safeJsonStringify(update.item.itemSnapshotJson || update.item.specJson || '{}'),
|
safeJsonStringify(effectiveItem.itemSnapshotJson || effectiveItem.specJson || '{}'),
|
||||||
update.item.updatedAt,
|
effectiveItem.updatedAt,
|
||||||
update.orderItemId,
|
update.orderItemId,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@@ -151,6 +156,72 @@ export async function replaceOrderItemsWithExecutor(
|
|||||||
return listOrderItemsByOrderIdWithExecutor(executor, orderId)
|
return listOrderItemsByOrderIdWithExecutor(executor, orderId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FROZEN_FULFILLMENT_SNAPSHOT_KEYS = [
|
||||||
|
'fulfillmentRoute',
|
||||||
|
'affiliateDash',
|
||||||
|
'classification',
|
||||||
|
'internalSku',
|
||||||
|
'internalSkuSource',
|
||||||
|
'internalSkuMappingId',
|
||||||
|
'internalSkuChannel',
|
||||||
|
'internalSkuChannelSource',
|
||||||
|
'internalSkuChannelReason',
|
||||||
|
'isConfigured',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export function preserveAssignedOrderItemState(
|
||||||
|
existing: OrderItemRow | undefined,
|
||||||
|
incoming: OrderItemReplaceInput,
|
||||||
|
): OrderItemReplaceInput {
|
||||||
|
if (
|
||||||
|
!existing ||
|
||||||
|
(existing.fulfillment_status !== 'assigned' && existing.fulfillment_status !== 'archived')
|
||||||
|
) {
|
||||||
|
return incoming
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingSnapshot = parseJsonObject(existing.item_snapshot_json)
|
||||||
|
const incomingSnapshot = parseJsonObject(incoming.itemSnapshotJson)
|
||||||
|
const frozenSnapshot: Record<string, unknown> = {}
|
||||||
|
for (const key of FROZEN_FULFILLMENT_SNAPSHOT_KEYS) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(existingSnapshot, key)) {
|
||||||
|
frozenSnapshot[key] = existingSnapshot[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingSpec = parseJsonObject(existing.spec_json)
|
||||||
|
const incomingSpec = parseJsonObject(incoming.specJson)
|
||||||
|
const frozenInternalSku = String(
|
||||||
|
existingSpec.skuNick || existingSnapshot.internalSku || incomingSpec.skuNick || '',
|
||||||
|
).trim()
|
||||||
|
|
||||||
|
return {
|
||||||
|
...incoming,
|
||||||
|
specJson: {
|
||||||
|
...incomingSpec,
|
||||||
|
...(frozenInternalSku ? { skuNick: frozenInternalSku } : {}),
|
||||||
|
},
|
||||||
|
itemSnapshotJson: {
|
||||||
|
...incomingSnapshot,
|
||||||
|
...frozenSnapshot,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonObject(value: unknown): Record<string, unknown> {
|
||||||
|
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||||
|
return value as Record<string, unknown>
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(String(value || '{}'))
|
||||||
|
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||||
|
? (parsed as Record<string, unknown>)
|
||||||
|
: {}
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveOrderItemSyncPlan(
|
export function resolveOrderItemSyncPlan(
|
||||||
existingItems: OrderItemRow[],
|
existingItems: OrderItemRow[],
|
||||||
nextItems: OrderItemReplaceInput[],
|
nextItems: OrderItemReplaceInput[],
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ import {
|
|||||||
unlockChannel,
|
unlockChannel,
|
||||||
upsertChannelRule,
|
upsertChannelRule,
|
||||||
} from '../../services/fulfillment/channel-assign-service.js'
|
} from '../../services/fulfillment/channel-assign-service.js'
|
||||||
|
import {
|
||||||
|
deleteInternalSkuMapping,
|
||||||
|
listInternalSkuMappingsPage,
|
||||||
|
upsertInternalSkuMapping,
|
||||||
|
type InternalSkuMappingMatchType,
|
||||||
|
} from '../../services/fulfillment/internal-sku-mapping-service.js'
|
||||||
import { listExceptionQueue } from '../../services/cost-center/cost-exception-service.js'
|
import { listExceptionQueue } from '../../services/cost-center/cost-exception-service.js'
|
||||||
import {
|
import {
|
||||||
runFinancePipelineOnce,
|
runFinancePipelineOnce,
|
||||||
@@ -53,6 +59,70 @@ router.get(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/fulfillment/internal-sku-mappings',
|
||||||
|
requireAdminPermission('finance.view'),
|
||||||
|
createJsonHandler(
|
||||||
|
(req) =>
|
||||||
|
listInternalSkuMappingsPage({
|
||||||
|
keyword: String(req.query.keyword || ''),
|
||||||
|
provider: String(req.query.provider || ''),
|
||||||
|
shopId: String(req.query.shopId || ''),
|
||||||
|
matchType: String(req.query.matchType || ''),
|
||||||
|
page: Number(req.query.page),
|
||||||
|
pageSize: Number(req.query.pageSize),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
successMessage: 'ok',
|
||||||
|
errorMessage: '读取 SKU 映射失败',
|
||||||
|
scope: '[admin/fulfillment/internal-sku-mappings]',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/fulfillment/internal-sku-mappings',
|
||||||
|
requireAdminPermission('finance.config'),
|
||||||
|
createJsonHandler(
|
||||||
|
async (req) => {
|
||||||
|
const body = bodyOf(req)
|
||||||
|
const id = await upsertInternalSkuMapping({
|
||||||
|
provider: String(body.provider || ''),
|
||||||
|
shopId: String(body.shopId || ''),
|
||||||
|
matchType: String(body.matchType || 'sku_code') as InternalSkuMappingMatchType,
|
||||||
|
matchValue: String(body.matchValue || ''),
|
||||||
|
internalSku: String(body.internalSku || ''),
|
||||||
|
priority: Number(body.priority),
|
||||||
|
source: String(body.source || 'manual'),
|
||||||
|
note: String(body.note || ''),
|
||||||
|
enabled: body.enabled !== false,
|
||||||
|
})
|
||||||
|
return { id }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
successMessage: 'SKU 映射已保存',
|
||||||
|
errorMessage: '保存 SKU 映射失败',
|
||||||
|
scope: '[admin/fulfillment/internal-sku-mappings/save]',
|
||||||
|
audit: (req, data) => ({
|
||||||
|
action: 'fulfillment.upsert_internal_sku_mapping',
|
||||||
|
targetType: 'internal_sku_mapping',
|
||||||
|
targetId: String((data as { id?: number })?.id || ''),
|
||||||
|
data: { payload: bodyOf(req) },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
'/fulfillment/internal-sku-mappings/:id',
|
||||||
|
requireAdminPermission('finance.config'),
|
||||||
|
createJsonHandler((req) => deleteInternalSkuMapping(Number(req.params.id)), {
|
||||||
|
successMessage: 'SKU 映射已删除',
|
||||||
|
errorMessage: '删除 SKU 映射失败',
|
||||||
|
scope: '[admin/fulfillment/internal-sku-mappings/delete]',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
'/fulfillment/exceptions',
|
'/fulfillment/exceptions',
|
||||||
requireAdminPermission('finance.view'),
|
requireAdminPermission('finance.view'),
|
||||||
|
|||||||
@@ -4,7 +4,14 @@ import test from 'node:test'
|
|||||||
import { AFFILIATE_FAILURE_STATUSES } from './affiliate-fallback-service.js'
|
import { AFFILIATE_FAILURE_STATUSES } from './affiliate-fallback-service.js'
|
||||||
|
|
||||||
test('自动发货失败状态全部纳入转人工', () => {
|
test('自动发货失败状态全部纳入转人工', () => {
|
||||||
for (const status of ['closed', 'expired', 'manual_review', 'retry_pending', 'cancelled']) {
|
for (const status of [
|
||||||
|
'failed',
|
||||||
|
'closed',
|
||||||
|
'expired',
|
||||||
|
'manual_review',
|
||||||
|
'retry_pending',
|
||||||
|
'cancelled',
|
||||||
|
]) {
|
||||||
assert.ok(AFFILIATE_FAILURE_STATUSES.includes(status as never))
|
assert.ok(AFFILIATE_FAILURE_STATUSES.includes(status as never))
|
||||||
}
|
}
|
||||||
assert.ok(!AFFILIATE_FAILURE_STATUSES.includes('redeemed' as never))
|
assert.ok(!AFFILIATE_FAILURE_STATUSES.includes('redeemed' as never))
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { refreshFactsForDates } from '../finance/refresh-facts.js'
|
|||||||
* 自动任务本身不改状态,只记录订单项的切换原因,避免重复触发。
|
* 自动任务本身不改状态,只记录订单项的切换原因,避免重复触发。
|
||||||
*/
|
*/
|
||||||
export const AFFILIATE_FAILURE_STATUSES = [
|
export const AFFILIATE_FAILURE_STATUSES = [
|
||||||
|
'failed',
|
||||||
'closed',
|
'closed',
|
||||||
'expired',
|
'expired',
|
||||||
'manual_review',
|
'manual_review',
|
||||||
@@ -55,6 +56,7 @@ export async function runAffiliateFallback(
|
|||||||
WHERE oi.fulfillment_status = 'assigned'
|
WHERE oi.fulfillment_status = 'assigned'
|
||||||
AND oi.fulfillment_channel = 'affiliate_dash'
|
AND oi.fulfillment_channel = 'affiliate_dash'
|
||||||
AND oi.fulfillment_locked = FALSE
|
AND oi.fulfillment_locked = FALSE
|
||||||
|
AND cfg.id IS NOT NULL
|
||||||
AND EXISTS (
|
AND EXISTS (
|
||||||
SELECT 1 FROM internal_sku_channels channel
|
SELECT 1 FROM internal_sku_channels channel
|
||||||
WHERE channel.enabled = TRUE
|
WHERE channel.enabled = TRUE
|
||||||
@@ -99,7 +101,29 @@ export async function runAffiliateFallback(
|
|||||||
channel_switch_reason = 'affiliate_failed',
|
channel_switch_reason = 'affiliate_failed',
|
||||||
channel_switched_at = NOW(),
|
channel_switched_at = NOW(),
|
||||||
fulfillment_config_id = COALESCE(fulfillment_config_id, $2),
|
fulfillment_config_id = COALESCE(fulfillment_config_id, $2),
|
||||||
canonical_product_id = $3,
|
canonical_product_id = COALESCE($3, canonical_product_id),
|
||||||
|
fulfillment_pending_reason = NULL,
|
||||||
|
fulfillment_pending_detail = NULL,
|
||||||
|
fulfillment_decided_at = NOW(),
|
||||||
|
item_snapshot_json = COALESCE(item_snapshot_json, '{}'::jsonb) || jsonb_build_object(
|
||||||
|
'internalSkuChannel', 'manual',
|
||||||
|
'internalSkuChannelSource', 'affiliate_fallback',
|
||||||
|
'internalSkuChannelReason', '自动发货失败,转人工兜底',
|
||||||
|
'isConfigured', TRUE,
|
||||||
|
'affiliateDash', NULL,
|
||||||
|
'fulfillmentRoute', jsonb_build_object(
|
||||||
|
'selectedExecutorKey', 'manual_dispatch',
|
||||||
|
'selectedRuleId', '',
|
||||||
|
'reason', '自动发货失败,转人工兜底',
|
||||||
|
'matchedRule', NULL,
|
||||||
|
'candidates', '[]'::jsonb,
|
||||||
|
'skipped', '[]'::jsonb
|
||||||
|
),
|
||||||
|
'classification', jsonb_build_object(
|
||||||
|
'channel', 'manual',
|
||||||
|
'canonicalSku', resolve_internal_sku(manual_sku_key, sku_name, sku_code)
|
||||||
|
)
|
||||||
|
),
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
AND fulfillment_status = 'assigned'
|
AND fulfillment_status = 'assigned'
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import { INACTIVE_CARRIER_TASK_STATUSES, type FulfillmentChannel } from './chann
|
|||||||
import { applyIntakeChannelDecision } from './intake-channel-decision-service.js'
|
import { applyIntakeChannelDecision } from './intake-channel-decision-service.js'
|
||||||
import type { ResolvedFulfillmentItem } from './product-resolution-service.js'
|
import type { ResolvedFulfillmentItem } from './product-resolution-service.js'
|
||||||
import { canonicalizeInternalSku, resolveInternalSkuKey } from './internal-sku-resolver.js'
|
import { canonicalizeInternalSku, resolveInternalSkuKey } from './internal-sku-resolver.js'
|
||||||
|
import {
|
||||||
|
findConflictingCarrier,
|
||||||
|
loadActiveCarrierEvidenceForItem,
|
||||||
|
} from './carrier-evidence-service.js'
|
||||||
|
|
||||||
function parseObject(value: unknown): Record<string, unknown> {
|
function parseObject(value: unknown): Record<string, unknown> {
|
||||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||||
@@ -77,6 +81,18 @@ export async function ensureCarrierForOrderItem(input: {
|
|||||||
channel: FulfillmentChannel
|
channel: FulfillmentChannel
|
||||||
}): Promise<EnsureCarrierResult> {
|
}): Promise<EnsureCarrierResult> {
|
||||||
const { order, item, channel } = input
|
const { order, item, channel } = input
|
||||||
|
const existingCarrier = findConflictingCarrier(
|
||||||
|
await loadActiveCarrierEvidenceForItem(Number(item.id)),
|
||||||
|
channel,
|
||||||
|
)
|
||||||
|
if (existingCarrier) {
|
||||||
|
return {
|
||||||
|
ready: false,
|
||||||
|
created: false,
|
||||||
|
reason: 'existing_carrier_conflict',
|
||||||
|
message: `订单项已有其它真实载体:${existingCarrier}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (channel === 'manual') {
|
if (channel === 'manual') {
|
||||||
const config = await query<{ id: number; canonical_product_id: number | null }>(
|
const config = await query<{ id: number; canonical_product_id: number | null }>(
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
import { findConflictingCarrier } from './carrier-evidence-service.js'
|
||||||
|
|
||||||
|
const empty = {
|
||||||
|
workOrder: false,
|
||||||
|
affiliateTask: false,
|
||||||
|
manualTask: false,
|
||||||
|
channels: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
test('真实载体冲突:同渠道不算冲突', () => {
|
||||||
|
assert.equal(
|
||||||
|
findConflictingCarrier(
|
||||||
|
{ ...empty, affiliateTask: true, channels: ['affiliate_dash'] },
|
||||||
|
'affiliate_dash',
|
||||||
|
),
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('真实载体冲突:其它渠道算冲突', () => {
|
||||||
|
assert.equal(
|
||||||
|
findConflictingCarrier(
|
||||||
|
{ ...empty, workOrder: true, channels: ['work_order'] },
|
||||||
|
'affiliate_dash',
|
||||||
|
),
|
||||||
|
'work_order',
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { query } from '../../db/client.js'
|
||||||
|
import { INACTIVE_CARRIER_TASK_STATUSES, type FulfillmentChannel } from './channel-types.js'
|
||||||
|
|
||||||
|
export type ActiveCarrierEvidence = {
|
||||||
|
workOrder: boolean
|
||||||
|
affiliateTask: boolean
|
||||||
|
manualTask: boolean
|
||||||
|
channels: FulfillmentChannel[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量读取订单项的真实履约载体证据。
|
||||||
|
* 关闭、失败、过期等任务不算载体,规则与渠道判定管道保持一致。
|
||||||
|
*/
|
||||||
|
export async function loadActiveCarrierEvidence(
|
||||||
|
orderItemIds: number[],
|
||||||
|
): Promise<Map<number, ActiveCarrierEvidence>> {
|
||||||
|
const ids = [...new Set(orderItemIds.map(Number).filter((id) => Number.isInteger(id) && id > 0))]
|
||||||
|
const result = new Map<number, ActiveCarrierEvidence>()
|
||||||
|
if (ids.length === 0) return result
|
||||||
|
|
||||||
|
const rows = await query<{
|
||||||
|
id: number
|
||||||
|
has_work_order: boolean
|
||||||
|
has_affiliate_task: boolean
|
||||||
|
has_manual_task: boolean
|
||||||
|
}>(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
oi.id,
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM work_orders wo
|
||||||
|
WHERE wo.order_item_id = oi.id AND wo.status <> 'cancelled'
|
||||||
|
) AS has_work_order,
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM fulfillment_tasks ft
|
||||||
|
WHERE ft.order_item_id = oi.id
|
||||||
|
AND ft.executor_key = 'affiliate_dash'
|
||||||
|
AND ft.task_status <> ALL($2::text[])
|
||||||
|
) AS has_affiliate_task,
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM fulfillment_tasks ft
|
||||||
|
WHERE ft.order_item_id = oi.id
|
||||||
|
AND ft.executor_key = 'manual_dispatch'
|
||||||
|
AND ft.task_status <> ALL($2::text[])
|
||||||
|
) AS has_manual_task
|
||||||
|
FROM order_items oi
|
||||||
|
WHERE oi.id = ANY($1::bigint[])
|
||||||
|
`,
|
||||||
|
[ids, INACTIVE_CARRIER_TASK_STATUSES],
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const row of rows.rows) {
|
||||||
|
const channels: FulfillmentChannel[] = []
|
||||||
|
if (row.has_work_order) channels.push('work_order')
|
||||||
|
if (row.has_affiliate_task) channels.push('affiliate_dash')
|
||||||
|
if (row.has_manual_task) channels.push('manual')
|
||||||
|
result.set(Number(row.id), {
|
||||||
|
workOrder: row.has_work_order,
|
||||||
|
affiliateTask: row.has_affiliate_task,
|
||||||
|
manualTask: row.has_manual_task,
|
||||||
|
channels,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadActiveCarrierEvidenceForItem(
|
||||||
|
orderItemId: number,
|
||||||
|
): Promise<ActiveCarrierEvidence> {
|
||||||
|
const evidence = await loadActiveCarrierEvidence([orderItemId])
|
||||||
|
return (
|
||||||
|
evidence.get(Number(orderItemId)) || {
|
||||||
|
workOrder: false,
|
||||||
|
affiliateTask: false,
|
||||||
|
manualTask: false,
|
||||||
|
channels: [],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findConflictingCarrier(
|
||||||
|
evidence: ActiveCarrierEvidence | undefined,
|
||||||
|
targetChannel: FulfillmentChannel,
|
||||||
|
): FulfillmentChannel | null {
|
||||||
|
return evidence?.channels.find((channel) => channel !== targetChannel) || null
|
||||||
|
}
|
||||||
@@ -545,9 +545,72 @@ async function assignOrderItemsAfterCarrierReady(input: {
|
|||||||
SELECT count(*)::int AS count FROM updated`,
|
SELECT count(*)::int AS count FROM updated`,
|
||||||
[ids, input.channel, input.source, input.lock],
|
[ids, input.channel, input.source, input.lock],
|
||||||
)
|
)
|
||||||
|
await syncAssignedChannelSnapshots(ids, input.channel, input.source)
|
||||||
return { updated: Number(final.rows[0]?.count || assignedIds.length), carrierFailed }
|
return { updated: Number(final.rows[0]?.count || assignedIds.length), carrierFailed }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function syncAssignedChannelSnapshots(
|
||||||
|
ids: number[],
|
||||||
|
channel: FulfillmentChannel,
|
||||||
|
source: FulfillmentSource,
|
||||||
|
) {
|
||||||
|
if (ids.length === 0) return
|
||||||
|
const configured = channel === 'affiliate_dash' || channel === 'manual'
|
||||||
|
const route =
|
||||||
|
channel === 'manual'
|
||||||
|
? {
|
||||||
|
selectedExecutorKey: 'manual_dispatch',
|
||||||
|
selectedRuleId: '',
|
||||||
|
reason: '运营指定人工客服',
|
||||||
|
matchedRule: null,
|
||||||
|
candidates: [],
|
||||||
|
skipped: [],
|
||||||
|
}
|
||||||
|
: channel === 'affiliate_dash'
|
||||||
|
? {
|
||||||
|
selectedExecutorKey: 'affiliate_dash',
|
||||||
|
selectedRuleId: '',
|
||||||
|
reason: '运营指定自动发货',
|
||||||
|
matchedRule: null,
|
||||||
|
candidates: [],
|
||||||
|
skipped: [],
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
selectedExecutorKey: '',
|
||||||
|
selectedRuleId: '',
|
||||||
|
reason:
|
||||||
|
channel === 'work_order'
|
||||||
|
? '内部 SKU 渠道配置指定接单平台'
|
||||||
|
: '内部 SKU 渠道配置指定第三方卡券',
|
||||||
|
matchedRule: null,
|
||||||
|
candidates: [],
|
||||||
|
skipped: [],
|
||||||
|
}
|
||||||
|
const decision = {
|
||||||
|
internalSkuChannel: channel,
|
||||||
|
internalSkuChannelSource: source,
|
||||||
|
internalSkuChannelReason: '',
|
||||||
|
isConfigured: configured,
|
||||||
|
fulfillmentRoute: route,
|
||||||
|
...(channel !== 'affiliate_dash' ? { affiliateDash: null } : {}),
|
||||||
|
}
|
||||||
|
await query(
|
||||||
|
`UPDATE order_items
|
||||||
|
SET item_snapshot_json = (
|
||||||
|
COALESCE(item_snapshot_json, '{}'::jsonb) || $2::jsonb
|
||||||
|
) || jsonb_build_object(
|
||||||
|
'classification',
|
||||||
|
COALESCE(item_snapshot_json->'classification', '{}'::jsonb)
|
||||||
|
|| jsonb_build_object('channel', $3)
|
||||||
|
),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = ANY($1::bigint[])
|
||||||
|
AND fulfillment_status = 'assigned'
|
||||||
|
AND fulfillment_channel = $3`,
|
||||||
|
[ids, JSON.stringify(decision), channel],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const WORK_ORDER_SWITCH_BLOCKED = new Set([
|
const WORK_ORDER_SWITCH_BLOCKED = new Set([
|
||||||
'open',
|
'open',
|
||||||
'in_progress',
|
'in_progress',
|
||||||
@@ -1131,10 +1194,15 @@ async function resolveInternalSkuCarrierConfig(
|
|||||||
`SELECT id, canonical_product_id
|
`SELECT id, canonical_product_id
|
||||||
FROM manual_fulfillment_configs
|
FROM manual_fulfillment_configs
|
||||||
WHERE canonicalize_internal_sku(sku_key) = $1 AND enabled = TRUE
|
WHERE canonicalize_internal_sku(sku_key) = $1 AND enabled = TRUE
|
||||||
ORDER BY id
|
ORDER BY id`,
|
||||||
LIMIT 1`,
|
|
||||||
[key],
|
[key],
|
||||||
)
|
)
|
||||||
|
if (manual.rows.length > 1) {
|
||||||
|
throw createHttpError(`内部 SKU「${key}」存在多份人工履约配置`, {
|
||||||
|
statusCode: 409,
|
||||||
|
errorCode: 'carrier_config_ambiguous',
|
||||||
|
})
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
canonicalProductId: Number(manual.rows[0]?.canonical_product_id || 0) || null,
|
canonicalProductId: Number(manual.rows[0]?.canonical_product_id || 0) || null,
|
||||||
configJson: manual.rows[0] ? { manualConfigId: Number(manual.rows[0].id) } : {},
|
configJson: manual.rows[0] ? { manualConfigId: Number(manual.rows[0].id) } : {},
|
||||||
@@ -1149,12 +1217,27 @@ async function resolveInternalSkuCarrierConfig(
|
|||||||
WHERE status = 'active'
|
WHERE status = 'active'
|
||||||
AND (canonical_sku LIKE $2 OR canonical_sku LIKE $3)
|
AND (canonical_sku LIKE $2 OR canonical_sku LIKE $3)
|
||||||
AND canonicalize_internal_sku(canonical_name) = $1
|
AND canonicalize_internal_sku(canonical_name) = $1
|
||||||
ORDER BY id
|
ORDER BY id`,
|
||||||
LIMIT 1`,
|
|
||||||
[key, `${prefixes[0]}%`, `${prefixes[1] || prefixes[0]}%`],
|
[key, `${prefixes[0]}%`, `${prefixes[1] || prefixes[0]}%`],
|
||||||
)
|
)
|
||||||
const row = product.rows[0]
|
const row = product.rows[0]
|
||||||
if (!row) return { canonicalProductId: null, configJson: {} }
|
if (!row) return { canonicalProductId: null, configJson: {} }
|
||||||
|
const distinctCarrierKeys = new Set(
|
||||||
|
product.rows.map((candidate) => {
|
||||||
|
const rawSku = String(candidate.canonical_sku)
|
||||||
|
const prefix = prefixes.find((item) => rawSku.startsWith(item)) || prefixes[0]!
|
||||||
|
return rawSku.slice(prefix.length)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if (distinctCarrierKeys.size > 1) {
|
||||||
|
throw createHttpError(
|
||||||
|
`内部 SKU「${key}」在 ${channel} 渠道存在多个载体配置:${[...distinctCarrierKeys].join(' / ')}`,
|
||||||
|
{
|
||||||
|
statusCode: 409,
|
||||||
|
errorCode: 'carrier_config_ambiguous',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
const rawSku = String(row.canonical_sku)
|
const rawSku = String(row.canonical_sku)
|
||||||
const matchedPrefix = prefixes.find((prefix) => rawSku.startsWith(prefix)) || prefixes[0]!
|
const matchedPrefix = prefixes.find((prefix) => rawSku.startsWith(prefix)) || prefixes[0]!
|
||||||
const carrierKey = rawSku.slice(matchedPrefix.length)
|
const carrierKey = rawSku.slice(matchedPrefix.length)
|
||||||
|
|||||||
@@ -28,17 +28,33 @@ test(
|
|||||||
switchChannelForOrderItems,
|
switchChannelForOrderItems,
|
||||||
upsertChannelRule,
|
upsertChannelRule,
|
||||||
} = await import('./channel-assign-service.js')
|
} = await import('./channel-assign-service.js')
|
||||||
|
const { runAffiliateFallback } = await import('./affiliate-fallback-service.js')
|
||||||
|
const {
|
||||||
|
deleteInternalSkuMapping,
|
||||||
|
listInternalSkuMappingsPage,
|
||||||
|
resolveInternalSkuMapping,
|
||||||
|
upsertInternalSkuMapping,
|
||||||
|
} = await import('./internal-sku-mapping-service.js')
|
||||||
const suffix = `${Date.now()}_${Math.floor(Math.random() * 1_000_000)}`
|
const suffix = `${Date.now()}_${Math.floor(Math.random() * 1_000_000)}`
|
||||||
const shopId = `it-routing-${suffix}`
|
const shopId = `it-routing-${suffix}`
|
||||||
const manualSku = `集成测试人工-${suffix}`
|
const manualSku = `集成测试人工-${suffix}`
|
||||||
const missingAffiliateSku = `集成测试自动-${suffix}`
|
const missingAffiliateSku = `集成测试自动-${suffix}`
|
||||||
const voucherSku = `集成测试卡券-${suffix}`
|
const voucherSku = `集成测试卡券-${suffix}`
|
||||||
const triggerSku = `集成测试规范-${suffix}`
|
const triggerSku = `集成测试规范-${suffix}`
|
||||||
|
const fallbackSku = `集成测试兜底-${suffix}`
|
||||||
const orderIds: number[] = []
|
const orderIds: number[] = []
|
||||||
const orderItemIds: number[] = []
|
const orderItemIds: number[] = []
|
||||||
const crossShopSku = `集成测试跨店-${suffix}`
|
const crossShopSku = `集成测试跨店-${suffix}`
|
||||||
const internalSkus = [manualSku, missingAffiliateSku, voucherSku, crossShopSku, triggerSku]
|
const internalSkus = [
|
||||||
|
manualSku,
|
||||||
|
missingAffiliateSku,
|
||||||
|
voucherSku,
|
||||||
|
crossShopSku,
|
||||||
|
triggerSku,
|
||||||
|
fallbackSku,
|
||||||
|
]
|
||||||
let generatedVoucherProductId: number | null = null
|
let generatedVoucherProductId: number | null = null
|
||||||
|
let fallbackProfileId: number | null = null
|
||||||
|
|
||||||
async function createPendingOrderItem(
|
async function createPendingOrderItem(
|
||||||
sku: string,
|
sku: string,
|
||||||
@@ -75,13 +91,16 @@ test(
|
|||||||
const canonicalResolution = await query<{
|
const canonicalResolution = await query<{
|
||||||
internal_sku: string | null
|
internal_sku: string | null
|
||||||
platform_only: string | null
|
platform_only: string | null
|
||||||
|
cjk_extension: string | null
|
||||||
}>(
|
}>(
|
||||||
`SELECT
|
`SELECT
|
||||||
resolve_internal_sku('platform-code', ' 套装-浪漫 天命 ', 'platform-code') AS internal_sku,
|
resolve_internal_sku('platform-code', ' 套装-浪漫 天命 ', 'platform-code') AS internal_sku,
|
||||||
resolve_internal_sku('platform-code', 'Platform Title', 'platform-code') AS platform_only`,
|
resolve_internal_sku('platform-code', 'Platform Title', 'platform-code') AS platform_only,
|
||||||
|
resolve_internal_sku('', '𠀀', '') AS cjk_extension`,
|
||||||
)
|
)
|
||||||
assert.equal(canonicalResolution.rows[0]?.internal_sku, '套装-浪漫天命')
|
assert.equal(canonicalResolution.rows[0]?.internal_sku, '套装-浪漫天命')
|
||||||
assert.equal(canonicalResolution.rows[0]?.platform_only, null)
|
assert.equal(canonicalResolution.rows[0]?.platform_only, null)
|
||||||
|
assert.equal(canonicalResolution.rows[0]?.cjk_extension, '𠀀')
|
||||||
|
|
||||||
await query(
|
await query(
|
||||||
`INSERT INTO internal_sku_channels (internal_sku, channel, priority, is_fallback, enabled, source)
|
`INSERT INTO internal_sku_channels (internal_sku, channel, priority, is_fallback, enabled, source)
|
||||||
@@ -93,6 +112,32 @@ test(
|
|||||||
)
|
)
|
||||||
assert.equal(normalizedInsert.rows[0]?.internal_sku, triggerSku)
|
assert.equal(normalizedInsert.rows[0]?.internal_sku, triggerSku)
|
||||||
|
|
||||||
|
const mappingValue = `MAP-${suffix}`
|
||||||
|
const mappingSku = `集成映射-${suffix}`
|
||||||
|
const mappingId = await upsertInternalSkuMapping({
|
||||||
|
provider: '91kaquan',
|
||||||
|
shopId,
|
||||||
|
matchType: 'sku_code',
|
||||||
|
matchValue: ` ${mappingValue} `,
|
||||||
|
internalSku: mappingSku,
|
||||||
|
priority: 10,
|
||||||
|
source: 'integration_test',
|
||||||
|
note: '集成测试映射',
|
||||||
|
})
|
||||||
|
const resolvedMapping = await resolveInternalSkuMapping({
|
||||||
|
provider: '91kaquan',
|
||||||
|
shopId,
|
||||||
|
candidates: [{ matchType: 'sku_code', value: mappingValue }],
|
||||||
|
})
|
||||||
|
assert.equal(resolvedMapping?.internalSku, mappingSku)
|
||||||
|
const mappingPage = await listInternalSkuMappingsPage({
|
||||||
|
keyword: mappingSku,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
})
|
||||||
|
assert.equal(mappingPage.total, 1)
|
||||||
|
assert.equal(await deleteInternalSkuMapping(mappingId), true)
|
||||||
|
|
||||||
const manualItemId = await createPendingOrderItem(manualSku)
|
const manualItemId = await createPendingOrderItem(manualSku)
|
||||||
await query(
|
await query(
|
||||||
`INSERT INTO manual_fulfillment_configs (
|
`INSERT INTO manual_fulfillment_configs (
|
||||||
@@ -136,9 +181,10 @@ test(
|
|||||||
original_channel: string | null
|
original_channel: string | null
|
||||||
canonical_product_id: number | null
|
canonical_product_id: number | null
|
||||||
canonical_sku: string | null
|
canonical_sku: string | null
|
||||||
|
item_snapshot_json: Record<string, unknown>
|
||||||
}>(
|
}>(
|
||||||
`SELECT oi.fulfillment_status, oi.fulfillment_channel, oi.original_channel,
|
`SELECT oi.fulfillment_status, oi.fulfillment_channel, oi.original_channel,
|
||||||
oi.canonical_product_id, product.canonical_sku
|
oi.canonical_product_id, product.canonical_sku, oi.item_snapshot_json
|
||||||
FROM order_items oi
|
FROM order_items oi
|
||||||
LEFT JOIN product_catalog product ON product.id = oi.canonical_product_id
|
LEFT JOIN product_catalog product ON product.id = oi.canonical_product_id
|
||||||
WHERE oi.id = $1`,
|
WHERE oi.id = $1`,
|
||||||
@@ -148,6 +194,11 @@ test(
|
|||||||
assert.equal(switchedItem.rows[0]?.fulfillment_channel, 'third_party_voucher')
|
assert.equal(switchedItem.rows[0]?.fulfillment_channel, 'third_party_voucher')
|
||||||
assert.equal(switchedItem.rows[0]?.original_channel, 'manual')
|
assert.equal(switchedItem.rows[0]?.original_channel, 'manual')
|
||||||
assert.ok(switchedItem.rows[0]?.canonical_sku?.startsWith('tpv:'))
|
assert.ok(switchedItem.rows[0]?.canonical_sku?.startsWith('tpv:'))
|
||||||
|
assert.equal(
|
||||||
|
switchedItem.rows[0]?.item_snapshot_json.internalSkuChannel,
|
||||||
|
'third_party_voucher',
|
||||||
|
)
|
||||||
|
assert.equal(switchedItem.rows[0]?.item_snapshot_json.isConfigured, false)
|
||||||
generatedVoucherProductId = Number(switchedItem.rows[0]?.canonical_product_id || 0) || null
|
generatedVoucherProductId = Number(switchedItem.rows[0]?.canonical_product_id || 0) || null
|
||||||
|
|
||||||
const missingAffiliateItemId = await createPendingOrderItem(missingAffiliateSku)
|
const missingAffiliateItemId = await createPendingOrderItem(missingAffiliateSku)
|
||||||
@@ -191,6 +242,104 @@ test(
|
|||||||
assert.deepEqual(pendingGroups.items[0]?.providers.sort(), ['91kaquan', 'pdd'])
|
assert.deepEqual(pendingGroups.items[0]?.providers.sort(), ['91kaquan', 'pdd'])
|
||||||
assert.deepEqual(pendingGroups.items[0]?.shop_ids.sort(), [crossShopA, crossShopB])
|
assert.deepEqual(pendingGroups.items[0]?.shop_ids.sort(), [crossShopA, crossShopB])
|
||||||
|
|
||||||
|
const fallbackItemId = await createPendingOrderItem(fallbackSku)
|
||||||
|
await query(
|
||||||
|
`INSERT INTO internal_sku_channels (
|
||||||
|
internal_sku, channel, priority, is_fallback, enabled, config_json, source
|
||||||
|
) VALUES
|
||||||
|
($1, 'affiliate_dash', 10, FALSE, TRUE, '{}'::jsonb, 'integration_test'),
|
||||||
|
($1, 'manual', 90, TRUE, TRUE, '{}'::jsonb, 'integration_test')`,
|
||||||
|
[fallbackSku],
|
||||||
|
)
|
||||||
|
const fallbackItem = await query<{ order_id: number }>(
|
||||||
|
`UPDATE order_items
|
||||||
|
SET fulfillment_status = 'assigned', fulfillment_channel = 'affiliate_dash',
|
||||||
|
fulfillment_source = 'operator_assigned', fulfillment_locked = FALSE,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING order_id`,
|
||||||
|
[fallbackItemId],
|
||||||
|
)
|
||||||
|
const fallbackOrderId = Number(fallbackItem.rows[0]!.order_id)
|
||||||
|
const fallbackProfile = await query<{ id: number }>(
|
||||||
|
`INSERT INTO fulfillment_profiles (
|
||||||
|
profile_key, name, executor_key, requires_claim, auto_dispatch,
|
||||||
|
inventory_strategy, config_json, created_at, updated_at
|
||||||
|
) VALUES ($1, '集成测试自动发货', 'affiliate_dash', TRUE, FALSE,
|
||||||
|
'static_pool', '{}'::jsonb, NOW(), NOW())
|
||||||
|
RETURNING id`,
|
||||||
|
[`it-affiliate-${suffix}`],
|
||||||
|
)
|
||||||
|
fallbackProfileId = Number(fallbackProfile.rows[0]!.id)
|
||||||
|
const fallbackOrder = await query<{
|
||||||
|
provider: string
|
||||||
|
platform: string
|
||||||
|
shop_id: string
|
||||||
|
shop_name: string
|
||||||
|
platform_order_id: string
|
||||||
|
}>(
|
||||||
|
`SELECT provider, platform, shop_id, shop_name, platform_order_id
|
||||||
|
FROM orders WHERE id = $1`,
|
||||||
|
[fallbackOrderId],
|
||||||
|
)
|
||||||
|
const orderRow = fallbackOrder.rows[0]!
|
||||||
|
await query(
|
||||||
|
`INSERT INTO fulfillment_tasks (
|
||||||
|
order_id, order_item_id, unit_index, task_no, provider, platform,
|
||||||
|
shop_id, shop_name, platform_order_id, profile_id, executor_key,
|
||||||
|
task_status, created_at, updated_at
|
||||||
|
) VALUES ($1, $2, 1, $3, $4, $5, $6, $7, $8, $9, 'affiliate_dash', 'failed', NOW(), NOW())`,
|
||||||
|
[
|
||||||
|
fallbackOrderId,
|
||||||
|
fallbackItemId,
|
||||||
|
`IT-AFF-FAIL-${suffix}`,
|
||||||
|
orderRow.provider,
|
||||||
|
orderRow.platform,
|
||||||
|
orderRow.shop_id,
|
||||||
|
orderRow.shop_name,
|
||||||
|
orderRow.platform_order_id,
|
||||||
|
fallbackProfileId,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
const fallbackWithoutConfig = await runAffiliateFallback({ limit: 50 })
|
||||||
|
assert.equal(fallbackWithoutConfig.switched, 0)
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
await query<{ fulfillment_channel: string }>(
|
||||||
|
`SELECT fulfillment_channel FROM order_items WHERE id = $1`,
|
||||||
|
[fallbackItemId],
|
||||||
|
)
|
||||||
|
).rows[0]?.fulfillment_channel,
|
||||||
|
'affiliate_dash',
|
||||||
|
)
|
||||||
|
|
||||||
|
const manualFallbackConfig = await query<{ id: number }>(
|
||||||
|
`INSERT INTO manual_fulfillment_configs (
|
||||||
|
sku_key, platform_key, credit_quantity, cost_mode, enabled, created_at, updated_at
|
||||||
|
) VALUES ($1, 'nanhang', 0, 'variable', TRUE, NOW(), NOW())
|
||||||
|
RETURNING id`,
|
||||||
|
[fallbackSku],
|
||||||
|
)
|
||||||
|
const fallbackWithConfig = await runAffiliateFallback({ limit: 50 })
|
||||||
|
assert.equal(fallbackWithConfig.switched, 1)
|
||||||
|
const fallbackAssigned = await query<{
|
||||||
|
fulfillment_channel: string
|
||||||
|
fulfillment_config_id: number | null
|
||||||
|
item_snapshot_json: Record<string, unknown>
|
||||||
|
}>(
|
||||||
|
`SELECT fulfillment_channel, fulfillment_config_id, item_snapshot_json
|
||||||
|
FROM order_items WHERE id = $1`,
|
||||||
|
[fallbackItemId],
|
||||||
|
)
|
||||||
|
assert.equal(fallbackAssigned.rows[0]?.fulfillment_channel, 'manual')
|
||||||
|
assert.equal(fallbackAssigned.rows[0]?.item_snapshot_json.internalSkuChannel, 'manual')
|
||||||
|
assert.equal(fallbackAssigned.rows[0]?.item_snapshot_json.isConfigured, true)
|
||||||
|
assert.equal(
|
||||||
|
Number(fallbackAssigned.rows[0]?.fulfillment_config_id),
|
||||||
|
Number(manualFallbackConfig.rows[0]!.id),
|
||||||
|
)
|
||||||
|
|
||||||
await ensureVoucherRuleForSku(voucherSku)
|
await ensureVoucherRuleForSku(voucherSku)
|
||||||
const voucherChannel = await query<{ channel: string; source: string }>(
|
const voucherChannel = await query<{ channel: string; source: string }>(
|
||||||
`SELECT channel, source FROM internal_sku_channels
|
`SELECT channel, source FROM internal_sku_channels
|
||||||
@@ -244,7 +393,13 @@ test(
|
|||||||
await query(`DELETE FROM orders WHERE id = ANY($1::bigint[])`, [orderIds])
|
await query(`DELETE FROM orders WHERE id = ANY($1::bigint[])`, [orderIds])
|
||||||
}
|
}
|
||||||
await query(`DELETE FROM cost_exceptions WHERE scope_key LIKE $1`, [`%${suffix}%`])
|
await query(`DELETE FROM cost_exceptions WHERE scope_key LIKE $1`, [`%${suffix}%`])
|
||||||
await query(`DELETE FROM manual_fulfillment_configs WHERE sku_key = $1`, [manualSku])
|
await query(`DELETE FROM manual_fulfillment_configs WHERE sku_key = ANY($1::text[])`, [
|
||||||
|
internalSkus,
|
||||||
|
])
|
||||||
|
await query(
|
||||||
|
`DELETE FROM internal_sku_mappings WHERE provider = '91kaquan' AND shop_id = $1`,
|
||||||
|
[shopId],
|
||||||
|
)
|
||||||
await query(`DELETE FROM internal_sku_channels WHERE internal_sku = ANY($1::text[])`, [
|
await query(`DELETE FROM internal_sku_channels WHERE internal_sku = ANY($1::text[])`, [
|
||||||
internalSkus,
|
internalSkus,
|
||||||
])
|
])
|
||||||
@@ -271,6 +426,9 @@ test(
|
|||||||
`tpv:${manualSku}`,
|
`tpv:${manualSku}`,
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
if (fallbackProfileId) {
|
||||||
|
await query(`DELETE FROM fulfillment_profiles WHERE id = $1`, [fallbackProfileId])
|
||||||
|
}
|
||||||
await closeDb()
|
await closeDb()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { query, withTransaction } from '../../db/client.js'
|
import { query, withTransaction } from '../../db/client.js'
|
||||||
import { canonicalizeInternalSku, containsCjk } from './internal-sku-resolver.js'
|
import { canonicalizeInternalSku, containsCjk } from './internal-sku-resolver.js'
|
||||||
|
import { createHttpError } from '../../utils/http.js'
|
||||||
|
|
||||||
export type InternalSkuMappingMatchType =
|
export type InternalSkuMappingMatchType =
|
||||||
| 'sku_code'
|
| 'sku_code'
|
||||||
@@ -13,12 +14,34 @@ export type InternalSkuMappingCandidate = {
|
|||||||
value: unknown
|
value: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const INTERNAL_SKU_MAPPING_MATCH_TYPES = new Set<InternalSkuMappingMatchType>([
|
||||||
|
'sku_code',
|
||||||
|
'sku_nick',
|
||||||
|
'out_sku_sn',
|
||||||
|
'spec',
|
||||||
|
'sku_name',
|
||||||
|
])
|
||||||
|
|
||||||
export type ResolvedInternalSkuMapping = {
|
export type ResolvedInternalSkuMapping = {
|
||||||
id: number
|
id: number
|
||||||
internalSku: string
|
internalSku: string
|
||||||
source: string
|
source: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type InternalSkuMappingRow = {
|
||||||
|
id: number
|
||||||
|
provider: string
|
||||||
|
shop_id: string
|
||||||
|
match_type: InternalSkuMappingMatchType
|
||||||
|
match_value: string
|
||||||
|
internal_sku: string
|
||||||
|
priority: number
|
||||||
|
enabled: boolean
|
||||||
|
source: string
|
||||||
|
note: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizeInternalSkuMappingValue(value: unknown): string {
|
export function normalizeInternalSkuMappingValue(value: unknown): string {
|
||||||
return canonicalizeInternalSku(value)
|
return canonicalizeInternalSku(value)
|
||||||
}
|
}
|
||||||
@@ -83,14 +106,28 @@ export async function upsertInternalSkuMapping(input: {
|
|||||||
priority?: number
|
priority?: number
|
||||||
source?: string
|
source?: string
|
||||||
note?: string
|
note?: string
|
||||||
|
enabled?: boolean
|
||||||
}): Promise<number> {
|
}): Promise<number> {
|
||||||
const provider = String(input.provider || '').trim()
|
const provider = String(input.provider || '').trim()
|
||||||
const shopId = String(input.shopId || '').trim()
|
const shopId = String(input.shopId || '').trim()
|
||||||
|
if (!INTERNAL_SKU_MAPPING_MATCH_TYPES.has(input.matchType)) {
|
||||||
|
throw createHttpError('映射类型无效', {
|
||||||
|
statusCode: 400,
|
||||||
|
errorCode: 'internal_sku_mapping_type_invalid',
|
||||||
|
})
|
||||||
|
}
|
||||||
const matchValue = normalizeInternalSkuMappingValue(input.matchValue)
|
const matchValue = normalizeInternalSkuMappingValue(input.matchValue)
|
||||||
const internalSku = canonicalizeInternalSku(input.internalSku)
|
const internalSku = canonicalizeInternalSku(input.internalSku)
|
||||||
const priority = Number.isFinite(Number(input.priority)) ? Number(input.priority) : 100
|
const priority = Number.isFinite(Number(input.priority)) ? Number(input.priority) : 100
|
||||||
const source = String(input.source || 'manual').trim() || 'manual'
|
const source = String(input.source || 'manual').trim() || 'manual'
|
||||||
const note = String(input.note || '').trim()
|
const note = String(input.note || '').trim()
|
||||||
|
const enabled = input.enabled !== false
|
||||||
|
if (!matchValue || !internalSku || !containsCjk(internalSku)) {
|
||||||
|
throw createHttpError('内部 SKU 和匹配值不能为空,且内部 SKU 必须包含中文', {
|
||||||
|
statusCode: 400,
|
||||||
|
errorCode: 'internal_sku_mapping_invalid',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return withTransaction(async (client) => {
|
return withTransaction(async (client) => {
|
||||||
const existing = await client.query<{ id: number }>(
|
const existing = await client.query<{ id: number }>(
|
||||||
@@ -98,32 +135,114 @@ export async function upsertInternalSkuMapping(input: {
|
|||||||
WHERE provider = $1 AND shop_id = $2 AND match_type = $3
|
WHERE provider = $1 AND shop_id = $2 AND match_type = $3
|
||||||
AND canonicalize_internal_sku(match_value) = $4
|
AND canonicalize_internal_sku(match_value) = $4
|
||||||
ORDER BY id
|
ORDER BY id
|
||||||
LIMIT 1
|
|
||||||
FOR UPDATE`,
|
FOR UPDATE`,
|
||||||
[provider, shopId, input.matchType, matchValue],
|
[provider, shopId, input.matchType, matchValue],
|
||||||
)
|
)
|
||||||
|
if (existing.rows.length > 1) {
|
||||||
|
throw createHttpError('同一平台/店铺/字段存在多条规范化重复映射', {
|
||||||
|
statusCode: 409,
|
||||||
|
errorCode: 'internal_sku_mapping_ambiguous',
|
||||||
|
})
|
||||||
|
}
|
||||||
const existingId = Number(existing.rows[0]?.id || 0)
|
const existingId = Number(existing.rows[0]?.id || 0)
|
||||||
const result = existingId
|
const result = existingId
|
||||||
? await client.query<{ id: number }>(
|
? await client.query<{ id: number }>(
|
||||||
`UPDATE internal_sku_mappings
|
`UPDATE internal_sku_mappings
|
||||||
SET match_value = $2, internal_sku = $3, priority = $4,
|
SET match_value = $2, internal_sku = $3, priority = $4,
|
||||||
enabled = TRUE, source = $5, note = $6, updated_at = NOW()
|
enabled = $5, source = $6, note = $7, updated_at = NOW()
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING id`,
|
RETURNING id`,
|
||||||
[existingId, matchValue, internalSku, priority, source, note],
|
[existingId, matchValue, internalSku, priority, enabled, source, note],
|
||||||
)
|
)
|
||||||
: await client.query<{ id: number }>(
|
: await client.query<{ id: number }>(
|
||||||
`INSERT INTO internal_sku_mappings (
|
`INSERT INTO internal_sku_mappings (
|
||||||
provider, shop_id, match_type, match_value, internal_sku,
|
provider, shop_id, match_type, match_value, internal_sku,
|
||||||
priority, enabled, source, note, created_at, updated_at
|
priority, enabled, source, note, created_at, updated_at
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, TRUE, $7, $8, NOW(), NOW())
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), NOW())
|
||||||
RETURNING id`,
|
RETURNING id`,
|
||||||
[provider, shopId, input.matchType, matchValue, internalSku, priority, source, note],
|
[
|
||||||
|
provider,
|
||||||
|
shopId,
|
||||||
|
input.matchType,
|
||||||
|
matchValue,
|
||||||
|
internalSku,
|
||||||
|
priority,
|
||||||
|
enabled,
|
||||||
|
source,
|
||||||
|
note,
|
||||||
|
],
|
||||||
)
|
)
|
||||||
return Number(result.rows[0]?.id || 0)
|
return Number(result.rows[0]?.id || 0)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listInternalSkuMappingsPage(input: {
|
||||||
|
keyword?: string
|
||||||
|
provider?: string
|
||||||
|
shopId?: string
|
||||||
|
matchType?: string
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
}): Promise<{ items: InternalSkuMappingRow[]; total: number; page: number; pageSize: number }> {
|
||||||
|
const page = Math.max(1, Math.trunc(Number(input.page) || 1))
|
||||||
|
const pageSize = Math.min(200, Math.max(1, Math.trunc(Number(input.pageSize) || 30)))
|
||||||
|
const params: unknown[] = []
|
||||||
|
const clauses: string[] = []
|
||||||
|
const keyword = String(input.keyword || '').trim()
|
||||||
|
if (keyword) {
|
||||||
|
params.push(`%${keyword}%`)
|
||||||
|
clauses.push(
|
||||||
|
`(match_value ILIKE $${params.length} OR internal_sku ILIKE $${params.length} OR note ILIKE $${params.length})`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (input.provider) {
|
||||||
|
params.push(String(input.provider).trim())
|
||||||
|
clauses.push(`provider = $${params.length}`)
|
||||||
|
}
|
||||||
|
if (input.shopId) {
|
||||||
|
params.push(String(input.shopId).trim())
|
||||||
|
clauses.push(`shop_id = $${params.length}`)
|
||||||
|
}
|
||||||
|
if (input.matchType) {
|
||||||
|
params.push(String(input.matchType).trim())
|
||||||
|
clauses.push(`match_type = $${params.length}`)
|
||||||
|
}
|
||||||
|
const where = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : ''
|
||||||
|
const pageParams = [...params, pageSize, (page - 1) * pageSize]
|
||||||
|
const [countResult, rows] = await Promise.all([
|
||||||
|
query<{ total: number }>(
|
||||||
|
`SELECT COUNT(*)::int AS total FROM internal_sku_mappings ${where}`,
|
||||||
|
params,
|
||||||
|
),
|
||||||
|
query<InternalSkuMappingRow>(
|
||||||
|
`SELECT id, provider, shop_id, match_type, match_value, internal_sku,
|
||||||
|
priority, enabled, source, note, updated_at
|
||||||
|
FROM internal_sku_mappings
|
||||||
|
${where}
|
||||||
|
ORDER BY enabled DESC, priority ASC, id DESC
|
||||||
|
LIMIT $${pageParams.length - 1} OFFSET $${pageParams.length}`,
|
||||||
|
pageParams,
|
||||||
|
),
|
||||||
|
])
|
||||||
|
return {
|
||||||
|
items: rows.rows.map((row) => ({
|
||||||
|
...row,
|
||||||
|
id: Number(row.id),
|
||||||
|
priority: Number(row.priority),
|
||||||
|
})),
|
||||||
|
total: Number(countResult.rows[0]?.total || 0),
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteInternalSkuMapping(mappingId: number): Promise<boolean> {
|
||||||
|
const id = Math.trunc(Number(mappingId) || 0)
|
||||||
|
if (id <= 0) return false
|
||||||
|
const result = await query(`DELETE FROM internal_sku_mappings WHERE id = $1`, [id])
|
||||||
|
return (result.rowCount || 0) > 0
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 订单项级内部 SKU 补全:
|
* 订单项级内部 SKU 补全:
|
||||||
* - 现有 skuNick/outSkuSn/skuCode/skuName 已有中文 → 不查库;
|
* - 现有 skuNick/outSkuSn/skuCode/skuName 已有中文 → 不查库;
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ test('syncDeliveryTasksForOrder 为 affiliate_dash 商品创建任务上下文',
|
|||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
{
|
{
|
||||||
|
loadActiveCarrierEvidence: async () => new Map(),
|
||||||
listTasksByOrderId: async () => [],
|
listTasksByOrderId: async () => [],
|
||||||
getFulfillmentProfileByKey: async () => buildAffiliateProfile(),
|
getFulfillmentProfileByKey: async () => buildAffiliateProfile(),
|
||||||
createTask: async (input) => {
|
createTask: async (input) => {
|
||||||
@@ -167,6 +168,7 @@ test('syncDeliveryTasksForOrder 已有部分任务时按订单项补建缺失任
|
|||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
{
|
{
|
||||||
|
loadActiveCarrierEvidence: async () => new Map(),
|
||||||
listTasksByOrderId: async () => [existingTask],
|
listTasksByOrderId: async () => [existingTask],
|
||||||
getFulfillmentProfileByKey: async () => buildAffiliateProfile(),
|
getFulfillmentProfileByKey: async () => buildAffiliateProfile(),
|
||||||
createTask: async (input) => {
|
createTask: async (input) => {
|
||||||
@@ -216,6 +218,7 @@ test('syncDeliveryTasksForOrder 数量增加时只补建缺失单元', async ()
|
|||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
{
|
{
|
||||||
|
loadActiveCarrierEvidence: async () => new Map(),
|
||||||
listTasksByOrderId: async () => [existingTask],
|
listTasksByOrderId: async () => [existingTask],
|
||||||
getFulfillmentProfileByKey: async () => buildAffiliateProfile(),
|
getFulfillmentProfileByKey: async () => buildAffiliateProfile(),
|
||||||
createTask: async (input) => {
|
createTask: async (input) => {
|
||||||
@@ -263,6 +266,7 @@ test('syncDeliveryTasksForOrder 规划失败时记录可定位的路由诊断',
|
|||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
{
|
{
|
||||||
|
loadActiveCarrierEvidence: async () => new Map(),
|
||||||
listTasksByOrderId: async () => [],
|
listTasksByOrderId: async () => [],
|
||||||
getFulfillmentProfileByKey: async () => buildAffiliateProfile(),
|
getFulfillmentProfileByKey: async () => buildAffiliateProfile(),
|
||||||
createTaskEvent: async () => null,
|
createTaskEvent: async () => null,
|
||||||
@@ -318,6 +322,7 @@ test('syncDeliveryTasksForOrder 并发创建冲突时复用已落库任务', asy
|
|||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
{
|
{
|
||||||
|
loadActiveCarrierEvidence: async () => new Map(),
|
||||||
listTasksByOrderId: async () => (listCount++ === 0 ? [] : [concurrentTask]),
|
listTasksByOrderId: async () => (listCount++ === 0 ? [] : [concurrentTask]),
|
||||||
getFulfillmentProfileByKey: async () => buildAffiliateProfile(),
|
getFulfillmentProfileByKey: async () => buildAffiliateProfile(),
|
||||||
createTask: async () => {
|
createTask: async () => {
|
||||||
@@ -341,6 +346,77 @@ test('syncDeliveryTasksForOrder 并发创建冲突时复用已落库任务', asy
|
|||||||
assert.equal(eventCount, 0)
|
assert.equal(eventCount, 0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('来源重推时已分配其它渠道的订单不会补建 affiliate 任务', async () => {
|
||||||
|
let createdCount = 0
|
||||||
|
const tasks = await syncDeliveryTasksForOrderWithDeps(
|
||||||
|
stubOrder({ id: 7, platform_order_id: 'assigned-other-channel' }),
|
||||||
|
[
|
||||||
|
stubOrderItem({
|
||||||
|
id: 60,
|
||||||
|
order_id: 7,
|
||||||
|
sku_code: 'SKU-assigned',
|
||||||
|
fulfillment_status: 'assigned',
|
||||||
|
fulfillment_channel: 'work_order',
|
||||||
|
item_snapshot_json: buildAffiliateSnapshot('AFF-A', '商品 A'),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
{
|
||||||
|
loadActiveCarrierEvidence: async () => new Map(),
|
||||||
|
listTasksByOrderId: async () => [],
|
||||||
|
getFulfillmentProfileByKey: async () => buildAffiliateProfile(),
|
||||||
|
createTask: async (input) => {
|
||||||
|
createdCount += 1
|
||||||
|
return stubCreatedTask(501, input as CreatedTaskInput)
|
||||||
|
},
|
||||||
|
logInfo: () => null,
|
||||||
|
logWarn: () => null,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.equal(createdCount, 0)
|
||||||
|
assert.deepEqual(tasks, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('来源重推时已有其它真实载体的订单不会补建 affiliate 任务', async () => {
|
||||||
|
let createdCount = 0
|
||||||
|
const tasks = await syncDeliveryTasksForOrderWithDeps(
|
||||||
|
stubOrder({ id: 8, platform_order_id: 'existing-carrier' }),
|
||||||
|
[
|
||||||
|
stubOrderItem({
|
||||||
|
id: 70,
|
||||||
|
order_id: 8,
|
||||||
|
sku_code: 'SKU-existing-carrier',
|
||||||
|
item_snapshot_json: buildAffiliateSnapshot('AFF-A', '商品 A'),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
{
|
||||||
|
loadActiveCarrierEvidence: async () =>
|
||||||
|
new Map([
|
||||||
|
[
|
||||||
|
70,
|
||||||
|
{
|
||||||
|
workOrder: true,
|
||||||
|
affiliateTask: false,
|
||||||
|
manualTask: false,
|
||||||
|
channels: ['work_order'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
listTasksByOrderId: async () => [],
|
||||||
|
getFulfillmentProfileByKey: async () => buildAffiliateProfile(),
|
||||||
|
createTask: async (input) => {
|
||||||
|
createdCount += 1
|
||||||
|
return stubCreatedTask(502, input as CreatedTaskInput)
|
||||||
|
},
|
||||||
|
logInfo: () => null,
|
||||||
|
logWarn: () => null,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.equal(createdCount, 0)
|
||||||
|
assert.deepEqual(tasks, [])
|
||||||
|
})
|
||||||
|
|
||||||
function buildAffiliateSnapshot(sku: string, productName: string): JsonObject {
|
function buildAffiliateSnapshot(sku: string, productName: string): JsonObject {
|
||||||
return {
|
return {
|
||||||
isConfigured: true,
|
isConfigured: true,
|
||||||
|
|||||||
@@ -8,13 +8,21 @@ import {
|
|||||||
type FulfillmentBindingLike,
|
type FulfillmentBindingLike,
|
||||||
} from '../fulfillment/planner.js'
|
} from '../fulfillment/planner.js'
|
||||||
import { preparePaidFulfillmentTask } from '../fulfillment/executors/registry.js'
|
import { preparePaidFulfillmentTask } from '../fulfillment/executors/registry.js'
|
||||||
import type { FulfillmentPrepareDeps } from '../fulfillment/executors/types.js'
|
import {
|
||||||
|
FULFILLMENT_EXECUTOR_KEYS,
|
||||||
|
type FulfillmentPrepareDeps,
|
||||||
|
} from '../fulfillment/executors/types.js'
|
||||||
import { nowIso } from '../../utils/time.js'
|
import { nowIso } from '../../utils/time.js'
|
||||||
import { randomId } from '../../utils/random.js'
|
import { randomId } from '../../utils/random.js'
|
||||||
import { logInfo, logWarn } from '../../utils/logger.js'
|
import { logInfo, logWarn } from '../../utils/logger.js'
|
||||||
import { TASK_STATUS, resolveInitialPaidTaskStatus } from '../../domain/task-status.js'
|
import { TASK_STATUS, resolveInitialPaidTaskStatus } from '../../domain/task-status.js'
|
||||||
import type { JsonObject } from '../../types/json.js'
|
import type { JsonObject } from '../../types/json.js'
|
||||||
import type { OrderItemRow, OrderRow, TaskRow } from '../../types/repository/rows.js'
|
import type { OrderItemRow, OrderRow, TaskRow } from '../../types/repository/rows.js'
|
||||||
|
import {
|
||||||
|
findConflictingCarrier,
|
||||||
|
loadActiveCarrierEvidence,
|
||||||
|
} from '../fulfillment/carrier-evidence-service.js'
|
||||||
|
import type { FulfillmentChannel } from '../fulfillment/channel-types.js'
|
||||||
|
|
||||||
type ClaimTokenLike = {
|
type ClaimTokenLike = {
|
||||||
token: string
|
token: string
|
||||||
@@ -30,6 +38,7 @@ type DeliveryTaskRow = TaskRow & {
|
|||||||
type DeliveryTaskDeps = {
|
type DeliveryTaskDeps = {
|
||||||
createTask?: typeof createTask
|
createTask?: typeof createTask
|
||||||
listTasksByOrderId?: typeof listTasksByOrderId
|
listTasksByOrderId?: typeof listTasksByOrderId
|
||||||
|
loadActiveCarrierEvidence?: typeof loadActiveCarrierEvidence
|
||||||
updateTask?: typeof updateTask
|
updateTask?: typeof updateTask
|
||||||
getFulfillmentProfileByKey?: (profileKey: string) => Promise<FulfillmentBindingLike | null>
|
getFulfillmentProfileByKey?: (profileKey: string) => Promise<FulfillmentBindingLike | null>
|
||||||
createTaskClaimToken?: (taskId: number | string) => Promise<ClaimTokenLike>
|
createTaskClaimToken?: (taskId: number | string) => Promise<ClaimTokenLike>
|
||||||
@@ -60,6 +69,7 @@ export async function syncDeliveryTasksForOrderWithDeps(
|
|||||||
const {
|
const {
|
||||||
createTask: createDeliveryTask = createTask,
|
createTask: createDeliveryTask = createTask,
|
||||||
listTasksByOrderId: listTasks = listTasksByOrderId,
|
listTasksByOrderId: listTasks = listTasksByOrderId,
|
||||||
|
loadActiveCarrierEvidence: loadCarrierEvidence = loadActiveCarrierEvidence,
|
||||||
updateTask: updateDeliveryTask = updateTask,
|
updateTask: updateDeliveryTask = updateTask,
|
||||||
getFulfillmentProfileByKey: getProfileByKey = getFulfillmentProfileByKey,
|
getFulfillmentProfileByKey: getProfileByKey = getFulfillmentProfileByKey,
|
||||||
createTaskClaimToken: createClaimToken = createTaskClaimToken,
|
createTaskClaimToken: createClaimToken = createTaskClaimToken,
|
||||||
@@ -81,6 +91,7 @@ export async function syncDeliveryTasksForOrderWithDeps(
|
|||||||
const existingTasks = await listTasks(order.id)
|
const existingTasks = await listTasks(order.id)
|
||||||
const tasks: DeliveryTaskRow[] = [...existingTasks]
|
const tasks: DeliveryTaskRow[] = [...existingTasks]
|
||||||
const existingTaskKeys = new Set(existingTasks.map(buildTaskUnitKey))
|
const existingTaskKeys = new Set(existingTasks.map(buildTaskUnitKey))
|
||||||
|
const carrierEvidence = await loadCarrierEvidence(orderItems.map((item) => Number(item.id)))
|
||||||
|
|
||||||
writeInfoLog('[delivery-task-service]', '开始核对订单履约任务完整性', {
|
writeInfoLog('[delivery-task-service]', '开始核对订单履约任务完整性', {
|
||||||
...buildOrderLogContext(order),
|
...buildOrderLogContext(order),
|
||||||
@@ -126,6 +137,32 @@ export async function syncDeliveryTasksForOrderWithDeps(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const plannedChannel = executorKeyToFulfillmentChannel(plan.executorKey)
|
||||||
|
if (plannedChannel) {
|
||||||
|
const assignedChannel = String(item.fulfillment_channel || '').trim()
|
||||||
|
if (item.fulfillment_status === 'assigned' && assignedChannel !== plannedChannel) {
|
||||||
|
writeInfoLog('[delivery-task-service]', '订单项已分配其它渠道,跳过载体补建', {
|
||||||
|
...buildOrderItemLogContext(item),
|
||||||
|
assignedChannel,
|
||||||
|
plannedChannel,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const conflictingCarrier = findConflictingCarrier(
|
||||||
|
carrierEvidence.get(Number(item.id)),
|
||||||
|
plannedChannel,
|
||||||
|
)
|
||||||
|
if (conflictingCarrier) {
|
||||||
|
writeWarnLog('[delivery-task-service]', '订单项已有其它真实载体,跳过载体补建', {
|
||||||
|
...buildOrderItemLogContext(item),
|
||||||
|
existingCarrier: conflictingCarrier,
|
||||||
|
plannedChannel,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const unitIndex of missingUnitIndexes) {
|
for (const unitIndex of missingUnitIndexes) {
|
||||||
const createdAt = getNowIso()
|
const createdAt = getNowIso()
|
||||||
const initialStatus =
|
const initialStatus =
|
||||||
@@ -256,6 +293,12 @@ export async function syncDeliveryTasksForOrderWithDeps(
|
|||||||
return preparedTasks.filter(isTaskRow)
|
return preparedTasks.filter(isTaskRow)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function executorKeyToFulfillmentChannel(executorKey: string): FulfillmentChannel | null {
|
||||||
|
if (executorKey === FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH) return 'affiliate_dash'
|
||||||
|
if (executorKey === FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH) return 'manual'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
async function preparePaidTaskWithDiagnostics(
|
async function preparePaidTaskWithDiagnostics(
|
||||||
order: OrderRow,
|
order: OrderRow,
|
||||||
task: TaskRow,
|
task: TaskRow,
|
||||||
|
|||||||
@@ -10,7 +10,13 @@ import {
|
|||||||
import { withTransaction } from '../../db/client.js'
|
import { withTransaction } from '../../db/client.js'
|
||||||
import { syncDeliveryTasksForOrder } from './delivery-task-service.js'
|
import { syncDeliveryTasksForOrder } from './delivery-task-service.js'
|
||||||
import { resolveOrderItemForFulfillment } from '../fulfillment/product-resolution-service.js'
|
import { resolveOrderItemForFulfillment } from '../fulfillment/product-resolution-service.js'
|
||||||
import { applyIntakeChannelDecision } from '../fulfillment/intake-channel-decision-service.js'
|
import { applyIntakeChannelPlan } from '../fulfillment/intake-channel-decision-service.js'
|
||||||
|
import {
|
||||||
|
loadInternalSkuChannelPlans,
|
||||||
|
planInternalSkuChannels,
|
||||||
|
type InternalSkuChannelPlan,
|
||||||
|
} from '../fulfillment/internal-sku-channel-service.js'
|
||||||
|
import { canonicalizeInternalSku } from '../fulfillment/internal-sku-resolver.js'
|
||||||
import {
|
import {
|
||||||
resolveOrderFulfillmentReadiness,
|
resolveOrderFulfillmentReadiness,
|
||||||
syncOrderFulfillmentAttachments,
|
syncOrderFulfillmentAttachments,
|
||||||
@@ -114,21 +120,24 @@ export async function upsertOrderFromSource(
|
|||||||
})
|
})
|
||||||
|
|
||||||
const resolvedItems = await Promise.all(
|
const resolvedItems = await Promise.all(
|
||||||
event.items.map(async (item) => {
|
event.items.map((item) =>
|
||||||
const resolved = await resolveOrderItemForFulfillment({
|
resolveOrderItemForFulfillment({
|
||||||
provider: event.provider,
|
provider: event.provider,
|
||||||
platform: event.platform,
|
platform: event.platform,
|
||||||
shopId: event.shopId,
|
shopId: event.shopId,
|
||||||
item,
|
item,
|
||||||
})
|
}),
|
||||||
return applyIntakeChannelDecision(resolved, {
|
),
|
||||||
provider: event.provider,
|
|
||||||
platform: event.platform,
|
|
||||||
shopId: event.shopId,
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
const resolvedFulfillmentItems = resolvedItems as FulfillmentOrderItem[]
|
const channelPlans = await loadInternalSkuChannelPlans(
|
||||||
|
resolvedItems.map((item) => String(item.snapshot.internalSku || '')),
|
||||||
|
)
|
||||||
|
const emptyPlan = planInternalSkuChannels([])
|
||||||
|
const resolvedFulfillmentItems = resolvedItems.map((item) => {
|
||||||
|
const internalSku = canonicalizeInternalSku(item.snapshot.internalSku)
|
||||||
|
const plan: InternalSkuChannelPlan = channelPlans.get(internalSku) || emptyPlan
|
||||||
|
return applyIntakeChannelPlan(item, plan)
|
||||||
|
}) as FulfillmentOrderItem[]
|
||||||
const configuredItems = resolvedFulfillmentItems.filter((item) => item.isConfigured)
|
const configuredItems = resolvedFulfillmentItems.filter((item) => item.isConfigured)
|
||||||
|
|
||||||
const persisted = await withTransaction(async (client) => {
|
const persisted = await withTransaction(async (client) => {
|
||||||
|
|||||||
@@ -71,3 +71,28 @@ test('工单门禁:无配置、冲突、缺模板、模板不一致都不放
|
|||||||
'work_rule_mismatch',
|
'work_rule_mismatch',
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('工单门禁:已分配其它渠道或已有其它真实载体时不放行', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveWorkOrderChannelGate(plan(channel()), undefined, {
|
||||||
|
fulfillmentStatus: 'assigned',
|
||||||
|
assignedChannel: 'affiliate_dash',
|
||||||
|
}).reason,
|
||||||
|
'already_assigned_other_channel',
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
resolveWorkOrderChannelGate(plan(channel()), undefined, {
|
||||||
|
fulfillmentStatus: 'pending',
|
||||||
|
existingCarrierChannels: ['affiliate_dash'],
|
||||||
|
}).reason,
|
||||||
|
'existing_carrier_taken',
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
resolveWorkOrderChannelGate(plan(channel()), undefined, {
|
||||||
|
fulfillmentStatus: 'assigned',
|
||||||
|
assignedChannel: 'work_order',
|
||||||
|
existingCarrierChannels: ['work_order'],
|
||||||
|
}).allowed,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|||||||
@@ -51,11 +51,27 @@ import {
|
|||||||
canonicalizeInternalSku,
|
canonicalizeInternalSku,
|
||||||
resolveInternalSkuKey,
|
resolveInternalSkuKey,
|
||||||
} from '../fulfillment/internal-sku-resolver.js'
|
} from '../fulfillment/internal-sku-resolver.js'
|
||||||
|
import { loadActiveCarrierEvidence } from '../fulfillment/carrier-evidence-service.js'
|
||||||
|
|
||||||
export function resolveWorkOrderChannelGate(
|
export function resolveWorkOrderChannelGate(
|
||||||
plan: InternalSkuChannelPlan | undefined,
|
plan: InternalSkuChannelPlan | undefined,
|
||||||
preferredWorkRuleKey?: string,
|
preferredWorkRuleKey?: string,
|
||||||
|
context: {
|
||||||
|
fulfillmentStatus?: string
|
||||||
|
assignedChannel?: string
|
||||||
|
existingCarrierChannels?: string[]
|
||||||
|
} = {},
|
||||||
): { allowed: boolean; reason: string; workRuleKey: string } {
|
): { allowed: boolean; reason: string; workRuleKey: string } {
|
||||||
|
if (
|
||||||
|
context.fulfillmentStatus === 'assigned' &&
|
||||||
|
context.assignedChannel &&
|
||||||
|
context.assignedChannel !== 'work_order'
|
||||||
|
) {
|
||||||
|
return { allowed: false, reason: 'already_assigned_other_channel', workRuleKey: '' }
|
||||||
|
}
|
||||||
|
if (context.existingCarrierChannels?.some((channel) => channel !== 'work_order')) {
|
||||||
|
return { allowed: false, reason: 'existing_carrier_taken', workRuleKey: '' }
|
||||||
|
}
|
||||||
if (!plan?.primary) {
|
if (!plan?.primary) {
|
||||||
return {
|
return {
|
||||||
allowed: false,
|
allowed: false,
|
||||||
@@ -109,6 +125,7 @@ export async function syncWorkerOrdersForSourceOrder(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
const channelPlans = await loadInternalSkuChannelPlans([...internalSkuByItemId.values()])
|
const channelPlans = await loadInternalSkuChannelPlans([...internalSkuByItemId.values()])
|
||||||
|
const carrierEvidence = await loadActiveCarrierEvidence(orderItems.map((item) => Number(item.id)))
|
||||||
|
|
||||||
for (const item of orderItems) {
|
for (const item of orderItems) {
|
||||||
const internalSku = canonicalizeInternalSku(internalSkuByItemId.get(Number(item.id)))
|
const internalSku = canonicalizeInternalSku(internalSkuByItemId.get(Number(item.id)))
|
||||||
@@ -123,7 +140,12 @@ export async function syncWorkerOrdersForSourceOrder(
|
|||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const channelGate = resolveWorkOrderChannelGate(channelPlan, options.preferredWorkRuleKey)
|
const assignedChannel = String(item.fulfillment_channel || '').trim()
|
||||||
|
const channelGate = resolveWorkOrderChannelGate(channelPlan, options.preferredWorkRuleKey, {
|
||||||
|
fulfillmentStatus: item.fulfillment_status || '',
|
||||||
|
assignedChannel,
|
||||||
|
existingCarrierChannels: carrierEvidence.get(Number(item.id))?.channels || [],
|
||||||
|
})
|
||||||
if (!channelGate.allowed) {
|
if (!channelGate.allowed) {
|
||||||
skipped.push({ orderItemId: Number(item.id), reason: channelGate.reason })
|
skipped.push({ orderItemId: Number(item.id), reason: channelGate.reason })
|
||||||
decisions.push(
|
decisions.push(
|
||||||
|
|||||||
@@ -35,14 +35,19 @@ import PercentTable from '@/components/common/PercentTable'
|
|||||||
import FinanceSubNav from '@/components/admin/FinanceSubNav'
|
import FinanceSubNav from '@/components/admin/FinanceSubNav'
|
||||||
import {
|
import {
|
||||||
assignChannelByInternalSku,
|
assignChannelByInternalSku,
|
||||||
|
deleteInternalSkuMapping,
|
||||||
deleteChannelRules,
|
deleteChannelRules,
|
||||||
fetchChannelExceptions,
|
fetchChannelExceptions,
|
||||||
fetchChannelRules,
|
fetchChannelRules,
|
||||||
|
fetchInternalSkuMappings,
|
||||||
fetchPendingChannelGroups,
|
fetchPendingChannelGroups,
|
||||||
fetchPipelineStatus,
|
fetchPipelineStatus,
|
||||||
runFinancePipeline,
|
runFinancePipeline,
|
||||||
type FinancePipelineStatus,
|
type FinancePipelineStatus,
|
||||||
|
type InternalSkuMapping,
|
||||||
|
type InternalSkuMappingMatchType,
|
||||||
saveChannelRule,
|
saveChannelRule,
|
||||||
|
saveInternalSkuMapping,
|
||||||
type ChannelRule,
|
type ChannelRule,
|
||||||
type PendingChannelGroup,
|
type PendingChannelGroup,
|
||||||
} from '@/services/admin'
|
} from '@/services/admin'
|
||||||
@@ -74,6 +79,17 @@ const CHANNEL_SOURCE_LABEL: Record<string, string> = {
|
|||||||
|
|
||||||
const MATCH_KEY_OPTIONS = [{ value: 'internal_sku', label: '内部 SKU' }]
|
const MATCH_KEY_OPTIONS = [{ value: 'internal_sku', label: '内部 SKU' }]
|
||||||
|
|
||||||
|
const INTERNAL_SKU_MAPPING_TYPES: Array<{
|
||||||
|
value: InternalSkuMappingMatchType
|
||||||
|
label: string
|
||||||
|
}> = [
|
||||||
|
{ value: 'sku_code', label: '平台 SKU 编码' },
|
||||||
|
{ value: 'sku_nick', label: 'SKU 昵称' },
|
||||||
|
{ value: 'out_sku_sn', label: '外部 SKU 编码' },
|
||||||
|
{ value: 'spec', label: '规格值' },
|
||||||
|
{ value: 'sku_name', label: '商品名称' },
|
||||||
|
]
|
||||||
|
|
||||||
export default function AdminFulfillmentChannelPage() {
|
export default function AdminFulfillmentChannelPage() {
|
||||||
return (
|
return (
|
||||||
<div className="page-container">
|
<div className="page-container">
|
||||||
@@ -82,6 +98,7 @@ export default function AdminFulfillmentChannelPage() {
|
|||||||
defaultActiveKey="pending"
|
defaultActiveKey="pending"
|
||||||
items={[
|
items={[
|
||||||
{ key: 'pending', label: '待分类队列', children: <PendingPanel /> },
|
{ key: 'pending', label: '待分类队列', children: <PendingPanel /> },
|
||||||
|
{ key: 'mappings', label: 'SKU 映射', children: <InternalSkuMappingsPanel /> },
|
||||||
{ key: 'rules', label: '渠道规则', children: <RulesPanel /> },
|
{ key: 'rules', label: '渠道规则', children: <RulesPanel /> },
|
||||||
{ key: 'exceptions', label: '问题队列', children: <ExceptionsPanel /> },
|
{ key: 'exceptions', label: '问题队列', children: <ExceptionsPanel /> },
|
||||||
{ key: 'pipelines', label: '管道状态', children: <PipelinesPanel /> },
|
{ key: 'pipelines', label: '管道状态', children: <PipelinesPanel /> },
|
||||||
@@ -119,7 +136,7 @@ function PendingPanel() {
|
|||||||
}
|
}
|
||||||
void queryClient.invalidateQueries({ queryKey: ['fulfillment-pending'] })
|
void queryClient.invalidateQueries({ queryKey: ['fulfillment-pending'] })
|
||||||
},
|
},
|
||||||
onError: () => message.error('指定渠道失败'),
|
onError: (error) => message.error(error instanceof Error ? error.message : '指定渠道失败'),
|
||||||
})
|
})
|
||||||
|
|
||||||
const pipelineMutation = useMutation({
|
const pipelineMutation = useMutation({
|
||||||
@@ -260,6 +277,234 @@ function PendingPanel() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function InternalSkuMappingsPanel() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [form] = Form.useForm()
|
||||||
|
const [keyword, setKeyword] = useState('')
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE)
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [editing, setEditing] = useState<InternalSkuMapping | null>(null)
|
||||||
|
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: ['fulfillment-internal-sku-mappings', keyword, page, pageSize],
|
||||||
|
queryFn: () => fetchInternalSkuMappings({ keyword, page, pageSize }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: (values: Record<string, unknown>) =>
|
||||||
|
saveInternalSkuMapping({
|
||||||
|
provider: String(values.provider || '').trim(),
|
||||||
|
shopId: String(values.shopId || '').trim(),
|
||||||
|
matchType: values.matchType as InternalSkuMappingMatchType,
|
||||||
|
matchValue: String(values.matchValue || ''),
|
||||||
|
internalSku: String(values.internalSku || ''),
|
||||||
|
priority: Number(values.priority || 100),
|
||||||
|
note: String(values.note || ''),
|
||||||
|
enabled: values.enabled !== false,
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('SKU 映射已保存,后续管道将按新映射归一')
|
||||||
|
setOpen(false)
|
||||||
|
setEditing(null)
|
||||||
|
form.resetFields()
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['fulfillment-internal-sku-mappings'] })
|
||||||
|
},
|
||||||
|
onError: (error) => message.error(error instanceof Error ? error.message : '保存 SKU 映射失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: number) => deleteInternalSkuMapping(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('SKU 映射已删除')
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['fulfillment-internal-sku-mappings'] })
|
||||||
|
},
|
||||||
|
onError: (error) => message.error(error instanceof Error ? error.message : '删除 SKU 映射失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
function openEditor(row?: InternalSkuMapping) {
|
||||||
|
setEditing(row || null)
|
||||||
|
form.resetFields()
|
||||||
|
form.setFieldsValue(
|
||||||
|
row
|
||||||
|
? {
|
||||||
|
provider: row.provider,
|
||||||
|
shopId: row.shop_id,
|
||||||
|
matchType: row.match_type,
|
||||||
|
matchValue: row.match_value,
|
||||||
|
internalSku: row.internal_sku,
|
||||||
|
priority: row.priority,
|
||||||
|
note: row.note,
|
||||||
|
enabled: row.enabled,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
provider: '',
|
||||||
|
shopId: '',
|
||||||
|
matchType: 'sku_code',
|
||||||
|
priority: 100,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
setOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: '来源平台',
|
||||||
|
dataIndex: 'provider',
|
||||||
|
width: 120,
|
||||||
|
render: (value: string) => value || <Tag color="blue">全部平台</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '店铺',
|
||||||
|
dataIndex: 'shop_id',
|
||||||
|
width: 150,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (value: string) => value || <Tag color="blue">全部店铺</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '匹配字段',
|
||||||
|
dataIndex: 'match_type',
|
||||||
|
width: 130,
|
||||||
|
render: (value: InternalSkuMappingMatchType) =>
|
||||||
|
INTERNAL_SKU_MAPPING_TYPES.find((item) => item.value === value)?.label || value,
|
||||||
|
},
|
||||||
|
{ title: '平台匹配值', dataIndex: 'match_value', ellipsis: true },
|
||||||
|
{ title: '中文内部 SKU', dataIndex: 'internal_sku', ellipsis: true },
|
||||||
|
{ title: '优先级', dataIndex: 'priority', width: 90 },
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'enabled',
|
||||||
|
width: 80,
|
||||||
|
render: (value: boolean) => (value ? <Tag color="green">启用</Tag> : <Tag>停用</Tag>),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 150,
|
||||||
|
render: (_: unknown, row: InternalSkuMapping) => (
|
||||||
|
<Space>
|
||||||
|
<Button size="small" onClick={() => openEditor(row)}>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
danger
|
||||||
|
size="small"
|
||||||
|
loading={deleteMutation.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
Modal.confirm({
|
||||||
|
title: '删除 SKU 映射',
|
||||||
|
content: `${row.provider || '全部平台'} / ${row.shop_id || '全部店铺'} / ${row.match_value}`,
|
||||||
|
okText: '删除',
|
||||||
|
okButtonProps: { danger: true },
|
||||||
|
cancelText: '取消',
|
||||||
|
onOk: () => deleteMutation.mutateAsync(row.id),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
title={`SKU 映射(${query.data?.data?.total ?? 0})`}
|
||||||
|
extra={
|
||||||
|
<Space>
|
||||||
|
<Input.Search
|
||||||
|
allowClear
|
||||||
|
placeholder="平台 SKU / 中文 SKU"
|
||||||
|
style={{ width: 220 }}
|
||||||
|
onSearch={(value) => {
|
||||||
|
setKeyword(value.trim())
|
||||||
|
setPage(1)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button icon={<ReloadOutlined />} onClick={() => void query.refetch()}>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
<Button type="primary" icon={<PlusOutlined />} onClick={() => openEditor()}>
|
||||||
|
新增映射
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Alert
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: 12 }}
|
||||||
|
message="平台 SKU 只在这里映射为中文内部 SKU。店铺级映射优先于平台级,平台级优先于全局;渠道配置仍然只按中文内部 SKU 全局生效。"
|
||||||
|
/>
|
||||||
|
<PercentTable
|
||||||
|
rowKey="id"
|
||||||
|
size="small"
|
||||||
|
loading={query.isFetching}
|
||||||
|
dataSource={query.data?.data?.items || []}
|
||||||
|
columns={columns}
|
||||||
|
pagination={buildAdminTablePagination({
|
||||||
|
current: query.data?.data?.page || page,
|
||||||
|
pageSize: query.data?.data?.pageSize || pageSize,
|
||||||
|
total: query.data?.data?.total || 0,
|
||||||
|
onChange: (nextPage, nextPageSize) => {
|
||||||
|
setPage(nextPageSize !== pageSize ? 1 : nextPage)
|
||||||
|
setPageSize(nextPageSize)
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={editing ? '编辑 SKU 映射' : '新增 SKU 映射'}
|
||||||
|
open={open}
|
||||||
|
onCancel={() => {
|
||||||
|
setOpen(false)
|
||||||
|
setEditing(null)
|
||||||
|
}}
|
||||||
|
onOk={() => form.submit()}
|
||||||
|
confirmLoading={saveMutation.isPending}
|
||||||
|
destroyOnHidden
|
||||||
|
>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
initialValues={{ matchType: 'sku_code', priority: 100, enabled: true }}
|
||||||
|
onFinish={(values) => saveMutation.mutate(values)}
|
||||||
|
>
|
||||||
|
<Form.Item name="provider" label="来源平台">
|
||||||
|
<Input placeholder="留空表示全部平台" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="shopId" label="店铺 ID">
|
||||||
|
<Input placeholder="留空表示全部店铺" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="matchType" label="匹配字段" rules={[{ required: true }]}>
|
||||||
|
<Select options={INTERNAL_SKU_MAPPING_TYPES} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="matchValue" label="平台匹配值" rules={[{ required: true }]}>
|
||||||
|
<Input placeholder="例如平台 SKU、SKU 昵称或规格值" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="internalSku"
|
||||||
|
label="中文内部 SKU"
|
||||||
|
rules={[{ required: true, message: '请输入包含中文的内部 SKU' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="例如:幸运币2个" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="priority" label="优先级" extra="数字越小越优先">
|
||||||
|
<InputNumber min={0} precision={0} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="note" label="备注">
|
||||||
|
<Input.TextArea rows={2} maxLength={300} showCount />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="enabled" label="启用" valuePropName="checked">
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function RulesPanel() {
|
function RulesPanel() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
@@ -287,7 +532,7 @@ function RulesPanel() {
|
|||||||
form.resetFields()
|
form.resetFields()
|
||||||
void queryClient.invalidateQueries({ queryKey: ['fulfillment-rules'] })
|
void queryClient.invalidateQueries({ queryKey: ['fulfillment-rules'] })
|
||||||
},
|
},
|
||||||
onError: () => message.error('保存规则失败'),
|
onError: (error) => message.error(error instanceof Error ? error.message : '保存规则失败'),
|
||||||
})
|
})
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
|
|||||||
@@ -42,6 +42,34 @@ export type ChannelRuleListResult = {
|
|||||||
pageSize: number
|
pageSize: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type InternalSkuMappingMatchType =
|
||||||
|
| 'sku_code'
|
||||||
|
| 'sku_nick'
|
||||||
|
| 'out_sku_sn'
|
||||||
|
| 'spec'
|
||||||
|
| 'sku_name'
|
||||||
|
|
||||||
|
export type InternalSkuMapping = {
|
||||||
|
id: number
|
||||||
|
provider: string
|
||||||
|
shop_id: string
|
||||||
|
match_type: InternalSkuMappingMatchType
|
||||||
|
match_value: string
|
||||||
|
internal_sku: string
|
||||||
|
priority: number
|
||||||
|
enabled: boolean
|
||||||
|
source: string
|
||||||
|
note: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InternalSkuMappingListResult = {
|
||||||
|
items: InternalSkuMapping[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
}
|
||||||
|
|
||||||
export type ChannelException = {
|
export type ChannelException = {
|
||||||
id: number
|
id: number
|
||||||
reason: string
|
reason: string
|
||||||
@@ -142,6 +170,39 @@ export function fetchChannelRules(
|
|||||||
return apiGet<ChannelRuleListResult>('/api/v1/admin/fulfillment/rules', params)
|
return apiGet<ChannelRuleListResult>('/api/v1/admin/fulfillment/rules', params)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function fetchInternalSkuMappings(
|
||||||
|
params: {
|
||||||
|
keyword?: string
|
||||||
|
provider?: string
|
||||||
|
shopId?: string
|
||||||
|
matchType?: string
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
return apiGet<InternalSkuMappingListResult>(
|
||||||
|
'/api/v1/admin/fulfillment/internal-sku-mappings',
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveInternalSkuMapping(payload: {
|
||||||
|
provider?: string
|
||||||
|
shopId?: string
|
||||||
|
matchType: InternalSkuMappingMatchType
|
||||||
|
matchValue: string
|
||||||
|
internalSku: string
|
||||||
|
priority?: number
|
||||||
|
note?: string
|
||||||
|
enabled?: boolean
|
||||||
|
}) {
|
||||||
|
return apiPost<{ id: number }>('/api/v1/admin/fulfillment/internal-sku-mappings', payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteInternalSkuMapping(id: number) {
|
||||||
|
return apiDelete<boolean>(`/api/v1/admin/fulfillment/internal-sku-mappings/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
export function saveChannelRule(payload: {
|
export function saveChannelRule(payload: {
|
||||||
id?: number
|
id?: number
|
||||||
matchKeyType: string
|
matchKeyType: string
|
||||||
|
|||||||
@@ -155,6 +155,9 @@ internal_sku_channels (
|
|||||||
|
|
||||||
下单入口、财务、成本中心都读这一份配置。
|
下单入口、财务、成本中心都读这一份配置。
|
||||||
|
|
||||||
|
后台「履约渠道 → SKU 映射」维护平台字段到中文内部 SKU 的映射:
|
||||||
|
店铺级优先于平台级,平台级优先于全局;渠道规则仍只按中文内部 SKU 全局生效。
|
||||||
|
|
||||||
规范化规则固定为 NFKC → 去全部空白 → 小写。没有中文内部 SKU 的订单项保持 pending,
|
规范化规则固定为 NFKC → 去全部空白 → 小写。没有中文内部 SKU 的订单项保持 pending,
|
||||||
不再回退到平台 SKU 或英文码。
|
不再回退到平台 SKU 或英文码。
|
||||||
|
|
||||||
@@ -220,6 +223,7 @@ pending 只保留:
|
|||||||
- 三平台订单:下单 → 内部 SKU → 渠道 → 载体 → 成本 → 销售事实全链路可追踪;
|
- 三平台订单:下单 → 内部 SKU → 渠道 → 载体 → 成本 → 销售事实全链路可追踪;
|
||||||
- 同名不同平台码不再拆 pending;
|
- 同名不同平台码不再拆 pending;
|
||||||
- 同一内部 SKU 跨平台/跨店铺只展示一条待分类、一条渠道配置和一个问题聚合;
|
- 同一内部 SKU 跨平台/跨店铺只展示一条待分类、一条渠道配置和一个问题聚合;
|
||||||
|
- 已分配订单再次收到平台状态推送时冻结原渠道决策,只更新平台字段,不重复创建第二种载体;
|
||||||
- 财务渠道与真实载体一致;
|
- 财务渠道与真实载体一致;
|
||||||
- pending 仅三类语义;
|
- pending 仅三类语义;
|
||||||
- 审计脚本 0 空内部 SKU、0 纯数字(映射后)、0 载体/渠道矛盾;
|
- 审计脚本 0 空内部 SKU、0 纯数字(映射后)、0 载体/渠道矛盾;
|
||||||
|
|||||||
@@ -150,6 +150,8 @@ npx tsx scripts/preflight-fulfillment-routing.ts --strict --include-demo
|
|||||||
- 规范化后重复的内部 SKU 渠道配置;
|
- 规范化后重复的内部 SKU 渠道配置;
|
||||||
- `work_order` 缺 `workRuleKey` 或规则不存在;
|
- `work_order` 缺 `workRuleKey` 或规则不存在;
|
||||||
- 人工渠道缺启用的人工履约配置;
|
- 人工渠道缺启用的人工履约配置;
|
||||||
|
- 同一中文内部 SKU 存在多个载体配置;
|
||||||
|
- 规范化后重复的人工履约配置或 SKU 映射;
|
||||||
- pending 订单项无法解析出中文内部 SKU;
|
- pending 订单项无法解析出中文内部 SKU;
|
||||||
- 仍启用的 `sku_code` 兼容规则。
|
- 仍启用的 `sku_code` 兼容规则。
|
||||||
|
|
||||||
@@ -160,20 +162,23 @@ npx tsx scripts/preflight-fulfillment-routing.ts --strict --include-demo
|
|||||||
npx tsx scripts/merge-duplicate-products.ts
|
npx tsx scripts/merge-duplicate-products.ts
|
||||||
npx tsx scripts/cleanup-legacy-sku-code-rules.ts
|
npx tsx scripts/cleanup-legacy-sku-code-rules.ts
|
||||||
npx tsx scripts/reclassify-post-cutover.ts
|
npx tsx scripts/reclassify-post-cutover.ts
|
||||||
|
npx tsx scripts/normalize-internal-sku-configs.ts
|
||||||
|
|
||||||
# 确认后 apply
|
# 确认后 apply
|
||||||
npx tsx scripts/merge-duplicate-products.ts --apply
|
npx tsx scripts/merge-duplicate-products.ts --apply
|
||||||
npx tsx scripts/cleanup-legacy-sku-code-rules.ts --apply
|
npx tsx scripts/cleanup-legacy-sku-code-rules.ts --apply
|
||||||
npx tsx scripts/reclassify-post-cutover.ts --apply
|
npx tsx scripts/reclassify-post-cutover.ts --apply
|
||||||
|
npx tsx scripts/normalize-internal-sku-configs.ts --apply
|
||||||
```
|
```
|
||||||
|
|
||||||
顺序:
|
顺序:
|
||||||
|
|
||||||
1. 合并重复商品;
|
1. 合并重复商品;
|
||||||
2. 清理旧 sku_code 规则;
|
2. 清理旧 sku_code 规则;
|
||||||
3. 重判断代后订单;
|
3. 合并规范化重复的渠道配置与 SKU 映射;
|
||||||
4. 跑一次完整管道;
|
4. 重判断代后订单;
|
||||||
5. 核对成本/销售事实。
|
5. 跑一次完整管道;
|
||||||
|
6. 核对成本/销售事实。
|
||||||
|
|
||||||
## 6. 回滚
|
## 6. 回滚
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user