Merge branch 'main' into dev
This commit is contained in:
@@ -0,0 +1,339 @@
|
||||
#!/usr/bin/env tsx
|
||||
/**
|
||||
* 财务销售/成本/总账一致性只读审计。
|
||||
*
|
||||
* 用法:
|
||||
* npm run audit:finance
|
||||
* npm run audit:finance -- --strict
|
||||
* npm run audit:finance -- --date-from=2026-11-01 --date-to=2026-11-30
|
||||
*
|
||||
* 唯一口径:
|
||||
* v_finance_order_item_scope 是销售范围的唯一来源;
|
||||
* sales_daily_facts 必须与其收入、退款守恒;
|
||||
* finance_journal_entries(ref_type=sales_fact) 必须与事实守恒。
|
||||
*/
|
||||
import process from 'node:process'
|
||||
|
||||
import { closeDb, query } from '../src/db/client.js'
|
||||
|
||||
const strict = process.argv.includes('--strict')
|
||||
const options = parseOptions(process.argv.slice(2))
|
||||
|
||||
type Totals = {
|
||||
revenueFen: number
|
||||
refundFen: number
|
||||
costFen: number
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const viewReady = await query<{ ready: boolean }>(
|
||||
`SELECT to_regclass('public.v_finance_order_item_scope') IS NOT NULL AS ready`,
|
||||
)
|
||||
if (!viewReady.rows[0]?.ready) {
|
||||
throw new Error('缺少 v_finance_order_item_scope;请先执行 npm run db:migrate')
|
||||
}
|
||||
|
||||
const bounds = await query<{ date_from: string | null; date_to: string | null }>(
|
||||
`WITH dates AS (
|
||||
SELECT stat_date AS stat_date
|
||||
FROM v_finance_order_item_scope
|
||||
UNION ALL
|
||||
SELECT stat_date
|
||||
FROM sales_daily_facts
|
||||
UNION ALL
|
||||
SELECT (occurred_at AT TIME ZONE 'Asia/Shanghai')::date
|
||||
FROM finance_journal_entries
|
||||
WHERE ref_type = 'sales_fact' AND status = 'posted'
|
||||
)
|
||||
SELECT MIN(stat_date)::text AS date_from, MAX(stat_date)::text AS date_to
|
||||
FROM dates`,
|
||||
)
|
||||
const dateFrom = normalizeDate(options.dateFrom) || bounds.rows[0]?.date_from || today()
|
||||
const dateTo = normalizeDate(options.dateTo) || bounds.rows[0]?.date_to || dateFrom
|
||||
if (dateFrom > dateTo) throw new Error('date-from 不能晚于 date-to')
|
||||
|
||||
const scope = await loadScopeTotals(dateFrom, dateTo)
|
||||
const facts = await loadFactTotals(dateFrom, dateTo)
|
||||
const ledger = await loadLedgerTotals(dateFrom, dateTo)
|
||||
const snapshots = await loadSnapshotTotals(dateFrom, dateTo)
|
||||
const quality = await loadQualityChecks(dateFrom, dateTo)
|
||||
|
||||
const critical: string[] = []
|
||||
compare('销售事实收入', scope.revenueFen, facts.revenueFen, critical)
|
||||
compare('销售事实退款', scope.refundFen, facts.refundFen, critical)
|
||||
compare('销售事实成本', snapshots.costFen, facts.costFen, critical)
|
||||
compare('总账收入', facts.revenueFen, ledger.revenueFen, critical)
|
||||
compare('总账退款', facts.refundFen, ledger.refundFen, critical)
|
||||
compare('总账成本', facts.costFen, ledger.costFen, critical)
|
||||
if (quality.duplicateActiveSnapshots > 0) critical.push('存在重复有效成本快照')
|
||||
if (quality.negativeFactRows > 0) critical.push('销售事实存在负数金额')
|
||||
if (quality.unresolvedReconciliations > 0) critical.push('存在未解决平台对账差异')
|
||||
|
||||
const result = {
|
||||
dateFrom,
|
||||
dateTo,
|
||||
scope,
|
||||
facts,
|
||||
ledger,
|
||||
snapshots,
|
||||
quality,
|
||||
critical,
|
||||
}
|
||||
console.log(JSON.stringify(result, null, 2))
|
||||
|
||||
if (strict && critical.length > 0) {
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
|
||||
async function loadScopeTotals(
|
||||
dateFrom: string,
|
||||
dateTo: string,
|
||||
): Promise<Totals & { items: number }> {
|
||||
const result = await query<{
|
||||
items: number
|
||||
revenue_fen: string
|
||||
refund_fen: string
|
||||
}>(
|
||||
`SELECT
|
||||
COUNT(*)::int AS items,
|
||||
COALESCE(SUM(revenue_fen), 0)::bigint AS revenue_fen,
|
||||
COALESCE(SUM(refund_fen), 0)::bigint AS refund_fen
|
||||
FROM v_finance_order_item_scope
|
||||
WHERE stat_date BETWEEN $1::date AND $2::date`,
|
||||
[dateFrom, dateTo],
|
||||
)
|
||||
const row = result.rows[0]
|
||||
return {
|
||||
items: Number(row?.items || 0),
|
||||
revenueFen: Number(row?.revenue_fen || 0),
|
||||
refundFen: Number(row?.refund_fen || 0),
|
||||
costFen: 0,
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFactTotals(
|
||||
dateFrom: string,
|
||||
dateTo: string,
|
||||
): Promise<Totals & { rows: number }> {
|
||||
const result = await query<{
|
||||
rows: number
|
||||
revenue_fen: string
|
||||
refund_fen: string
|
||||
cost_fen: string
|
||||
}>(
|
||||
`SELECT
|
||||
COUNT(*)::int AS rows,
|
||||
COALESCE(SUM(gross_revenue_fen), 0)::bigint AS revenue_fen,
|
||||
COALESCE(SUM(refund_amount_fen), 0)::bigint AS refund_fen,
|
||||
COALESCE(SUM(cost_amount_fen), 0)::bigint AS cost_fen
|
||||
FROM sales_daily_facts
|
||||
WHERE stat_date BETWEEN $1::date AND $2::date`,
|
||||
[dateFrom, dateTo],
|
||||
)
|
||||
const row = result.rows[0]
|
||||
return {
|
||||
rows: Number(row?.rows || 0),
|
||||
revenueFen: Number(row?.revenue_fen || 0),
|
||||
refundFen: Number(row?.refund_fen || 0),
|
||||
costFen: Number(row?.cost_fen || 0),
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLedgerTotals(
|
||||
dateFrom: string,
|
||||
dateTo: string,
|
||||
): Promise<Totals & { entries: number }> {
|
||||
const result = await query<{
|
||||
entries: number
|
||||
revenue_fen: string
|
||||
refund_fen: string
|
||||
cost_fen: string
|
||||
}>(
|
||||
`WITH entries AS (
|
||||
SELECT id
|
||||
FROM finance_journal_entries
|
||||
WHERE ref_type = 'sales_fact'
|
||||
AND status = 'posted'
|
||||
AND occurred_at >= ($1::date::timestamp AT TIME ZONE 'Asia/Shanghai')
|
||||
AND occurred_at < (($2::date + interval '1 day')::timestamp AT TIME ZONE 'Asia/Shanghai')
|
||||
)
|
||||
SELECT
|
||||
(SELECT COUNT(*)::int FROM entries) AS entries,
|
||||
COALESCE(SUM(line.debit_fen - line.credit_fen) FILTER (
|
||||
WHERE line.account_code = '1101'
|
||||
AND entry.event_type IN ('sales_revenue', 'sales_revenue_adjustment')
|
||||
), 0)::bigint AS revenue_fen,
|
||||
COALESCE(SUM(line.debit_fen - line.credit_fen) FILTER (
|
||||
WHERE line.account_code = '4101'
|
||||
AND entry.event_type IN ('sales_refund', 'sales_refund_adjustment')
|
||||
), 0)::bigint AS refund_fen,
|
||||
COALESCE(SUM(line.debit_fen - line.credit_fen) FILTER (
|
||||
WHERE line.account_code = '5001'
|
||||
AND entry.event_type IN ('sales_cost', 'sales_cost_adjustment')
|
||||
), 0)::bigint AS cost_fen
|
||||
FROM entries
|
||||
JOIN finance_journal_entries entry ON entry.id = entries.id
|
||||
JOIN finance_journal_lines line ON line.entry_id = entry.id`,
|
||||
[dateFrom, dateTo],
|
||||
)
|
||||
const row = result.rows[0]
|
||||
return {
|
||||
entries: Number(row?.entries || 0),
|
||||
revenueFen: Number(row?.revenue_fen || 0),
|
||||
refundFen: Number(row?.refund_fen || 0),
|
||||
costFen: Number(row?.cost_fen || 0),
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSnapshotTotals(
|
||||
dateFrom: string,
|
||||
dateTo: string,
|
||||
): Promise<Totals & { estimated: number; actual: number }> {
|
||||
const result = await query<{
|
||||
estimated: number
|
||||
actual: number
|
||||
cost_fen: string
|
||||
}>(
|
||||
`SELECT
|
||||
COUNT(*) FILTER (WHERE snapshot.snapshot_stage = 'estimated')::int AS estimated,
|
||||
COUNT(*) FILTER (WHERE snapshot.snapshot_stage = 'actual')::int AS actual,
|
||||
COALESCE(SUM(snapshot.total_amount_fen), 0)::bigint AS cost_fen
|
||||
FROM cost_snapshots snapshot
|
||||
JOIN v_finance_order_item_scope scope ON scope.order_item_id = snapshot.order_item_id
|
||||
WHERE snapshot.superseded_at IS NULL
|
||||
AND snapshot.reversed_at IS NULL
|
||||
AND scope.stat_date BETWEEN $1::date AND $2::date`,
|
||||
[dateFrom, dateTo],
|
||||
)
|
||||
const row = result.rows[0]
|
||||
return {
|
||||
estimated: Number(row?.estimated || 0),
|
||||
actual: Number(row?.actual || 0),
|
||||
revenueFen: 0,
|
||||
refundFen: 0,
|
||||
costFen: Number(row?.cost_fen || 0),
|
||||
}
|
||||
}
|
||||
|
||||
async function loadQualityChecks(dateFrom: string, dateTo: string) {
|
||||
const result = await query<{
|
||||
pending_normalization: number
|
||||
duplicate_active_snapshots: number
|
||||
open_cost_exceptions: number
|
||||
open_manual_cost_exceptions: number
|
||||
open_on_account_cost_exceptions: number
|
||||
negative_fact_rows: number
|
||||
unresolved_reconciliations: number
|
||||
closed_periods: number
|
||||
}>(
|
||||
`SELECT
|
||||
(
|
||||
SELECT COUNT(*)::int
|
||||
FROM v_finance_order_item_scope
|
||||
WHERE stat_date BETWEEN $1::date AND $2::date
|
||||
AND channel <> 'unclassified'
|
||||
AND canonical_product_id IS NULL
|
||||
) AS pending_normalization,
|
||||
(
|
||||
SELECT COUNT(*)::int
|
||||
FROM (
|
||||
SELECT subject_type, subject_id, cost_item_id
|
||||
FROM cost_snapshots
|
||||
WHERE superseded_at IS NULL AND reversed_at IS NULL
|
||||
GROUP BY subject_type, subject_id, cost_item_id
|
||||
HAVING COUNT(*) > 1
|
||||
) duplicate
|
||||
) AS duplicate_active_snapshots,
|
||||
(
|
||||
SELECT COUNT(*)::int
|
||||
FROM cost_exceptions
|
||||
WHERE reason = 'cost_missing' AND status <> 'resolved'
|
||||
) AS open_cost_exceptions,
|
||||
(
|
||||
SELECT COUNT(*)::int
|
||||
FROM cost_exceptions
|
||||
WHERE reason = 'manual_cost_missing' AND status <> 'resolved'
|
||||
) AS open_manual_cost_exceptions,
|
||||
(
|
||||
SELECT COUNT(*)::int
|
||||
FROM cost_exceptions
|
||||
WHERE reason = 'on_account_cost_pending' AND status <> 'resolved'
|
||||
) AS open_on_account_cost_exceptions,
|
||||
(
|
||||
SELECT COUNT(*)::int
|
||||
FROM sales_daily_facts
|
||||
WHERE stat_date BETWEEN $1::date AND $2::date
|
||||
AND (
|
||||
gross_revenue_fen < 0
|
||||
OR refund_amount_fen < 0
|
||||
OR cost_amount_fen < 0
|
||||
)
|
||||
) AS negative_fact_rows,
|
||||
(
|
||||
SELECT COUNT(*)::int
|
||||
FROM finance_reconciliations
|
||||
WHERE status = 'differences'
|
||||
AND period_from <= $2::date
|
||||
AND period_to >= $1::date
|
||||
) AS unresolved_reconciliations,
|
||||
(
|
||||
SELECT COUNT(*)::int
|
||||
FROM finance_periods
|
||||
WHERE status = 'closed'
|
||||
AND period_key BETWEEN to_char($1::date, 'YYYY-MM') AND to_char($2::date, 'YYYY-MM')
|
||||
) AS closed_periods`,
|
||||
[dateFrom, dateTo],
|
||||
)
|
||||
const row = result.rows[0]
|
||||
return {
|
||||
pendingNormalization: Number(row?.pending_normalization || 0),
|
||||
duplicateActiveSnapshots: Number(row?.duplicate_active_snapshots || 0),
|
||||
openCostExceptions: Number(row?.open_cost_exceptions || 0),
|
||||
openManualCostExceptions: Number(row?.open_manual_cost_exceptions || 0),
|
||||
openOnAccountCostExceptions: Number(row?.open_on_account_cost_exceptions || 0),
|
||||
negativeFactRows: Number(row?.negative_fact_rows || 0),
|
||||
unresolvedReconciliations: Number(row?.unresolved_reconciliations || 0),
|
||||
closedPeriods: Number(row?.closed_periods || 0),
|
||||
}
|
||||
}
|
||||
|
||||
function compare(label: string, expected: number, actual: number, critical: string[]) {
|
||||
if (expected !== actual) {
|
||||
critical.push(`${label}不一致:应为 ${expected},实际 ${actual}`)
|
||||
}
|
||||
}
|
||||
|
||||
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 key = index === -1 ? body : body.slice(0, index)
|
||||
result[key.replace(/-([a-z])/g, (_, char: string) => char.toUpperCase())] =
|
||||
index === -1 ? 'true' : body.slice(index + 1)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function normalizeDate(value: unknown): string | null {
|
||||
const text = String(value || '').trim()
|
||||
return /^\d{4}-\d{2}-\d{2}$/.test(text) ? text : null
|
||||
}
|
||||
|
||||
function today() {
|
||||
return new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).format(new Date())
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
.finally(() => closeDb())
|
||||
@@ -1,243 +0,0 @@
|
||||
/**
|
||||
* 履约载体 ↔ 财务渠道 对齐审计(只读)。
|
||||
*
|
||||
* 用法:npx tsx scripts/audit-fulfillment-finance-alignment.ts
|
||||
*/
|
||||
import { closeDb, query } from '../src/db/client.js'
|
||||
|
||||
async function main() {
|
||||
const pending = await query<{
|
||||
total: number
|
||||
with_work_order: number
|
||||
with_affiliate_task: number
|
||||
with_manual_task: number
|
||||
no_carrier: number
|
||||
}>(
|
||||
`
|
||||
SELECT
|
||||
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 with_work_order,
|
||||
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 with_affiliate_task,
|
||||
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 with_manual_task,
|
||||
count(*) FILTER (
|
||||
WHERE 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)
|
||||
)::int AS no_carrier
|
||||
FROM order_items oi
|
||||
WHERE oi.fulfillment_status = 'pending'
|
||||
`,
|
||||
)
|
||||
|
||||
const channelCarrier = await query<{
|
||||
channel: string
|
||||
total: number
|
||||
work_orders: number
|
||||
affiliate_tasks: number
|
||||
manual_tasks: number
|
||||
config_missing: number
|
||||
no_carrier: number
|
||||
}>(
|
||||
`
|
||||
SELECT
|
||||
COALESCE(NULLIF(oi.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,
|
||||
count(*) FILTER (
|
||||
WHERE oi.fulfillment_channel = 'manual' AND oi.fulfillment_config_id IS NULL
|
||||
)::int AS config_missing,
|
||||
count(*) FILTER (
|
||||
WHERE (
|
||||
oi.fulfillment_channel = 'affiliate_dash'
|
||||
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')
|
||||
)
|
||||
) OR (
|
||||
oi.fulfillment_channel = 'work_order'
|
||||
AND NOT EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id AND wo.status <> 'cancelled')
|
||||
)
|
||||
)::int AS no_carrier
|
||||
FROM order_items oi
|
||||
WHERE oi.fulfillment_status = 'assigned'
|
||||
GROUP BY 1
|
||||
ORDER BY 2 DESC
|
||||
`,
|
||||
)
|
||||
|
||||
const internalSku = await query<{
|
||||
total: number
|
||||
null_or_blank: number
|
||||
numeric_only: number
|
||||
with_cjk: number
|
||||
}>(
|
||||
`
|
||||
SELECT
|
||||
count(*)::int AS total,
|
||||
count(*) FILTER (WHERE btrim(COALESCE(resolve_internal_sku(
|
||||
manual_sku_key, sku_name, sku_code
|
||||
), '')) = '')::int AS null_or_blank,
|
||||
count(*) FILTER (WHERE btrim(COALESCE(resolve_internal_sku(
|
||||
manual_sku_key, sku_name, sku_code
|
||||
), '')) ~ '^[0-9]+$')::int AS numeric_only,
|
||||
count(*) FILTER (WHERE btrim(COALESCE(resolve_internal_sku(
|
||||
manual_sku_key, sku_name, sku_code
|
||||
), '')) ~ '[㐀-䶿一-鿿豈-𠀀-𰀀-𲎯]')::int AS with_cjk
|
||||
FROM order_items
|
||||
WHERE fulfillment_status = 'assigned'
|
||||
`,
|
||||
)
|
||||
|
||||
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
|
||||
FROM (
|
||||
SELECT canonical_name, count(*) AS total
|
||||
FROM product_catalog
|
||||
WHERE status = 'active'
|
||||
GROUP BY canonical_name
|
||||
HAVING count(*) > 1
|
||||
) grouped
|
||||
`,
|
||||
)
|
||||
|
||||
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<{
|
||||
channel: string
|
||||
source: string
|
||||
total: number
|
||||
}>(
|
||||
`SELECT channel, source, count(*)::int AS total
|
||||
FROM internal_sku_channels
|
||||
WHERE enabled = TRUE
|
||||
GROUP BY channel, source
|
||||
ORDER BY channel, source`,
|
||||
)
|
||||
|
||||
const canonicalChannelDuplicates = await query<{
|
||||
internal_sku: string
|
||||
channel: string
|
||||
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
|
||||
HAVING count(*) > 1
|
||||
ORDER BY count(*) DESC, internal_sku, channel`,
|
||||
)
|
||||
|
||||
const assignedCarrierConflicts = await query<{
|
||||
order_item_id: number
|
||||
channel: string
|
||||
carrier_count: number
|
||||
}>(
|
||||
`SELECT oi.id AS order_item_id, oi.fulfillment_channel AS channel,
|
||||
(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 AS carrier_count
|
||||
FROM order_items oi
|
||||
WHERE oi.fulfillment_status = 'assigned'
|
||||
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
|
||||
ORDER BY oi.id
|
||||
LIMIT 200`,
|
||||
)
|
||||
|
||||
const byProvider = await query<{
|
||||
provider: string
|
||||
total: number
|
||||
pending: number
|
||||
with_carrier: number
|
||||
}>(
|
||||
`
|
||||
SELECT o.provider,
|
||||
count(*)::int AS total,
|
||||
count(*) FILTER (WHERE oi.fulfillment_status = 'pending')::int AS pending,
|
||||
count(*) FILTER (
|
||||
WHERE EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id AND wo.status <> 'cancelled')
|
||||
OR EXISTS (SELECT 1 FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id)
|
||||
)::int AS with_carrier
|
||||
FROM order_items oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
WHERE COALESCE(o.paid_at, o.created_at) >= '2026-10-31 00:00:00+08'
|
||||
GROUP BY 1
|
||||
ORDER BY 2 DESC
|
||||
`,
|
||||
)
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
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,
|
||||
assignedCarrierConflicts: assignedCarrierConflicts.rows,
|
||||
byProvider: byProvider.rows,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((error) => {
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
.finally(() => closeDb())
|
||||
@@ -93,7 +93,7 @@ async function loadRecentDuplicateAttempts(): Promise<CountRow[]> {
|
||||
return result.rows
|
||||
}
|
||||
|
||||
async function loadShadowModelDiffs(): Promise<CountRow[]> {
|
||||
async function loadTruthIntegrityChecks(): Promise<CountRow[]> {
|
||||
const result = await query<{
|
||||
missing_fulfillments: number
|
||||
fulfillment_state_diffs: number
|
||||
@@ -116,7 +116,7 @@ async function loadShadowModelDiffs(): Promise<CountRow[]> {
|
||||
fulfillment.issue_category
|
||||
) IS DISTINCT FROM ROW(
|
||||
CASE WHEN state.route_status = 'configured' THEN 'configured' ELSE 'unrouted' END,
|
||||
state.intended_channel,
|
||||
state.stored_channel,
|
||||
state.carrier_status,
|
||||
state.current_attempt_type,
|
||||
NULLIF(state.current_attempt_id, 0),
|
||||
@@ -162,8 +162,6 @@ async function loadShadowModelDiffs(): Promise<CountRow[]> {
|
||||
(
|
||||
SELECT COUNT(*)::int
|
||||
FROM fulfillment_state_events event
|
||||
JOIN v_fulfillment_item_state active_state
|
||||
ON active_state.order_item_id = event.order_item_id
|
||||
WHERE event.fulfillment_id IS NULL
|
||||
) AS unlinked_events
|
||||
FROM v_fulfillment_item_state state
|
||||
@@ -173,20 +171,20 @@ async function loadShadowModelDiffs(): Promise<CountRow[]> {
|
||||
)
|
||||
const row = result.rows[0]
|
||||
return [
|
||||
{ label: '影子履约缺失', count: Number(row?.missing_fulfillments || 0) },
|
||||
{ label: '影子履约状态差异', count: Number(row?.fulfillment_state_diffs || 0) },
|
||||
{ label: '影子执行尝试缺失', count: Number(row?.missing_attempts || 0) },
|
||||
{ label: '影子开放异常缺失', count: Number(row?.missing_open_issues || 0) },
|
||||
{ label: '履约事件未关联影子记录', count: Number(row?.unlinked_events || 0) },
|
||||
{ label: '正式履约缺失', count: Number(row?.missing_fulfillments || 0) },
|
||||
{ label: '正式履约状态差异', count: Number(row?.fulfillment_state_diffs || 0) },
|
||||
{ label: '执行尝试缺失', count: Number(row?.missing_attempts || 0) },
|
||||
{ label: '开放异常缺失', count: Number(row?.missing_open_issues || 0) },
|
||||
{ label: '履约事件未关联正式记录', count: Number(row?.unlinked_events || 0) },
|
||||
]
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const [counts, issues, duplicates, shadowDiffs] = await Promise.all([
|
||||
const [counts, issues, duplicates, integrityChecks] = await Promise.all([
|
||||
loadCounts(),
|
||||
loadIssueBreakdown(),
|
||||
loadRecentDuplicateAttempts(),
|
||||
loadShadowModelDiffs(),
|
||||
loadTruthIntegrityChecks(),
|
||||
])
|
||||
|
||||
console.info('[fulfillment-audit] 总览')
|
||||
@@ -208,20 +206,23 @@ async function main(): Promise<void> {
|
||||
console.info(` ${row.label}: ${row.count}`)
|
||||
}
|
||||
|
||||
console.info('[fulfillment-audit] 影子模型差异')
|
||||
for (const row of shadowDiffs) {
|
||||
console.info('[fulfillment-audit] 正式履约完整性')
|
||||
for (const row of integrityChecks) {
|
||||
console.info(` ${row.label}: ${row.count}`)
|
||||
}
|
||||
|
||||
const critical = new Map(counts.map((row) => [row.label, Number(row.count || 0)]))
|
||||
const duplicateCount = Number(duplicates[0]?.count || 0)
|
||||
const shadowDiffCount = shadowDiffs.reduce((sum, row) => sum + Number(row.count || 0), 0)
|
||||
const integrityDifferenceCount = integrityChecks.reduce(
|
||||
(sum, row) => sum + Number(row.count || 0),
|
||||
0,
|
||||
)
|
||||
const hasCritical =
|
||||
(critical.get('状态不一致') || 0) > 0 ||
|
||||
(critical.get('售后关闭仍进入履约队列') || 0) > 0 ||
|
||||
(critical.get('已分配但无有效载体') || 0) > 0 ||
|
||||
duplicateCount > 0 ||
|
||||
shadowDiffCount > 0
|
||||
integrityDifferenceCount > 0
|
||||
|
||||
if (strict && hasCritical) {
|
||||
process.exitCode = 1
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
#!/usr/bin/env tsx
|
||||
/**
|
||||
* 商品来源、内部 SKU 与履约路由只读审计。
|
||||
*
|
||||
* 用法:
|
||||
* npm run audit:product-routing
|
||||
* npm run audit:product-routing -- --strict
|
||||
*/
|
||||
import { closeDb, query } from '../src/db/client.js'
|
||||
|
||||
const strict = process.argv.includes('--strict')
|
||||
|
||||
async function main() {
|
||||
const [
|
||||
sourceChannel,
|
||||
routeCounts,
|
||||
manualConfigRoutes,
|
||||
orphanManualProducts,
|
||||
multiplePrimaryRoutes,
|
||||
missingInternalSku,
|
||||
unroutedProducts,
|
||||
invalidSourceKinds,
|
||||
] = await Promise.all([
|
||||
query<{ source_kind: string; primary_channel: string | null; total: number }>(
|
||||
`
|
||||
SELECT source_kind, active_primary_channel AS primary_channel, COUNT(*)::int AS total
|
||||
FROM v_product_current
|
||||
WHERE status = 'active'
|
||||
GROUP BY 1, 2
|
||||
ORDER BY 1, 2 NULLS FIRST
|
||||
`,
|
||||
),
|
||||
query<{ channel: string; route_role: string; total: number }>(
|
||||
`
|
||||
SELECT channel, route_role, COUNT(*)::int AS total
|
||||
FROM sku_channel_routes
|
||||
WHERE enabled = TRUE
|
||||
GROUP BY 1, 2
|
||||
ORDER BY 1, 2
|
||||
`,
|
||||
),
|
||||
query<{
|
||||
route_state: string
|
||||
total: number
|
||||
}>(
|
||||
`
|
||||
SELECT
|
||||
CASE
|
||||
WHEN primary_route.channel = 'manual' THEN 'primary_manual'
|
||||
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
|
||||
FROM manual_fulfillment_configs config
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT route.channel
|
||||
FROM sku_channel_routes route
|
||||
WHERE route.internal_sku_id = config.internal_sku_id
|
||||
AND route.enabled = TRUE
|
||||
AND route.route_role = 'primary'
|
||||
ORDER BY route.priority, route.channel
|
||||
LIMIT 1
|
||||
) primary_route ON TRUE
|
||||
LEFT JOIN sku_channel_routes manual_route
|
||||
ON manual_route.internal_sku_id = config.internal_sku_id
|
||||
AND manual_route.channel = 'manual'
|
||||
AND manual_route.enabled = TRUE
|
||||
WHERE config.enabled = TRUE
|
||||
GROUP BY 1
|
||||
ORDER BY 1
|
||||
`,
|
||||
),
|
||||
query<{
|
||||
product_id: number
|
||||
canonical_sku: string
|
||||
canonical_name: string
|
||||
}>(
|
||||
`
|
||||
SELECT product.id AS product_id,
|
||||
product.canonical_sku,
|
||||
product.canonical_name
|
||||
FROM product_catalog product
|
||||
WHERE product.status = 'active'
|
||||
AND product.source_kind = 'manual'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM sku_channel_routes route
|
||||
WHERE route.internal_sku_id = product.internal_sku_id
|
||||
AND route.enabled = TRUE
|
||||
AND route.route_role = 'primary'
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM manual_fulfillment_configs config
|
||||
WHERE config.enabled = TRUE
|
||||
AND (
|
||||
config.canonical_product_id = product.id
|
||||
OR config.internal_sku_id = product.internal_sku_id
|
||||
OR canonicalize_internal_sku(config.sku_key)
|
||||
= canonicalize_internal_sku(substring(product.canonical_sku from 5))
|
||||
)
|
||||
)
|
||||
ORDER BY product.id
|
||||
`,
|
||||
),
|
||||
query<{ internal_sku_id: number; total: number }>(
|
||||
`
|
||||
SELECT internal_sku_id, COUNT(*)::int AS total
|
||||
FROM sku_channel_routes
|
||||
WHERE enabled = TRUE AND route_role = 'primary'
|
||||
GROUP BY 1
|
||||
HAVING COUNT(*) > 1
|
||||
ORDER BY 2 DESC, 1
|
||||
`,
|
||||
),
|
||||
query<{ source_kind: string; total: number }>(
|
||||
`
|
||||
SELECT source_kind, COUNT(*)::int AS total
|
||||
FROM product_catalog
|
||||
WHERE status = 'active'
|
||||
AND source_kind IN ('manual', 'third_party_voucher')
|
||||
AND internal_sku_id IS NULL
|
||||
GROUP BY 1
|
||||
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
|
||||
FROM product_catalog
|
||||
WHERE status = 'active'
|
||||
AND source_kind NOT IN (
|
||||
'affiliate_dash',
|
||||
'work_order',
|
||||
'third_party_voucher',
|
||||
'manual',
|
||||
'unclassified'
|
||||
)
|
||||
GROUP BY 1
|
||||
ORDER BY 1
|
||||
`,
|
||||
),
|
||||
])
|
||||
|
||||
const critical = {
|
||||
orphanManualProducts: orphanManualProducts.rows.length,
|
||||
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] 商品来源 × 当前渠道')
|
||||
console.table(sourceChannel.rows)
|
||||
|
||||
console.info('[product-routing-audit] 路由分布')
|
||||
console.table(routeCounts.rows)
|
||||
|
||||
console.info('[product-routing-audit] 人工配置路由状态')
|
||||
console.table(manualConfigRoutes.rows)
|
||||
|
||||
console.info('[product-routing-audit] 无主路由且无人工配置的 man 商品')
|
||||
console.table(orphanManualProducts.rows)
|
||||
|
||||
console.info('[product-routing-audit] 多主路由内部 SKU')
|
||||
console.table(multiplePrimaryRoutes.rows)
|
||||
|
||||
console.info('[product-routing-audit] 缺少内部 SKU 的商品')
|
||||
console.table(missingInternalSku.rows)
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((error) => {
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
.finally(() => closeDb())
|
||||
@@ -0,0 +1,261 @@
|
||||
#!/usr/bin/env tsx
|
||||
/**
|
||||
* 为部署前已支付、已有可核销券但未进入订单级核销队列的订单补投任务。
|
||||
*
|
||||
* 只处理明确可核销的券:
|
||||
* - 快手电子凭证:UNUSED 且发码回调成功;
|
||||
* - 抖音码速达:issued/synced 且同步成功。
|
||||
*
|
||||
* 默认 dry-run。执行必须显式确认。
|
||||
*/
|
||||
import process from 'node:process'
|
||||
|
||||
import { closeDb, query } from '../src/db/client.js'
|
||||
import { loadAppConfigCacheFromDatabase } from '../src/services/config/app-config-store.js'
|
||||
import { getVoucherConsumePolicy } from '../src/services/fulfillment/voucher-consume-policy-service.js'
|
||||
import { scheduleVoucherConsumeForOrderReceived } from '../src/services/fulfillment/voucher-consume-queue-service.js'
|
||||
|
||||
const CONFIRMATION = 'ENQUEUE_ORDER_RECEIVED'
|
||||
const options = parseOptions(process.argv.slice(2))
|
||||
const execute = options.execute === 'true'
|
||||
const confirmation = String(options.confirm || '').trim()
|
||||
const days = normalizeInteger(options.days, 7, 1, 90)
|
||||
const batchSize = normalizeInteger(options.batchSize, 500, 1, 5000)
|
||||
const maxBatches = normalizeInteger(options.maxBatches, 1000, 1, 10000)
|
||||
const dateTo = normalizeDate(options.to) || today()
|
||||
const dateFrom = normalizeDate(options.from) || shiftDate(dateTo, -(days - 1))
|
||||
|
||||
const ELIGIBLE_ORDER_SQL = `
|
||||
o.created_at >= $1::date
|
||||
AND o.created_at < ($2::date + INTERVAL '1 day')
|
||||
AND o.pay_status = 'paid'
|
||||
AND o.order_status NOT IN ('cancelled', 'closed', 'refunded')
|
||||
AND (
|
||||
(
|
||||
$3::boolean
|
||||
AND (o.provider IN ('91kaquan', 'open_91', '91') OR o.platform = 'kuaishou')
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM kuaishou_industry_vouchers voucher
|
||||
WHERE voucher.oid = o.platform_order_id
|
||||
AND voucher.status = 'UNUSED'
|
||||
AND voucher.send_callback_status = 'success'
|
||||
)
|
||||
)
|
||||
OR
|
||||
(
|
||||
$4::boolean
|
||||
AND o.provider = 'douyin_mashida'
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM redeem_certificates certificate
|
||||
WHERE certificate.provider = 'douyin_mashida'
|
||||
AND (
|
||||
certificate.local_order_id = o.id
|
||||
OR certificate.order_id = o.platform_order_id
|
||||
)
|
||||
AND certificate.status IN ('issued', 'synced')
|
||||
AND certificate.sync_status = 'success'
|
||||
)
|
||||
)
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM voucher_consume_jobs job
|
||||
WHERE job.order_id = o.id
|
||||
AND job.status IN ('pending', 'processing', 'succeeded')
|
||||
)
|
||||
`
|
||||
|
||||
async function main() {
|
||||
if (dateFrom > dateTo) {
|
||||
throw new Error(`--from 不能晚于 --to:${dateFrom} > ${dateTo}`)
|
||||
}
|
||||
|
||||
await loadAppConfigCacheFromDatabase()
|
||||
const policy = getVoucherConsumePolicy()
|
||||
const enabledProviders = (['kuaishou_industry', 'douyin_mashida'] as const).filter(
|
||||
(provider) => policy.platforms[provider].channels.order_received.enabled,
|
||||
)
|
||||
if (enabledProviders.length === 0) {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
mode: execute ? 'execute' : 'dry-run',
|
||||
dateFrom,
|
||||
dateTo,
|
||||
policyDisabled: true,
|
||||
enabledProviders,
|
||||
pendingOrders: 0,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const includeKuaishou = enabledProviders.includes('kuaishou_industry')
|
||||
const includeDouyin = enabledProviders.includes('douyin_mashida')
|
||||
const pending = await countEligibleOrders(includeKuaishou, includeDouyin)
|
||||
const startedAt = Date.now()
|
||||
|
||||
if (!execute) {
|
||||
const samples = await previewEligibleOrders(includeKuaishou, includeDouyin)
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
mode: 'dry-run',
|
||||
dateFrom,
|
||||
dateTo,
|
||||
pendingOrders: pending,
|
||||
batchSize,
|
||||
estimatedBatches: Math.ceil(pending / batchSize),
|
||||
samples,
|
||||
durationMs: Date.now() - startedAt,
|
||||
hint: `确认后执行:npm run voucher:backfill-order-received -- --from=${dateFrom} --to=${dateTo} --execute --confirm=${CONFIRMATION}`,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (confirmation !== CONFIRMATION) {
|
||||
throw new Error(`--confirm 必须是 ${CONFIRMATION}`)
|
||||
}
|
||||
|
||||
let cursor = 0
|
||||
let scanned = 0
|
||||
let queued = 0
|
||||
let batches = 0
|
||||
|
||||
while (batches < maxBatches) {
|
||||
const orderIds = await loadEligibleOrderIds(cursor, batchSize, includeKuaishou, includeDouyin)
|
||||
if (orderIds.length === 0) break
|
||||
|
||||
const scheduled = await scheduleVoucherConsumeForOrderReceived(orderIds)
|
||||
scanned += orderIds.length
|
||||
queued += scheduled.queuedCount
|
||||
cursor = orderIds[orderIds.length - 1]!
|
||||
batches += 1
|
||||
|
||||
if (orderIds.length < batchSize) break
|
||||
}
|
||||
|
||||
const remaining = await countEligibleOrders(includeKuaishou, includeDouyin)
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
mode: 'execute',
|
||||
dateFrom,
|
||||
dateTo,
|
||||
pendingBefore: pending,
|
||||
batches,
|
||||
scannedOrders: scanned,
|
||||
queuedJobs: queued,
|
||||
remainingOrders: remaining,
|
||||
durationMs: Date.now() - startedAt,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
|
||||
if (remaining > 0) {
|
||||
console.error(`仍有 ${remaining} 个订单未入队;请增大 --max-batches 后重跑(幂等)。`)
|
||||
process.exitCode = 2
|
||||
}
|
||||
}
|
||||
|
||||
async function countEligibleOrders(includeKuaishou: boolean, includeDouyin: boolean) {
|
||||
const result = await query<{ count: number }>(
|
||||
`SELECT COUNT(*)::int AS count
|
||||
FROM orders o
|
||||
WHERE ${ELIGIBLE_ORDER_SQL}`,
|
||||
[dateFrom, dateTo, includeKuaishou, includeDouyin],
|
||||
)
|
||||
return Number(result.rows[0]?.count || 0)
|
||||
}
|
||||
|
||||
async function loadEligibleOrderIds(
|
||||
afterId: number,
|
||||
limit: number,
|
||||
includeKuaishou: boolean,
|
||||
includeDouyin: boolean,
|
||||
) {
|
||||
const result = await query<{ id: number }>(
|
||||
`SELECT o.id
|
||||
FROM orders o
|
||||
WHERE o.id > $5
|
||||
AND ${ELIGIBLE_ORDER_SQL}
|
||||
ORDER BY o.id
|
||||
LIMIT $6`,
|
||||
[dateFrom, dateTo, includeKuaishou, includeDouyin, afterId, limit],
|
||||
)
|
||||
return result.rows.map((row) => Number(row.id))
|
||||
}
|
||||
|
||||
async function previewEligibleOrders(includeKuaishou: boolean, includeDouyin: boolean) {
|
||||
const result = await query<{
|
||||
id: number
|
||||
provider: string
|
||||
platform_order_id: string
|
||||
created_at: string
|
||||
}>(
|
||||
`SELECT o.id, o.provider, o.platform_order_id, o.created_at::text
|
||||
FROM orders o
|
||||
WHERE ${ELIGIBLE_ORDER_SQL}
|
||||
ORDER BY o.id
|
||||
LIMIT 10`,
|
||||
[dateFrom, dateTo, includeKuaishou, includeDouyin],
|
||||
)
|
||||
return result.rows
|
||||
}
|
||||
|
||||
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 key = index === -1 ? body : body.slice(0, index)
|
||||
const value = index === -1 ? 'true' : body.slice(index + 1)
|
||||
result[key] = value
|
||||
result[toCamelCase(key)] = value
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function toCamelCase(value: string) {
|
||||
return value.replace(/-([a-z])/g, (_, char: string) => char.toUpperCase())
|
||||
}
|
||||
|
||||
function normalizeInteger(value: unknown, fallback: number, min: number, max: number) {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isInteger(parsed)) return fallback
|
||||
return Math.min(max, Math.max(min, parsed))
|
||||
}
|
||||
|
||||
function normalizeDate(value: unknown): string {
|
||||
const text = String(value || '').trim()
|
||||
return /^\d{4}-\d{2}-\d{2}$/.test(text) ? text : ''
|
||||
}
|
||||
|
||||
function today() {
|
||||
return new Date().toLocaleDateString('sv-SE', { timeZone: 'Asia/Shanghai' })
|
||||
}
|
||||
|
||||
function shiftDate(value: string, days: number) {
|
||||
const date = new Date(`${value}T12:00:00+08:00`)
|
||||
date.setUTCDate(date.getUTCDate() + days)
|
||||
return date.toLocaleDateString('sv-SE', { timeZone: 'Asia/Shanghai' })
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
.finally(() => closeDb())
|
||||
@@ -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())
|
||||
@@ -1,259 +0,0 @@
|
||||
import process from 'node:process'
|
||||
|
||||
import { closeDb, query } from '../src/db/client.js'
|
||||
|
||||
/**
|
||||
* 财务断代 v2:清理断代前的派生数据,为 2026-10-31 起点重建让路。
|
||||
*
|
||||
* 只删「派生数据」,不删业务原始票据:
|
||||
* 删除:cost_snapshots / cost_exceptions(+items) / sales_daily_facts /
|
||||
* finance_journal_lines(+entries) / finance_reconciliations(+items) /
|
||||
* finance_refund_intents / order_refunds / manual_expenses /
|
||||
* fund_entries / platform_credit_entries / manual_sku_registry
|
||||
* 保留: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(配置)
|
||||
*
|
||||
* 前置:
|
||||
* 1. 迁移 112/113 已执行;
|
||||
* 2. 已做数据库备份。
|
||||
*
|
||||
* 断代本身包含两步:先把断代前的订单项批量置为 archived,再清理派生数据。
|
||||
*
|
||||
* 默认 dry-run,只打印将删除的行数;加 `--execute --confirm=2026-10-31` 才真正执行。
|
||||
* 不可重复执行:成功后会写入 finance_cutovers,第二次会被拦下。
|
||||
*/
|
||||
|
||||
const CUTOVER_DATE = '2026-10-31'
|
||||
|
||||
const options = parseOptions(process.argv.slice(2))
|
||||
const execute = options.execute === 'true'
|
||||
const confirm = String(options.confirm || '').trim()
|
||||
|
||||
/** 派生表:按外键顺序删除。 */
|
||||
const DERIVED_TABLES: Array<{ table: string; order: number; where?: string }> = [
|
||||
{ table: 'cost_exception_items', order: 10 },
|
||||
{ table: 'cost_exceptions', order: 20 },
|
||||
{ table: 'cost_snapshots', order: 30 },
|
||||
{ table: 'sales_daily_facts', order: 40 },
|
||||
{ table: 'finance_journal_lines', order: 50 },
|
||||
{ table: 'finance_journal_entries', order: 60 },
|
||||
{ table: 'finance_reconciliation_items', order: 70 },
|
||||
{ table: 'finance_reconciliations', order: 80 },
|
||||
{ table: 'finance_refund_intents', order: 90 },
|
||||
{ table: 'order_refunds', order: 100 },
|
||||
{ table: 'manual_expenses', order: 110 },
|
||||
{ table: 'fund_entries', order: 120 },
|
||||
{ table: 'platform_credit_entries', order: 130 },
|
||||
{ table: 'manual_sku_registry', order: 140 },
|
||||
]
|
||||
|
||||
async function main() {
|
||||
await assertPreconditions()
|
||||
|
||||
const startedAt = Date.now()
|
||||
const plan = await buildPlan()
|
||||
|
||||
if (!execute) {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
mode: 'dry-run',
|
||||
cutoverDate: CUTOVER_DATE,
|
||||
deletePlan: plan,
|
||||
hint: '确认无误后追加 --execute --confirm=2026-10-31',
|
||||
durationMs: Date.now() - startedAt,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (confirm !== CUTOVER_DATE) {
|
||||
throw new Error(`--confirm 必须是 ${CUTOVER_DATE},当前为 "${confirm}"`)
|
||||
}
|
||||
|
||||
await assertNotExecuted()
|
||||
|
||||
const archived = await archiveHistoricalItems()
|
||||
|
||||
const deleted: Record<string, number> = {}
|
||||
for (const item of [...DERIVED_TABLES].sort((a, b) => a.order - b.order)) {
|
||||
const result = await query(
|
||||
`DELETE FROM ${item.table}${item.where ? ` WHERE ${item.where}` : ''}`,
|
||||
)
|
||||
deleted[item.table] = result.rowCount || 0
|
||||
process.stderr.write(`删除 ${item.table}: ${result.rowCount || 0}\n`)
|
||||
}
|
||||
|
||||
// 旧归档表不再保留:生产库不留可查询的历史异常副本。
|
||||
await query(`DROP TABLE IF EXISTS cost_exceptions_archive`)
|
||||
|
||||
// 水位归零:管道从断代日起重新扫描。
|
||||
await query(
|
||||
`
|
||||
UPDATE finance_watermarks
|
||||
SET last_item_id = 0, last_stat_date = $1::date, last_success_at = NULL,
|
||||
last_error = '', updated_at = NOW()
|
||||
`,
|
||||
[CUTOVER_DATE],
|
||||
)
|
||||
|
||||
await query(
|
||||
`
|
||||
INSERT INTO finance_cutovers (cutover_date, executed_at, detail_json)
|
||||
VALUES ($1::date, NOW(), $2::jsonb)
|
||||
ON CONFLICT (cutover_date) DO NOTHING
|
||||
`,
|
||||
[CUTOVER_DATE, JSON.stringify({ deleted, tables: DERIVED_TABLES.map((t) => t.table) })],
|
||||
)
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
mode: 'execute',
|
||||
cutoverDate: CUTOVER_DATE,
|
||||
archivedItems: archived.updated,
|
||||
deleted,
|
||||
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 buildPlan() {
|
||||
const plan: Array<{ table: string; rows: number }> = []
|
||||
for (const item of [...DERIVED_TABLES].sort((a, b) => a.order - b.order)) {
|
||||
const result = await query<{ count: number }>(
|
||||
`SELECT COUNT(*)::int AS count FROM ${item.table}${item.where ? ` WHERE ${item.where}` : ''}`,
|
||||
)
|
||||
plan.push({ table: item.table, rows: result.rows[0]?.count || 0 })
|
||||
}
|
||||
return plan
|
||||
}
|
||||
|
||||
async function assertPreconditions() {
|
||||
const columns = await query<{ column_name: string }>(
|
||||
`
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'order_items'
|
||||
AND column_name IN ('fulfillment_channel', 'fulfillment_status', 'fulfillment_source')
|
||||
`,
|
||||
)
|
||||
if (columns.rows.length < 3) {
|
||||
throw new Error('缺少 112 的三列;请先执行迁移 112')
|
||||
}
|
||||
|
||||
const watermark = await query<{ exists: boolean }>(
|
||||
`SELECT to_regclass('public.finance_watermarks') IS NOT NULL AS exists`,
|
||||
)
|
||||
if (!watermark.rows[0]?.exists) {
|
||||
throw new Error('finance_watermarks 不存在;请先执行迁移 112')
|
||||
}
|
||||
|
||||
await query(
|
||||
`
|
||||
CREATE TABLE IF NOT EXISTS finance_cutovers (
|
||||
cutover_date DATE PRIMARY KEY,
|
||||
executed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
detail_json JSONB NOT NULL DEFAULT '{}'::jsonb
|
||||
)
|
||||
`,
|
||||
)
|
||||
|
||||
const inconsistent = await query<{ count: number }>(
|
||||
`
|
||||
SELECT COUNT(*)::int AS count
|
||||
FROM order_items
|
||||
WHERE fulfillment_status = 'assigned' AND fulfillment_channel IS NULL
|
||||
`,
|
||||
)
|
||||
const bad = inconsistent.rows[0]?.count || 0
|
||||
if (bad > 0) {
|
||||
throw new Error(
|
||||
`有 ${bad} 行 status=assigned 但 channel 为空;请先执行 fulfillment:backfill-channel`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 断代前的订单项统一置为 archived(分批,避免 42 万行整表更新撞超时)。
|
||||
*/
|
||||
async function archiveHistoricalItems(): Promise<{ updated: number }> {
|
||||
let cursor = 0
|
||||
let updated = 0
|
||||
for (let batch = 0; batch < 500; batch += 1) {
|
||||
const rows = await query<{ id: string }>(
|
||||
`
|
||||
SELECT oi.id::text
|
||||
FROM order_items oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
WHERE (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date < $1::date
|
||||
AND oi.fulfillment_status <> 'archived'
|
||||
AND oi.id > $2
|
||||
ORDER BY oi.id
|
||||
LIMIT 20000
|
||||
`,
|
||||
[CUTOVER_DATE, cursor],
|
||||
)
|
||||
if (rows.rows.length === 0) break
|
||||
cursor = Number(rows.rows[rows.rows.length - 1]!.id)
|
||||
|
||||
const result = await query(
|
||||
`
|
||||
UPDATE order_items
|
||||
SET fulfillment_status = 'archived',
|
||||
fulfillment_channel = NULL,
|
||||
fulfillment_source = 'cutover_archive',
|
||||
fulfillment_decided_at = NOW(),
|
||||
updated_at = NOW()
|
||||
WHERE id = ANY($1::bigint[])
|
||||
AND fulfillment_status <> 'archived'
|
||||
`,
|
||||
[rows.rows.map((row) => Number(row.id))],
|
||||
)
|
||||
updated += result.rowCount || 0
|
||||
process.stderr.write(`归档 ${result.rowCount || 0} 行(游标 ${cursor})\n`)
|
||||
}
|
||||
return { updated }
|
||||
}
|
||||
|
||||
async function assertNotExecuted() {
|
||||
const done = await query<{ count: number }>(
|
||||
`SELECT COUNT(*)::int AS count FROM finance_cutovers WHERE cutover_date = $1::date`,
|
||||
[CUTOVER_DATE],
|
||||
)
|
||||
if ((done.rows[0]?.count || 0) > 0) {
|
||||
throw new Error(`${CUTOVER_DATE} 的断代已执行过,禁止重复执行`)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -1,251 +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],
|
||||
)
|
||||
|
||||
// 映射:先删掉会与主商品冲突的重复映射,再迁移其余。
|
||||
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(
|
||||
`
|
||||
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()
|
||||
`,
|
||||
[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
|
||||
)
|
||||
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,
|
||||
enabled = TRUE,
|
||||
canonical_product_id = COALESCE(EXCLUDED.canonical_product_id, internal_sku_channels.canonical_product_id),
|
||||
updated_at = NOW()
|
||||
`,
|
||||
[group.canonical_name, 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,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())
|
||||
@@ -1,111 +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 fulfillment_status = 'assigned')::int AS assigned,
|
||||
COUNT(*) FILTER (WHERE fulfillment_status = 'pending')::int AS pending,
|
||||
COUNT(*) FILTER (WHERE fulfillment_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 order_items
|
||||
`,
|
||||
)
|
||||
|
||||
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.sku_channel_rules') IS NOT NULL AS ready`,
|
||||
)
|
||||
if (!ready.rows[0]?.ready) {
|
||||
throw new Error('缺少 112 迁移产物;请先执行 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))
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
#!/usr/bin/env tsx
|
||||
/**
|
||||
* 从统一履约/商品真源重建财务派生数据。
|
||||
*
|
||||
* 只删除可重建数据:
|
||||
* - estimated 成本快照;
|
||||
* - 指定期间的销售事实;
|
||||
* - 商品/成本类异常明细。
|
||||
*
|
||||
* 永久保留:
|
||||
* - 订单、订单项、退款、真实资金流水、人工支出;
|
||||
* - actual 成本快照;
|
||||
* - 全部已过账凭证;销售凭证只追加反向/补差调整,不删除;
|
||||
* - 手工凭证与已导入的平台对账原始批次。
|
||||
*
|
||||
* 默认 dry-run;执行需要显式确认。
|
||||
*
|
||||
* npm run finance:rebuild-derived
|
||||
* npm run finance:rebuild-derived -- --execute --confirm=REBUILD_DERIVED
|
||||
*/
|
||||
import process from 'node:process'
|
||||
|
||||
import { closeDb, query } from '../src/db/client.js'
|
||||
import { syncCostSnapshotsForItems } from '../src/services/cost-center/cost-snapshot-service.js'
|
||||
import {
|
||||
resolveExceptionsForItemsByReason,
|
||||
upsertAggregatedExceptionsBatch,
|
||||
type AggregatedExceptionEntry,
|
||||
type CostExceptionReason,
|
||||
} from '../src/services/cost-center/cost-exception-service.js'
|
||||
import { normalizeProductsForItems } from '../src/services/cost-center/product-normalize-service.js'
|
||||
import { syncFinanceLedgerFromFacts } from '../src/services/finance/finance-ledger-service.js'
|
||||
import { rebuildChannelSalesDailyFacts } from '../src/services/finance/sales-facts-channel-service.js'
|
||||
|
||||
const CONFIRMATION = 'REBUILD_DERIVED'
|
||||
const REBUILD_EXCEPTION_REASONS = [
|
||||
'product_mapping_missing',
|
||||
'cost_missing',
|
||||
'manual_cost_missing',
|
||||
'on_account_cost_pending',
|
||||
] as const satisfies readonly CostExceptionReason[]
|
||||
|
||||
const options = parseOptions(process.argv.slice(2))
|
||||
const execute = options.execute === 'true'
|
||||
const confirmation = String(options.confirm || '').trim()
|
||||
|
||||
async function main() {
|
||||
await assertSchema()
|
||||
const bounds = await resolveDateRange()
|
||||
const closedPeriods = await loadClosedPeriods(bounds.dateFrom, bounds.dateTo)
|
||||
if (closedPeriods.length > 0) {
|
||||
throw new Error(
|
||||
`区间包含已关账期间 ${closedPeriods.join(', ')};请先反关账,禁止直接重建已关账数据`,
|
||||
)
|
||||
}
|
||||
|
||||
const before = await loadProtectedTotals()
|
||||
const scopeItemIds = await loadScopeItemIds(bounds.dateFrom, bounds.dateTo)
|
||||
const plan = await loadPlan(bounds.dateFrom, bounds.dateTo)
|
||||
const startedAt = Date.now()
|
||||
|
||||
if (!execute) {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
mode: 'dry-run',
|
||||
...bounds,
|
||||
scopeItems: scopeItemIds.length,
|
||||
closedPeriods,
|
||||
plan,
|
||||
protectedTotals: before,
|
||||
hint: `确认后执行:npm run finance:rebuild-derived -- --execute --confirm=${CONFIRMATION}`,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
if (confirmation !== CONFIRMATION) {
|
||||
throw new Error(`--confirm 必须是 ${CONFIRMATION}`)
|
||||
}
|
||||
|
||||
const clearedExceptions = await clearFinanceExceptions(scopeItemIds)
|
||||
const clearedSnapshots = await clearEstimatedSnapshots(bounds.dateFrom, bounds.dateTo)
|
||||
const clearedFacts = await clearSalesFacts(bounds.dateFrom, bounds.dateTo)
|
||||
|
||||
const normalization = { matched: 0, unmatched: 0, batches: 0 }
|
||||
const cost = { created: 0, superseded: 0, missing: 0, affectedDates: new Set<string>() }
|
||||
for (const ids of chunk(scopeItemIds, 2000)) {
|
||||
const normalized = await normalizeProductsForItems(ids)
|
||||
normalization.matched += normalized.matched
|
||||
normalization.unmatched += normalized.unmatched
|
||||
normalization.batches += 1
|
||||
|
||||
const snapshots = await syncCostSnapshotsForItems(ids)
|
||||
cost.created += snapshots.created
|
||||
cost.superseded += snapshots.superseded
|
||||
cost.missing += snapshots.missing
|
||||
for (const date of snapshots.affectedDates) cost.affectedDates.add(date)
|
||||
}
|
||||
|
||||
const facts = await rebuildChannelSalesDailyFacts(bounds)
|
||||
const ledger = await syncFinanceLedgerFromFacts(bounds)
|
||||
const regeneratedExceptions = await regenerateCostExceptions(scopeItemIds)
|
||||
const after = await loadProtectedTotals()
|
||||
assertProtectedTotals(before, after)
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
mode: 'execute',
|
||||
...bounds,
|
||||
scopeItems: scopeItemIds.length,
|
||||
cleared: {
|
||||
estimatedSnapshots: clearedSnapshots,
|
||||
salesFacts: clearedFacts,
|
||||
financeExceptionItems: clearedExceptions,
|
||||
},
|
||||
normalization,
|
||||
cost: {
|
||||
...cost,
|
||||
affectedDates: [...cost.affectedDates].sort(),
|
||||
},
|
||||
facts,
|
||||
ledger,
|
||||
regeneratedExceptions,
|
||||
protectedTotals: after,
|
||||
durationMs: Date.now() - startedAt,
|
||||
auditCommand: 'npm run audit:finance -- --strict',
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
async function resolveDateRange() {
|
||||
const bounds = await query<{ date_from: string | null; date_to: string | null }>(
|
||||
`WITH dates AS (
|
||||
SELECT stat_date AS stat_date
|
||||
FROM v_finance_order_item_scope
|
||||
UNION ALL
|
||||
SELECT stat_date
|
||||
FROM sales_daily_facts
|
||||
UNION ALL
|
||||
SELECT (occurred_at AT TIME ZONE 'Asia/Shanghai')::date
|
||||
FROM finance_journal_entries
|
||||
WHERE ref_type = 'sales_fact' AND status = 'posted'
|
||||
)
|
||||
SELECT MIN(stat_date)::text AS date_from, MAX(stat_date)::text AS date_to
|
||||
FROM dates`,
|
||||
)
|
||||
const dateFrom = normalizeDate(options.dateFrom) || bounds.rows[0]?.date_from
|
||||
const dateTo = normalizeDate(options.dateTo) || bounds.rows[0]?.date_to || dateFrom
|
||||
if (!dateFrom || !dateTo) throw new Error('财务销售范围没有可用业务日')
|
||||
if (dateFrom > dateTo) throw new Error('date-from 不能晚于 date-to')
|
||||
return { dateFrom, dateTo }
|
||||
}
|
||||
|
||||
async function loadClosedPeriods(dateFrom: string, dateTo: string): Promise<string[]> {
|
||||
const result = await query<{ period_key: string }>(
|
||||
`SELECT period_key
|
||||
FROM finance_periods
|
||||
WHERE status = 'closed'
|
||||
AND period_key BETWEEN to_char($1::date, 'YYYY-MM') AND to_char($2::date, 'YYYY-MM')
|
||||
ORDER BY period_key`,
|
||||
[dateFrom, dateTo],
|
||||
)
|
||||
return result.rows.map((row) => row.period_key)
|
||||
}
|
||||
|
||||
async function loadScopeItemIds(dateFrom: string, dateTo: string): Promise<number[]> {
|
||||
const result = await query<{ order_item_id: number }>(
|
||||
`SELECT order_item_id
|
||||
FROM v_finance_order_item_scope
|
||||
WHERE stat_date BETWEEN $1::date AND $2::date
|
||||
ORDER BY order_item_id`,
|
||||
[dateFrom, dateTo],
|
||||
)
|
||||
return result.rows.map((row) => Number(row.order_item_id))
|
||||
}
|
||||
|
||||
async function loadPlan(dateFrom: string, dateTo: string) {
|
||||
const result = await query<{
|
||||
estimated_snapshots: number
|
||||
sales_facts: number
|
||||
sales_journal_entries_to_adjust: number
|
||||
finance_exception_items: number
|
||||
}>(
|
||||
`SELECT
|
||||
(
|
||||
SELECT COUNT(*)::int
|
||||
FROM cost_snapshots snapshot
|
||||
JOIN v_finance_order_item_scope scope ON scope.order_item_id = snapshot.order_item_id
|
||||
WHERE scope.stat_date BETWEEN $1::date AND $2::date
|
||||
AND snapshot.snapshot_stage = 'estimated'
|
||||
) AS estimated_snapshots,
|
||||
(
|
||||
SELECT COUNT(*)::int
|
||||
FROM sales_daily_facts
|
||||
WHERE stat_date BETWEEN $1::date AND $2::date
|
||||
) AS sales_facts,
|
||||
(
|
||||
SELECT COUNT(*)::int
|
||||
FROM finance_journal_entries
|
||||
WHERE ref_type = 'sales_fact'
|
||||
AND occurred_at >= ($1::date::timestamp AT TIME ZONE 'Asia/Shanghai')
|
||||
AND occurred_at < (($2::date + interval '1 day')::timestamp AT TIME ZONE 'Asia/Shanghai')
|
||||
) AS sales_journal_entries_to_adjust,
|
||||
(
|
||||
SELECT COUNT(*)::int
|
||||
FROM cost_exception_items item
|
||||
JOIN cost_exceptions exception ON exception.id = item.exception_id
|
||||
JOIN v_finance_order_item_scope scope ON scope.order_item_id = item.order_item_id
|
||||
WHERE exception.reason = ANY($3::text[])
|
||||
AND scope.stat_date BETWEEN $1::date AND $2::date
|
||||
) AS finance_exception_items`,
|
||||
[dateFrom, dateTo, REBUILD_EXCEPTION_REASONS],
|
||||
)
|
||||
const row = result.rows[0]
|
||||
return {
|
||||
estimatedSnapshots: Number(row?.estimated_snapshots || 0),
|
||||
salesFacts: Number(row?.sales_facts || 0),
|
||||
salesJournalEntriesToAdjust: Number(row?.sales_journal_entries_to_adjust || 0),
|
||||
financeExceptionItems: Number(row?.finance_exception_items || 0),
|
||||
}
|
||||
}
|
||||
|
||||
async function clearFinanceExceptions(itemIds: number[]): Promise<number> {
|
||||
let resolved = 0
|
||||
for (const ids of chunk(itemIds, 2000)) {
|
||||
for (const reason of REBUILD_EXCEPTION_REASONS) {
|
||||
resolved += await resolveExceptionsForItemsByReason(ids, reason)
|
||||
}
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
async function clearEstimatedSnapshots(dateFrom: string, dateTo: string): Promise<number> {
|
||||
const result = await query(
|
||||
`DELETE FROM cost_snapshots snapshot
|
||||
USING v_finance_order_item_scope scope
|
||||
WHERE scope.order_item_id = snapshot.order_item_id
|
||||
AND scope.stat_date BETWEEN $1::date AND $2::date
|
||||
AND snapshot.snapshot_stage = 'estimated'`,
|
||||
[dateFrom, dateTo],
|
||||
)
|
||||
return result.rowCount || 0
|
||||
}
|
||||
|
||||
async function clearSalesFacts(dateFrom: string, dateTo: string): Promise<number> {
|
||||
const result = await query(
|
||||
`DELETE FROM sales_daily_facts WHERE stat_date BETWEEN $1::date AND $2::date`,
|
||||
[dateFrom, dateTo],
|
||||
)
|
||||
return result.rowCount || 0
|
||||
}
|
||||
|
||||
async function regenerateCostExceptions(itemIds: number[]) {
|
||||
const manualRows = itemIds.length
|
||||
? await query<{ order_item_id: number; sku_key: string }>(
|
||||
`SELECT DISTINCT ON (scope.order_item_id)
|
||||
scope.order_item_id,
|
||||
resolve_internal_sku(item.manual_sku_key, item.sku_name, item.sku_code) AS sku_key
|
||||
FROM v_finance_order_item_scope scope
|
||||
JOIN order_items item ON item.id = scope.order_item_id
|
||||
JOIN fulfillments fulfillment ON fulfillment.order_item_id = scope.order_item_id
|
||||
WHERE scope.order_item_id = ANY($1::bigint[])
|
||||
AND fulfillment.channel = 'manual'
|
||||
AND fulfillment.fulfillment_config_id IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM cost_snapshots snapshot
|
||||
WHERE snapshot.subject_type = 'manual_case'
|
||||
AND snapshot.order_item_id = scope.order_item_id
|
||||
AND snapshot.superseded_at IS NULL
|
||||
AND snapshot.reversed_at IS NULL
|
||||
)
|
||||
ORDER BY scope.order_item_id`,
|
||||
[itemIds],
|
||||
)
|
||||
: { rows: [] }
|
||||
const onAccountRows = itemIds.length
|
||||
? await query<{ order_item_id: number; sku_key: string; bill_id: number }>(
|
||||
`SELECT
|
||||
bill.order_item_id,
|
||||
bill.sku_key,
|
||||
bill.id AS bill_id
|
||||
FROM on_account_bills bill
|
||||
WHERE bill.status = 'pending'
|
||||
AND bill.order_item_id = ANY($1::bigint[])
|
||||
ORDER BY bill.order_item_id`,
|
||||
[itemIds],
|
||||
)
|
||||
: { rows: [] }
|
||||
|
||||
const entries: AggregatedExceptionEntry[] = [
|
||||
...manualRows.rows.map((row) => ({
|
||||
reason: 'manual_cost_missing',
|
||||
scopeKey: `manual_cost_missing|-|-|${row.sku_key}|manual`,
|
||||
orderItemId: Number(row.order_item_id),
|
||||
detail: { skuKey: row.sku_key },
|
||||
})),
|
||||
...onAccountRows.rows.map((row) => ({
|
||||
reason: 'on_account_cost_pending',
|
||||
scopeKey: `on_account_cost_pending|-|-|${row.sku_key}|on_account`,
|
||||
orderItemId: Number(row.order_item_id),
|
||||
detail: { billId: Number(row.bill_id), internalSku: row.sku_key },
|
||||
})),
|
||||
]
|
||||
await upsertAggregatedExceptionsBatch(entries)
|
||||
return {
|
||||
manualCostMissing: manualRows.rows.length,
|
||||
onAccountCostPending: onAccountRows.rows.length,
|
||||
linked: entries.length,
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProtectedTotals() {
|
||||
const result = await query<{
|
||||
orders: number
|
||||
order_items: number
|
||||
order_revenue_fen: string
|
||||
refunds: number
|
||||
refund_fen: string
|
||||
manual_expenses: number
|
||||
manual_expense_fen: string
|
||||
fund_entries: number
|
||||
}>(
|
||||
`SELECT
|
||||
(SELECT COUNT(*)::int FROM orders) AS orders,
|
||||
(SELECT COUNT(*)::int FROM order_items) AS order_items,
|
||||
(SELECT COALESCE(SUM(revenue_amount_fen), 0)::bigint FROM order_items) AS order_revenue_fen,
|
||||
(SELECT COUNT(*)::int FROM order_refunds WHERE status = 'confirmed') AS refunds,
|
||||
(SELECT COALESCE(SUM(amount_fen), 0)::bigint FROM order_refunds WHERE status = 'confirmed')
|
||||
AS refund_fen,
|
||||
(SELECT COUNT(*)::int FROM manual_expenses) AS manual_expenses,
|
||||
(SELECT COALESCE(SUM(amount_fen), 0)::bigint FROM manual_expenses) AS manual_expense_fen,
|
||||
(SELECT COUNT(*)::int FROM fund_entries) AS fund_entries`,
|
||||
)
|
||||
const row = result.rows[0]
|
||||
return {
|
||||
orders: Number(row?.orders || 0),
|
||||
orderItems: Number(row?.order_items || 0),
|
||||
orderRevenueFen: Number(row?.order_revenue_fen || 0),
|
||||
refunds: Number(row?.refunds || 0),
|
||||
refundFen: Number(row?.refund_fen || 0),
|
||||
manualExpenses: Number(row?.manual_expenses || 0),
|
||||
manualExpenseFen: Number(row?.manual_expense_fen || 0),
|
||||
fundEntries: Number(row?.fund_entries || 0),
|
||||
}
|
||||
}
|
||||
|
||||
function assertProtectedTotals(
|
||||
before: Awaited<ReturnType<typeof loadProtectedTotals>>,
|
||||
after: Awaited<ReturnType<typeof loadProtectedTotals>>,
|
||||
) {
|
||||
if (JSON.stringify(before) !== JSON.stringify(after)) {
|
||||
throw new Error(`原始订单/退款/资金数据在重建前后不守恒:${JSON.stringify({ before, after })}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function assertSchema() {
|
||||
const result = await query<{ ready: boolean }>(
|
||||
`SELECT
|
||||
to_regclass('public.v_finance_order_item_scope') IS NOT NULL
|
||||
AND to_regclass('public.fulfillments') IS NOT NULL
|
||||
AND to_regclass('public.v_order_item_current') IS NOT NULL AS ready`,
|
||||
)
|
||||
if (!result.rows[0]?.ready) {
|
||||
throw new Error('财务真源视图缺失;请先执行 npm run db:migrate')
|
||||
}
|
||||
}
|
||||
|
||||
function chunk<T>(items: T[], size: number): T[][] {
|
||||
const result: T[][] = []
|
||||
for (let index = 0; index < items.length; index += size) {
|
||||
result.push(items.slice(index, index + size))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
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 normalizeDate(value: unknown): string | null {
|
||||
const text = String(value || '').trim()
|
||||
return /^\d{4}-\d{2}-\d{2}$/.test(text) ? text : null
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
.finally(() => closeDb())
|
||||
@@ -1,179 +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 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(
|
||||
oi.manual_sku_key, oi.sku_name, oi.sku_code
|
||||
)
|
||||
ORDER BY
|
||||
c.priority ASC,
|
||||
CASE c.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
|
||||
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())
|
||||
@@ -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