重构:硬切履约路由唯一真源
This commit is contained in:
@@ -90,22 +90,6 @@ async function main() {
|
||||
`,
|
||||
)
|
||||
|
||||
const rules = await query<{
|
||||
match_key_type: string
|
||||
channel: string
|
||||
scope: string
|
||||
total: number
|
||||
}>(
|
||||
`
|
||||
SELECT match_key_type, channel,
|
||||
CASE WHEN provider = '' AND shop_id = '' THEN 'global' ELSE 'scoped' END AS scope,
|
||||
count(*)::int AS total
|
||||
FROM sku_channel_rules
|
||||
GROUP BY 1, 2, 3
|
||||
ORDER BY 1, 2, 3
|
||||
`,
|
||||
)
|
||||
|
||||
const duplicateProducts = await query<{ duplicate_names: number; duplicate_rows: number }>(
|
||||
`
|
||||
SELECT count(*)::int AS duplicate_names, COALESCE(sum(total), 0)::int AS duplicate_rows
|
||||
@@ -119,43 +103,29 @@ async function main() {
|
||||
`,
|
||||
)
|
||||
|
||||
const capabilities = await query<{ channel: string; total: number }>(
|
||||
`SELECT channel, count(*)::int AS total FROM product_channel_capabilities WHERE enabled GROUP BY 1 ORDER BY 2 DESC`,
|
||||
)
|
||||
|
||||
const internalChannelConflicts = await query<{ internal_sku: string; total: number }>(
|
||||
`SELECT internal_sku, count(*)::int AS total
|
||||
FROM internal_sku_channels
|
||||
WHERE enabled = TRUE AND is_fallback = FALSE
|
||||
GROUP BY internal_sku
|
||||
HAVING count(*) > 1
|
||||
ORDER BY count(*) DESC, internal_sku`,
|
||||
)
|
||||
|
||||
const internalChannels = await query<{
|
||||
const routes = await query<{
|
||||
channel: string
|
||||
route_role: string
|
||||
source: string
|
||||
total: number
|
||||
}>(
|
||||
`SELECT channel, source, count(*)::int AS total
|
||||
FROM internal_sku_channels
|
||||
`SELECT channel, route_role, source, count(*)::int AS total
|
||||
FROM sku_channel_routes
|
||||
WHERE enabled = TRUE
|
||||
GROUP BY channel, source
|
||||
ORDER BY channel, source`,
|
||||
GROUP BY channel, route_role, source
|
||||
ORDER BY channel, route_role, source`,
|
||||
)
|
||||
|
||||
const canonicalChannelDuplicates = await query<{
|
||||
internal_sku: string
|
||||
channel: string
|
||||
const internalRouteConflicts = await query<{
|
||||
internal_sku_id: number
|
||||
total: number
|
||||
}>(
|
||||
`SELECT canonicalize_internal_sku(internal_sku) AS internal_sku,
|
||||
channel, count(*)::int AS total
|
||||
FROM internal_sku_channels
|
||||
WHERE enabled = TRUE
|
||||
GROUP BY canonicalize_internal_sku(internal_sku), channel
|
||||
`SELECT internal_sku_id, count(*)::int AS total
|
||||
FROM sku_channel_routes
|
||||
WHERE enabled = TRUE AND route_role = 'primary'
|
||||
GROUP BY internal_sku_id
|
||||
HAVING count(*) > 1
|
||||
ORDER BY count(*) DESC, internal_sku, channel`,
|
||||
ORDER BY count(*) DESC, internal_sku_id`,
|
||||
)
|
||||
|
||||
const assignedCarrierConflicts = await query<{
|
||||
@@ -220,12 +190,9 @@ async function main() {
|
||||
pending: pending.rows[0],
|
||||
channelCarrier: channelCarrier.rows,
|
||||
internalSku: internalSku.rows[0],
|
||||
rules: rules.rows,
|
||||
duplicateProducts: duplicateProducts.rows[0],
|
||||
capabilities: capabilities.rows,
|
||||
internalChannels: internalChannels.rows,
|
||||
internalChannelConflicts: internalChannelConflicts.rows,
|
||||
canonicalChannelDuplicates: canonicalChannelDuplicates.rows,
|
||||
routes: routes.rows,
|
||||
internalRouteConflicts: internalRouteConflicts.rows,
|
||||
assignedCarrierConflicts: assignedCarrierConflicts.rows,
|
||||
byProvider: byProvider.rows,
|
||||
},
|
||||
|
||||
@@ -18,6 +18,7 @@ async function main() {
|
||||
orphanManualProducts,
|
||||
multiplePrimaryRoutes,
|
||||
missingInternalSku,
|
||||
unroutedProducts,
|
||||
invalidSourceKinds,
|
||||
] = await Promise.all([
|
||||
query<{ source_kind: string; primary_channel: string | null; total: number }>(
|
||||
@@ -46,7 +47,9 @@ async function main() {
|
||||
SELECT
|
||||
CASE
|
||||
WHEN primary_route.channel = 'manual' THEN 'primary_manual'
|
||||
WHEN manual_route.route_role = 'fallback' THEN 'manual_fallback'
|
||||
WHEN primary_route.channel IS NOT NULL
|
||||
AND manual_route.route_role = 'fallback' THEN 'manual_fallback'
|
||||
WHEN manual_route.route_role = 'fallback' THEN 'fallback_without_primary'
|
||||
ELSE 'missing_manual_route'
|
||||
END AS route_state,
|
||||
COUNT(*)::int AS total
|
||||
@@ -123,6 +126,19 @@ async function main() {
|
||||
ORDER BY 1
|
||||
`,
|
||||
),
|
||||
query<{
|
||||
product_id: number
|
||||
canonical_sku: string
|
||||
canonical_name: string
|
||||
source_kind: string
|
||||
}>(
|
||||
`
|
||||
SELECT id AS product_id, canonical_sku, canonical_name, source_kind
|
||||
FROM v_product_current
|
||||
WHERE status = 'active' AND active_primary_channel IS NULL
|
||||
ORDER BY source_kind, id
|
||||
`,
|
||||
),
|
||||
query<{ source_kind: string; total: number }>(
|
||||
`
|
||||
SELECT source_kind, COUNT(*)::int AS total
|
||||
@@ -146,6 +162,11 @@ async function main() {
|
||||
multiplePrimaryRoutes: multiplePrimaryRoutes.rows.length,
|
||||
missingInternalSku: missingInternalSku.rows.reduce((sum, row) => sum + Number(row.total), 0),
|
||||
invalidSourceKinds: invalidSourceKinds.rows.reduce((sum, row) => sum + Number(row.total), 0),
|
||||
invalidManualConfigRoutes: manualConfigRoutes.rows
|
||||
.filter(
|
||||
(row) => row.route_state !== 'primary_manual' && row.route_state !== 'manual_fallback',
|
||||
)
|
||||
.reduce((sum, row) => sum + Number(row.total), 0),
|
||||
}
|
||||
|
||||
console.info('[product-routing-audit] 商品来源 × 当前渠道')
|
||||
@@ -169,6 +190,9 @@ async function main() {
|
||||
console.info('[product-routing-audit] 非法商品来源')
|
||||
console.table(invalidSourceKinds.rows)
|
||||
|
||||
console.info('[product-routing-audit] 未路由活动商品(待人工分类)')
|
||||
console.table(unroutedProducts.rows)
|
||||
|
||||
console.info('[product-routing-audit] 关键异常', critical)
|
||||
if (strict && Object.values(critical).some((count) => count > 0)) {
|
||||
process.exitCode = 1
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
/**
|
||||
* 把旧的 sku_code 渠道规则转换为 internal_sku 全局规则,并停用旧规则。
|
||||
*
|
||||
* 用法:
|
||||
* npx tsx scripts/cleanup-legacy-sku-code-rules.ts
|
||||
* npx tsx scripts/cleanup-legacy-sku-code-rules.ts --apply
|
||||
*/
|
||||
import { closeDb, query } from '../src/db/client.js'
|
||||
|
||||
type LegacyRule = {
|
||||
id: number
|
||||
provider: string
|
||||
shop_id: string
|
||||
match_key: string
|
||||
channel: string
|
||||
priority: number
|
||||
source: string
|
||||
}
|
||||
|
||||
async function resolveInternalSku(rule: LegacyRule): Promise<string> {
|
||||
if (rule.channel === 'affiliate_dash') {
|
||||
const product = await query<{ canonical_name: string }>(
|
||||
`SELECT canonical_name FROM product_catalog
|
||||
WHERE canonical_sku = $1 AND status = 'active' AND btrim(canonical_name) <> ''
|
||||
LIMIT 1`,
|
||||
[`aff:${rule.match_key}`],
|
||||
)
|
||||
if (product.rows[0]?.canonical_name) return String(product.rows[0].canonical_name).trim()
|
||||
}
|
||||
|
||||
const params: unknown[] = [rule.match_key]
|
||||
let scope = ''
|
||||
if (rule.provider) {
|
||||
params.push(rule.provider)
|
||||
scope += ` AND o.provider = $${params.length}`
|
||||
}
|
||||
if (rule.shop_id) {
|
||||
params.push(rule.shop_id)
|
||||
scope += ` AND o.shop_id = $${params.length}`
|
||||
}
|
||||
const items = await query<{ internal_sku: string }>(
|
||||
`
|
||||
SELECT DISTINCT btrim(oi.manual_sku_key) AS internal_sku
|
||||
FROM order_items oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
WHERE oi.sku_code = $1
|
||||
AND btrim(COALESCE(oi.manual_sku_key, '')) ~ '[㐀-䶿一-鿿豈-𠀀-𰀀-𲎯]'
|
||||
${scope}
|
||||
LIMIT 2
|
||||
`,
|
||||
params,
|
||||
)
|
||||
const values = items.rows.map((row) => String(row.internal_sku || '').trim()).filter(Boolean)
|
||||
return values.length === 1 ? values[0]! : ''
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const apply = process.argv.includes('--apply')
|
||||
const rules = await query<LegacyRule>(
|
||||
`
|
||||
SELECT id, provider, shop_id, match_key, channel, priority, source
|
||||
FROM sku_channel_rules
|
||||
WHERE match_key_type = 'sku_code' AND enabled = TRUE
|
||||
ORDER BY id
|
||||
`,
|
||||
)
|
||||
|
||||
const converted: Array<{ id: number; matchKey: string; internalSku: string; channel: string }> =
|
||||
[]
|
||||
const unresolved: LegacyRule[] = []
|
||||
for (const rule of rules.rows) {
|
||||
const internalSku = await resolveInternalSku(rule)
|
||||
if (!internalSku) {
|
||||
unresolved.push(rule)
|
||||
continue
|
||||
}
|
||||
converted.push({ id: rule.id, matchKey: rule.match_key, internalSku, channel: rule.channel })
|
||||
if (!apply) continue
|
||||
|
||||
await query(
|
||||
`UPDATE internal_sku_channels
|
||||
SET is_fallback = TRUE,
|
||||
priority = GREATEST(priority, 90),
|
||||
updated_at = NOW()
|
||||
WHERE btrim(internal_sku) = $1
|
||||
AND channel <> $2
|
||||
AND enabled = TRUE
|
||||
AND is_fallback = FALSE`,
|
||||
[internalSku, rule.channel],
|
||||
)
|
||||
await query(
|
||||
`
|
||||
INSERT INTO internal_sku_channels (
|
||||
internal_sku, channel, priority, is_fallback, enabled, source
|
||||
) VALUES ($1, $2, $3, FALSE, TRUE, 'legacy-rule')
|
||||
ON CONFLICT (internal_sku, channel) DO UPDATE SET
|
||||
priority = LEAST(internal_sku_channels.priority, EXCLUDED.priority),
|
||||
is_fallback = FALSE,
|
||||
enabled = TRUE,
|
||||
updated_at = NOW()
|
||||
`,
|
||||
[internalSku, rule.channel, rule.priority],
|
||||
)
|
||||
await query(
|
||||
`UPDATE sku_channel_rules
|
||||
SET enabled = FALSE,
|
||||
note = CONCAT(COALESCE(note, ''), ' [已迁移 internal_sku:', $2::text, ']'),
|
||||
updated_at = NOW()
|
||||
WHERE id = $1`,
|
||||
[rule.id, internalSku],
|
||||
)
|
||||
}
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
apply,
|
||||
legacyRules: rules.rows.length,
|
||||
convertible: converted.length,
|
||||
converted,
|
||||
unresolved,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((error) => {
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
.finally(() => closeDb())
|
||||
@@ -13,7 +13,7 @@ import { closeDb, query } from '../src/db/client.js'
|
||||
* 保留:orders / order_items / work_orders / fulfillment_tasks(审计只读)
|
||||
* product_catalog / channel_product_mappings / cost_items /
|
||||
* cost_versions / work_product_rules / manual_fulfillment_configs /
|
||||
* sku_channel_rules(配置)
|
||||
* sku_channel_routes(配置)
|
||||
*
|
||||
* 前置:
|
||||
* 1. 迁移 112/113 已执行;
|
||||
|
||||
@@ -70,6 +70,10 @@ async function mergeGroup(
|
||||
'UPDATE manual_fulfillment_configs SET canonical_product_id = $2, updated_at = NOW() WHERE canonical_product_id = $1',
|
||||
[duplicateId, group.keeper_id],
|
||||
)
|
||||
await client.query(
|
||||
'UPDATE sku_channel_routes SET canonical_product_id = $2, updated_at = NOW() WHERE canonical_product_id = $1',
|
||||
[duplicateId, group.keeper_id],
|
||||
)
|
||||
|
||||
// 映射:先删掉会与主商品冲突的重复映射,再迁移其余。
|
||||
const conflict = await client.query(
|
||||
@@ -136,44 +140,42 @@ async function mergeGroup(
|
||||
}
|
||||
|
||||
await query(
|
||||
`
|
||||
INSERT INTO product_channel_capabilities (product_id, channel, priority, is_fallback, enabled, source)
|
||||
VALUES ($1, 'affiliate_dash', 10, FALSE, TRUE, 'merged'), ($1, 'manual', 20, TRUE, TRUE, 'merged')
|
||||
ON CONFLICT (product_id, channel) DO UPDATE
|
||||
SET priority = LEAST(product_channel_capabilities.priority, EXCLUDED.priority),
|
||||
is_fallback = product_channel_capabilities.is_fallback OR EXCLUDED.is_fallback,
|
||||
updated_at = NOW()
|
||||
`,
|
||||
`UPDATE sku_channel_routes
|
||||
SET route_role = 'fallback',
|
||||
priority = GREATEST(priority, 90),
|
||||
updated_at = NOW()
|
||||
WHERE internal_sku_id = (
|
||||
SELECT internal_sku_id FROM product_catalog WHERE id = $1
|
||||
)
|
||||
AND channel <> 'affiliate_dash'
|
||||
AND enabled = TRUE
|
||||
AND route_role = 'primary'`,
|
||||
[group.keeper_id],
|
||||
)
|
||||
|
||||
await query(
|
||||
`UPDATE internal_sku_channels
|
||||
SET is_fallback = TRUE,
|
||||
priority = GREATEST(priority, 90),
|
||||
updated_at = NOW()
|
||||
WHERE btrim(internal_sku) = $1
|
||||
AND channel <> 'affiliate_dash'
|
||||
AND enabled = TRUE
|
||||
AND is_fallback = FALSE`,
|
||||
[group.canonical_name],
|
||||
)
|
||||
|
||||
await query(
|
||||
`
|
||||
INSERT INTO internal_sku_channels (
|
||||
internal_sku, channel, priority, is_fallback, enabled, canonical_product_id, source
|
||||
INSERT INTO sku_channel_routes (
|
||||
internal_sku_id, channel, route_role, priority, enabled, canonical_product_id, source
|
||||
)
|
||||
VALUES ($1, 'affiliate_dash', 10, FALSE, TRUE, $2, 'merged'),
|
||||
($1, 'manual', 20, TRUE, TRUE, $2, 'merged')
|
||||
ON CONFLICT (internal_sku, channel) DO UPDATE SET
|
||||
priority = LEAST(internal_sku_channels.priority, EXCLUDED.priority),
|
||||
is_fallback = internal_sku_channels.is_fallback OR EXCLUDED.is_fallback,
|
||||
SELECT product.internal_sku_id, merged.channel, merged.route_role,
|
||||
merged.priority, TRUE, product.id, 'merged'
|
||||
FROM product_catalog product
|
||||
CROSS JOIN (
|
||||
VALUES
|
||||
('affiliate_dash', 'primary', 10),
|
||||
('manual', 'fallback', 20)
|
||||
) merged(channel, route_role, priority)
|
||||
WHERE product.id = $1
|
||||
AND product.internal_sku_id IS NOT NULL
|
||||
ON CONFLICT (internal_sku_id, channel) DO UPDATE SET
|
||||
priority = LEAST(sku_channel_routes.priority, EXCLUDED.priority),
|
||||
route_role = EXCLUDED.route_role,
|
||||
enabled = TRUE,
|
||||
canonical_product_id = COALESCE(EXCLUDED.canonical_product_id, internal_sku_channels.canonical_product_id),
|
||||
canonical_product_id = COALESCE(EXCLUDED.canonical_product_id, sku_channel_routes.canonical_product_id),
|
||||
updated_at = NOW()
|
||||
`,
|
||||
[group.canonical_name, group.keeper_id],
|
||||
[group.keeper_id],
|
||||
)
|
||||
|
||||
// 只删除已经没有引用的重复商品,仍被引用就归档。
|
||||
|
||||
@@ -1,201 +0,0 @@
|
||||
/**
|
||||
* 合并规范化后重复的内部 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())
|
||||
@@ -1,345 +0,0 @@
|
||||
/**
|
||||
* 履约/财务统一分流部署后核对(只读)。
|
||||
*
|
||||
* 用法:
|
||||
* npx tsx scripts/preflight-fulfillment-routing.ts
|
||||
* npx tsx scripts/preflight-fulfillment-routing.ts --strict
|
||||
*/
|
||||
import { closeDb, query } from '../src/db/client.js'
|
||||
|
||||
type CountRow = { label: string; total: number }
|
||||
|
||||
async function main() {
|
||||
const strict = process.argv.includes('--strict')
|
||||
const includeDemo = process.argv.includes('--include-demo')
|
||||
const checks: Record<string, unknown> = {}
|
||||
|
||||
checks.migrations = (
|
||||
await query<{ name: string }>(`SELECT name FROM schema_migrations ORDER BY id DESC LIMIT 5`)
|
||||
).rows.map((row) => row.name)
|
||||
|
||||
checks.pendingReasons = (
|
||||
await query<CountRow>(
|
||||
`SELECT COALESCE(fulfillment_pending_reason, '(null)') AS label, count(*)::int AS total
|
||||
FROM order_items WHERE fulfillment_status = 'pending'
|
||||
GROUP BY 1 ORDER BY 2 DESC`,
|
||||
)
|
||||
).rows
|
||||
|
||||
checks.renameCardChannels = (
|
||||
await query<CountRow>(
|
||||
`SELECT COALESCE(fulfillment_channel, '(pending)') AS label, count(*)::int AS total
|
||||
FROM order_items
|
||||
WHERE sku_name = '11月改名卡'
|
||||
GROUP BY 1 ORDER BY 2 DESC`,
|
||||
)
|
||||
).rows
|
||||
|
||||
checks.rules = (
|
||||
await query<CountRow>(
|
||||
`SELECT match_key_type || '/' || channel || '/' ||
|
||||
CASE WHEN enabled THEN 'on' ELSE 'off' END AS label, count(*)::int AS total
|
||||
FROM sku_channel_rules GROUP BY 1 ORDER BY 2 DESC`,
|
||||
)
|
||||
).rows
|
||||
|
||||
checks.internalChannels = (
|
||||
await query<CountRow>(
|
||||
`SELECT channel AS label, count(*)::int AS total
|
||||
FROM internal_sku_channels WHERE enabled GROUP BY 1 ORDER BY 2 DESC`,
|
||||
)
|
||||
).rows
|
||||
|
||||
checks.uppercaseInternalChannels = (
|
||||
await query<CountRow>(
|
||||
`SELECT 'uppercase_internal_sku' AS label, count(*)::int AS total
|
||||
FROM internal_sku_channels
|
||||
WHERE internal_sku ~ '[A-Z]'`,
|
||||
)
|
||||
).rows[0]
|
||||
|
||||
checks.pendingWithoutChannel = (
|
||||
await query<CountRow>(
|
||||
`SELECT 'pending_no_internal_channel' AS label, count(*)::int AS total
|
||||
FROM order_items oi
|
||||
WHERE oi.fulfillment_status = 'pending'
|
||||
AND btrim(COALESCE(resolve_internal_sku(
|
||||
oi.manual_sku_key, oi.sku_name, oi.sku_code
|
||||
), '')) <> ''
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM internal_sku_channels c
|
||||
WHERE c.enabled AND btrim(c.internal_sku) = resolve_internal_sku(
|
||||
oi.manual_sku_key, oi.sku_name, oi.sku_code
|
||||
)
|
||||
)`,
|
||||
)
|
||||
).rows[0]
|
||||
|
||||
checks.assignedWithoutCarrier = (
|
||||
await query<CountRow>(
|
||||
`SELECT 'assigned_without_carrier' AS label, count(*)::int AS total
|
||||
FROM order_items oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
WHERE oi.fulfillment_status = 'assigned'
|
||||
AND oi.fulfillment_channel IN ('affiliate_dash', 'work_order')
|
||||
AND ($1::boolean OR o.shop_id NOT LIKE 'sim-%')
|
||||
AND NOT EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id AND wo.status <> 'cancelled')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM fulfillment_tasks ft
|
||||
WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash'
|
||||
AND ft.task_status NOT IN ('closed','expired','failed','manual_review','retry_pending','cancelled')
|
||||
)`,
|
||||
[includeDemo],
|
||||
)
|
||||
).rows[0]
|
||||
|
||||
checks.assignedWithMultipleCarriers = (
|
||||
await query<CountRow>(
|
||||
`SELECT 'assigned_with_multiple_carriers' AS label, count(*)::int AS total
|
||||
FROM order_items oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
WHERE oi.fulfillment_status = 'assigned'
|
||||
AND ($1::boolean OR o.shop_id NOT LIKE 'sim-%')
|
||||
AND (EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id AND wo.status <> 'cancelled'))::int
|
||||
+ (EXISTS (
|
||||
SELECT 1 FROM fulfillment_tasks ft
|
||||
WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash'
|
||||
AND ft.task_status NOT IN ('closed','expired','failed','manual_review','retry_pending','cancelled')
|
||||
))::int
|
||||
+ (EXISTS (
|
||||
SELECT 1 FROM fulfillment_tasks ft
|
||||
WHERE ft.order_item_id = oi.id AND ft.executor_key = 'manual_dispatch'
|
||||
AND ft.task_status NOT IN ('closed','expired','failed','manual_review','retry_pending','cancelled')
|
||||
))::int > 1`,
|
||||
[includeDemo],
|
||||
)
|
||||
).rows[0]
|
||||
|
||||
checks.internalChannelConflicts = (
|
||||
await query<CountRow>(
|
||||
`SELECT internal_sku AS label, count(*)::int AS total
|
||||
FROM internal_sku_channels
|
||||
WHERE enabled = TRUE AND is_fallback = FALSE
|
||||
GROUP BY internal_sku
|
||||
HAVING count(*) > 1
|
||||
ORDER BY count(*) DESC, internal_sku`,
|
||||
)
|
||||
).rows
|
||||
|
||||
checks.canonicalChannelDuplicates = (
|
||||
await query<CountRow>(
|
||||
`SELECT canonicalize_internal_sku(internal_sku) AS label, count(*)::int AS total
|
||||
FROM internal_sku_channels
|
||||
WHERE enabled = TRUE
|
||||
GROUP BY canonicalize_internal_sku(internal_sku), channel
|
||||
HAVING count(*) > 1
|
||||
ORDER BY count(*) DESC, label`,
|
||||
)
|
||||
).rows
|
||||
|
||||
checks.incompleteCarrierConfigs = (
|
||||
await query<CountRow>(
|
||||
`SELECT channel AS label, count(*)::int AS total
|
||||
FROM internal_sku_channels
|
||||
WHERE enabled = TRUE
|
||||
AND (
|
||||
(
|
||||
channel = 'work_order'
|
||||
AND (
|
||||
COALESCE(config_json->>'workRuleKey', '') = ''
|
||||
OR NOT EXISTS (
|
||||
SELECT 1 FROM work_product_rules rule
|
||||
WHERE rule.rule_key = config_json->>'workRuleKey'
|
||||
AND rule.enabled = TRUE
|
||||
)
|
||||
)
|
||||
)
|
||||
OR (
|
||||
channel = 'manual'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM manual_fulfillment_configs cfg
|
||||
WHERE cfg.enabled = TRUE
|
||||
AND canonicalize_internal_sku(cfg.sku_key)
|
||||
= canonicalize_internal_sku(internal_sku_channels.internal_sku)
|
||||
)
|
||||
)
|
||||
)
|
||||
GROUP BY channel ORDER BY count(*) DESC`,
|
||||
)
|
||||
).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 = (
|
||||
await query<CountRow>(
|
||||
`SELECT 'pending_without_chinese_internal_sku' AS label, count(*)::int AS total
|
||||
FROM order_items oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
WHERE oi.fulfillment_status = 'pending'
|
||||
AND ($1::boolean OR o.shop_id NOT LIKE 'sim-%')
|
||||
AND COALESCE(resolve_internal_sku(
|
||||
oi.manual_sku_key, oi.sku_name, oi.sku_code
|
||||
), '') = ''`,
|
||||
[includeDemo],
|
||||
)
|
||||
).rows[0]
|
||||
|
||||
checks.enabledLegacyRules = (
|
||||
await query<CountRow>(
|
||||
`SELECT match_key_type || '/' || channel AS label, count(*)::int AS total
|
||||
FROM sku_channel_rules
|
||||
WHERE enabled = TRUE AND match_key_type = 'sku_code'
|
||||
GROUP BY 1 ORDER BY 2 DESC`,
|
||||
)
|
||||
).rows
|
||||
|
||||
checks.demoPending = (
|
||||
await query<CountRow>(
|
||||
`SELECT 'demo_pending' AS label, count(*)::int AS total
|
||||
FROM order_items oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
WHERE oi.fulfillment_status = 'pending' AND o.shop_id LIKE 'sim-%'`,
|
||||
)
|
||||
).rows[0]
|
||||
|
||||
checks.pendingWithoutReason = (
|
||||
await query<CountRow>(
|
||||
`SELECT 'pending_without_reason' AS label, count(*)::int AS total
|
||||
FROM order_items oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
WHERE oi.fulfillment_status = 'pending'
|
||||
AND ($1::boolean OR o.shop_id NOT LIKE 'sim-%')
|
||||
AND oi.fulfillment_pending_reason IS NULL`,
|
||||
[includeDemo],
|
||||
)
|
||||
).rows[0]
|
||||
|
||||
checks.channelCarrier = (
|
||||
await query<{
|
||||
channel: string
|
||||
total: number
|
||||
work_orders: number
|
||||
affiliate_tasks: number
|
||||
manual_tasks: number
|
||||
}>(
|
||||
`SELECT COALESCE(fulfillment_channel, '(pending)') AS channel,
|
||||
count(*)::int AS total,
|
||||
count(*) FILTER (WHERE EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id AND wo.status <> 'cancelled'))::int AS work_orders,
|
||||
count(*) FILTER (WHERE EXISTS (SELECT 1 FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash' AND ft.task_status NOT IN ('closed','expired','failed','manual_review','retry_pending','cancelled')))::int AS affiliate_tasks,
|
||||
count(*) FILTER (WHERE EXISTS (SELECT 1 FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id AND ft.executor_key = 'manual_dispatch' AND ft.task_status NOT IN ('closed','expired','failed','manual_review','retry_pending','cancelled')))::int AS manual_tasks
|
||||
FROM order_items oi
|
||||
WHERE oi.fulfillment_status = 'assigned'
|
||||
GROUP BY 1 ORDER BY 2 DESC`,
|
||||
)
|
||||
).rows
|
||||
|
||||
const failed: string[] = []
|
||||
const rename = checks.renameCardChannels as CountRow[]
|
||||
if (rename.some((row) => row.label === 'pending')) failed.push('11月改名卡仍有 pending')
|
||||
if (!includeDemo && (checks.demoPending as CountRow).total > 0) {
|
||||
checks.excludedDemoPending = (checks.demoPending as CountRow).total
|
||||
}
|
||||
if ((checks.pendingWithoutChannel as CountRow).total > 0 && !includeDemo) {
|
||||
// 本地演示店铺故意保留 pending;严格模式默认只校验非演示数据。
|
||||
const productionPending = await query<CountRow>(
|
||||
`SELECT 'pending_no_internal_channel' AS label, count(*)::int AS total
|
||||
FROM order_items oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
WHERE oi.fulfillment_status = 'pending'
|
||||
AND o.shop_id NOT LIKE 'sim-%'
|
||||
AND btrim(COALESCE(resolve_internal_sku(
|
||||
oi.manual_sku_key, oi.sku_name, oi.sku_code
|
||||
), '')) <> ''
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM internal_sku_channels c
|
||||
WHERE c.enabled AND btrim(c.internal_sku) = resolve_internal_sku(
|
||||
oi.manual_sku_key, oi.sku_name, oi.sku_code
|
||||
)
|
||||
)`,
|
||||
)
|
||||
if (productionPending.rows[0]!.total > 0)
|
||||
failed.push('存在有内部 SKU 但无渠道能力的非演示 pending')
|
||||
} else if ((checks.pendingWithoutChannel as CountRow).total > 0) {
|
||||
failed.push('存在有内部 SKU 但无渠道能力的 pending')
|
||||
}
|
||||
if ((checks.assignedWithoutCarrier as CountRow).total > 0)
|
||||
failed.push('存在已分配自动发货/接单渠道但没有真实载体的订单项')
|
||||
if ((checks.assignedWithMultipleCarriers as CountRow).total > 0)
|
||||
failed.push('存在同时挂载多个真实履约载体的已分配订单项')
|
||||
if ((checks.internalChannelConflicts as CountRow[]).length > 0)
|
||||
failed.push('存在多个非兜底主渠道')
|
||||
if ((checks.uppercaseInternalChannels as CountRow).total > 0)
|
||||
failed.push('内部 SKU 渠道配置仍含未规范化的大写英文')
|
||||
if ((checks.canonicalChannelDuplicates as CountRow[]).length > 0)
|
||||
failed.push('存在规范化后重复的内部 SKU 渠道配置')
|
||||
if ((checks.incompleteCarrierConfigs as CountRow[]).length > 0)
|
||||
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)
|
||||
failed.push('存在无法归一到中文内部 SKU 的 pending 订单项')
|
||||
if ((checks.enabledLegacyRules as CountRow[]).length > 0)
|
||||
failed.push('仍有启用的 sku_code 兼容规则,未完全收敛到内部 SKU')
|
||||
if ((checks.pendingWithoutReason as CountRow).total > 0)
|
||||
failed.push('存在未写入 pending 原因的订单项,请先执行履约/财务管道')
|
||||
|
||||
console.log(JSON.stringify({ strict, failed, ...checks }, null, 2))
|
||||
if (strict && failed.length > 0) process.exitCode = 2
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((error) => {
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
.finally(() => closeDb())
|
||||
@@ -83,10 +83,12 @@ main()
|
||||
|
||||
async function assertSchema() {
|
||||
const ready = await query<{ ready: boolean }>(
|
||||
`SELECT to_regclass('public.sku_channel_rules') IS NOT NULL AS ready`,
|
||||
`SELECT
|
||||
to_regclass('public.internal_skus') IS NOT NULL
|
||||
AND to_regclass('public.sku_channel_routes') IS NOT NULL AS ready`,
|
||||
)
|
||||
if (!ready.rows[0]?.ready) {
|
||||
throw new Error('缺少 112 迁移产物;请先执行 npm run db:migrate')
|
||||
throw new Error('缺少统一 SKU 路由迁移产物;请先执行 npm run db:migrate')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,23 +32,25 @@ async function loadCandidates(limit: number): Promise<Candidate[]> {
|
||||
FROM order_items oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
JOIN LATERAL (
|
||||
SELECT c.channel
|
||||
FROM internal_sku_channels c
|
||||
WHERE c.enabled
|
||||
AND c.channel IN ('affiliate_dash', 'manual', 'third_party_voucher')
|
||||
AND btrim(c.internal_sku) = resolve_internal_sku(
|
||||
SELECT route.channel
|
||||
FROM sku_channel_routes route
|
||||
JOIN internal_skus sku ON sku.id = route.internal_sku_id
|
||||
WHERE route.enabled
|
||||
AND route.route_role = 'primary'
|
||||
AND route.channel IN ('affiliate_dash', 'work_order', 'manual', 'third_party_voucher')
|
||||
AND sku.internal_sku_key = resolve_internal_sku(
|
||||
oi.manual_sku_key, oi.sku_name, oi.sku_code
|
||||
)
|
||||
ORDER BY
|
||||
c.priority ASC,
|
||||
CASE c.channel
|
||||
route.priority ASC,
|
||||
CASE route.channel
|
||||
WHEN 'affiliate_dash' THEN 1
|
||||
WHEN 'work_order' THEN 2
|
||||
WHEN 'third_party_voucher' THEN 3
|
||||
WHEN 'manual' THEN 4
|
||||
ELSE 9
|
||||
END,
|
||||
c.channel ASC
|
||||
route.channel ASC
|
||||
LIMIT 1
|
||||
) cap ON TRUE
|
||||
WHERE oi.fulfillment_status = 'assigned'
|
||||
|
||||
@@ -18,26 +18,19 @@ async function cleanup() {
|
||||
)
|
||||
await query(`DELETE FROM sales_daily_facts WHERE shop_id = ANY($1)`, [SHOPS])
|
||||
await query(
|
||||
`DELETE FROM internal_sku_channels
|
||||
WHERE internal_sku IN ('模拟自动发货商品', '模拟三方卡券', '模拟人工商品')`,
|
||||
)
|
||||
await query(
|
||||
`DELETE FROM order_items WHERE order_id IN (SELECT id FROM orders WHERE shop_id = ANY($1))`,
|
||||
[SHOPS],
|
||||
'ALTER TABLE fulfillment_state_events DISABLE TRIGGER trg_fulfillment_state_events_append_only',
|
||||
)
|
||||
try {
|
||||
await query(
|
||||
`DELETE FROM order_items WHERE order_id IN (SELECT id FROM orders WHERE shop_id = ANY($1))`,
|
||||
[SHOPS],
|
||||
)
|
||||
} finally {
|
||||
await query(
|
||||
'ALTER TABLE fulfillment_state_events ENABLE TRIGGER trg_fulfillment_state_events_append_only',
|
||||
)
|
||||
}
|
||||
await query(`DELETE FROM orders WHERE shop_id = ANY($1)`, [SHOPS])
|
||||
await query(
|
||||
`DELETE FROM sku_channel_rules
|
||||
WHERE match_key IN ('SIM_AFF_1', 'SIMV001', 'SIMM001',
|
||||
'模拟自动发货商品', '模拟三方卡券', '模拟人工商品')`,
|
||||
)
|
||||
await query(
|
||||
`DELETE FROM product_channel_capabilities
|
||||
WHERE product_id IN (
|
||||
SELECT id FROM product_catalog
|
||||
WHERE canonical_sku IN ('aff:SIM_AFF_1', 'man:模拟人工商品')
|
||||
)`,
|
||||
)
|
||||
await query(
|
||||
`DELETE FROM channel_product_mappings
|
||||
WHERE canonical_product_id IN (
|
||||
@@ -51,7 +44,9 @@ async function cleanup() {
|
||||
`DELETE FROM cost_versions
|
||||
WHERE canonical_product_id IN (
|
||||
SELECT id FROM product_catalog
|
||||
WHERE canonical_sku IN ('aff:SIM_AFF_1', 'tpv:模拟三方卡券', 'tpv:模拟三方卡券B')
|
||||
WHERE canonical_sku IN (
|
||||
'aff:SIM_AFF_1', 'man:模拟人工商品', 'tpv:模拟三方卡券', 'tpv:模拟三方卡券B'
|
||||
)
|
||||
)`,
|
||||
)
|
||||
await query(
|
||||
@@ -64,6 +59,14 @@ async function cleanup() {
|
||||
'aff:SIM_AFF_1', 'man:模拟人工商品', 'tpv:模拟三方卡券', 'tpv:模拟三方卡券B'
|
||||
)`,
|
||||
)
|
||||
await query(
|
||||
`DELETE FROM internal_skus
|
||||
WHERE internal_sku_key IN (
|
||||
canonicalize_internal_sku('模拟自动发货商品'),
|
||||
canonicalize_internal_sku('模拟三方卡券'),
|
||||
canonicalize_internal_sku('模拟人工商品')
|
||||
)`,
|
||||
)
|
||||
}
|
||||
|
||||
async function createOrder(shop: string, orderNo: string, amountFen = 1000) {
|
||||
@@ -119,11 +122,6 @@ async function main() {
|
||||
VALUES ($1,'product',$2,500,0,'2026-10-01T00:00:00+08:00','active','本地演示采购价',NOW(),NOW())`,
|
||||
[affCostItem.rows[0]!.id, affProductId],
|
||||
)
|
||||
await query(
|
||||
`INSERT INTO product_channel_capabilities (product_id, channel, priority, is_fallback, enabled, source)
|
||||
VALUES ($1,'affiliate_dash',10,FALSE,TRUE,'demo'), ($1,'manual',20,TRUE,TRUE,'demo')`,
|
||||
[affProductId],
|
||||
)
|
||||
const manualProduct = await query<{ id: number }>(
|
||||
`INSERT INTO product_catalog (
|
||||
canonical_sku, canonical_name, category, unit_name, status,
|
||||
@@ -134,12 +132,6 @@ async function main() {
|
||||
) RETURNING id`,
|
||||
)
|
||||
const manualProductId = Number(manualProduct.rows[0]!.id)
|
||||
await query(
|
||||
`INSERT INTO product_channel_capabilities (
|
||||
product_id, channel, priority, is_fallback, enabled, source
|
||||
) VALUES ($1, 'manual', 10, FALSE, TRUE, 'demo')`,
|
||||
[manualProductId],
|
||||
)
|
||||
const manualCostItem = await query<{ id: number }>(
|
||||
`SELECT id FROM cost_items WHERE code = 'sys_manual_service'`,
|
||||
)
|
||||
@@ -154,17 +146,6 @@ async function main() {
|
||||
'active', '本地演示人工成本', NOW(), NOW())`,
|
||||
[manualCostItem.rows[0]!.id, affProductId, manualProductId],
|
||||
)
|
||||
await query(
|
||||
`INSERT INTO internal_sku_channels (
|
||||
internal_sku, channel, priority, is_fallback, enabled,
|
||||
canonical_product_id, config_json, source, created_at, updated_at
|
||||
) VALUES
|
||||
('模拟自动发货商品', 'affiliate_dash', 10, FALSE, TRUE, $1,
|
||||
'{"affiliateSku":"SIM_AFF_1"}'::jsonb, 'demo', NOW(), NOW()),
|
||||
('模拟自动发货商品', 'manual', 90, TRUE, TRUE, $1,
|
||||
'{}'::jsonb, 'demo', NOW(), NOW())`,
|
||||
[affProductId],
|
||||
)
|
||||
// 人工演示配置
|
||||
await query(
|
||||
`INSERT INTO manual_fulfillment_configs (
|
||||
@@ -176,9 +157,31 @@ async function main() {
|
||||
[affProductId, manualProductId],
|
||||
)
|
||||
await query(
|
||||
`INSERT INTO internal_sku_channels (
|
||||
internal_sku, channel, priority, is_fallback, enabled, source, created_at, updated_at
|
||||
) VALUES ('模拟人工商品', 'manual', 10, FALSE, TRUE, 'demo', NOW(), NOW())`,
|
||||
`
|
||||
INSERT INTO sku_channel_routes (
|
||||
internal_sku_id, channel, route_role, priority, enabled,
|
||||
canonical_product_id, carrier_config_json, source, created_at, updated_at
|
||||
)
|
||||
SELECT sku_id, channel, route_role, priority, TRUE, product_id, config_json, 'demo', NOW(), NOW()
|
||||
FROM (
|
||||
VALUES
|
||||
(ensure_canonical_internal_sku('模拟自动发货商品'), 'affiliate_dash', 'primary', 10,
|
||||
$1::bigint, '{"affiliateSku":"SIM_AFF_1"}'::jsonb),
|
||||
(ensure_canonical_internal_sku('模拟自动发货商品'), 'manual', 'fallback', 90,
|
||||
$1::bigint, '{}'::jsonb),
|
||||
(ensure_canonical_internal_sku('模拟人工商品'), 'manual', 'primary', 10,
|
||||
$2::bigint, '{}'::jsonb)
|
||||
) demo(sku_id, channel, route_role, priority, product_id, config_json)
|
||||
ON CONFLICT (internal_sku_id, channel) DO UPDATE SET
|
||||
route_role = EXCLUDED.route_role,
|
||||
priority = EXCLUDED.priority,
|
||||
enabled = TRUE,
|
||||
canonical_product_id = EXCLUDED.canonical_product_id,
|
||||
carrier_config_json = EXCLUDED.carrier_config_json,
|
||||
source = EXCLUDED.source,
|
||||
updated_at = NOW()
|
||||
`,
|
||||
[affProductId, manualProductId],
|
||||
)
|
||||
|
||||
// 1. 三方卡券:待分类,指定后自动建商品
|
||||
|
||||
Reference in New Issue
Block a user