重构:硬切商品来源并移除旧运维入口
This commit is contained in:
@@ -9,8 +9,6 @@
|
||||
"db:migrate": "tsx src/db/migrate.ts",
|
||||
"db:migrate:create": "tsx scripts/create-migration.ts",
|
||||
"db:migrate:status": "tsx scripts/migration-status.ts",
|
||||
"finance:sync": "tsx scripts/sync-cost-finance.ts",
|
||||
"finance:rebuild-cutover": "tsx scripts/rebuild-after-cutover.ts",
|
||||
"finance:rebuild-derived": "tsx scripts/rebuild-finance-derived.ts",
|
||||
"audit:finance": "tsx scripts/audit-finance-derived.ts",
|
||||
"revenue:backfill": "tsx scripts/backfill-revenue-basis.ts",
|
||||
@@ -39,8 +37,6 @@
|
||||
"check": "npm run format:check && npm run check:sql && npm run lint:check && npm run typecheck && npm test && npm run build",
|
||||
"start": "node dist/index.js",
|
||||
"start:src": "tsx src/index.ts",
|
||||
"products:merge-duplicates": "tsx scripts/merge-duplicate-products.ts",
|
||||
"products:reclassify": "tsx scripts/reclassify-post-cutover.ts",
|
||||
"demo:seed-finance": "tsx scripts/seed-local-finance-demo.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,253 +0,0 @@
|
||||
/**
|
||||
* 合并同一内部 SKU 的重复商品(aff: / man:)。
|
||||
*
|
||||
* 默认 dry-run;--apply 才写库。
|
||||
* 保留 aff: 商品作为主商品,把 man: 商品的引用全部迁过去,
|
||||
* 再把人工/自动两种渠道能力挂到主商品上,并生成中文内部 SKU 的自动发货规则。
|
||||
*
|
||||
* 用法:
|
||||
* npm run products:merge-duplicates -- --dry-run
|
||||
* npm run products:merge-duplicates -- --apply
|
||||
*/
|
||||
import process from 'node:process'
|
||||
|
||||
import { closeDb, query, withTransaction } from '../src/db/client.js'
|
||||
import { refreshFactsForDates } from '../src/services/finance/refresh-facts.js'
|
||||
|
||||
type GroupRow = {
|
||||
canonical_name: string
|
||||
keeper_id: number
|
||||
keeper_sku: string
|
||||
duplicate_ids: number[]
|
||||
duplicate_skus: string[]
|
||||
}
|
||||
|
||||
async function loadGroups(): Promise<GroupRow[]> {
|
||||
const result = await query<{
|
||||
canonical_name: string
|
||||
keeper_id: number
|
||||
keeper_sku: string
|
||||
duplicate_ids: number[]
|
||||
duplicate_skus: string[]
|
||||
}>(
|
||||
`
|
||||
SELECT
|
||||
canonical_name,
|
||||
(array_agg(id ORDER BY (canonical_sku LIKE 'aff:%') DESC, id))[1] AS keeper_id,
|
||||
(array_agg(canonical_sku ORDER BY (canonical_sku LIKE 'aff:%') DESC, id))[1] AS keeper_sku,
|
||||
array_remove(array_agg(id ORDER BY id), (array_agg(id ORDER BY (canonical_sku LIKE 'aff:%') DESC, id))[1]) AS duplicate_ids,
|
||||
array_remove(array_agg(canonical_sku ORDER BY id), (array_agg(canonical_sku ORDER BY (canonical_sku LIKE 'aff:%') DESC, id))[1]) AS duplicate_skus
|
||||
FROM product_catalog
|
||||
WHERE status = 'active'
|
||||
GROUP BY canonical_name
|
||||
HAVING count(*) > 1
|
||||
AND bool_or(canonical_sku LIKE 'aff:%')
|
||||
AND bool_or(canonical_sku LIKE 'man:%')
|
||||
ORDER BY canonical_name
|
||||
`,
|
||||
)
|
||||
return result.rows
|
||||
}
|
||||
|
||||
async function mergeGroup(
|
||||
group: GroupRow,
|
||||
): Promise<{ mappings: number; versions: number; facts: number }> {
|
||||
let mappings = 0
|
||||
let versions = 0
|
||||
let facts = 0
|
||||
|
||||
for (const duplicateId of group.duplicate_ids) {
|
||||
await withTransaction(async (client) => {
|
||||
await client.query(
|
||||
'UPDATE order_items SET canonical_product_id = $2, updated_at = NOW() WHERE canonical_product_id = $1',
|
||||
[duplicateId, group.keeper_id],
|
||||
)
|
||||
await client.query(
|
||||
'UPDATE cost_snapshots SET canonical_product_id = $2 WHERE canonical_product_id = $1',
|
||||
[duplicateId, group.keeper_id],
|
||||
)
|
||||
await client.query(
|
||||
'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(
|
||||
`
|
||||
DELETE FROM channel_product_mappings d
|
||||
USING channel_product_mappings k
|
||||
WHERE d.canonical_product_id = $1 AND k.canonical_product_id = $2
|
||||
AND d.provider = k.provider AND d.shop_id = k.shop_id
|
||||
AND d.platform_sku_code = k.platform_sku_code AND d.match_type = k.match_type
|
||||
AND d.platform_sku_name_key = k.platform_sku_name_key
|
||||
`,
|
||||
[duplicateId, group.keeper_id],
|
||||
)
|
||||
mappings += conflict.rowCount || 0
|
||||
const movedMappings = await client.query(
|
||||
'UPDATE channel_product_mappings SET canonical_product_id = $2 WHERE canonical_product_id = $1',
|
||||
[duplicateId, group.keeper_id],
|
||||
)
|
||||
mappings += movedMappings.rowCount || 0
|
||||
|
||||
// 成本版本:有唯一键冲突就中止该组,避免静默丢价格。
|
||||
const versionConflict = await client.query(
|
||||
`
|
||||
SELECT 1 FROM cost_versions d
|
||||
JOIN cost_versions k
|
||||
ON k.cost_item_id = d.cost_item_id
|
||||
AND k.scope_type = d.scope_type
|
||||
AND k.provider = d.provider
|
||||
AND k.shop_id = d.shop_id
|
||||
AND COALESCE(k.work_product_rule_id, 0) = COALESCE(d.work_product_rule_id, 0)
|
||||
AND k.effective_from = d.effective_from
|
||||
WHERE d.canonical_product_id = $1 AND k.canonical_product_id = $2
|
||||
LIMIT 1
|
||||
`,
|
||||
[duplicateId, group.keeper_id],
|
||||
)
|
||||
if (versionConflict.rowCount) {
|
||||
throw new Error(`商品 ${duplicateId} 的成本版本与主商品冲突,已中止`)
|
||||
}
|
||||
const movedVersions = await client.query(
|
||||
'UPDATE cost_versions SET canonical_product_id = $2 WHERE canonical_product_id = $1',
|
||||
[duplicateId, group.keeper_id],
|
||||
)
|
||||
versions += movedVersions.rowCount || 0
|
||||
|
||||
// 事实:先删冲突行,其余迁移;迁移后统一重算受影响日期。
|
||||
const conflictFacts = await client.query(
|
||||
`
|
||||
DELETE FROM sales_daily_facts d
|
||||
USING sales_daily_facts k
|
||||
WHERE d.canonical_product_id = $1 AND k.canonical_product_id = $2
|
||||
AND d.stat_date = k.stat_date AND d.provider = k.provider
|
||||
AND d.shop_id = k.shop_id AND d.channel = k.channel
|
||||
`,
|
||||
[duplicateId, group.keeper_id],
|
||||
)
|
||||
facts += conflictFacts.rowCount || 0
|
||||
const movedFacts = await client.query(
|
||||
'UPDATE sales_daily_facts SET canonical_product_id = $2 WHERE canonical_product_id = $1',
|
||||
[duplicateId, group.keeper_id],
|
||||
)
|
||||
facts += movedFacts.rowCount || 0
|
||||
})
|
||||
}
|
||||
|
||||
await query(
|
||||
`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(
|
||||
`
|
||||
INSERT INTO sku_channel_routes (
|
||||
internal_sku_id, channel, route_role, priority, enabled, canonical_product_id, source
|
||||
)
|
||||
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, sku_channel_routes.canonical_product_id),
|
||||
updated_at = NOW()
|
||||
`,
|
||||
[group.keeper_id],
|
||||
)
|
||||
|
||||
// 只删除已经没有引用的重复商品,仍被引用就归档。
|
||||
for (const duplicateId of group.duplicate_ids) {
|
||||
const refs = await query<{ total: number }>(
|
||||
`
|
||||
SELECT (
|
||||
(SELECT count(*) FROM order_items WHERE canonical_product_id = $1) +
|
||||
(SELECT count(*) FROM cost_snapshots WHERE canonical_product_id = $1) +
|
||||
(SELECT count(*) FROM sales_daily_facts WHERE canonical_product_id = $1) +
|
||||
(SELECT count(*) FROM cost_versions WHERE canonical_product_id = $1) +
|
||||
(SELECT count(*) FROM channel_product_mappings WHERE canonical_product_id = $1) +
|
||||
(SELECT count(*) FROM manual_fulfillment_configs WHERE canonical_product_id = $1)
|
||||
)::int AS total
|
||||
`,
|
||||
[duplicateId],
|
||||
)
|
||||
if (Number(refs.rows[0]?.total || 0) === 0) {
|
||||
await query('DELETE FROM product_catalog WHERE id = $1', [duplicateId])
|
||||
} else {
|
||||
await query(
|
||||
"UPDATE product_catalog SET status = 'archived', updated_at = NOW() WHERE id = $1",
|
||||
[duplicateId],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return { mappings, versions, facts }
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const apply = process.argv.includes('--apply')
|
||||
const groups = await loadGroups()
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
mode: apply ? 'apply' : 'dry-run',
|
||||
groups: groups.map((g) => ({
|
||||
name: g.canonical_name,
|
||||
keeper: g.keeper_sku,
|
||||
duplicates: g.duplicate_skus,
|
||||
})),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
if (!apply || groups.length === 0) return
|
||||
|
||||
const dates = await query<{ stat_date: string }>(
|
||||
`SELECT DISTINCT stat_date::text FROM sales_daily_facts WHERE stat_date >= '2026-10-31'`,
|
||||
)
|
||||
const affectedDates = dates.rows.map((row) => row.stat_date)
|
||||
let totals = { mappings: 0, versions: 0, facts: 0 }
|
||||
for (const group of groups) {
|
||||
const result = await mergeGroup(group)
|
||||
totals = {
|
||||
mappings: totals.mappings + result.mappings,
|
||||
versions: totals.versions + result.versions,
|
||||
facts: totals.facts + result.facts,
|
||||
}
|
||||
console.log(
|
||||
`merged ${group.canonical_name}: keeper=${group.keeper_sku} dup=${group.duplicate_skus.join(',')}`,
|
||||
)
|
||||
}
|
||||
await refreshFactsForDates(affectedDates)
|
||||
console.log(JSON.stringify({ totals, affectedDates }, null, 2))
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((error) => {
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
.finally(() => closeDb())
|
||||
@@ -1,113 +0,0 @@
|
||||
import process from 'node:process'
|
||||
|
||||
import { closeDb, query } from '../src/db/client.js'
|
||||
import { assignPendingOrderItemChannels } from '../src/services/fulfillment/channel-assign-service.js'
|
||||
import { normalizeOrderItemProducts } from '../src/services/cost-center/product-normalize-service.js'
|
||||
import { syncChannelCostSnapshots } from '../src/services/cost-center/cost-snapshot-service.js'
|
||||
import { rebuildChannelSalesDailyFacts } from '../src/services/finance/sales-facts-channel-service.js'
|
||||
import { syncFinanceLedgerFromFacts } from '../src/services/finance/finance-ledger-service.js'
|
||||
import { formatShanghaiDate } from '../src/utils/finance-date-range.js'
|
||||
|
||||
/**
|
||||
* 断代后重建:从 cutover 日起重跑三条管道,生成成本快照、销售事实与期初分录。
|
||||
*
|
||||
* 幂等:渠道判定/商品归一/成本快照都按「缺什么补什么」推进,事实按日先删后插,
|
||||
* 总账按「event_type + 日期」幂等。可以反复执行。
|
||||
*
|
||||
* 用法:
|
||||
* npm run finance:rebuild-cutover -- --date-from=2026-10-31 --date-to=2026-10-31
|
||||
* npm run finance:rebuild-cutover -- --max-batches=200
|
||||
*/
|
||||
|
||||
const options = parseOptions(process.argv.slice(2))
|
||||
const dateFrom = String(options.dateFrom || '2026-10-31').slice(0, 10)
|
||||
const dateTo = String(options.dateTo || formatShanghaiDate(new Date())).slice(0, 10)
|
||||
const maxBatches = clampInt(options.maxBatches, 200, 1, 2000)
|
||||
const batchSize = clampInt(options.batchSize, 2000, 1, 5000)
|
||||
|
||||
async function main() {
|
||||
await assertSchema()
|
||||
const startedAt = Date.now()
|
||||
|
||||
const channel = await assignPendingOrderItemChannels({ batchSize, maxBatches })
|
||||
const product = await normalizeOrderItemProducts({ batchSize, maxBatches })
|
||||
const cost = await syncChannelCostSnapshots({ batchSize, maxBatches })
|
||||
const facts = await rebuildChannelSalesDailyFacts({ dateFrom, dateTo })
|
||||
const ledger = await syncFinanceLedgerFromFacts({ dateFrom, dateTo })
|
||||
|
||||
const summary = await query<{
|
||||
assigned: number
|
||||
pending: number
|
||||
archived: number
|
||||
facts: number
|
||||
snapshots: number
|
||||
}>(
|
||||
`
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE status = 'assigned')::int AS assigned,
|
||||
COUNT(*) FILTER (WHERE status = 'pending')::int AS pending,
|
||||
COUNT(*) FILTER (WHERE status = 'archived')::int AS archived,
|
||||
(SELECT COUNT(*)::int FROM sales_daily_facts) AS facts,
|
||||
(SELECT COUNT(*)::int FROM cost_snapshots WHERE superseded_at IS NULL) AS snapshots
|
||||
FROM fulfillments
|
||||
`,
|
||||
)
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
dateFrom,
|
||||
dateTo,
|
||||
channel,
|
||||
product,
|
||||
cost,
|
||||
facts,
|
||||
ledger,
|
||||
totals: summary.rows[0],
|
||||
durationMs: Date.now() - startedAt,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
.finally(async () => {
|
||||
await closeDb()
|
||||
})
|
||||
|
||||
async function assertSchema() {
|
||||
const ready = await query<{ ready: boolean }>(
|
||||
`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('缺少统一 SKU 路由迁移产物;请先执行 npm run db:migrate')
|
||||
}
|
||||
}
|
||||
|
||||
function parseOptions(args: string[]) {
|
||||
const result: Record<string, string> = {}
|
||||
for (const arg of args) {
|
||||
if (!arg.startsWith('--')) continue
|
||||
const body = arg.slice(2)
|
||||
const index = body.indexOf('=')
|
||||
const rawKey = index === -1 ? body : body.slice(0, index)
|
||||
const value = index === -1 ? 'true' : body.slice(index + 1)
|
||||
result[rawKey] = value
|
||||
result[rawKey.replace(/-([a-z])/g, (_, char: string) => char.toUpperCase())] = value
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function clampInt(value: unknown, fallback: number, min: number, max: number) {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isInteger(parsed)) return fallback
|
||||
return Math.min(max, Math.max(min, parsed))
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
/**
|
||||
* 断代后订单按新的商品渠道能力重判。
|
||||
*
|
||||
* 仅处理:assigned、未锁定、无成功履约证据、有商品能力的订单项。
|
||||
* 默认 dry-run;--apply 才写库。
|
||||
*/
|
||||
import process from 'node:process'
|
||||
|
||||
import { closeDb, query, withTransaction } from '../src/db/client.js'
|
||||
import { syncCostSnapshotsForItems } from '../src/services/cost-center/cost-snapshot-service.js'
|
||||
import { refreshFactsForDates } from '../src/services/finance/refresh-facts.js'
|
||||
|
||||
const CHANNEL_COST_CODE: Record<string, string> = {
|
||||
affiliate_dash: 'sys_affiliate_purchase',
|
||||
third_party_voucher: 'sys_third_party_voucher_cost',
|
||||
work_order: 'sys_work_order_reward',
|
||||
}
|
||||
|
||||
type Candidate = {
|
||||
id: number
|
||||
current_channel: string
|
||||
desired_channel: string
|
||||
basis_date: string
|
||||
}
|
||||
|
||||
async function loadCandidates(limit: number): Promise<Candidate[]> {
|
||||
const result = await query<Candidate>(
|
||||
`
|
||||
SELECT oi.id, oi.fulfillment_channel AS current_channel,
|
||||
cap.channel AS desired_channel,
|
||||
(COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date::text AS basis_date
|
||||
FROM order_items oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
JOIN LATERAL (
|
||||
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
|
||||
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,
|
||||
route.channel ASC
|
||||
LIMIT 1
|
||||
) cap ON TRUE
|
||||
WHERE oi.fulfillment_status = 'assigned'
|
||||
AND oi.fulfillment_locked = FALSE
|
||||
AND COALESCE(o.paid_at, o.created_at) >= '2026-10-31 00:00:00+08'
|
||||
AND oi.fulfillment_source NOT IN ('operator_assigned', 'manual_confirm')
|
||||
AND (
|
||||
cap.channel <> 'affiliate_dash'
|
||||
OR 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')
|
||||
)
|
||||
)
|
||||
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 IN ('redeemed', 'paid')
|
||||
)
|
||||
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 cost_snapshots cs
|
||||
JOIN cost_items ci ON ci.id = cs.cost_item_id
|
||||
WHERE cs.order_item_id = oi.id
|
||||
AND ci.code = 'sys_manual_service'
|
||||
AND cs.superseded_at IS NULL
|
||||
AND cs.reversed_at IS NULL
|
||||
)
|
||||
ORDER BY oi.id
|
||||
LIMIT $1
|
||||
`,
|
||||
[limit],
|
||||
)
|
||||
return result.rows
|
||||
}
|
||||
|
||||
async function applyOne(row: Candidate): Promise<boolean> {
|
||||
const previous = row.current_channel
|
||||
if (!previous || previous === row.desired_channel) return false
|
||||
|
||||
const changed = await withTransaction(async (client) => {
|
||||
const result = await client.query(
|
||||
`
|
||||
UPDATE order_items
|
||||
SET original_channel = COALESCE(original_channel, $2),
|
||||
fulfillment_channel = $3,
|
||||
fulfillment_source = 'auto_rule',
|
||||
channel_switch_reason = 'reclassify',
|
||||
channel_switched_at = NOW(),
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
AND fulfillment_status = 'assigned'
|
||||
AND fulfillment_locked = FALSE
|
||||
AND fulfillment_channel = $2
|
||||
RETURNING id
|
||||
`,
|
||||
[row.id, previous, row.desired_channel],
|
||||
)
|
||||
if ((result.rowCount || 0) === 0) return false
|
||||
|
||||
const code = CHANNEL_COST_CODE[previous]
|
||||
if (code) {
|
||||
await client.query(
|
||||
`
|
||||
UPDATE cost_snapshots
|
||||
SET reversed_at = NOW(), reversal_reason = 'reclassify'
|
||||
WHERE order_item_id = $1
|
||||
AND cost_item_id = (SELECT id FROM cost_items WHERE code = $2)
|
||||
AND superseded_at IS NULL
|
||||
AND reversed_at IS NULL
|
||||
`,
|
||||
[row.id, code],
|
||||
)
|
||||
}
|
||||
return true
|
||||
})
|
||||
if (!changed) return false
|
||||
|
||||
await syncCostSnapshotsForItems([row.id]).catch(() => undefined)
|
||||
return true
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const apply = process.argv.includes('--apply')
|
||||
const limit = 5000
|
||||
const rows = await loadCandidates(limit)
|
||||
const changes = rows.filter((row) => row.current_channel !== row.desired_channel)
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
mode: apply ? 'apply' : 'dry-run',
|
||||
scanned: rows.length,
|
||||
toChange: changes.length,
|
||||
sample: changes.slice(0, 20).map((row) => ({
|
||||
id: row.id,
|
||||
from: row.current_channel,
|
||||
to: row.desired_channel,
|
||||
date: row.basis_date,
|
||||
})),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
if (!apply || changes.length === 0) return
|
||||
|
||||
let switched = 0
|
||||
const dates = new Set<string>()
|
||||
for (const row of changes) {
|
||||
if (await applyOne(row)) {
|
||||
switched += 1
|
||||
dates.add(row.basis_date)
|
||||
}
|
||||
}
|
||||
await refreshFactsForDates([...dates])
|
||||
console.log(JSON.stringify({ switched, dates: [...dates].sort() }, null, 2))
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((error) => {
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
.finally(() => closeDb())
|
||||
@@ -0,0 +1,29 @@
|
||||
-- 168_product_source_kind_constraint.sql
|
||||
--
|
||||
-- 商品来源统一由 product_catalog.source_kind 表达。
|
||||
-- 运行期不再解析 canonical_sku 前缀;数据库同步收紧允许值。
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_constraint
|
||||
WHERE conname = 'chk_product_catalog_source_kind'
|
||||
) THEN
|
||||
ALTER TABLE product_catalog
|
||||
ADD CONSTRAINT chk_product_catalog_source_kind
|
||||
CHECK (
|
||||
source_kind IN (
|
||||
'affiliate_dash',
|
||||
'work_order',
|
||||
'third_party_voucher',
|
||||
'manual',
|
||||
'unclassified'
|
||||
)
|
||||
)
|
||||
NOT VALID;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
ALTER TABLE product_catalog
|
||||
VALIDATE CONSTRAINT chk_product_catalog_source_kind;
|
||||
@@ -110,7 +110,7 @@ export async function syncAdminAffiliateDashProducts() {
|
||||
GROUP BY sku_code, sku_name
|
||||
)
|
||||
INSERT INTO product_catalog (
|
||||
canonical_sku, canonical_name, category, unit_name, status,
|
||||
canonical_sku, canonical_name, category, unit_name, source_kind, status,
|
||||
attributes_json, created_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
@@ -118,6 +118,7 @@ export async function syncAdminAffiliateDashProducts() {
|
||||
ranked.sku_name,
|
||||
'',
|
||||
CASE WHEN ranked.sku_name LIKE '%个' THEN '个' ELSE '件' END,
|
||||
'affiliate_dash',
|
||||
'active',
|
||||
jsonb_build_object(
|
||||
'source', 'affiliate_dash_task',
|
||||
@@ -133,6 +134,7 @@ export async function syncAdminAffiliateDashProducts() {
|
||||
ON CONFLICT (canonical_sku) DO UPDATE
|
||||
SET canonical_name = CASE WHEN product_catalog.canonical_name = '' THEN EXCLUDED.canonical_name ELSE product_catalog.canonical_name END,
|
||||
unit_name = EXCLUDED.unit_name,
|
||||
source_kind = EXCLUDED.source_kind,
|
||||
attributes_json = product_catalog.attributes_json || EXCLUDED.attributes_json,
|
||||
updated_at = $1
|
||||
RETURNING (xmax = 0) AS inserted
|
||||
@@ -319,12 +321,15 @@ export async function setAdminAffiliateProductPrice(
|
||||
id: number
|
||||
canonical_sku: string
|
||||
canonical_name: string
|
||||
}>('SELECT id, canonical_sku, canonical_name FROM product_catalog WHERE id = $1', [productId])
|
||||
source_kind: string
|
||||
}>('SELECT id, canonical_sku, canonical_name, source_kind FROM product_catalog WHERE id = $1', [
|
||||
productId,
|
||||
])
|
||||
const product = productResult.rows[0]
|
||||
if (!product) {
|
||||
throw createHttpError('商品不存在', { statusCode: 404 })
|
||||
}
|
||||
if (!product.canonical_sku.startsWith('aff:')) {
|
||||
if (product.source_kind !== 'affiliate_dash') {
|
||||
throw createHttpError('该商品不是自动发货商品,无法设置自动发货采购成本', { statusCode: 400 })
|
||||
}
|
||||
|
||||
@@ -501,16 +506,17 @@ export async function importAdminAffiliateDashPricesBaseline(session: AdminSessi
|
||||
const productInsert = await query<{ id: number; inserted: boolean }>(
|
||||
`
|
||||
INSERT INTO product_catalog (
|
||||
canonical_sku, canonical_name, category, unit_name, status,
|
||||
canonical_sku, canonical_name, category, unit_name, source_kind, status,
|
||||
attributes_json, created_at, updated_at
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, 'active',
|
||||
$1, $2, $3, $4, 'affiliate_dash', 'active',
|
||||
jsonb_build_object('source', 'affiliate_dash_price_import', 'skuCode', $5::text),
|
||||
$6, $6
|
||||
)
|
||||
ON CONFLICT (canonical_sku) DO UPDATE
|
||||
SET canonical_name = CASE WHEN product_catalog.canonical_name = '' THEN EXCLUDED.canonical_name ELSE product_catalog.canonical_name END,
|
||||
category = CASE WHEN product_catalog.category = '' THEN EXCLUDED.category ELSE product_catalog.category END,
|
||||
source_kind = EXCLUDED.source_kind,
|
||||
updated_at = $6
|
||||
RETURNING id, (xmax = 0) AS inserted
|
||||
`,
|
||||
|
||||
@@ -103,8 +103,8 @@ export const PROVIDERS = ['91kaquan', 'pdd', 'douyin_mashida']
|
||||
export const WORK_ORDER_TEMPLATE_COST_BASELINE = '2026-07-10T00:00:00+08:00'
|
||||
|
||||
/**
|
||||
* 商品来源前缀。商品表不再存履约载体,来源只由 canonical_sku 前缀表达。
|
||||
* 前缀之外的 SKU 归入「未分类」,不允许反推履约方式。
|
||||
* 商品创建时使用的编码前缀。运行期来源统一读取 product_catalog.source_kind,
|
||||
* 不能再用 canonical_sku 前缀反推商品来源或履约方式。
|
||||
*/
|
||||
export const PRODUCT_SOURCE_PREFIXES: Array<{
|
||||
prefix: string
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* 把数据库行映射成接口 DTO,并提供「成本模型是否适用于履约方式」这类口径函数。
|
||||
*/
|
||||
import { GENERIC_COST_MODELS, PRODUCT_SOURCE_PREFIXES } from './cost-center-constants.js'
|
||||
import { GENERIC_COST_MODELS } from './cost-center-constants.js'
|
||||
import type { FulfillmentChannel } from './cost-center-constants.js'
|
||||
import { parseJsonObject } from './cost-center-normalizers.js'
|
||||
import type {
|
||||
@@ -33,26 +33,9 @@ export const PRODUCT_CURRENT_CHANNEL_FILTERS = [
|
||||
|
||||
export type ProductCurrentChannelFilter = (typeof PRODUCT_CURRENT_CHANNEL_FILTERS)[number]
|
||||
|
||||
/**
|
||||
* 由 canonical_sku 前缀推导商品来源。
|
||||
* 返回空串表示未分类;未分类商品不允许反推履约方式。
|
||||
*/
|
||||
export function productSourceOf(canonicalSku: string): string {
|
||||
const sku = String(canonicalSku || '').trim()
|
||||
for (const item of PRODUCT_SOURCE_PREFIXES) {
|
||||
if (sku.startsWith(item.prefix)) return item.source
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/** 商品来源筛选的 SQL 片段:按 canonical_sku 前缀过滤。 */
|
||||
export function productSourceFilterSql(paramIndex: number, column = 'p.canonical_sku'): string {
|
||||
return `(
|
||||
($${paramIndex} = 'affiliate_dash' AND ${column} LIKE 'aff:%')
|
||||
OR ($${paramIndex} = 'work_order' AND (${column} LIKE 'tpl:%' OR ${column} LIKE 'wo:%'))
|
||||
OR ($${paramIndex} = 'third_party_voucher' AND ${column} LIKE 'tpv:%')
|
||||
OR ($${paramIndex} = 'manual' AND ${column} LIKE 'man:%')
|
||||
)`
|
||||
/** 商品来源筛选的唯一 SQL 口径:读取 source_kind,不解析 canonical_sku。 */
|
||||
export function productSourceFilterSql(paramIndex: number, column = 'p.source_kind'): string {
|
||||
return `${column} = $${paramIndex}`
|
||||
}
|
||||
|
||||
/** 当前渠道筛选:标准商品页签的唯一筛选口径。 */
|
||||
@@ -121,8 +104,8 @@ export function mapProduct(row: ProductRow) {
|
||||
canonicalSku: row.canonical_sku,
|
||||
canonicalName: row.canonical_name,
|
||||
category: row.category || '',
|
||||
source: String(row.source_kind || productSourceOf(row.canonical_sku)),
|
||||
sourceKind: String(row.source_kind || productSourceOf(row.canonical_sku)),
|
||||
source: String(row.source_kind || 'unclassified'),
|
||||
sourceKind: String(row.source_kind || 'unclassified'),
|
||||
rootCategoryId: row.root_category_id ? Number(row.root_category_id) : null,
|
||||
rootCategoryName: row.root_category_name || row.category || '',
|
||||
subCategoryId: row.sub_category_id ? Number(row.sub_category_id) : null,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* 口径(2026-10-23 重写):
|
||||
* 1. 履约渠道只有 fulfillments.channel/status 一个真源;
|
||||
* 2. 成本项用 cost_model 表达「成本怎么算」,与履约方式正交;
|
||||
* 3. 商品来源由 canonical_sku 前缀推导,商品表不再存履约载体;
|
||||
* 3. 商品来源读取 product_catalog.source_kind,商品表不再存履约载体;
|
||||
* 4. 快照与异常表都不再存履约载体,join order_items 即可拿到。
|
||||
*
|
||||
* 2026-10-25 拆分:804 行的共享层按职责拆成四个模块,本文件只做统一出口,
|
||||
|
||||
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import test from 'node:test'
|
||||
|
||||
import { costModelAppliesTo } from './cost-center-mappers.js'
|
||||
import { costModelAppliesTo, mapProduct } from './cost-center-mappers.js'
|
||||
import {
|
||||
normalizeMappingMatchType,
|
||||
normalizeProviderKey,
|
||||
@@ -288,8 +288,9 @@ test('接单模板改价必须在同一事务中同步成本版本与模板投
|
||||
test('成本模型与商品来源必须覆盖第三方卡券渠道', () => {
|
||||
assert.equal(costModelAppliesTo('third_party_voucher_cost', 'third_party_voucher'), true)
|
||||
assert.equal(costModelAppliesTo('third_party_voucher_cost', 'manual'), false)
|
||||
assert.ok(productSourceFilterSql(1).includes("'third_party_voucher'"))
|
||||
assert.ok(productSourceFilterSql(1).includes("'tpv:%'"))
|
||||
const sourceSql = productSourceFilterSql(1)
|
||||
assert.equal(sourceSql, 'p.source_kind = $1')
|
||||
assert.ok(!sourceSql.includes('canonical_sku'))
|
||||
const channelSql = productChannelFilterSql(1)
|
||||
assert.ok(channelSql.includes('active_primary_channel'))
|
||||
assert.ok(channelSql.includes("$1 = 'unrouted'"))
|
||||
@@ -351,11 +352,35 @@ test('运行期停止写入已废弃的 product_channel_capabilities', async ()
|
||||
}
|
||||
})
|
||||
|
||||
test('tpv: 商品来源归为第三方卡券', async () => {
|
||||
const { productSourceOf } = await import('./cost-center-mappers.js')
|
||||
const assert = (await import('node:assert/strict')).default
|
||||
assert.equal(productSourceOf('tpv:模拟卡券'), 'third_party_voucher')
|
||||
assert.equal(productSourceOf('man:模拟人工'), 'manual')
|
||||
test('商品来源只读 source_kind,不再按 canonical_sku 前缀回退', () => {
|
||||
const product = mapProduct({
|
||||
id: 1,
|
||||
canonical_sku: 'man:编码与来源故意不一致',
|
||||
canonical_name: '测试商品',
|
||||
category: '',
|
||||
unit_name: '件',
|
||||
status: 'active',
|
||||
root_category_id: null,
|
||||
root_category_name: '',
|
||||
sub_category_id: null,
|
||||
sub_category_name: '',
|
||||
attributes_json: {},
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
source_kind: 'third_party_voucher',
|
||||
})
|
||||
assert.equal(product.source, 'third_party_voucher')
|
||||
assert.equal(product.sourceKind, 'third_party_voucher')
|
||||
})
|
||||
|
||||
test('阶段 6 不再保留会写旧履约列的运维脚本', async () => {
|
||||
const packageJson = JSON.parse(
|
||||
await readFile(new URL('../../../package.json', import.meta.url), 'utf8'),
|
||||
) as { scripts?: Record<string, string> }
|
||||
assert.equal(packageJson.scripts?.['finance:sync'], undefined)
|
||||
assert.equal(packageJson.scripts?.['finance:rebuild-cutover'], undefined)
|
||||
assert.equal(packageJson.scripts?.['products:merge-duplicates'], undefined)
|
||||
assert.equal(packageJson.scripts?.['products:reclassify'], undefined)
|
||||
})
|
||||
|
||||
test('人工标准商品禁止绕过人工 SKU 配置手工创建', async () => {
|
||||
|
||||
@@ -247,7 +247,7 @@ export async function listManualSkus(input: Record<string, unknown> = {}) {
|
||||
const platformKey = String(input.platformKey || input.platform_key || '').trim()
|
||||
const provider = String(input.provider || '').trim()
|
||||
|
||||
const conditions: string[] = [`p.status = 'active'`, `p.canonical_sku LIKE 'man:%'`]
|
||||
const conditions: string[] = [`p.status = 'active'`]
|
||||
const params: unknown[] = ['manual']
|
||||
conditions.push(productSourceFilterSql(params.length))
|
||||
|
||||
@@ -277,8 +277,14 @@ export async function listManualSkus(input: Record<string, unknown> = {}) {
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
WHERE oi.fulfillment_status = 'assigned' AND oi.fulfillment_channel = 'manual'
|
||||
AND o.provider = $${params.length}
|
||||
AND resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code)
|
||||
= canonicalize_internal_sku(substring(p.canonical_sku from 5))
|
||||
AND p.internal_sku_id = (
|
||||
SELECT sku.id
|
||||
FROM internal_skus sku
|
||||
WHERE sku.internal_sku_key = resolve_internal_sku(
|
||||
oi.manual_sku_key, oi.sku_name, oi.sku_code
|
||||
)
|
||||
LIMIT 1
|
||||
)
|
||||
)`)
|
||||
}
|
||||
|
||||
@@ -287,11 +293,11 @@ export async function listManualSkus(input: Record<string, unknown> = {}) {
|
||||
`
|
||||
SELECT COUNT(*)::int AS total
|
||||
FROM product_catalog p
|
||||
JOIN internal_skus product_sku ON product_sku.id = p.internal_sku_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT *
|
||||
FROM manual_fulfillment_configs cfg
|
||||
WHERE canonicalize_internal_sku(cfg.sku_key)
|
||||
= canonicalize_internal_sku(substring(p.canonical_sku from 5))
|
||||
WHERE canonicalize_internal_sku(cfg.sku_key) = product_sku.internal_sku_key
|
||||
ORDER BY cfg.id
|
||||
LIMIT 1
|
||||
) cfg ON TRUE
|
||||
@@ -322,7 +328,7 @@ export async function listManualSkus(input: Record<string, unknown> = {}) {
|
||||
)
|
||||
SELECT
|
||||
p.id,
|
||||
substring(p.canonical_sku from 5) AS sku_key,
|
||||
product_sku.internal_sku_key AS sku_key,
|
||||
p.canonical_name AS display_name,
|
||||
COALESCE(manual_stats.shop_count, 0)::int AS shop_count,
|
||||
COALESCE(manual_stats.order_count, 0)::int AS order_count,
|
||||
@@ -340,16 +346,16 @@ export async function listManualSkus(input: Record<string, unknown> = {}) {
|
||||
cfg.enabled AS config_enabled,
|
||||
cfg.channels_json AS config_channels_json
|
||||
FROM product_catalog p
|
||||
JOIN internal_skus product_sku ON product_sku.id = p.internal_sku_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT *
|
||||
FROM manual_fulfillment_configs cfg
|
||||
WHERE canonicalize_internal_sku(cfg.sku_key)
|
||||
= canonicalize_internal_sku(substring(p.canonical_sku from 5))
|
||||
WHERE canonicalize_internal_sku(cfg.sku_key) = product_sku.internal_sku_key
|
||||
ORDER BY cfg.id
|
||||
LIMIT 1
|
||||
) cfg ON TRUE
|
||||
LEFT JOIN manual_stats
|
||||
ON manual_stats.sku_key = canonicalize_internal_sku(substring(p.canonical_sku from 5))
|
||||
ON manual_stats.sku_key = product_sku.internal_sku_key
|
||||
WHERE ${where}
|
||||
ORDER BY COALESCE(manual_stats.item_count, 0) DESC, p.canonical_name ASC, p.id ASC
|
||||
LIMIT $${listParams.length - 1} OFFSET $${listParams.length}
|
||||
@@ -388,18 +394,22 @@ export async function getManualSkuOverview() {
|
||||
GROUP BY 1
|
||||
),
|
||||
manual_scope AS (
|
||||
SELECT p.id, p.canonical_sku, p.canonical_name, cfg.id AS config_id
|
||||
SELECT
|
||||
p.id,
|
||||
p.canonical_sku,
|
||||
p.canonical_name,
|
||||
product_sku.internal_sku_key,
|
||||
cfg.id AS config_id
|
||||
FROM product_catalog p
|
||||
JOIN internal_skus product_sku ON product_sku.id = p.internal_sku_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT cfg.id
|
||||
FROM manual_fulfillment_configs cfg
|
||||
WHERE canonicalize_internal_sku(cfg.sku_key)
|
||||
= canonicalize_internal_sku(substring(p.canonical_sku from 5))
|
||||
WHERE canonicalize_internal_sku(cfg.sku_key) = product_sku.internal_sku_key
|
||||
ORDER BY cfg.id
|
||||
LIMIT 1
|
||||
) cfg ON TRUE
|
||||
WHERE p.status = 'active'
|
||||
AND p.canonical_sku LIKE 'man:%'
|
||||
AND ${productSourceFilterSql(1)}
|
||||
)
|
||||
SELECT
|
||||
@@ -414,9 +424,7 @@ export async function getManualSkuOverview() {
|
||||
MAX(manual_stats.last_seen_date)::text AS last_scan_at
|
||||
FROM manual_scope
|
||||
LEFT JOIN manual_stats
|
||||
ON manual_stats.sku_key = canonicalize_internal_sku(
|
||||
substring(manual_scope.canonical_sku from 5)
|
||||
)
|
||||
ON manual_stats.sku_key = manual_scope.internal_sku_key
|
||||
`,
|
||||
['manual'],
|
||||
)
|
||||
@@ -469,8 +477,12 @@ export async function saveManualFulfillmentConfig(payload: JsonObject) {
|
||||
let resolvedProductId = canonicalProductId
|
||||
|
||||
if (resolvedProductId) {
|
||||
const product = await client.query<{ canonical_sku: string; status: string }>(
|
||||
`SELECT canonical_sku, status
|
||||
const product = await client.query<{
|
||||
canonical_sku: string
|
||||
source_kind: string
|
||||
status: string
|
||||
}>(
|
||||
`SELECT canonical_sku, source_kind, status
|
||||
FROM product_catalog
|
||||
WHERE id = $1
|
||||
LIMIT 1`,
|
||||
@@ -479,9 +491,9 @@ export async function saveManualFulfillmentConfig(payload: JsonObject) {
|
||||
if (
|
||||
!product.rows[0] ||
|
||||
product.rows[0].status !== 'active' ||
|
||||
!String(product.rows[0].canonical_sku || '').startsWith('man:')
|
||||
product.rows[0].source_kind !== 'manual'
|
||||
) {
|
||||
throw createHttpError('人工履约配置只能关联有效的 man: 标准商品', {
|
||||
throw createHttpError('人工履约配置只能关联来源为人工的有效标准商品', {
|
||||
statusCode: 400,
|
||||
errorCode: 'manual_config_product_source_invalid',
|
||||
})
|
||||
@@ -510,10 +522,10 @@ export async function saveManualFulfillmentConfig(payload: JsonObject) {
|
||||
.slice(0, 120) || skuKey.slice(0, 120)
|
||||
const product = await client.query<{ id: number }>(
|
||||
`INSERT INTO product_catalog (
|
||||
canonical_sku, canonical_name, category, unit_name, status,
|
||||
canonical_sku, canonical_name, category, unit_name, source_kind, status,
|
||||
attributes_json, created_at, updated_at
|
||||
) VALUES (
|
||||
$1, $2, '人工', '件', 'active',
|
||||
$1, $2, '人工', '件', 'manual', 'active',
|
||||
'{"source":"manual_config","auto_created":true}'::jsonb, $3, $3
|
||||
)
|
||||
ON CONFLICT (canonical_sku) DO UPDATE SET
|
||||
@@ -789,8 +801,8 @@ export async function deleteManualSkuClassification(
|
||||
),
|
||||
updated_at = $2::timestamptz
|
||||
WHERE status = 'active'
|
||||
AND canonical_sku LIKE 'man:%'
|
||||
AND canonicalize_internal_sku(substring(canonical_sku from 5)) = $1
|
||||
AND source_kind = 'manual'
|
||||
AND internal_sku_id = (SELECT id FROM internal_skus WHERE internal_sku_key = $1 LIMIT 1)
|
||||
RETURNING id
|
||||
`,
|
||||
[key, nowIso()],
|
||||
|
||||
@@ -67,13 +67,10 @@ import {
|
||||
/** 标准商品页签的当前渠道筛选取值。 */
|
||||
const PRODUCT_CHANNEL_FILTERS = new Set<string>(PRODUCT_CURRENT_CHANNEL_FILTERS)
|
||||
|
||||
/**
|
||||
* 校验新建商品的 SKU 前缀。
|
||||
* 前缀是商品来源的唯一表达,不允许出现无法归类的商品。
|
||||
*/
|
||||
function assertKnownSourcePrefix(canonicalSku: string) {
|
||||
/** 创建商品时把编码前缀固化为 source_kind,运行期不再反推来源。 */
|
||||
function resolveProductSourceKind(canonicalSku: string) {
|
||||
for (const item of PRODUCT_SOURCE_PREFIXES) {
|
||||
if (canonicalSku.startsWith(item.prefix)) return
|
||||
if (canonicalSku.startsWith(item.prefix)) return item.source
|
||||
}
|
||||
throw createHttpError(
|
||||
`标准 SKU 必须以下列前缀开头:${PRODUCT_SOURCE_PREFIXES.map((item) => item.prefix).join(' / ')}`,
|
||||
@@ -225,9 +222,9 @@ export async function listAdminProducts(payload: JsonObject = {}) {
|
||||
ORDER BY
|
||||
p.status ASC,
|
||||
CASE
|
||||
WHEN p.canonical_sku LIKE 'aff:%' THEN 1
|
||||
WHEN p.canonical_sku LIKE 'tpl:%' OR p.canonical_sku LIKE 'wo:%' THEN 2
|
||||
WHEN p.canonical_sku LIKE 'man:%' THEN 3
|
||||
WHEN p.source_kind = 'affiliate_dash' THEN 1
|
||||
WHEN p.source_kind = 'work_order' THEN 2
|
||||
WHEN p.source_kind = 'manual' THEN 3
|
||||
ELSE 4
|
||||
END,
|
||||
p.canonical_name ASC,
|
||||
@@ -323,7 +320,7 @@ export async function createAdminProduct(payload: JsonObject, _session: AdminSes
|
||||
errorCode: 'cost_product_sku_invalid',
|
||||
})
|
||||
}
|
||||
assertKnownSourcePrefix(canonicalSku)
|
||||
const sourceKind = resolveProductSourceKind(canonicalSku)
|
||||
const canonicalName = normalizeRequiredText(
|
||||
payload.canonicalName ?? payload.canonical_name,
|
||||
'商品名称',
|
||||
@@ -338,13 +335,22 @@ export async function createAdminProduct(payload: JsonObject, _session: AdminSes
|
||||
const result = await query<ProductRow>(
|
||||
`
|
||||
INSERT INTO product_catalog (
|
||||
canonical_sku, canonical_name, category, unit_name, status,
|
||||
canonical_sku, canonical_name, category, unit_name, source_kind, status,
|
||||
attributes_json, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $7)
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, $8)
|
||||
ON CONFLICT (canonical_sku) DO NOTHING
|
||||
RETURNING *
|
||||
`,
|
||||
[canonicalSku, canonicalName, category, unitName, status, JSON.stringify(attributes), now],
|
||||
[
|
||||
canonicalSku,
|
||||
canonicalName,
|
||||
category,
|
||||
unitName,
|
||||
sourceKind,
|
||||
status,
|
||||
JSON.stringify(attributes),
|
||||
now,
|
||||
],
|
||||
)
|
||||
const inserted = result.rows[0]
|
||||
if (!inserted) {
|
||||
@@ -449,7 +455,7 @@ export async function deleteAdminVoucherProduct(productId: number | string) {
|
||||
[id],
|
||||
)
|
||||
const current = requiredRow(currentResult.rows[0], '商品不存在')
|
||||
if (!current.canonical_sku.startsWith('tpv:')) {
|
||||
if (current.source_kind !== 'third_party_voucher') {
|
||||
throw createHttpError('该商品不是第三方卡券商品', {
|
||||
statusCode: 400,
|
||||
errorCode: 'voucher_product_type_invalid',
|
||||
@@ -1498,9 +1504,10 @@ export async function setAdminProductPrice(
|
||||
id: number
|
||||
canonical_sku: string
|
||||
canonical_name: string
|
||||
source_kind: string
|
||||
attributes_json: unknown
|
||||
}>(
|
||||
'SELECT id, canonical_sku, canonical_name, attributes_json FROM product_catalog WHERE id = $1',
|
||||
'SELECT id, canonical_sku, canonical_name, source_kind, attributes_json FROM product_catalog WHERE id = $1',
|
||||
[productId],
|
||||
)
|
||||
const product = productResult.rows[0]
|
||||
@@ -1509,7 +1516,7 @@ export async function setAdminProductPrice(
|
||||
}
|
||||
|
||||
// 1. 如果是自动发货商品,委托给 setAdminAffiliateProductPrice
|
||||
if (product.canonical_sku.startsWith('aff:')) {
|
||||
if (product.source_kind === 'affiliate_dash') {
|
||||
return setAdminAffiliateProductPrice(productId, payload, session)
|
||||
}
|
||||
|
||||
@@ -1523,7 +1530,7 @@ export async function setAdminProductPrice(
|
||||
const changeReason = String(payload.changeReason || '在商品与成本中心统一调整价格').trim()
|
||||
|
||||
// 2. 如果是接单模板商品 (tpl:)
|
||||
if (product.canonical_sku.startsWith('tpl:')) {
|
||||
if (product.source_kind === 'work_order' && product.canonical_sku.startsWith('tpl:')) {
|
||||
const ruleKey = product.canonical_sku.slice(4)
|
||||
const ruleResult = await query<{
|
||||
id: number
|
||||
@@ -1729,12 +1736,13 @@ export async function setAdminProductPrice(
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 其他标准商品 (wo: / tpv: / man:)
|
||||
const costItemCode = product.canonical_sku.startsWith('wo:')
|
||||
? 'sys_work_order_reward'
|
||||
: product.canonical_sku.startsWith('tpv:')
|
||||
? 'sys_third_party_voucher_cost'
|
||||
: 'sys_manual_service'
|
||||
// 3. 其他标准商品:成本项由 source_kind 决定,canonical_sku 只保留载体键。
|
||||
const costItemCode =
|
||||
product.source_kind === 'work_order'
|
||||
? 'sys_work_order_reward'
|
||||
: product.source_kind === 'third_party_voucher'
|
||||
? 'sys_third_party_voucher_cost'
|
||||
: 'sys_manual_service'
|
||||
const costItemResult = await query<{ id: number }>(
|
||||
'SELECT id FROM cost_items WHERE code = $1 LIMIT 1',
|
||||
[costItemCode],
|
||||
@@ -1911,13 +1919,16 @@ export async function syncAdminProductPrice(productIdInput: unknown, session: Ad
|
||||
id: number
|
||||
canonical_sku: string
|
||||
canonical_name: string
|
||||
}>('SELECT id, canonical_sku, canonical_name FROM product_catalog WHERE id = $1', [productId])
|
||||
source_kind: string
|
||||
}>('SELECT id, canonical_sku, canonical_name, source_kind FROM product_catalog WHERE id = $1', [
|
||||
productId,
|
||||
])
|
||||
const product = productResult.rows[0]
|
||||
if (!product) {
|
||||
throw createHttpError('商品不存在', { statusCode: 404 })
|
||||
}
|
||||
|
||||
if (product.canonical_sku.startsWith('tpl:')) {
|
||||
if (product.source_kind === 'work_order' && product.canonical_sku.startsWith('tpl:')) {
|
||||
const ruleKey = product.canonical_sku.slice(4)
|
||||
const ruleResult = await query<{ id: number }>(
|
||||
'SELECT id FROM work_product_rules WHERE rule_key = $1 LIMIT 1',
|
||||
@@ -1937,7 +1948,7 @@ export async function syncAdminProductPrice(productIdInput: unknown, session: Ad
|
||||
}
|
||||
}
|
||||
|
||||
if (product.canonical_sku.startsWith('aff:')) {
|
||||
if (product.source_kind === 'affiliate_dash') {
|
||||
// 成本快照由成本快照管道按缺补算,这里不再同步触发。
|
||||
return {
|
||||
success: true,
|
||||
|
||||
@@ -68,7 +68,7 @@ function item(patch: Record<string, unknown> = {}) {
|
||||
canonical_product_id: null as number | null,
|
||||
work_rule_key: null as string | null,
|
||||
manual_config_product_id: null as number | null,
|
||||
manual_config_product_sku: null as string | null,
|
||||
manual_config_product_source_kind: null as string | null,
|
||||
...patch,
|
||||
}
|
||||
}
|
||||
@@ -126,7 +126,7 @@ test('商品归一:人工渠道直接使用配置里指定的商品', () => {
|
||||
fulfillment_channel: 'manual',
|
||||
sku_code: 'unknown',
|
||||
manual_config_product_id: 44,
|
||||
manual_config_product_sku: 'man:配置商品',
|
||||
manual_config_product_source_kind: 'manual',
|
||||
}),
|
||||
index,
|
||||
),
|
||||
@@ -177,13 +177,13 @@ test('商品归一:其他已确定渠道也不能借用同名其他来源商
|
||||
}
|
||||
})
|
||||
|
||||
test('商品归一:人工配置也只能挂载 man: 商品', () => {
|
||||
test('商品归一:人工配置只能挂载来源为人工的标准商品', () => {
|
||||
assert.equal(
|
||||
resolveProductId(
|
||||
item({
|
||||
fulfillment_channel: 'manual',
|
||||
manual_config_product_id: 99,
|
||||
manual_config_product_sku: 'aff:错误商品',
|
||||
manual_config_product_source_kind: 'affiliate_dash',
|
||||
}),
|
||||
index,
|
||||
),
|
||||
@@ -194,7 +194,7 @@ test('商品归一:人工配置也只能挂载 man: 商品', () => {
|
||||
item({
|
||||
fulfillment_channel: 'on_account',
|
||||
manual_config_product_id: 99,
|
||||
manual_config_product_sku: 'man:正确商品',
|
||||
manual_config_product_source_kind: 'manual',
|
||||
}),
|
||||
index,
|
||||
),
|
||||
@@ -240,10 +240,11 @@ test('三方卡券自动建商品时优先使用内部中文 SKU', () => {
|
||||
test('商品归一扫描会纠正所有渠道的错误来源商品', async () => {
|
||||
const source = await readFile(new URL('./product-normalize-service.ts', import.meta.url), 'utf8')
|
||||
|
||||
assert.ok(source.includes("current_product.canonical_sku NOT LIKE 'aff:%'"))
|
||||
assert.ok(source.includes("current_product.canonical_sku NOT LIKE 'man:%'"))
|
||||
assert.ok(source.includes("current_product.canonical_sku NOT LIKE 'tpl:%'"))
|
||||
assert.ok(source.includes("current_product.canonical_sku NOT LIKE 'tpv:%'"))
|
||||
assert.ok(source.includes("current_product.source_kind IS DISTINCT FROM 'affiliate_dash'"))
|
||||
assert.ok(source.includes("current_product.source_kind IS DISTINCT FROM 'manual'"))
|
||||
assert.ok(source.includes("current_product.source_kind IS DISTINCT FROM 'work_order'"))
|
||||
assert.ok(source.includes("current_product.source_kind IS DISTINCT FROM 'third_party_voucher'"))
|
||||
assert.ok(!source.includes("current_product.canonical_sku NOT LIKE 'man:%'"))
|
||||
assert.ok(source.includes('canonicalProductId: null'))
|
||||
assert.ok(source.includes('onlyWhenCanonicalProductDifferent: true'))
|
||||
})
|
||||
|
||||
@@ -57,7 +57,15 @@ type ItemRow = {
|
||||
canonical_product_id?: number | null
|
||||
work_rule_key: string | null
|
||||
manual_config_product_id: number | null
|
||||
manual_config_product_sku?: string | null
|
||||
manual_config_product_source_kind?: string | null
|
||||
}
|
||||
|
||||
const EXPECTED_PRODUCT_SOURCE: Record<string, string> = {
|
||||
affiliate_dash: 'affiliate_dash',
|
||||
work_order: 'work_order',
|
||||
third_party_voucher: 'third_party_voucher',
|
||||
manual: 'manual',
|
||||
on_account: 'manual',
|
||||
}
|
||||
|
||||
function key(...parts: string[]): string {
|
||||
@@ -179,10 +187,13 @@ async function ensureVoucherProductForItem(item: ItemRow): Promise<number | null
|
||||
const created = await query<{ id: number }>(
|
||||
`
|
||||
INSERT INTO product_catalog (
|
||||
canonical_sku, canonical_name, category, unit_name, status,
|
||||
canonical_sku, canonical_name, category, unit_name, source_kind, status,
|
||||
attributes_json, created_at, updated_at
|
||||
)
|
||||
VALUES ($1, $2, '卡券', '张', 'active', '{"source":"third_party_voucher","auto_created":true}'::jsonb, NOW(), NOW())
|
||||
VALUES (
|
||||
$1, $2, '卡券', '张', 'third_party_voucher', 'active',
|
||||
'{"source":"third_party_voucher","auto_created":true}'::jsonb, NOW(), NOW()
|
||||
)
|
||||
ON CONFLICT (canonical_sku) DO NOTHING
|
||||
RETURNING id
|
||||
`,
|
||||
@@ -235,7 +246,7 @@ export function resolveProductId(item: ItemRow, index: MappingIndex): number | n
|
||||
// 已确定渠道必须命中本渠道商品,不能借用同名的其他来源商品。
|
||||
if (item.fulfillment_channel === 'manual' || item.fulfillment_channel === 'on_account') {
|
||||
return item.manual_config_product_id &&
|
||||
String(item.manual_config_product_sku || '').startsWith('man:')
|
||||
item.manual_config_product_source_kind === EXPECTED_PRODUCT_SOURCE.manual
|
||||
? Number(item.manual_config_product_id)
|
||||
: null
|
||||
}
|
||||
@@ -338,7 +349,7 @@ export async function normalizeOrderItemProducts(
|
||||
oi.canonical_product_id,
|
||||
wpr.rule_key AS work_rule_key,
|
||||
cfg.canonical_product_id AS manual_config_product_id,
|
||||
cfg_product.canonical_sku AS manual_config_product_sku
|
||||
cfg_product.source_kind AS manual_config_product_source_kind
|
||||
FROM v_order_item_current oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
LEFT JOIN work_orders wo ON wo.order_item_id = oi.id AND wo.status <> 'cancelled'
|
||||
@@ -349,36 +360,22 @@ export async function normalizeOrderItemProducts(
|
||||
WHERE oi.fulfillment_status = 'assigned'
|
||||
AND (
|
||||
oi.canonical_product_id IS NULL
|
||||
OR current_product.id IS NULL
|
||||
OR (
|
||||
oi.fulfillment_channel = 'third_party_voucher'
|
||||
AND (
|
||||
current_product.id IS NULL
|
||||
OR current_product.canonical_sku NOT LIKE 'tpv:%'
|
||||
)
|
||||
AND current_product.source_kind IS DISTINCT FROM 'third_party_voucher'
|
||||
)
|
||||
OR (
|
||||
oi.fulfillment_channel = 'affiliate_dash'
|
||||
AND (
|
||||
current_product.id IS NULL
|
||||
OR current_product.canonical_sku NOT LIKE 'aff:%'
|
||||
)
|
||||
AND current_product.source_kind IS DISTINCT FROM 'affiliate_dash'
|
||||
)
|
||||
OR (
|
||||
oi.fulfillment_channel IN ('manual', 'on_account')
|
||||
AND (
|
||||
current_product.id IS NULL
|
||||
OR current_product.canonical_sku NOT LIKE 'man:%'
|
||||
)
|
||||
AND current_product.source_kind IS DISTINCT FROM 'manual'
|
||||
)
|
||||
OR (
|
||||
oi.fulfillment_channel = 'work_order'
|
||||
AND (
|
||||
current_product.id IS NULL
|
||||
OR (
|
||||
current_product.canonical_sku NOT LIKE 'tpl:%'
|
||||
AND current_product.canonical_sku NOT LIKE 'wo:%'
|
||||
)
|
||||
)
|
||||
AND current_product.source_kind IS DISTINCT FROM 'work_order'
|
||||
)
|
||||
)
|
||||
AND oi.id > $1
|
||||
@@ -450,7 +447,7 @@ async function loadMappingIndex(): Promise<MappingIndex> {
|
||||
`
|
||||
SELECT id, canonical_sku
|
||||
FROM product_catalog
|
||||
WHERE status = 'active' AND canonical_sku LIKE 'aff:%'
|
||||
WHERE status = 'active' AND source_kind = 'affiliate_dash'
|
||||
`,
|
||||
)
|
||||
|
||||
@@ -458,7 +455,9 @@ async function loadMappingIndex(): Promise<MappingIndex> {
|
||||
`
|
||||
SELECT id, canonical_sku
|
||||
FROM product_catalog
|
||||
WHERE status = 'active' AND canonical_sku LIKE 'tpl:%'
|
||||
WHERE status = 'active'
|
||||
AND source_kind = 'work_order'
|
||||
AND canonical_sku LIKE 'tpl:%'
|
||||
`,
|
||||
)
|
||||
|
||||
@@ -466,7 +465,7 @@ async function loadMappingIndex(): Promise<MappingIndex> {
|
||||
`
|
||||
SELECT id, canonical_sku
|
||||
FROM product_catalog
|
||||
WHERE status = 'active' AND canonical_sku LIKE 'tpv:%'
|
||||
WHERE status = 'active' AND source_kind = 'third_party_voucher'
|
||||
`,
|
||||
)
|
||||
|
||||
@@ -528,7 +527,7 @@ export async function normalizeProductsForItems(itemIds: number[]): Promise<{
|
||||
oi.canonical_product_id,
|
||||
wpr.rule_key AS work_rule_key,
|
||||
cfg.canonical_product_id AS manual_config_product_id,
|
||||
cfg_product.canonical_sku AS manual_config_product_sku
|
||||
cfg_product.source_kind AS manual_config_product_source_kind
|
||||
FROM v_order_item_current oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
LEFT JOIN work_orders wo ON wo.order_item_id = oi.id AND wo.status <> 'cancelled'
|
||||
@@ -539,36 +538,22 @@ export async function normalizeProductsForItems(itemIds: number[]): Promise<{
|
||||
WHERE oi.fulfillment_status = 'assigned'
|
||||
AND (
|
||||
oi.canonical_product_id IS NULL
|
||||
OR current_product.id IS NULL
|
||||
OR (
|
||||
oi.fulfillment_channel = 'third_party_voucher'
|
||||
AND (
|
||||
current_product.id IS NULL
|
||||
OR current_product.canonical_sku NOT LIKE 'tpv:%'
|
||||
)
|
||||
AND current_product.source_kind IS DISTINCT FROM 'third_party_voucher'
|
||||
)
|
||||
OR (
|
||||
oi.fulfillment_channel = 'affiliate_dash'
|
||||
AND (
|
||||
current_product.id IS NULL
|
||||
OR current_product.canonical_sku NOT LIKE 'aff:%'
|
||||
)
|
||||
AND current_product.source_kind IS DISTINCT FROM 'affiliate_dash'
|
||||
)
|
||||
OR (
|
||||
oi.fulfillment_channel IN ('manual', 'on_account')
|
||||
AND (
|
||||
current_product.id IS NULL
|
||||
OR current_product.canonical_sku NOT LIKE 'man:%'
|
||||
)
|
||||
AND current_product.source_kind IS DISTINCT FROM 'manual'
|
||||
)
|
||||
OR (
|
||||
oi.fulfillment_channel = 'work_order'
|
||||
AND (
|
||||
current_product.id IS NULL
|
||||
OR (
|
||||
current_product.canonical_sku NOT LIKE 'tpl:%'
|
||||
AND current_product.canonical_sku NOT LIKE 'wo:%'
|
||||
)
|
||||
)
|
||||
AND current_product.source_kind IS DISTINCT FROM 'work_order'
|
||||
)
|
||||
)
|
||||
AND oi.id = ANY($1::bigint[])
|
||||
|
||||
@@ -168,9 +168,9 @@ export async function applyAdminTemplateProductBootstrap(
|
||||
const inserted = await client.query<{ id: number }>(
|
||||
`
|
||||
INSERT INTO product_catalog (
|
||||
canonical_sku, canonical_name, category, unit_name, status,
|
||||
canonical_sku, canonical_name, category, unit_name, source_kind, status,
|
||||
attributes_json, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $7)
|
||||
) VALUES ($1, $2, $3, $4, 'work_order', $5, $6::jsonb, $7, $7)
|
||||
ON CONFLICT (canonical_sku) DO NOTHING
|
||||
RETURNING id
|
||||
`,
|
||||
|
||||
@@ -60,14 +60,15 @@ export async function upsertAdminVoucherProduct(payload: JsonObject, session: Ad
|
||||
const productResult = await client.query<ProductRow>(
|
||||
`
|
||||
INSERT INTO product_catalog (
|
||||
canonical_sku, canonical_name, category, unit_name, status,
|
||||
canonical_sku, canonical_name, category, unit_name, source_kind, status,
|
||||
attributes_json, created_at, updated_at
|
||||
) VALUES (
|
||||
$1, $2, '卡券', '张', 'active',
|
||||
$1, $2, '卡券', '张', 'third_party_voucher', 'active',
|
||||
$3::jsonb, $4, $4
|
||||
)
|
||||
ON CONFLICT (canonical_sku) DO UPDATE SET
|
||||
status = 'active',
|
||||
source_kind = EXCLUDED.source_kind,
|
||||
canonical_name = EXCLUDED.canonical_name,
|
||||
attributes_json = product_catalog.attributes_json || EXCLUDED.attributes_json,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
|
||||
@@ -141,7 +141,7 @@ export async function backfillAdminWorkOrderProductSkus(payload: Record<string,
|
||||
)
|
||||
)
|
||||
INSERT INTO product_catalog (
|
||||
canonical_sku, canonical_name, category, unit_name, status,
|
||||
canonical_sku, canonical_name, category, unit_name, source_kind, status,
|
||||
attributes_json, created_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
@@ -153,6 +153,7 @@ export async function backfillAdminWorkOrderProductSkus(payload: Record<string,
|
||||
WHEN m.sample_name LIKE '%个%' THEN '个'
|
||||
ELSE '件'
|
||||
END,
|
||||
'work_order',
|
||||
'active',
|
||||
jsonb_build_object('source', 'work_order_sku_name', 'normalizedName', m.name),
|
||||
$1,
|
||||
|
||||
@@ -109,13 +109,14 @@ export async function syncWorkOrderTemplateProduct(
|
||||
client,
|
||||
`
|
||||
INSERT INTO product_catalog (
|
||||
canonical_sku, canonical_name, category, unit_name, status,
|
||||
canonical_sku, canonical_name, category, unit_name, source_kind, status,
|
||||
attributes_json, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, 'active', $5::jsonb, $6, $6)
|
||||
) VALUES ($1, $2, $3, $4, 'work_order', 'active', $5::jsonb, $6, $6)
|
||||
ON CONFLICT (canonical_sku) DO UPDATE SET
|
||||
canonical_name = EXCLUDED.canonical_name,
|
||||
category = EXCLUDED.category,
|
||||
unit_name = EXCLUDED.unit_name,
|
||||
source_kind = EXCLUDED.source_kind,
|
||||
attributes_json = COALESCE(product_catalog.attributes_json, '{}'::jsonb) || EXCLUDED.attributes_json,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
RETURNING id, status
|
||||
|
||||
@@ -156,7 +156,12 @@ test('人工确认前必须自动匹配履约配置,配置保存/定时任务
|
||||
const listBody = configSource.slice(listStart, listEnd)
|
||||
assert.ok(
|
||||
listBody.includes('FROM product_catalog p') && listBody.includes('productSourceFilterSql'),
|
||||
'人工成本配置必须以 man: 标准商品为唯一清单来源',
|
||||
'人工成本配置必须以 source_kind=manual 的标准商品为唯一清单来源',
|
||||
)
|
||||
assert.ok(
|
||||
listBody.includes('JOIN internal_skus product_sku') &&
|
||||
!listBody.includes('substring(p.canonical_sku'),
|
||||
'人工成本配置必须通过 internal_sku_id 读取内部 SKU,不能解析 man: 前缀',
|
||||
)
|
||||
assert.ok(
|
||||
reconcileMigration.includes('tmp_manual_product_merge') &&
|
||||
|
||||
@@ -2112,11 +2112,21 @@ async function ensureDraftProductForChannel(
|
||||
const key = canonicalizeInternalSku(internalSku)
|
||||
if (!key) return null
|
||||
const definition = {
|
||||
affiliate_dash: { prefix: 'aff:', category: '自动发货', unit: '件' },
|
||||
work_order: { prefix: 'tpl:', category: '接单', unit: '件' },
|
||||
manual: { prefix: 'man:', category: '人工', unit: '件' },
|
||||
on_account: { prefix: 'man:', category: '统一结算', unit: '件' },
|
||||
third_party_voucher: { prefix: 'tpv:', category: '卡券', unit: '张' },
|
||||
affiliate_dash: {
|
||||
prefix: 'aff:',
|
||||
category: '自动发货',
|
||||
unit: '件',
|
||||
sourceKind: 'affiliate_dash',
|
||||
},
|
||||
work_order: { prefix: 'tpl:', category: '接单', unit: '件', sourceKind: 'work_order' },
|
||||
manual: { prefix: 'man:', category: '人工', unit: '件', sourceKind: 'manual' },
|
||||
on_account: { prefix: 'man:', category: '统一结算', unit: '件', sourceKind: 'manual' },
|
||||
third_party_voucher: {
|
||||
prefix: 'tpv:',
|
||||
category: '卡券',
|
||||
unit: '张',
|
||||
sourceKind: 'third_party_voucher',
|
||||
},
|
||||
}[channel]
|
||||
const canonicalSku = `${definition.prefix}${key}`
|
||||
if (canonicalSku.length > 64) return null
|
||||
@@ -2129,16 +2139,18 @@ async function ensureDraftProductForChannel(
|
||||
canonical_name,
|
||||
category,
|
||||
unit_name,
|
||||
source_kind,
|
||||
status,
|
||||
attributes_json,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, 'active',
|
||||
$5::jsonb, $6, $6
|
||||
$1, $2, $3, $4, $5, 'active',
|
||||
$6::jsonb, $7, $7
|
||||
)
|
||||
ON CONFLICT (canonical_sku) DO UPDATE
|
||||
SET status = 'active',
|
||||
source_kind = EXCLUDED.source_kind,
|
||||
canonical_name = CASE
|
||||
WHEN BTRIM(product_catalog.canonical_name) = '' THEN EXCLUDED.canonical_name
|
||||
ELSE product_catalog.canonical_name
|
||||
@@ -2157,6 +2169,7 @@ async function ensureDraftProductForChannel(
|
||||
key.slice(0, 120),
|
||||
definition.category,
|
||||
definition.unit,
|
||||
definition.sourceKind,
|
||||
JSON.stringify({
|
||||
source: channel,
|
||||
auto_created: true,
|
||||
|
||||
Reference in New Issue
Block a user