feat(admin): 履约渠道接口与断代运维脚本
- 新增 /admin/fulfillment 待分类、指定渠道、规则 CRUD、问题队列、管道触发 - backfill-fulfillment-channel:438k 行分批回填,幂等可续跑 - finance-cutover-v2:默认 dry-run,--execute 才清理派生数据,禁止重复执行 - rebuild-after-cutover:按断代日重跑三条管道并生成期初分录
This commit is contained in:
@@ -10,7 +10,9 @@
|
||||
"db:migrate:create": "tsx scripts/create-migration.ts",
|
||||
"db:migrate:status": "tsx scripts/migration-status.ts",
|
||||
"finance:sync": "tsx scripts/sync-cost-finance.ts",
|
||||
"finance:rebuild-cutover": "tsx scripts/rebuild-after-cutover.ts",
|
||||
"fulfillment:backfill": "tsx scripts/backfill-fulfillment-mode.ts",
|
||||
"fulfillment:backfill-channel": "tsx scripts/backfill-fulfillment-channel.ts",
|
||||
"revenue:backfill": "tsx scripts/backfill-revenue-basis.ts",
|
||||
"cost-exceptions:archive": "tsx scripts/archive-cost-exceptions.ts",
|
||||
"storage:migrate": "tsx scripts/migrate-storage.ts",
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import process from 'node:process'
|
||||
|
||||
import { closeDb, query } from '../src/db/client.js'
|
||||
|
||||
/**
|
||||
* 112 之后的渠道回填:把旧 `fulfillment_mode` 转成
|
||||
* `fulfillment_channel` + `fulfillment_status` + `fulfillment_source`。
|
||||
*
|
||||
* 规则(2026-10-31 断代):
|
||||
* 1. 业务日期 < 2026-10-31 → status=archived,source=cutover_archive;
|
||||
* channel 按旧 mode 映射(legacy_platform 没有新渠道,置空)。
|
||||
* 2. 业务日期 >= 2026-10-31:
|
||||
* · work_order / affiliate_dash → status=assigned,source=carrier_evidence;
|
||||
* · manual → status=pending(旧 manual 是兜底值,必须由运营重新确认);
|
||||
* · 其余 / NULL → status=pending。
|
||||
*
|
||||
* 幂等:每批按 id 重算,只有值变化才写;运营锁定行(fulfillment_locked)跳过。
|
||||
* 分批:keyset 扫描,单批默认 5000,避免 438k 行整表 UPDATE 撞语句超时。
|
||||
*
|
||||
* 用法:
|
||||
* npm run fulfillment:backfill-channel -- --dry-run
|
||||
* npm run fulfillment:backfill-channel -- --batch-size=5000
|
||||
* npm run fulfillment:backfill-channel -- --max-batches=100 --after-id=123456
|
||||
*/
|
||||
|
||||
/** 财务断代日:这一天之前的数据只保留审计价值。 */
|
||||
const CUTOVER_DATE = '2026-10-31'
|
||||
|
||||
const options = parseOptions(process.argv.slice(2))
|
||||
const batchSize = normalizeInteger(options.batchSize, 5000, 1, 50_000)
|
||||
const maxBatches = normalizeInteger(options.maxBatches, 1000, 1, 100_000)
|
||||
const afterId = normalizeInteger(options.afterId, 0, 0, Number.MAX_SAFE_INTEGER)
|
||||
const dryRun = options.dryRun === 'true' || options['dry-run'] === 'true'
|
||||
|
||||
const CHANNEL_SQL = `
|
||||
CASE oi.fulfillment_mode
|
||||
WHEN 'work_order' THEN 'work_order'
|
||||
WHEN 'affiliate_dash' THEN 'affiliate_dash'
|
||||
WHEN 'manual' THEN 'manual'
|
||||
ELSE NULL
|
||||
END
|
||||
`
|
||||
|
||||
const DECISION_SQL = `
|
||||
CASE
|
||||
WHEN (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date
|
||||
< DATE '${CUTOVER_DATE}' THEN 'archived'
|
||||
WHEN oi.fulfillment_mode = 'manual' THEN 'pending'
|
||||
WHEN oi.fulfillment_mode IN ('work_order', 'affiliate_dash') THEN 'assigned'
|
||||
ELSE 'pending'
|
||||
END
|
||||
`
|
||||
|
||||
const SOURCE_SQL = `
|
||||
CASE
|
||||
WHEN (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date
|
||||
< DATE '${CUTOVER_DATE}' THEN 'cutover_archive'
|
||||
WHEN oi.fulfillment_mode IN ('work_order', 'affiliate_dash') THEN 'carrier_evidence'
|
||||
ELSE ''
|
||||
END
|
||||
`
|
||||
|
||||
type BatchRow = {
|
||||
id: number
|
||||
channel: string | null
|
||||
status: string
|
||||
source: string
|
||||
changed: boolean
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await assertSchema()
|
||||
|
||||
const startedAt = Date.now()
|
||||
let cursor = afterId
|
||||
let scanned = 0
|
||||
let updated = 0
|
||||
const summary = new Map<string, number>()
|
||||
|
||||
for (let batch = 0; batch < maxBatches; batch += 1) {
|
||||
const rows = await query<BatchRow>(
|
||||
`
|
||||
SELECT
|
||||
oi.id,
|
||||
${CHANNEL_SQL} AS channel,
|
||||
${DECISION_SQL} AS status,
|
||||
${SOURCE_SQL} AS source,
|
||||
(
|
||||
oi.fulfillment_channel IS DISTINCT FROM (${CHANNEL_SQL})
|
||||
OR oi.fulfillment_status IS DISTINCT FROM (${DECISION_SQL})
|
||||
OR oi.fulfillment_source IS DISTINCT FROM (${SOURCE_SQL})
|
||||
) AS changed
|
||||
FROM order_items oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
WHERE oi.id > $1
|
||||
ORDER BY oi.id
|
||||
LIMIT $2
|
||||
`,
|
||||
[cursor, batchSize],
|
||||
)
|
||||
|
||||
if (rows.rows.length === 0) break
|
||||
|
||||
scanned += rows.rows.length
|
||||
cursor = rows.rows[rows.rows.length - 1]!.id
|
||||
|
||||
for (const row of rows.rows) {
|
||||
const key = `${row.status}/${row.channel ?? '-'}`
|
||||
summary.set(key, (summary.get(key) || 0) + 1)
|
||||
}
|
||||
|
||||
const changedRows = rows.rows.filter((row) => row.changed)
|
||||
if (changedRows.length === 0) {
|
||||
process.stderr.write(`批 ${batch + 1}: 扫描 ${rows.rows.length},无变化\n`)
|
||||
continue
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
updated += changedRows.length
|
||||
process.stderr.write(
|
||||
`批 ${batch + 1}: 扫描 ${rows.rows.length},将更新 ${changedRows.length}\n`,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
const ids = changedRows.map((row) => row.id)
|
||||
const channels = changedRows.map((row) => row.channel)
|
||||
const statuses = changedRows.map((row) => row.status)
|
||||
const sources = changedRows.map((row) => row.source)
|
||||
|
||||
const result = await query(
|
||||
`
|
||||
UPDATE order_items oi
|
||||
SET
|
||||
fulfillment_channel = input.channel,
|
||||
fulfillment_status = input.status,
|
||||
fulfillment_source = input.source,
|
||||
fulfillment_decided_at = NOW(),
|
||||
updated_at = NOW()
|
||||
FROM unnest($1::bigint[], $2::text[], $3::text[], $4::text[])
|
||||
AS input(id, channel, status, source)
|
||||
WHERE oi.id = input.id
|
||||
AND oi.fulfillment_locked = FALSE
|
||||
`,
|
||||
[ids, channels, statuses, sources],
|
||||
)
|
||||
|
||||
updated += result.rowCount || 0
|
||||
process.stderr.write(
|
||||
`批 ${batch + 1}: 扫描 ${rows.rows.length},更新 ${result.rowCount || 0}\n`,
|
||||
)
|
||||
}
|
||||
|
||||
const remaining = await query<{ count: number }>(
|
||||
`SELECT COUNT(*)::int AS count FROM order_items WHERE id > $1`,
|
||||
[cursor],
|
||||
)
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
cutoverDate: CUTOVER_DATE,
|
||||
dryRun,
|
||||
batchSize,
|
||||
afterId,
|
||||
scanned,
|
||||
updated,
|
||||
lastId: cursor,
|
||||
remaining: remaining.rows[0]?.count || 0,
|
||||
summary: Object.fromEntries([...summary.entries()].sort()),
|
||||
durationMs: Date.now() - startedAt,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
|
||||
if ((remaining.rows[0]?.count || 0) > 0) {
|
||||
console.error(
|
||||
`仍有 ${remaining.rows[0]?.count} 个订单项未扫描;请增大 --max-batches 或用 --after-id=${cursor} 续跑。`,
|
||||
)
|
||||
process.exitCode = 2
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
.finally(async () => {
|
||||
await closeDb()
|
||||
})
|
||||
|
||||
async function assertSchema() {
|
||||
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_mode', 'fulfillment_channel',
|
||||
'fulfillment_status', 'fulfillment_source', 'fulfillment_locked'
|
||||
)
|
||||
`,
|
||||
)
|
||||
const found = new Set(columns.rows.map((row) => row.column_name))
|
||||
const missing = [
|
||||
'fulfillment_mode',
|
||||
'fulfillment_channel',
|
||||
'fulfillment_status',
|
||||
'fulfillment_source',
|
||||
'fulfillment_locked',
|
||||
].filter((name) => !found.has(name))
|
||||
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`order_items 缺少列:${missing.join(', ')};请先执行迁移 112`)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
// 同时支持 --batch-size 与 --batchSize 两种写法。
|
||||
result[rawKey.replace(/-([a-z])/g, (_, char: string) => char.toUpperCase())] = value
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function normalizeInteger(value: unknown, fallback: number, min: number, max: number) {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isInteger(parsed)) return fallback
|
||||
return Math.min(max, Math.max(min, parsed))
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
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 已执行;
|
||||
* 2. `npm run fulfillment:backfill-channel` 已完成(不允许有 assigned 但无渠道的行);
|
||||
* 3. 已做数据库备份。
|
||||
*
|
||||
* 默认 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 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, 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`,
|
||||
)
|
||||
}
|
||||
|
||||
const pendingBackfill = await query<{ count: number }>(
|
||||
`
|
||||
SELECT COUNT(*)::int AS count
|
||||
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'
|
||||
`,
|
||||
[CUTOVER_DATE],
|
||||
)
|
||||
const notArchived = pendingBackfill.rows[0]?.count || 0
|
||||
if (notArchived > 0) {
|
||||
throw new Error(`有 ${notArchived} 行断代前订单项未归档;请先执行 fulfillment:backfill-channel`)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
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))
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import dashboardRouter from './admin/dashboard.js'
|
||||
import devMockRouter from './admin/dev-mock.js'
|
||||
import douyinMashidaRouter from './admin/douyin-mashida.js'
|
||||
import financeRouter from './admin/finance.js'
|
||||
import fulfillmentChannelRouter from './admin/fulfillment-channel.js'
|
||||
import financeLedgerRouter from './admin/finance-ledger.js'
|
||||
import fundRouter from './admin/fund.js'
|
||||
import filesRouter from './admin/files.js'
|
||||
@@ -31,6 +32,7 @@ router.use(dashboardRouter)
|
||||
router.use(financeRouter)
|
||||
router.use(financeLedgerRouter)
|
||||
router.use(costCenterRouter)
|
||||
router.use(fulfillmentChannelRouter)
|
||||
router.use(fundRouter)
|
||||
router.use(usersRouter)
|
||||
router.use(rbacRouter)
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* 履约渠道 v2 路由:待分类队列、运营指定渠道、渠道规则、管道触发。
|
||||
*
|
||||
* 与成本中心的分工:
|
||||
* · 这里只处理「渠道」;
|
||||
* · 商品归一、成本版本、异常处置仍在成本中心的既有页面里。
|
||||
*/
|
||||
import { Router } from 'express'
|
||||
|
||||
import {
|
||||
assignChannelBySku,
|
||||
assignChannelToOrderItems,
|
||||
deleteChannelRule,
|
||||
listChannelRules,
|
||||
listPendingChannelGroups,
|
||||
unlockChannel,
|
||||
upsertChannelRule,
|
||||
} from '../../services/fulfillment/channel-assign-service.js'
|
||||
import { listExceptionQueue } from '../../services/cost-center/cost-exception-service.js'
|
||||
import { runFinancePipelineOnce } from '../../services/scheduler/finance-pipeline-jobs.js'
|
||||
import { bodyOf } from './request-helpers.js'
|
||||
import { createJsonHandler, requireAdminPermission, requireAdminRoles } from './session.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.use('/fulfillment', requireAdminRoles(['admin', 'operator', 'finance', 'support']))
|
||||
|
||||
router.get(
|
||||
'/fulfillment/pending',
|
||||
requireAdminPermission('finance.view'),
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
listPendingChannelGroups({
|
||||
page: Number(req.query.page),
|
||||
pageSize: Number(req.query.pageSize),
|
||||
keyword: String(req.query.keyword || ''),
|
||||
}),
|
||||
{
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取待分类队列失败',
|
||||
scope: '[admin/fulfillment/pending]',
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.get(
|
||||
'/fulfillment/exceptions',
|
||||
requireAdminPermission('finance.view'),
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
listExceptionQueue({
|
||||
reason: String(req.query.reason || ''),
|
||||
status: String(req.query.status || 'open'),
|
||||
page: Number(req.query.page),
|
||||
pageSize: Number(req.query.pageSize),
|
||||
}),
|
||||
{
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取问题队列失败',
|
||||
scope: '[admin/fulfillment/exceptions]',
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/fulfillment/assign',
|
||||
requireAdminPermission('finance.config'),
|
||||
createJsonHandler(
|
||||
async (req) => {
|
||||
const body = bodyOf(req)
|
||||
const channel = String(body.channel || '').trim()
|
||||
const adminUserId = req.adminSession?.userId ?? null
|
||||
const note = String(body.note || '')
|
||||
|
||||
if (body.skuCode) {
|
||||
return assignChannelBySku({
|
||||
provider: String(body.provider || ''),
|
||||
shopId: String(body.shopId || ''),
|
||||
skuCode: String(body.skuCode),
|
||||
channel,
|
||||
adminUserId,
|
||||
note,
|
||||
})
|
||||
}
|
||||
|
||||
const ids = Array.isArray(body.orderItemIds)
|
||||
? body.orderItemIds.map((value: unknown) => Number(value))
|
||||
: [Number(body.orderItemId)].filter((value) => Number.isInteger(value) && value > 0)
|
||||
|
||||
return assignChannelToOrderItems({ orderItemIds: ids, channel, adminUserId, note })
|
||||
},
|
||||
{
|
||||
successMessage: '已指定渠道',
|
||||
errorMessage: '指定渠道失败',
|
||||
scope: '[admin/fulfillment/assign]',
|
||||
audit: (req, data) => ({
|
||||
action: 'fulfillment.assign_channel',
|
||||
targetType: 'order_item',
|
||||
targetId: String(bodyOf(req).skuCode || bodyOf(req).orderItemId || ''),
|
||||
data: { payload: bodyOf(req), result: data as Record<string, unknown> },
|
||||
}),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/fulfillment/unlock',
|
||||
requireAdminPermission('finance.config'),
|
||||
createJsonHandler(
|
||||
async (req) => {
|
||||
const body = bodyOf(req)
|
||||
const ids = Array.isArray(body.orderItemIds)
|
||||
? body.orderItemIds.map((value: unknown) => Number(value))
|
||||
: []
|
||||
return unlockChannel(ids)
|
||||
},
|
||||
{
|
||||
successMessage: '已解锁',
|
||||
errorMessage: '解锁失败',
|
||||
scope: '[admin/fulfillment/unlock]',
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.get(
|
||||
'/fulfillment/rules',
|
||||
requireAdminPermission('finance.view'),
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
listChannelRules({
|
||||
channel: String(req.query.channel || ''),
|
||||
...(req.query.enabled === undefined
|
||||
? {}
|
||||
: { enabled: String(req.query.enabled) !== 'false' }),
|
||||
}),
|
||||
{
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取渠道规则失败',
|
||||
scope: '[admin/fulfillment/rules]',
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/fulfillment/rules',
|
||||
requireAdminPermission('finance.config'),
|
||||
createJsonHandler(
|
||||
async (req) => {
|
||||
const body = bodyOf(req)
|
||||
return upsertChannelRule({
|
||||
...(body.id ? { id: Number(body.id) } : {}),
|
||||
provider: String(body.provider || ''),
|
||||
shopId: String(body.shopId || ''),
|
||||
matchKeyType: String(body.matchKeyType || 'sku_code'),
|
||||
matchKey: String(body.matchKey || ''),
|
||||
channel: String(body.channel || ''),
|
||||
priority: Number(body.priority),
|
||||
source: String(body.source || 'operator'),
|
||||
enabled: body.enabled !== false,
|
||||
note: String(body.note || ''),
|
||||
adminUserId: req.adminSession?.userId ?? null,
|
||||
})
|
||||
},
|
||||
{
|
||||
successMessage: '规则已保存',
|
||||
errorMessage: '保存渠道规则失败',
|
||||
scope: '[admin/fulfillment/rules]',
|
||||
audit: (req, data) => ({
|
||||
action: 'fulfillment.upsert_rule',
|
||||
targetType: 'sku_channel_rule',
|
||||
targetId: String((data as { id?: number })?.id || ''),
|
||||
data: { payload: bodyOf(req) },
|
||||
}),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.delete(
|
||||
'/fulfillment/rules/:id',
|
||||
requireAdminPermission('finance.config'),
|
||||
createJsonHandler((req) => deleteChannelRule(Number(req.params.id)), {
|
||||
successMessage: '规则已删除',
|
||||
errorMessage: '删除渠道规则失败',
|
||||
scope: '[admin/fulfillment/rules]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/fulfillment/pipeline/run',
|
||||
requireAdminPermission('finance.config'),
|
||||
createJsonHandler(
|
||||
async (req) => {
|
||||
const body = bodyOf(req)
|
||||
const days = Number(body.days)
|
||||
return runFinancePipelineOnce({ days: Number.isFinite(days) ? days : 3 })
|
||||
},
|
||||
{
|
||||
successMessage: '管道已执行',
|
||||
errorMessage: '执行管道失败',
|
||||
scope: '[admin/fulfillment/pipeline]',
|
||||
audit: (req, data) => ({
|
||||
action: 'fulfillment.run_pipeline',
|
||||
targetType: 'pipeline',
|
||||
targetId: 'finance',
|
||||
data: { payload: bodyOf(req), result: data as Record<string, unknown> },
|
||||
}),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
export default router
|
||||
Reference in New Issue
Block a user