diff --git a/apps/backend/package.json b/apps/backend/package.json index 7f6f950b..cd67a411 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -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", diff --git a/apps/backend/scripts/backfill-fulfillment-channel.ts b/apps/backend/scripts/backfill-fulfillment-channel.ts new file mode 100644 index 00000000..440f8e2f --- /dev/null +++ b/apps/backend/scripts/backfill-fulfillment-channel.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() + + for (let batch = 0; batch < maxBatches; batch += 1) { + const rows = await query( + ` + 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 = {} + 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)) +} diff --git a/apps/backend/scripts/finance-cutover-v2.ts b/apps/backend/scripts/finance-cutover-v2.ts new file mode 100644 index 00000000..acee8a5e --- /dev/null +++ b/apps/backend/scripts/finance-cutover-v2.ts @@ -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 = {} + 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 = {} + 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 +} diff --git a/apps/backend/scripts/rebuild-after-cutover.ts b/apps/backend/scripts/rebuild-after-cutover.ts new file mode 100644 index 00000000..ffe534e4 --- /dev/null +++ b/apps/backend/scripts/rebuild-after-cutover.ts @@ -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 = {} + 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)) +} diff --git a/apps/backend/src/routes/admin.ts b/apps/backend/src/routes/admin.ts index 07289822..e59ecb5a 100644 --- a/apps/backend/src/routes/admin.ts +++ b/apps/backend/src/routes/admin.ts @@ -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) diff --git a/apps/backend/src/routes/admin/fulfillment-channel.ts b/apps/backend/src/routes/admin/fulfillment-channel.ts new file mode 100644 index 00000000..cb4b302e --- /dev/null +++ b/apps/backend/src/routes/admin/fulfillment-channel.ts @@ -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 }, + }), + }, + ), +) + +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 }, + }), + }, + ), +) + +export default router