feat(admin): 履约渠道接口与断代运维脚本

- 新增 /admin/fulfillment 待分类、指定渠道、规则 CRUD、问题队列、管道触发
- backfill-fulfillment-channel:438k 行分批回填,幂等可续跑
- finance-cutover-v2:默认 dry-run,--execute 才清理派生数据,禁止重复执行
- rebuild-after-cutover:按断代日重跑三条管道并生成期初分录
This commit is contained in:
yml2213
2026-10-31 18:12:25 +08:00
parent 75a3801236
commit 855f2efcff
6 changed files with 790 additions and 0 deletions
+2
View File
@@ -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