refactor(finance)!: 强制渠道模型,移除旧履约方式与同步链路

- 删除 fulfillment_mode 相关的旧同步/回填链路与定时任务
- 删除 cost_finance_sync、成本快照同步、人工商品归一等遗留服务
- 迁移 113 直接删除 order_items.fulfillment_mode 及其索引约束
- 断代脚本自带归档步骤,不再依赖独立回填脚本
- 退款/事实/总账统一走渠道版销售事实
This commit is contained in:
yml2213
2026-10-31 19:52:03 +08:00
parent 808949f3b8
commit 65b7f4f9dc
25 changed files with 156 additions and 2383 deletions
-2
View File
@@ -11,8 +11,6 @@
"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",
@@ -1,241 +0,0 @@
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))
}
@@ -1,88 +0,0 @@
import process from 'node:process'
import { closeDb, query } from '../src/db/client.js'
import {
getFulfillmentModeCounts,
reconcilePendingFulfillmentModes,
} from '../src/services/fulfillment/fulfillment-mode-service.js'
const options = parseOptions(process.argv.slice(2))
const batchSize = normalizeInteger(options.batchSize, 5000, 1, 50_000)
const maxBatches = normalizeInteger(options.maxBatches, 1000, 1, 10_000)
const afterId = normalizeInteger(options.afterId, 0, 0, Number.MAX_SAFE_INTEGER)
async function main() {
const column = await query<{ exists: boolean }>(
`
SELECT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'order_items'
AND column_name = 'fulfillment_mode'
) AS exists
`,
)
if (!column.rows[0]?.exists) {
throw new Error('order_items.fulfillment_mode 不存在,请先执行 npm run db:migrate(迁移 105)')
}
const startedAt = Date.now()
const result = await reconcilePendingFulfillmentModes({
batchSize,
maxBatches,
reason: 'backfill_2026_10_23',
afterId,
})
const counts = await getFulfillmentModeCounts()
console.log(
JSON.stringify(
{
...result,
batchSize,
maxBatches,
counts,
durationMs: Date.now() - startedAt,
},
null,
2,
),
)
if (result.remainingCount > 0) {
console.error(`仍有 ${result.remainingCount} 个订单项未回填;请增大 --max-batches 或检查数据。`)
process.exitCode = 2
}
}
main()
.catch((error) => {
console.error(error instanceof Error ? error.message : error)
process.exitCode = 1
})
.finally(async () => {
await closeDb()
})
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('=')
if (index === -1) {
result[body] = 'true'
} else {
result[body.slice(0, index)] = body.slice(index + 1)
}
}
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))
}
+53 -17
View File
@@ -16,9 +16,10 @@ import { closeDb, query } from '../src/db/client.js'
* sku_channel_rules(配置)
*
* 前置:
* 1. 迁移 112 已执行;
* 2. `npm run fulfillment:backfill-channel` 已完成(不允许有 assigned 但无渠道的行);
* 3. 已做数据库备份。
* 1. 迁移 112/113 已执行;
* 2. 已做数据库备份。
*
* 断代本身包含两步:先把断代前的订单项批量置为 archived,再清理派生数据。
*
* 默认 dry-run,只打印将删除的行数;加 `--execute --confirm=2026-10-31` 才真正执行。
* 不可重复执行:成功后会写入 finance_cutovers,第二次会被拦下。
@@ -77,6 +78,8 @@ async function main() {
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(
@@ -110,7 +113,13 @@ async function main() {
console.log(
JSON.stringify(
{ mode: 'execute', cutoverDate: CUTOVER_DATE, deleted, durationMs: Date.now() - startedAt },
{
mode: 'execute',
cutoverDate: CUTOVER_DATE,
archivedItems: archived.updated,
deleted,
durationMs: Date.now() - startedAt,
},
null,
2,
),
@@ -181,21 +190,48 @@ async function assertPreconditions() {
`有 ${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`)
/**
* 断代前的订单项统一置为 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() {
-91
View File
@@ -1,91 +0,0 @@
import process from 'node:process'
import { closeDb } from '../src/db/client.js'
import {
syncActualWorkOrderCostSnapshots,
syncEstimatedOrderItemCostSnapshots,
} from '../src/services/cost-center/cost-snapshot-sync-service.js'
import { rebuildSalesDailyFacts } from '../src/services/finance/sales-facts-service.js'
import { resolveFinanceDateRange, shiftDate } from '../src/utils/finance-date-range.js'
import { nowIso } from '../src/utils/time.js'
type Mode = 'estimated' | 'actual' | 'facts' | 'all'
const options = parseOptions(process.argv.slice(2))
const mode = normalizeMode(options.mode)
const days = normalizeDays(options.days)
const fallbackTo = options.to || formatShanghaiDate(new Date())
const fallbackFrom = options.from || shiftDate(fallbackTo, -(days - 1))
async function main() {
const input = { dateFrom: fallbackFrom, dateTo: fallbackTo }
const range = resolveFinanceDateRange(input, { defaultDays: days, maxDays: 400 })
const startedAt = Date.now()
const result: Record<string, unknown> = { mode, range }
if (mode === 'estimated' || mode === 'all') {
result.estimated = await syncEstimatedOrderItemCostSnapshots(input)
}
if (mode === 'actual' || mode === 'all') {
result.actual = await syncActualWorkOrderCostSnapshots(input)
}
if (mode === 'facts' || mode === 'all') {
result.facts = await rebuildSalesDailyFacts(input)
}
result.durationMs = Date.now() - startedAt
result.finishedAt = nowIso()
console.log(JSON.stringify(result, null, 2))
}
main()
.catch((error) => {
console.error(error instanceof Error ? error.message : error)
process.exitCode = 1
})
.finally(async () => {
await closeDb()
})
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('=')
if (index === -1) {
result[body] = 'true'
} else {
result[body.slice(0, index)] = body.slice(index + 1)
}
}
return result
}
function normalizeMode(value: unknown): Mode {
const mode = String(value || 'all').trim()
if (mode === 'estimated' || mode === 'actual' || mode === 'facts' || mode === 'all') {
return mode
}
throw new Error('--mode 只能是 estimated / actual / facts / all')
}
function normalizeDays(value: unknown) {
const parsed = Number(value || 7)
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 400) {
throw new Error('--days 必须是 1-400 的整数')
}
return parsed
}
/** 模块级只构造一次:`new Intl.DateTimeFormat()` 每次约 0.5ms,逐条调用会线性放大。 */
const SHANGHAI_DATE_FORMATTER = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
function formatShanghaiDate(value: Date) {
return SHANGHAI_DATE_FORMATTER.format(value)
}
@@ -0,0 +1,40 @@
-- 113_drop_legacy_fulfillment_mode.sql
--
-- 强制新模型:彻底删除旧 `order_items.fulfillment_mode` 及其索引/约束。
--
-- 背景:
-- · 112 已加入 fulfillment_channel + fulfillment_status + fulfillment_source;
-- · 代码侧已全部改为读写新列,`fulfillment_mode` 不再有任何读写方;
-- · 保留旧列只会让「新旧并存」继续存在,因此这一版直接删除。
--
-- 注意:
-- 1. 本迁移只做 DDL,不搬运数据;
-- 2. 升级时应在维护窗口内先跑迁移,紧接着执行 `npm run finance:cutover-v2`
-- 完成断代归档与派生数据清理,避免中间态被业务读到;
-- 3. 已被 112 标记为 archived 的历史行不受影响。
-- 上一版 113 曾加过「旧异常写入口」兼容触发器,这里一并清理,不再保留兼容层。
DROP TRIGGER IF EXISTS trg_cost_exceptions_fill_scope_key ON cost_exceptions;
DROP FUNCTION IF EXISTS cost_exceptions_fill_scope_key();
ALTER TABLE order_items
DROP CONSTRAINT IF EXISTS chk_order_items_fulfillment_mode;
-- 依赖旧列的部分索引必须先删,否则 DROP COLUMN 会被拒绝。
DROP INDEX IF EXISTS idx_order_items_fulfillment_mode;
DROP INDEX IF EXISTS idx_order_items_fulfillment_pending;
DROP INDEX IF EXISTS idx_order_items_fulfillment_id;
DROP INDEX IF EXISTS idx_order_items_fulfillment_pending_id;
DROP INDEX IF EXISTS idx_order_items_fulfillment_product;
DROP INDEX IF EXISTS idx_order_items_manual_pending_cover;
DROP INDEX IF EXISTS idx_order_items_manual_sku_key_stored;
DROP INDEX IF EXISTS idx_order_items_manual_internal_sku;
DROP INDEX IF EXISTS idx_order_items_fulfillment_config;
ALTER TABLE order_items
DROP COLUMN IF EXISTS fulfillment_mode;
COMMENT ON COLUMN order_items.fulfillment_channel IS
'履约渠道(唯一真源):work_order / affiliate_dash / third_party_voucher / manual;pending 时为空';
COMMENT ON COLUMN order_items.fulfillment_status IS
'渠道状态:pending 待分类 / assigned 已确定 / archived 断代归档';
@@ -6,7 +6,6 @@
*/
import { Router } from 'express'
import { normalizeManualProducts } from '../../services/cost-center/manual-product-normalization-service.js'
import {
backfillAdminNormalizedQuantities,
backfillAdminProductMappings,
@@ -27,7 +26,6 @@ import {
backfillAdminAffiliateDashSkus,
importAdminAffiliateDashPricesBaseline,
setAdminAffiliateProductPrice,
syncAdminAffiliateDashCostSnapshots,
syncAdminAffiliateDashProducts,
} from '../../services/cost-center/affiliate-product-service.js'
import {
@@ -153,22 +151,6 @@ router.post(
),
)
router.post(
'/cost-center/affiliate-products/sync-snapshots',
requireAdminPermission('finance.config'),
createJsonHandler(() => withBatchTimeout(() => syncAdminAffiliateDashCostSnapshots()), {
successMessage: '已重算自动发货预估成本快照与销售事实',
errorMessage: '重算自动发货成本快照失败',
scope: '[admin/cost-center/affiliate-products/sync-snapshots]',
audit: (_req, data) => ({
action: 'cost_affiliate_snapshots_synced',
targetType: 'cost_snapshots',
targetId: '',
data: data as Record<string, unknown>,
}),
}),
)
router.post(
'/cost-center/work-order-products/backfill',
requireAdminPermission('finance.config'),
@@ -204,32 +186,6 @@ router.post(
}),
)
router.post(
'/cost-center/manual-products/normalize',
requireAdminPermission('finance.config'),
createJsonHandler(
(req) =>
withBatchTimeout(() =>
normalizeManualProducts({
dateFrom: bodyOf(req).dateFrom as string | undefined,
dateTo: bodyOf(req).dateTo as string | undefined,
dryRun: bodyOf(req).dryRun === true,
}),
),
{
successMessage: '人工商品归一完成',
errorMessage: '归一人工商品失败',
scope: '[admin/cost-center/manual-products/normalize]',
audit: (_req, data) => ({
action: 'cost_manual_products_normalized',
targetType: 'product_catalog',
targetId: '',
data: data as Record<string, unknown>,
}),
},
),
)
router.get(
'/cost-center/products',
requireAdminPermission('finance.view'),
@@ -21,10 +21,6 @@ import {
createAdminCostSnapshot,
previewAdminCost,
} from '../../services/cost-center/cost-resolution-service.js'
import {
syncActualWorkOrderCostSnapshots,
syncEstimatedOrderItemCostSnapshots,
} from '../../services/cost-center/cost-snapshot-sync-service.js'
import type { JsonObject } from '../../types/json.js'
import { bodyOf, idOf, sessionOf } from './request-helpers.js'
import { createJsonHandler, requireAdminPermission } from './session.js'
@@ -244,50 +240,4 @@ router.post(
}),
)
router.post(
'/cost-center/snapshots/sync',
requireAdminPermission('finance.config'),
createJsonHandler(
async (req) => {
const body = bodyOf(req)
const mode = String(body.mode || 'both').trim()
const estimated = mode === 'actual' ? null : await syncEstimatedOrderItemCostSnapshots(body)
const actual = mode === 'estimated' ? null : await syncActualWorkOrderCostSnapshots(body)
return { mode, estimated, actual }
},
{
successMessage: '成本快照回填完成',
errorMessage: '回填成本快照失败',
scope: '[admin/cost-center/snapshots/sync]',
audit: (req, data) => {
const result = data as {
mode?: string
estimated?: { createdCount?: number; exceptionCreatedCount?: number } | null
actual?: {
createdCount?: number
directRewardCount?: number
upgradedCount?: number
estimatedRewardCount?: number
} | null
}
return {
action: 'cost_snapshot_synced',
targetType: 'cost_snapshot',
targetId: '',
data: {
mode: result.mode || '',
estimatedCreated: result.estimated?.createdCount || 0,
actualCreated:
(result.actual?.directRewardCount || 0) + (result.actual?.upgradedCount || 0),
estimatedRewardCreated: result.actual?.estimatedRewardCount || 0,
exceptions: result.estimated?.exceptionCreatedCount || 0,
dateFrom: req.body?.dateFrom || '',
dateTo: req.body?.dateTo || '',
},
}
},
},
),
)
export default router
+10 -5
View File
@@ -7,8 +7,8 @@ import {
import {
getSalesProfitOverview,
listSalesDailyFacts,
rebuildSalesDailyFacts,
} from '../../services/finance/sales-facts-service.js'
import { rebuildChannelSalesDailyFacts } from '../../services/finance/sales-facts-channel-service.js'
import {
confirmManualFulfillment,
listManualFulfillmentPending,
@@ -70,10 +70,15 @@ router.post(
requireAdminRoles(['admin', 'operator', 'finance']),
requireAdminPermission('finance.post'),
createJsonHandler(
(req) =>
rebuildSalesDailyFacts(
req.body && typeof req.body === 'object' ? (req.body as Record<string, unknown>) : {},
),
(req) => {
const body =
req.body && typeof req.body === 'object' ? (req.body as Record<string, unknown>) : {}
const today = new Date().toLocaleDateString('en-CA', { timeZone: 'Asia/Shanghai' })
return rebuildChannelSalesDailyFacts({
dateFrom: String(body.dateFrom || body.date_from || today).slice(0, 10),
dateTo: String(body.dateTo || body.date_to || today).slice(0, 10),
})
},
{
successMessage: '销售事实已重算',
errorMessage: '重算销售事实失败',
@@ -4,8 +4,7 @@ import type { JsonObject } from '../../types/json.js'
import { createHttpError } from '../../utils/http.js'
import { formatShanghaiDate, shiftDate } from '../../utils/finance-date-range.js'
import { nowIso } from '../../utils/time.js'
import { rebuildSalesDailyFacts } from '../finance/sales-facts-service.js'
import { syncEstimatedOrderItemCostSnapshots } from './cost-snapshot-sync-service.js'
import { rebuildChannelSalesDailyFacts } from '../finance/sales-facts-channel-service.js'
import { AFFILIATE_DASH_PRICES_BASELINE } from './affiliate-dash-prices-baseline.js'
/**
@@ -149,9 +148,9 @@ export async function rebuildFactsForRange(fromDate: string, toDate: string) {
while (cursor <= toDate) {
const windowEnd = shiftDate(cursor, 399)
const chunkEnd = windowEnd > toDate ? toDate : windowEnd
const rebuilt = await rebuildSalesDailyFacts({ dateFrom: cursor, dateTo: chunkEnd })
deletedCount += rebuilt.deletedCount
insertedCount += rebuilt.insertedCount
const rebuilt = await rebuildChannelSalesDailyFacts({ dateFrom: cursor, dateTo: chunkEnd })
deletedCount += rebuilt.deleted
insertedCount += rebuilt.inserted
batchCount += 1
cursor = shiftDate(chunkEnd, 1)
}
@@ -548,24 +547,3 @@ export async function importAdminAffiliateDashPricesBaseline(session: AdminSessi
skippedCount,
}
}
/**
* 一键重算自动发货预估成本快照并重算销售事实。
* 默认统计最新的一个月(30 天)数据,避免扫描混乱的历史遗留数据。
*/
export async function syncAdminAffiliateDashCostSnapshots(options: { days?: number } = {}) {
const days = options.days && options.days > 0 ? options.days : 30
const today = formatShanghaiDate(new Date())
const fromDate = shiftDate(today, -(days - 1))
const snapshotResult = await syncEstimatedOrderItemCostSnapshots({
dateFrom: fromDate,
dateTo: today,
})
const factsResult = await rebuildFactsForRange(fromDate, today)
return {
snapshotResult,
factsResult,
}
}
@@ -1,806 +0,0 @@
import type { PoolClient } from 'pg'
import { withTransaction } from '../../db/client.js'
import { resolveFinanceDateRange } from '../../utils/finance-date-range.js'
import { costModelScopeSql } from './cost-center-shared.js'
export type CostSnapshotSyncInput = {
dateFrom?: unknown
dateTo?: unknown
from?: unknown
to?: unknown
}
export type CostSnapshotSyncResult = {
range: { fromDate: string; toDate: string }
createdCount: number
exceptionCreatedCount: number
resolvedExceptionCount: number
}
export type ActualCostSnapshotSyncResult = {
range: { fromDate: string; toDate: string }
createdCount: number
upgradedCount: number
directRewardCount: number
estimatedRewardCount: number
}
/**
* 按订单项批量生成预估成本快照。
*
* 只处理已有 canonical_product_id 的订单项;未归一订单项进入
* product_mapping_missing 异常,不在这里猜测商品。
*/
export async function syncEstimatedOrderItemCostSnapshots(
input: CostSnapshotSyncInput = {},
): Promise<CostSnapshotSyncResult> {
const range = resolveFinanceDateRange(input, { defaultDays: 7, maxDays: 400 })
return withTransaction(async (client) => {
const inserted = await client.query(
`
WITH item_scope AS (
SELECT
oi.id AS order_item_id,
oi.order_id,
o.provider,
o.shop_id,
oi.sku_code,
oi.sku_name,
oi.canonical_product_id,
COALESCE(o.paid_at, o.created_at) AS basis_at,
GREATEST(COALESCE(oi.normalized_quantity, oi.quantity, 1), 1)::numeric AS quantity,
COALESCE(oi.revenue_amount_fen, 0)::bigint AS revenue_fen,
wo.id AS work_order_id,
wo.product_rule_id AS work_product_rule_id,
COALESCE(oi.fulfillment_mode, 'manual') AS fulfillment_mode
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
-- order_item_id 上有唯一部分索引,一个订单项最多一张工单;
-- 原来的 ORDER BY id DESC LIMIT 1 逐行探测会做 3.7 万次索引查找。
LEFT JOIN work_orders wo ON wo.order_item_id = oi.id
WHERE o.pay_status IN ('paid', 'refunded')
AND (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date
BETWEEN $1::date AND $2::date
),
resolved AS (
SELECT
s.*,
ci.id AS cost_item_id,
ci.pricing_mode,
v.id AS cost_version_id,
v.amount_fen,
v.rate_bps
FROM item_scope s
JOIN cost_items ci ON ci.status = 'active'
AND ${costModelScopeSql('s.fulfillment_mode')}
AND ci.pricing_mode IN ('per_item', 'per_order', 'percent_of_revenue')
AND ci.code <> 'sys_work_order_reward'
-- 人工成本只能在「确认履约」时按实际支出写 actual 快照,
-- 这里的版本金额是 0 元占位,不能拿来生成预估快照。
AND ci.cost_model <> 'manual_actual'
JOIN LATERAL (
SELECT v.id, v.amount_fen, v.rate_bps
FROM cost_versions v
WHERE v.cost_item_id = ci.id
AND v.status = 'active'
AND v.effective_from <= s.basis_at
AND (v.effective_to IS NULL OR v.effective_to > s.basis_at)
AND (
(v.scope_type = 'rule' AND s.work_product_rule_id IS NOT NULL
AND v.work_product_rule_id = s.work_product_rule_id)
OR (v.scope_type = 'product' AND s.canonical_product_id IS NOT NULL
AND v.canonical_product_id = s.canonical_product_id)
OR (v.scope_type = 'shop' AND v.provider = s.provider AND v.shop_id = s.shop_id)
OR (v.scope_type = 'provider' AND v.provider = s.provider)
OR (v.scope_type = 'global')
)
ORDER BY
CASE v.scope_type
WHEN 'rule' THEN 1
WHEN 'product' THEN 2
WHEN 'shop' THEN 3
WHEN 'provider' THEN 4
ELSE 5
END,
v.effective_from DESC,
v.id DESC
LIMIT 1
) v ON TRUE
WHERE s.canonical_product_id IS NOT NULL
),
calculated AS (
SELECT
r.*,
CASE r.pricing_mode
WHEN 'per_item' THEN ROUND(r.amount_fen::numeric * r.quantity)::bigint
WHEN 'per_order' THEN r.amount_fen
WHEN 'percent_of_revenue'
THEN ROUND((r.revenue_fen::numeric * r.rate_bps::numeric) / 10000.0)::bigint
ELSE r.amount_fen
END AS total_amount_fen
FROM resolved r
)
INSERT INTO cost_snapshots (
subject_type, subject_id, order_id, order_item_id, work_order_id,
cost_item_id, cost_version_id, canonical_product_id,
quantity, unit_amount_fen, total_amount_fen, snapshot_stage, source,
metadata_json, cost_basis_at, resolved_at, created_at
)
SELECT
'order_item', c.order_item_id, c.order_id, c.order_item_id, c.work_order_id,
c.cost_item_id, c.cost_version_id, c.canonical_product_id,
CASE WHEN c.pricing_mode = 'per_item' THEN c.quantity ELSE 1 END,
CASE WHEN c.pricing_mode = 'percent_of_revenue' THEN c.total_amount_fen ELSE c.amount_fen END,
c.total_amount_fen,
'estimated', 'resolved',
jsonb_build_object(
'provider', c.provider,
'shopId', c.shop_id,
'skuCode', c.sku_code,
'skuName', c.sku_name,
'pricingMode', c.pricing_mode,
'revenueFen', c.revenue_fen,
'normalizedQuantity', c.quantity,
'fulfillmentMode', c.fulfillment_mode,
'basisAt', c.basis_at
),
c.basis_at, NOW(), NOW()
FROM calculated c
ON CONFLICT (subject_type, subject_id, cost_item_id)
WHERE superseded_at IS NULL
DO NOTHING
`,
[range.fromDate, range.toDate],
)
const productExceptions = await insertAndResolveProductMappingExceptions(
client,
range.fromDate,
range.toDate,
)
const costExceptions = await insertAndResolveCostMissingExceptions(
client,
range.fromDate,
range.toDate,
)
return {
range,
createdCount: inserted.rowCount || 0,
exceptionCreatedCount: productExceptions.created + costExceptions.created,
resolvedExceptionCount: productExceptions.resolved + costExceptions.resolved,
}
})
}
/**
* 工单验收后,把订单项当前预估快照升级为实际快照。
*
* 如果配置了 work_order_reward 成本项,则用工单实际 reward_amount 覆盖金额;
* 否则仍冻结当前版本金额,但把阶段标为 actual。旧快照保留 superseded_at。
*/
export async function syncActualWorkOrderCostSnapshots(
input: CostSnapshotSyncInput = {},
): Promise<ActualCostSnapshotSyncResult> {
const range = resolveFinanceDateRange(input, { defaultDays: 30, maxDays: 400 })
return withTransaction(async (client) => {
const directReward = await syncDirectWorkOrderRewardSnapshots(
client,
range.fromDate,
range.toDate,
)
const estimatedReward = await syncCommittedWorkOrderRewardSnapshots(
client,
range.fromDate,
range.toDate,
)
const upgraded = await client.query(
`
WITH accepted AS (
SELECT DISTINCT ON (wo.order_item_id)
wo.id AS work_order_id,
wo.order_item_id,
GREATEST(COALESCE(wo.reward_amount, 0), 0)::bigint AS reward_amount,
COALESCE(wo.accepted_at, wo.updated_at, o.paid_at, o.created_at, NOW()) AS accepted_at
FROM work_orders wo
JOIN order_items oi ON oi.id = wo.order_item_id
JOIN orders o ON o.id = oi.order_id
WHERE wo.status = 'accepted'
AND wo.order_item_id IS NOT NULL
AND (COALESCE(wo.accepted_at, wo.updated_at, o.paid_at, o.created_at)
AT TIME ZONE 'Asia/Shanghai')::date BETWEEN $1::date AND $2::date
ORDER BY wo.order_item_id, wo.id DESC
),
active AS (
SELECT
cs.*,
a.work_order_id AS actual_work_order_id,
a.reward_amount,
a.accepted_at,
ci.cost_model
FROM cost_snapshots cs
JOIN accepted a ON cs.subject_type = 'order_item' AND cs.subject_id = a.order_item_id
JOIN cost_items ci ON ci.id = cs.cost_item_id
WHERE cs.superseded_at IS NULL
AND cs.snapshot_stage IN ('estimated', 'actual')
AND ci.code <> 'sys_work_order_reward'
),
target AS (
SELECT *
FROM active
WHERE snapshot_stage = 'estimated'
OR total_amount_fen <> CASE
WHEN cost_model = 'work_order_reward' AND reward_amount > 0 THEN reward_amount
ELSE total_amount_fen
END
),
superseded AS (
UPDATE cost_snapshots cs
SET superseded_at = NOW()
FROM target t
WHERE cs.id = t.id
AND cs.superseded_at IS NULL
RETURNING
cs.id, cs.subject_id, cs.order_id, cs.order_item_id,
cs.cost_item_id, cs.cost_version_id, cs.canonical_product_id,
cs.quantity, cs.unit_amount_fen, cs.total_amount_fen, cs.metadata_json
)
INSERT INTO cost_snapshots (
subject_type, subject_id, order_id, order_item_id, work_order_id,
cost_item_id, cost_version_id, canonical_product_id,
quantity, unit_amount_fen, total_amount_fen, snapshot_stage, source,
metadata_json, cost_basis_at, resolved_at, created_at
)
SELECT
'order_item', s.subject_id, s.order_id, s.order_item_id, a.work_order_id,
s.cost_item_id, s.cost_version_id, s.canonical_product_id,
CASE WHEN ci.cost_model = 'work_order_reward' AND a.reward_amount > 0 THEN 1
ELSE s.quantity END,
CASE WHEN ci.cost_model = 'work_order_reward' AND a.reward_amount > 0
THEN a.reward_amount ELSE s.unit_amount_fen END,
CASE WHEN ci.cost_model = 'work_order_reward' AND a.reward_amount > 0
THEN a.reward_amount ELSE s.total_amount_fen END,
'actual', 'resolved',
s.metadata_json || jsonb_build_object(
'actualWorkOrderId', a.work_order_id,
'actualRewardFen', a.reward_amount,
'basisAt', a.accepted_at
),
a.accepted_at, NOW(), NOW()
FROM superseded s
JOIN accepted a ON a.order_item_id = s.subject_id
JOIN cost_items ci ON ci.id = s.cost_item_id
ON CONFLICT (subject_type, subject_id, cost_item_id)
WHERE superseded_at IS NULL
DO NOTHING
`,
[range.fromDate, range.toDate],
)
return {
range,
createdCount: directReward.inserted + estimatedReward.inserted + (upgraded.rowCount || 0),
upgradedCount: upgraded.rowCount || 0,
directRewardCount: directReward.inserted,
estimatedRewardCount: estimatedReward.inserted,
}
})
}
/**
* 接单平台的实际成本不依赖预估快照:工单验收后直接按 reward_amount 写 actual。
* 系统成本项 sys_work_order_reward 的版本金额只是占位,不会进入预估阶段。
*/
async function syncDirectWorkOrderRewardSnapshots(
client: PoolClient,
fromDate: string,
toDate: string,
) {
const superseded = await client.query(
`
WITH system_item AS (
SELECT ci.id AS cost_item_id, v.id AS cost_version_id
FROM cost_items ci
JOIN LATERAL (
SELECT id
FROM cost_versions
WHERE cost_item_id = ci.id
AND status = 'active'
AND effective_from <= NOW()
ORDER BY effective_from DESC, id DESC
LIMIT 1
) v ON TRUE
WHERE ci.code = 'sys_work_order_reward'
AND ci.status = 'active'
),
accepted AS (
SELECT DISTINCT ON (wo.order_item_id)
wo.id AS work_order_id,
wo.order_item_id,
GREATEST(COALESCE(wo.reward_amount, 0), 0)::bigint AS reward_amount,
COALESCE(wo.accepted_at, wo.updated_at, o.paid_at, o.created_at, NOW()) AS accepted_at
FROM work_orders wo
JOIN order_items oi ON oi.id = wo.order_item_id
JOIN orders o ON o.id = oi.order_id
WHERE wo.status = 'accepted'
AND wo.order_item_id IS NOT NULL
AND COALESCE(wo.reward_amount, 0) >= 0
AND (COALESCE(wo.accepted_at, wo.updated_at, o.paid_at, o.created_at)
AT TIME ZONE 'Asia/Shanghai')::date BETWEEN $1::date AND $2::date
ORDER BY wo.order_item_id, wo.id DESC
)
UPDATE cost_snapshots cs
SET superseded_at = NOW()
FROM accepted a
JOIN system_item si ON TRUE
JOIN LATERAL (
SELECT id, snapshot_stage, total_amount_fen
FROM cost_snapshots
WHERE subject_type = 'order_item'
AND subject_id = a.order_item_id
AND cost_item_id = si.cost_item_id
AND superseded_at IS NULL
ORDER BY id DESC
LIMIT 1
FOR UPDATE
) current ON TRUE
WHERE cs.id = current.id
AND cs.superseded_at IS NULL
AND (current.snapshot_stage <> 'actual' OR current.total_amount_fen <> a.reward_amount)
`,
[fromDate, toDate],
)
const inserted = await client.query(
`
WITH system_item AS (
SELECT ci.id AS cost_item_id, v.id AS cost_version_id
FROM cost_items ci
JOIN LATERAL (
SELECT id
FROM cost_versions
WHERE cost_item_id = ci.id
AND status = 'active'
AND effective_from <= NOW()
ORDER BY effective_from DESC, id DESC
LIMIT 1
) v ON TRUE
WHERE ci.code = 'sys_work_order_reward'
AND ci.status = 'active'
),
accepted AS (
SELECT DISTINCT ON (wo.order_item_id)
wo.id AS work_order_id,
wo.order_item_id,
wo.product_rule_id,
GREATEST(COALESCE(wo.reward_amount, 0), 0)::bigint AS reward_amount,
COALESCE(wo.accepted_at, wo.updated_at, o.paid_at, o.created_at, NOW()) AS accepted_at
FROM work_orders wo
JOIN order_items oi ON oi.id = wo.order_item_id
JOIN orders o ON o.id = oi.order_id
WHERE wo.status = 'accepted'
AND wo.order_item_id IS NOT NULL
AND COALESCE(wo.reward_amount, 0) >= 0
AND (COALESCE(wo.accepted_at, wo.updated_at, o.paid_at, o.created_at)
AT TIME ZONE 'Asia/Shanghai')::date BETWEEN $1::date AND $2::date
ORDER BY wo.order_item_id, wo.id DESC
)
INSERT INTO cost_snapshots (
subject_type, subject_id, order_id, order_item_id, work_order_id,
cost_item_id, cost_version_id, canonical_product_id,
quantity, unit_amount_fen, total_amount_fen, snapshot_stage, source,
metadata_json, cost_basis_at, resolved_at, created_at
)
SELECT
'order_item', a.order_item_id, oi.order_id, a.order_item_id, a.work_order_id,
si.cost_item_id, COALESCE(rv.id, si.cost_version_id),
oi.canonical_product_id,
1, a.reward_amount, a.reward_amount, 'actual', 'resolved',
jsonb_build_object(
'source', 'system_work_order_reward',
'actualWorkOrderId', a.work_order_id,
'actualRewardFen', a.reward_amount,
'basisAt', a.accepted_at
),
a.accepted_at, NOW(), NOW()
FROM accepted a
CROSS JOIN system_item si
JOIN order_items oi ON oi.id = a.order_item_id
LEFT JOIN LATERAL (
SELECT v.id
FROM cost_versions v
WHERE v.cost_item_id = si.cost_item_id
AND v.scope_type = 'rule'
AND v.work_product_rule_id = a.product_rule_id
AND v.status IN ('active', 'retired')
AND v.effective_from <= a.accepted_at
AND (v.effective_to IS NULL OR v.effective_to > a.accepted_at)
ORDER BY v.effective_from DESC, v.id DESC
LIMIT 1
) rv ON TRUE
WHERE NOT EXISTS (
SELECT 1 FROM cost_snapshots cs
WHERE cs.subject_type = 'order_item'
AND cs.subject_id = a.order_item_id
AND cs.cost_item_id = si.cost_item_id
AND cs.superseded_at IS NULL
AND cs.snapshot_stage = 'actual'
AND cs.total_amount_fen = a.reward_amount
)
ON CONFLICT (subject_type, subject_id, cost_item_id)
WHERE superseded_at IS NULL
DO NOTHING
`,
[fromDate, toDate],
)
return {
inserted: inserted.rowCount || 0,
superseded: superseded.rowCount || 0,
}
}
/**
* 未验收工单的报酬在创建时已确定,先按 reward_amount 写 estimated 快照。
* 验收后由 syncDirectWorkOrderRewardSnapshots 升级为 actual;取消工单不计成本。
*/
async function syncCommittedWorkOrderRewardSnapshots(
client: PoolClient,
fromDate: string,
toDate: string,
) {
const superseded = await client.query(
`
WITH system_item AS (
SELECT ci.id AS cost_item_id
FROM cost_items ci
WHERE ci.code = 'sys_work_order_reward'
AND ci.status = 'active'
),
latest AS (
SELECT DISTINCT ON (wo.order_item_id)
wo.id AS work_order_id,
wo.order_item_id,
GREATEST(COALESCE(wo.reward_amount, 0), 0)::bigint AS reward_amount
FROM work_orders wo
JOIN orders o ON o.id = wo.order_id
WHERE wo.order_item_id IS NOT NULL
AND wo.status IN (
'pending_material', 'in_progress', 'unassigned', 'open',
'pending_acceptance', 'problem'
)
AND COALESCE(wo.reward_amount, 0) > 0
AND (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date
BETWEEN $1::date AND $2::date
ORDER BY wo.order_item_id, wo.id DESC
)
UPDATE cost_snapshots cs
SET superseded_at = NOW()
FROM latest l
JOIN system_item si ON TRUE
WHERE cs.subject_type = 'order_item'
AND cs.subject_id = l.order_item_id
AND cs.cost_item_id = si.cost_item_id
AND cs.superseded_at IS NULL
AND cs.snapshot_stage = 'estimated'
AND (
cs.work_order_id IS DISTINCT FROM l.work_order_id
OR cs.total_amount_fen <> l.reward_amount
)
`,
[fromDate, toDate],
)
const inserted = await client.query(
`
WITH system_item AS (
SELECT ci.id AS cost_item_id, v.id AS cost_version_id
FROM cost_items ci
JOIN LATERAL (
SELECT id
FROM cost_versions
WHERE cost_item_id = ci.id
AND status = 'active'
AND effective_from <= NOW()
ORDER BY effective_from DESC, id DESC
LIMIT 1
) v ON TRUE
WHERE ci.code = 'sys_work_order_reward'
AND ci.status = 'active'
),
latest AS (
SELECT DISTINCT ON (wo.order_item_id)
wo.id AS work_order_id,
wo.order_item_id,
wo.product_rule_id,
wo.status,
GREATEST(COALESCE(wo.reward_amount, 0), 0)::bigint AS reward_amount,
COALESCE(wo.created_at, o.paid_at, o.created_at, NOW()) AS basis_at
FROM work_orders wo
JOIN orders o ON o.id = wo.order_id
WHERE wo.order_item_id IS NOT NULL
AND wo.status IN (
'pending_material', 'in_progress', 'unassigned', 'open',
'pending_acceptance', 'problem'
)
AND COALESCE(wo.reward_amount, 0) > 0
AND (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date
BETWEEN $1::date AND $2::date
ORDER BY wo.order_item_id, wo.id DESC
)
INSERT INTO cost_snapshots (
subject_type, subject_id, order_id, order_item_id, work_order_id,
cost_item_id, cost_version_id, canonical_product_id,
quantity, unit_amount_fen, total_amount_fen, snapshot_stage, source,
metadata_json, cost_basis_at, resolved_at, created_at
)
SELECT
'order_item', l.order_item_id, oi.order_id, l.order_item_id, l.work_order_id,
si.cost_item_id, COALESCE(rv.id, si.cost_version_id),
oi.canonical_product_id,
1, l.reward_amount, l.reward_amount, 'estimated', 'resolved',
jsonb_build_object(
'source', 'work_order_reward_committed',
'workOrderStatus', l.status,
'estimatedRewardFen', l.reward_amount,
'basisAt', l.basis_at
),
l.basis_at, NOW(), NOW()
FROM latest l
CROSS JOIN system_item si
JOIN order_items oi ON oi.id = l.order_item_id
LEFT JOIN LATERAL (
SELECT v.id
FROM cost_versions v
WHERE v.cost_item_id = si.cost_item_id
AND v.scope_type = 'rule'
AND v.work_product_rule_id = l.product_rule_id
AND v.status IN ('active', 'retired')
AND v.effective_from <= l.basis_at
AND (v.effective_to IS NULL OR v.effective_to > l.basis_at)
ORDER BY v.effective_from DESC, v.id DESC
LIMIT 1
) rv ON TRUE
WHERE NOT EXISTS (
SELECT 1 FROM work_orders accepted
WHERE accepted.order_item_id = l.order_item_id
AND accepted.status = 'accepted'
)
AND NOT EXISTS (
SELECT 1 FROM cost_snapshots cs
WHERE cs.subject_type = 'order_item'
AND cs.subject_id = l.order_item_id
AND cs.cost_item_id = si.cost_item_id
AND cs.superseded_at IS NULL
)
ON CONFLICT (subject_type, subject_id, cost_item_id)
WHERE superseded_at IS NULL
DO NOTHING
`,
[fromDate, toDate],
)
return {
inserted: inserted.rowCount || 0,
superseded: superseded.rowCount || 0,
}
}
async function insertAndResolveProductMappingExceptions(
client: PoolClient,
fromDate: string,
toDate: string,
) {
const inserted = await client.query(
`
WITH item_scope AS (
SELECT
oi.id AS order_item_id,
o.provider,
o.shop_id,
oi.sku_code,
oi.sku_name,
oi.canonical_product_id,
COALESCE(o.paid_at, o.created_at) AS basis_at,
wo.id AS work_order_id,
COALESCE(oi.fulfillment_mode, 'manual') AS fulfillment_mode
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
LEFT JOIN work_orders wo ON wo.order_item_id = oi.id
WHERE o.pay_status IN ('paid', 'refunded')
AND (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date
BETWEEN $1::date AND $2::date
)
INSERT INTO cost_exceptions (
subject_type, subject_id, order_item_id, work_order_id,
reason, status, detail_json, created_at
)
SELECT
'order_item', s.order_item_id, s.order_item_id, s.work_order_id,
'product_mapping_missing', 'open',
jsonb_build_object(
'provider', s.provider,
'shopId', s.shop_id,
'skuCode', s.sku_code,
'skuName', s.sku_name,
'fulfillmentMode', s.fulfillment_mode,
'basisAt', s.basis_at,
'message', '订单项没有归一到标准商品,销售事实会进入未归一桶'
),
NOW()
FROM item_scope s
WHERE s.canonical_product_id IS NULL
ON CONFLICT (subject_type, subject_id, reason) DO NOTHING
`,
[fromDate, toDate],
)
const resolved = await client.query(
`
UPDATE cost_exceptions ce
SET status = 'resolved', resolved_at = NOW()
WHERE ce.status = 'open'
AND ce.subject_type = 'order_item'
AND ce.reason = 'product_mapping_missing'
-- 只收口最近创建、仍在同步视野内的异常:全表收口会随着历史异常累积
-- 变成每次同步都扫几十万行(41 万单测试库实测单次 7.4 秒)。
AND ce.created_at >= ($1::date - INTERVAL '7 days')
AND EXISTS (
SELECT 1 FROM order_items oi
WHERE oi.id = ce.subject_id AND oi.canonical_product_id IS NOT NULL
)
`,
[fromDate],
)
return { created: inserted.rowCount || 0, resolved: resolved.rowCount || 0 }
}
async function insertAndResolveCostMissingExceptions(
client: PoolClient,
fromDate: string,
toDate: string,
) {
const inserted = await client.query(
`
WITH item_scope AS (
SELECT
oi.id AS order_item_id,
oi.canonical_product_id,
oi.fulfillment_config_id,
COALESCE(o.paid_at, o.created_at) AS basis_at,
wo.id AS work_order_id,
COALESCE(oi.fulfillment_mode, 'manual') AS fulfillment_mode
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
LEFT JOIN work_orders wo ON wo.order_item_id = oi.id
WHERE o.pay_status IN ('paid', 'refunded')
AND (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date
BETWEEN $1::date AND $2::date
)
INSERT INTO cost_exceptions (
subject_type, subject_id, order_item_id, work_order_id,
reason, status, detail_json, created_at
)
SELECT
'order_item', s.order_item_id, s.order_item_id, s.work_order_id,
'cost_missing', 'open',
jsonb_build_object(
'canonicalProductId', s.canonical_product_id,
'fulfillmentMode', s.fulfillment_mode,
'basisAt', s.basis_at,
'message', '订单项没有匹配到任何成本版本'
),
NOW()
FROM item_scope s
WHERE s.canonical_product_id IS NOT NULL
-- 人工履约不靠成本版本结算:金额由「人工 SKU 配置 × 换算率」决定,
-- 并在客服确认履约时写 manual_case 实际快照,因此不参与成本缺失判定。
AND s.fulfillment_mode <> 'manual'
AND NOT EXISTS (
SELECT 1 FROM cost_snapshots cs
WHERE cs.subject_type = 'order_item'
AND cs.subject_id = s.order_item_id
AND cs.superseded_at IS NULL
)
ON CONFLICT (subject_type, subject_id, reason) DO NOTHING
`,
[fromDate, toDate],
)
const manualInserted = await client.query(
`
WITH cutoff AS (
SELECT MIN(created_at) AS enforce_from
FROM manual_fulfillment_configs
),
item_scope AS (
SELECT
oi.id AS order_item_id,
oi.fulfillment_config_id,
COALESCE(o.paid_at, o.created_at) AS basis_at
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
WHERE o.pay_status IN ('paid', 'refunded')
AND (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date
BETWEEN $1::date AND $2::date
AND oi.fulfillment_mode = 'manual'
)
INSERT INTO cost_exceptions (
subject_type, subject_id, order_item_id, work_order_id,
reason, status, detail_json, created_at
)
SELECT
'order_item', s.order_item_id, s.order_item_id, NULL,
'manual_cost_missing', 'open',
jsonb_build_object(
'fulfillmentMode', 'manual',
'basisAt', s.basis_at,
'configMissing', s.fulfillment_config_id IS NULL,
'message', CASE
WHEN s.fulfillment_config_id IS NULL
THEN '人工订单项未配置履约渠道,请先在成本中心配置人工 SKU'
ELSE '人工订单项尚未确认履约并记录成本'
END
),
NOW()
FROM item_scope s
CROSS JOIN cutoff c
WHERE c.enforce_from IS NOT NULL
-- 只监控「第一次配置人工履约之后」的订单项:历史人工单按既定口径不补录。
AND s.basis_at >= c.enforce_from
AND NOT EXISTS (
SELECT 1 FROM cost_snapshots cs
WHERE cs.subject_type = 'manual_case'
AND cs.order_item_id = s.order_item_id
AND cs.superseded_at IS NULL
)
ON CONFLICT (subject_type, subject_id, reason) DO NOTHING
`,
[fromDate, toDate],
)
const resolved = await client.query(
`
UPDATE cost_exceptions ce
SET status = 'resolved', resolved_at = NOW()
WHERE ce.status = 'open'
AND ce.subject_type = 'order_item'
AND ce.reason = 'cost_missing'
AND ce.created_at >= ($1::date - INTERVAL '7 days')
AND EXISTS (
SELECT 1 FROM cost_snapshots cs
WHERE cs.subject_type = 'order_item'
AND cs.subject_id = ce.subject_id
AND cs.superseded_at IS NULL
)
`,
[fromDate],
)
const manualResolved = await client.query(
`
UPDATE cost_exceptions ce
SET status = 'resolved', resolved_at = NOW()
WHERE ce.status = 'open'
AND ce.subject_type = 'order_item'
AND ce.reason = 'manual_cost_missing'
AND ce.created_at >= ($1::date - INTERVAL '7 days')
AND EXISTS (
SELECT 1 FROM cost_snapshots cs
WHERE cs.subject_type = 'manual_case'
AND cs.order_item_id = ce.subject_id
AND cs.superseded_at IS NULL
)
`,
[fromDate],
)
return {
created: (inserted.rowCount || 0) + (manualInserted.rowCount || 0),
resolved: (resolved.rowCount || 0) + (manualResolved.rowCount || 0),
}
}
@@ -1,274 +0,0 @@
/**
* P1 人工商品归一:把人工履约订单项归到 `man:<内部SKU>` 标准商品。
*
* 为什么不用渠道映射:人工单的「内部 SKU」是快手 spec_json.skuNick / 拼多多
* spec_json.outSkuSn,与平台 sku_code 不是同一个值(91kaquan 实测 0 条相同),
* 所以按 provider + shop + sku_code 建映射抓不到这些单。这里直接按内部 SKU 归一。
*
* 幂等:商品按 canonical_sku 去重;订单项只更新 canonical_product_id IS NULL 的行,
* 并按 id 分批(每批 2 万)避免撞 DATABASE_STATEMENT_TIMEOUT_MS。
*/
import { query } from '../../db/client.js'
import { nowIso } from '../../utils/time.js'
const MANUAL_PRODUCT_CATEGORY = '人工客服'
const MANUAL_PRODUCT_UNIT = '件'
const NORMALIZE_BATCH_SIZE = 20000
export type NormalizeManualProductsInput = {
dateFrom?: string | undefined
dateTo?: string | undefined
dryRun?: boolean
resolveExceptions?: boolean
}
export type NormalizeManualProductsResult = {
dryRun: boolean
range: { fromDate: string; toDate: string } | null
distinctSkuCount: number
pendingItemCount: number
createdProductCount: number
updatedItemCount: number
updatedSnapshotCount: number
resolvedExceptionCount: number
remainingItemCount: number
}
/**
* 内部 SKU:由 order_items.manual_sku_key 生成列统一提供
* (skuNick → outSkuSn → sku_code → sku_name,均去空格)。
*/
const MANUAL_SKU_KEY_SQL = `oi.manual_sku_key`
export async function normalizeManualProducts(
input: NormalizeManualProductsInput = {},
): Promise<NormalizeManualProductsResult> {
const fromDate = normalizeDate(input.dateFrom)
const toDate = normalizeDate(input.dateTo)
const hasRange = Boolean(fromDate || toDate)
const dryRun = input.dryRun === true
const now = nowIso()
const joinSql = hasRange ? 'JOIN orders o ON o.id = oi.order_id' : ''
// 日期条件按传入的起始占位符编号生成,便于不同语句复用。
function buildRangeClause(startIndex: number) {
const params: unknown[] = []
const parts: string[] = []
if (fromDate) {
parts.push(
`(COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date >= $${startIndex + params.length}::date`,
)
params.push(fromDate)
}
if (toDate) {
parts.push(
`(COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date <= $${startIndex + params.length}::date`,
)
params.push(toDate)
}
return { sql: parts.length ? `AND ${parts.join(' AND ')}` : '', params }
}
const candidateRange = buildRangeClause(1)
const pendingWhere = `
oi.fulfillment_mode = 'manual'
AND oi.canonical_product_id IS NULL
AND ${MANUAL_SKU_KEY_SQL} IS NOT NULL
${candidateRange.sql}
`
const candidatesResult = await query<{
sku_key: string
canonical_name: string
item_count: number
}>(
`
WITH item_keys AS (
SELECT
${MANUAL_SKU_KEY_SQL} AS sku_key,
NULLIF(TRIM(oi.sku_name), '') AS sku_name
FROM order_items oi
${joinSql}
WHERE ${pendingWhere}
)
SELECT
k.sku_key,
LEFT(
COALESCE(NULLIF(TRIM(r.display_name), ''), MIN(k.sku_name), k.sku_key),
120
) AS canonical_name,
COUNT(*)::int AS item_count
FROM item_keys k
LEFT JOIN manual_sku_registry r ON r.sku_key = k.sku_key
GROUP BY k.sku_key, r.display_name
ORDER BY COUNT(*) DESC, k.sku_key ASC
`,
candidateRange.params,
)
const candidates = candidatesResult.rows.map((row) => ({
skuKey: row.sku_key,
canonicalSku: `man:${row.sku_key}`,
canonicalName: row.canonical_name || row.sku_key,
itemCount: Number(row.item_count || 0),
}))
const pendingItemCount = candidates.reduce((sum, item) => sum + item.itemCount, 0)
if (dryRun) {
return {
dryRun: true,
range: hasRange ? { fromDate: fromDate || '', toDate: toDate || '' } : null,
distinctSkuCount: candidates.length,
pendingItemCount,
createdProductCount: 0,
updatedItemCount: 0,
updatedSnapshotCount: 0,
resolvedExceptionCount: 0,
remainingItemCount: pendingItemCount,
}
}
let createdProductCount = 0
if (candidates.length > 0) {
const inserted = await query(
`
INSERT INTO product_catalog (
canonical_sku, canonical_name, category, unit_name, status,
attributes_json, created_at, updated_at
)
SELECT
input.canonical_sku,
input.canonical_name,
$2,
$3,
'active',
jsonb_build_object('source', 'manual_normalization', 'internalSku', input.sku_key),
$4::timestamptz,
$4::timestamptz
FROM unnest($1::text[], $5::text[], $6::text[])
AS input(canonical_sku, canonical_name, sku_key)
ON CONFLICT (canonical_sku) DO NOTHING
RETURNING id
`,
[
candidates.map((item) => item.canonicalSku),
MANUAL_PRODUCT_CATEGORY,
MANUAL_PRODUCT_UNIT,
now,
candidates.map((item) => item.canonicalName),
candidates.map((item) => item.skuKey),
],
)
createdProductCount = inserted.rowCount || 0
}
// 回填订单项:按 id 分批,每批重新取日期参数,避免单条语句超过 15s。
let updatedItemCount = 0
let updatedSnapshotCount = 0
let lastId = 0
for (let batch = 0; batch < 40; batch += 1) {
const range = buildRangeClause(3)
const updated = await query(
`
WITH target AS (
SELECT oi.id, p.id AS product_id
FROM order_items oi
${joinSql}
JOIN product_catalog p
ON p.canonical_sku = 'man:' || ${MANUAL_SKU_KEY_SQL}
WHERE oi.fulfillment_mode = 'manual'
AND oi.canonical_product_id IS NULL
AND oi.id > $1
${range.sql}
ORDER BY oi.id
LIMIT $2
)
UPDATE order_items oi
SET canonical_product_id = t.product_id,
updated_at = NOW()
FROM target t
WHERE oi.id = t.id
AND oi.canonical_product_id IS NULL
RETURNING oi.id
`,
[lastId, NORMALIZE_BATCH_SIZE, ...range.params],
)
const rowCount = updated.rowCount || 0
if (rowCount === 0) break
updatedItemCount += rowCount
const ids = updated.rows as Array<{ id: number }>
lastId = Number(ids[ids.length - 1]?.id || lastId)
// 已有成本快照补商品维度:**只处理本批刚归一过的订单项**。
// 原来是无条件全表条件更新,要扫 900MB 的 order_items(堆 + 两个位图索引回表),
// 生产实测每次 15~16 秒、必然撞 statement_timeout,而且失败会把后半段
// (销售事实重算、影子总账同步)整条链跳过。收口到本批 id 后走 idx_cost_snapshots_item,
// 毫秒级完成,且结果完全一样(唯一会变的行就是这些刚补上商品的订单项)。
if (ids.length > 0) {
const updatedSnapshots = await query(
`
UPDATE cost_snapshots cs
SET canonical_product_id = oi.canonical_product_id
FROM order_items oi
WHERE oi.id = ANY($1::bigint[])
AND cs.order_item_id = oi.id
AND cs.canonical_product_id IS NULL
AND oi.canonical_product_id IS NOT NULL
`,
[ids.map((row) => Number(row.id))],
)
updatedSnapshotCount += updatedSnapshots.rowCount || 0
}
if (rowCount < NORMALIZE_BATCH_SIZE) break
}
let resolvedExceptionCount = 0
if (input.resolveExceptions !== false && updatedItemCount > 0) {
const resolved = await query(
`
UPDATE cost_exceptions ce
SET status = 'resolved', resolved_at = $1::timestamptz
WHERE ce.status = 'open'
AND ce.subject_type = 'order_item'
AND ce.reason = 'product_mapping_missing'
AND EXISTS (
SELECT 1 FROM order_items oi
WHERE oi.id = ce.subject_id AND oi.canonical_product_id IS NOT NULL
)
`,
[now],
)
resolvedExceptionCount = resolved.rowCount || 0
}
const remainingRange = buildRangeClause(1)
const remaining = await query<{ total: number }>(
`
SELECT COUNT(*)::int AS total
FROM order_items oi
${joinSql}
WHERE oi.fulfillment_mode = 'manual'
AND oi.canonical_product_id IS NULL
${remainingRange.sql}
`,
remainingRange.params,
)
return {
dryRun: false,
range: hasRange ? { fromDate: fromDate || '', toDate: toDate || '' } : null,
distinctSkuCount: candidates.length,
pendingItemCount,
createdProductCount,
updatedItemCount,
updatedSnapshotCount,
resolvedExceptionCount,
remainingItemCount: Number(remaining.rows[0]?.total || 0),
}
}
function normalizeDate(value: unknown): string {
const text = String(value || '').trim()
if (!text) return ''
if (!/^\d{4}-\d{2}-\d{2}$/.test(text)) return ''
return text
}
@@ -1,60 +0,0 @@
import assert from 'node:assert/strict'
import test from 'node:test'
/**
* 2026-10-30 生产事故复盘:cost-finance-sync 每 15 分钟的「成本快照补商品维度」
* 原先是一条无条件全表更新,要扫 899MB 的 order_items,实测 15.2s / 16.5s 撞
* statement_timeout,并且失败会把后面的「销售事实重算 + 影子总账同步」整条跳过。
*
* 修复口径:只更新本批刚归一过的 order_item_id(走 idx_cost_snapshots_item),
* 结果完全一样但毫秒级完成。这里用源码断言把它钉住。
*/
test('成本快照补商品维度必须按本批 order_item_id 收口,不能再全表更新', async () => {
const { readFile } = await import('node:fs/promises')
const src = await readFile(
new URL('./manual-product-normalization-service.ts', import.meta.url),
'utf8',
)
assert.ok(
src.includes('oi.id = ANY($1::bigint[])'),
'成本快照更新缺少本批 id 收口:会退化成扫 900MB 的 order_items(实测 15 秒超时)',
)
assert.ok(
src.includes('AND cs.order_item_id = oi.id'),
'收口后仍要按 order_item_id 关联,不能改成按 subject_id 猜',
)
// 更新必须发生在分批循环内部(本批 ids 还在作用域里),不能在循环之后
const loopStart = src.indexOf('for (let batch = 0; batch < 40; batch += 1) {')
const loopEnd = src.indexOf('let resolvedExceptionCount = 0')
const snapshotUpdateAt = src.indexOf('UPDATE cost_snapshots cs')
assert.ok(loopStart > 0 && loopEnd > loopStart, '找不到归一的分批循环')
assert.ok(
snapshotUpdateAt > loopStart && snapshotUpdateAt < loopEnd,
'成本快照更新必须留在分批循环内:循环外拿不到本批 ids,就只能退回全表条件',
)
// 兜底:不允许再出现「无 id 限制 + fulfillment_mode 条件」的老写法
const legacyPattern = /UPDATE cost_snapshots cs[\s\S]{0,400}?oi\.fulfillment_mode = 'manual'/
assert.equal(
legacyPattern.test(src),
false,
'不能再按 fulfillment_mode 全表更新(会整表 bitmap 回表)',
)
})
test('迁移 110 提供 order_items(fulfillment_mode, canonical_product_id) 覆盖索引', async () => {
const { readFile } = await import('node:fs/promises')
const migration = await readFile(
new URL('../../db/migrations/110_order_items_fulfillment_product_index.sql', import.meta.url),
'utf8',
)
assert.ok(migration.includes('idx_order_items_fulfillment_product'))
assert.ok(migration.includes('(fulfillment_mode, canonical_product_id)'))
assert.ok(migration.includes('INCLUDE (id)'))
assert.ok(
!/^\s*CREATE INDEX CONCURRENTLY/m.test(migration),
'迁移在事务内执行,不能使用 CONCURRENTLY',
)
})
@@ -5,7 +5,7 @@ import test from 'node:test'
const read = (path: URL) => readFile(path, 'utf8')
test('销售事实重算必须冻结已关账期间', async () => {
const source = await read(new URL('./sales-facts-service.ts', import.meta.url))
const source = await read(new URL('./sales-facts-channel-service.ts', import.meta.url))
assert.ok(source.includes('finance_periods'), '重算必须读取会计期间')
assert.ok(source.includes("p.status = 'closed'"), '必须识别已关账期间')
assert.ok(source.includes('skippedClosedPeriods'), '必须返回被跳过的关账期间')
@@ -85,7 +85,7 @@ test('人工确认前必须自动匹配履约配置,配置保存/定时任务
new URL('../cost-center/manual-fulfillment-config-service.ts', import.meta.url),
'utf8',
),
readFile(new URL('../scheduler/cost-finance-sync-job.ts', import.meta.url), 'utf8'),
readFile(new URL('../scheduler/finance-pipeline-jobs.ts', import.meta.url), 'utf8'),
readFile(new URL('../../db/migrations/106_manual_sku_key.sql', import.meta.url), 'utf8'),
])
assert.ok(
@@ -99,10 +99,9 @@ test('人工确认前必须自动匹配履约配置,配置保存/定时任务
'保存人工 SKU 配置后必须立即回填最近 30 天订单项',
)
assert.ok(
jobSource.includes('rematchManualFulfillments({') &&
jobSource.includes('onlyUnmatched: true') &&
jobSource.includes('dateFrom:'),
'定时同步必须自动回填未匹配的人工订单项,并限制在最近窗口',
jobSource.includes("fulfillment_channel = 'manual'") &&
jobSource.includes('fulfillment_config_id'),
'人工载体管道必须挂载人工配置',
)
assert.ok(
configSource.includes('manual_sku_key'),
@@ -117,21 +116,20 @@ test('人工确认前必须自动匹配履约配置,配置保存/定时任务
)
})
test('人工成本缺失必须进入异常队列,且只监控首次配置之后的订单项', async () => {
const source = await readFile(
new URL('../cost-center/cost-snapshot-sync-service.ts', import.meta.url),
'utf8',
)
assert.ok(source.includes("'manual_cost_missing'"), '缺少人工成本缺失异常')
test('人工成本缺失由管道按配置与快照生成并收口', async () => {
const [pipelineSource, manualSource] = await Promise.all([
readFile(new URL('../scheduler/finance-pipeline-jobs.ts', import.meta.url), 'utf8'),
readFile(new URL('./manual-fulfillment-service.ts', import.meta.url), 'utf8'),
])
assert.ok(pipelineSource.includes("'manual_cost_missing'"), '管道必须产出人工成本缺失异常')
assert.ok(
source.includes('FROM manual_fulfillment_configs') &&
source.includes('s.basis_at >= c.enforce_from'),
'人工异常必须以首次配置时间为起点,避免历史人工单刷屏',
pipelineSource.includes("cs.subject_type = 'manual_case'") &&
pipelineSource.includes('cs.order_item_id = oi.id'),
'人工成本缺失必须以人工快照是否存在为判定',
)
assert.ok(
source.includes("cs.subject_type = 'manual_case'") &&
source.includes('cs.order_item_id = s.order_item_id'),
'人工异常回填/收口必须按订单项关联人工快照',
manualSource.includes("reason = 'manual_cost_missing'"),
'确认履约后必须收口人工成本缺失异常',
)
})
@@ -21,8 +21,6 @@ test(
process.env.DATABASE_URL = TEST_DATABASE_URL
const { query, withTransaction, closeDb } = await import('../../db/client.js')
const { confirmManualFulfillment } = await import('./manual-fulfillment-service.js')
const { syncEstimatedOrderItemCostSnapshots } =
await import('../cost-center/cost-snapshot-sync-service.js')
const { rematchManualFulfillments } =
await import('../cost-center/manual-fulfillment-config-service.js')
const {
@@ -364,14 +362,6 @@ test(
orderIds.push(pendingItem.orderId)
orderItemIds.push(pendingItem.orderItemId)
await syncEstimatedOrderItemCostSnapshots({ dateFrom: today, dateTo: today })
const openManualException = await query<{ count: number }>(
`SELECT COUNT(*)::int AS count FROM cost_exceptions
WHERE reason = 'manual_cost_missing' AND status = 'open' AND subject_id = $1`,
[pendingItem.orderItemId],
)
assert.equal(Number(openManualException.rows[0]?.count), 1, '人工缺成本必须进异常队列')
await confirmManualFulfillment(
{ orderItemId: pendingItem.orderItemId, costFen: 100 },
session,
@@ -382,13 +372,6 @@ test(
[pendingItem.orderItemId],
)
assert.equal(Number(openAfterConfirm.rows[0]?.count), 0, '确认后应立即收口人工异常')
await syncEstimatedOrderItemCostSnapshots({ dateFrom: today, dateTo: today })
const resolvedManualException = await query<{ count: number }>(
`SELECT COUNT(*)::int AS count FROM cost_exceptions
WHERE reason = 'manual_cost_missing' AND status = 'resolved' AND subject_id = $1`,
[pendingItem.orderItemId],
)
assert.equal(Number(resolvedManualException.rows[0]?.count), 1, '确认后异常必须自动收口')
// 7. 总账:销售事实同步、试算平衡、关账阻断、平台对账匹配
const todayDate = shanghaiDate()
@@ -14,7 +14,7 @@ import type { AdminSession } from '../admin/admin-auth-service.js'
import { createHttpError } from '../../utils/http.js'
import { generateRefundNo } from '../../utils/random.js'
import { nowIso } from '../../utils/time.js'
import { rebuildSalesDailyFacts } from './sales-facts-service.js'
import { rebuildChannelSalesDailyFacts } from './sales-facts-channel-service.js'
import { approveKuaishouIndustryRefund } from '../platforms/kuaishou-industry/refund-service.js'
import { syncFinanceLedgerFromFacts } from './finance-ledger-service.js'
@@ -328,7 +328,7 @@ export async function createManualRefund(
const basisDate = toChinaDate(written.basisAt)
if (basisDate) {
await rebuildSalesDailyFacts({ dateFrom: basisDate, dateTo: basisDate })
await rebuildChannelSalesDailyFacts({ dateFrom: basisDate, dateTo: basisDate })
await syncFinanceLedgerFromFacts({ dateFrom: basisDate, dateTo: basisDate })
}
@@ -442,7 +442,7 @@ export async function voidOrderRefund(
const rebuiltDate = toChinaDate(context.basisAt)
if (rebuiltDate) {
await rebuildSalesDailyFacts({ dateFrom: rebuiltDate, dateTo: rebuiltDate })
await rebuildChannelSalesDailyFacts({ dateFrom: rebuiltDate, dateTo: rebuiltDate })
await syncFinanceLedgerFromFacts({ dateFrom: rebuiltDate, dateTo: rebuiltDate })
}
const refunds = await listOrderRefunds(context.orderId)
@@ -1,7 +1,6 @@
import { query, withTransaction } from '../../db/client.js'
import { query } from '../../db/client.js'
import { createHttpError } from '../../utils/http.js'
import { resolveFinanceDateRange } from '../../utils/finance-date-range.js'
import { nowIso } from '../../utils/time.js'
export type SalesFactsInput = {
dateFrom?: unknown
@@ -10,14 +9,6 @@ export type SalesFactsInput = {
to?: unknown
}
export type SalesFactsRebuildResult = {
range: { fromDate: string; toDate: string }
deletedCount: number
insertedCount: number
skippedClosedPeriods: string[]
computedAt: string
}
type SalesFactRow = {
[column: string]: unknown
id: number
@@ -45,167 +36,6 @@ type SalesFactRow = {
* 重算指定日期范围的销售事实。日期统一按 Asia/Shanghai 归属。
* 未归一商品允许 canonical_product_id 为空,汇总时形成“未归一”桶。
*/
export async function rebuildSalesDailyFacts(
input: SalesFactsInput = {},
): Promise<SalesFactsRebuildResult> {
const range = resolveFinanceDateRange(input, { defaultDays: 30, maxDays: 400 })
const computedAt = nowIso()
// 已关账期间必须冻结:只重算开放期间,避免“总账已关账、报表事实还在变”。
const closedPeriods = await query<{ period_key: string }>(
`SELECT period_key
FROM finance_periods
WHERE status = 'closed'
AND period_key BETWEEN $1 AND $2
ORDER BY period_key`,
[range.fromDate.slice(0, 7), range.toDate.slice(0, 7)],
)
const skippedClosedPeriods = closedPeriods.rows.map((row) => row.period_key)
return withTransaction(async (client) => {
const deleted = await client.query(
`DELETE FROM sales_daily_facts f
WHERE f.stat_date BETWEEN $1::date AND $2::date
AND NOT EXISTS (
SELECT 1 FROM finance_periods p
WHERE p.status = 'closed'
AND p.period_key = to_char(f.stat_date, 'YYYY-MM')
)`,
[range.fromDate, range.toDate],
)
const inserted = await client.query(
`
WITH scoped_orders AS MATERIALIZED (
-- 先物化 7 天窗口内的订单集合,避免 planner 对每个订单项做一次
-- orders 随机回表;后续全部走 hash/merge join。
SELECT
o.id,
o.provider,
o.shop_id,
o.pay_status,
COALESCE(o.paid_at, o.created_at) AS basis_at
FROM orders o
WHERE o.pay_status IN ('paid', 'refunded')
AND (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date
BETWEEN $1::date AND $2::date
AND NOT EXISTS (
SELECT 1 FROM finance_periods p
WHERE p.status = 'closed'
AND p.period_key = to_char(
(COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date,
'YYYY-MM'
)
)
),
scoped_items AS MATERIALIZED (
SELECT
oi.id,
oi.order_id,
oi.canonical_product_id,
oi.fulfillment_mode,
oi.revenue_amount_fen,
oi.normalized_quantity,
oi.quantity
FROM order_items oi
WHERE oi.order_id IN (SELECT id FROM scoped_orders)
),
cost_by_item AS (
-- 一次全量聚合后再 hash join,替代逐项 LATERAL 查 cost_snapshots。
SELECT
order_item_id,
SUM(total_amount_fen)::bigint AS cost_fen,
COUNT(*)::int AS snapshot_count,
COUNT(*) FILTER (WHERE snapshot_stage = 'actual')::int AS actual_count
FROM cost_snapshots
WHERE superseded_at IS NULL
GROUP BY order_item_id
),
refunds_by_item AS (
SELECT
order_item_id,
SUM(amount_fen)::bigint AS refund_fen,
COUNT(*)::int AS refund_count
FROM order_refunds
WHERE status = 'confirmed'
GROUP BY order_item_id
),
item_base AS (
SELECT
(o.basis_at AT TIME ZONE 'Asia/Shanghai')::date AS stat_date,
o.provider,
o.shop_id,
oi.canonical_product_id,
COALESCE(oi.fulfillment_mode, 'manual') AS fulfillment_mode,
oi.order_id,
COALESCE(oi.revenue_amount_fen, 0)::bigint AS revenue_fen,
CASE
WHEN COALESCE(refunds.refund_count, 0) > 0
THEN LEAST(
COALESCE(refunds.refund_fen, 0),
GREATEST(COALESCE(oi.revenue_amount_fen, 0), 0)
)::bigint
WHEN o.pay_status = 'refunded'
THEN GREATEST(COALESCE(oi.revenue_amount_fen, 0), 0)::bigint
ELSE 0::bigint
END AS refund_fen,
GREATEST(COALESCE(oi.normalized_quantity, oi.quantity, 1), 1)::numeric AS quantity,
COALESCE(cost.cost_fen, 0)::bigint AS cost_fen,
COALESCE(cost.snapshot_count, 0)::int AS snapshot_count,
COALESCE(cost.actual_count, 0)::int AS actual_count
FROM scoped_items oi
JOIN scoped_orders o ON o.id = oi.order_id
LEFT JOIN cost_by_item cost ON cost.order_item_id = oi.id
LEFT JOIN refunds_by_item refunds ON refunds.order_item_id = oi.id
)
INSERT INTO sales_daily_facts (
stat_date, provider, shop_id, canonical_product_id, fulfillment_mode,
order_count, item_count, quantity,
gross_revenue_fen, refund_amount_fen, net_revenue_fen,
cost_amount_fen, cost_missing_count, cost_stage, gross_profit_fen,
computed_at, created_at, updated_at
)
SELECT
stat_date,
provider,
shop_id,
canonical_product_id,
fulfillment_mode,
COUNT(DISTINCT order_id)::int,
COUNT(*)::int,
SUM(quantity),
SUM(revenue_fen),
SUM(refund_fen),
SUM(revenue_fen - refund_fen),
SUM(cost_fen),
COUNT(*) FILTER (WHERE snapshot_count = 0)::int,
CASE
WHEN COUNT(*) FILTER (WHERE snapshot_count = 0) = COUNT(*) THEN 'missing'
WHEN COUNT(*) FILTER (WHERE snapshot_count = 0) > 0 THEN 'partial'
WHEN COUNT(*) FILTER (WHERE snapshot_count > 0 AND actual_count < snapshot_count) > 0
THEN 'estimated'
ELSE 'actual'
END,
SUM(revenue_fen - refund_fen) - SUM(cost_fen),
$3::timestamptz,
$3::timestamptz,
$3::timestamptz
FROM item_base
GROUP BY stat_date, provider, shop_id, canonical_product_id, fulfillment_mode
`,
[range.fromDate, range.toDate, computedAt],
)
return {
range,
deletedCount: deleted.rowCount || 0,
insertedCount: inserted.rowCount || 0,
skippedClosedPeriods,
computedAt,
}
})
}
export async function getSalesProfitOverview(input: SalesFactsInput = {}) {
const range = resolveFinanceDateRange(input, { defaultDays: 30, maxDays: 400 })
@@ -1,75 +0,0 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { classifyFulfillmentMode, resolveReconcileCursor } from './fulfillment-mode-service.js'
test('履约判定优先级:工单 > 自动发货 > 历史平台 > 人工', () => {
assert.equal(
classifyFulfillmentMode({
hasWorkOrder: true,
hasAffiliateDashTask: true,
hasLegacyPlatformTask: true,
}),
'work_order',
)
assert.equal(
classifyFulfillmentMode({
hasWorkOrder: false,
hasAffiliateDashTask: true,
hasLegacyPlatformTask: true,
}),
'affiliate_dash',
)
assert.equal(
classifyFulfillmentMode({
hasWorkOrder: false,
hasAffiliateDashTask: false,
hasLegacyPlatformTask: true,
}),
'legacy_platform',
)
assert.equal(
classifyFulfillmentMode({
hasWorkOrder: false,
hasAffiliateDashTask: false,
hasLegacyPlatformTask: false,
}),
'manual',
)
})
test('keyset 游标:整批取满继续前进', () => {
assert.deepEqual(
resolveReconcileCursor({
cursor: 0,
selectedCount: 2000,
lastScannedId: 2000,
batchSize: 2000,
}),
{ nextCursor: 2000, done: false },
)
})
test('keyset 游标:不足一批表示已经扫到底', () => {
assert.deepEqual(
resolveReconcileCursor({
cursor: 1000,
selectedCount: 999,
lastScannedId: 1999,
batchSize: 2000,
}),
{ nextCursor: 1999, done: true },
)
})
test('keyset 游标:游标没有前进时强制结束,避免死循环', () => {
assert.deepEqual(
resolveReconcileCursor({
cursor: 500,
selectedCount: 2000,
lastScannedId: 500,
batchSize: 2000,
}),
{ nextCursor: 500, done: true },
)
})
@@ -1,238 +0,0 @@
import { query } from '../../db/client.js'
import { nowIso } from '../../utils/time.js'
export type OrderFulfillmentMode = 'work_order' | 'affiliate_dash' | 'legacy_platform' | 'manual'
export type FulfillmentModeDecisionInput = {
hasWorkOrder: boolean
hasAffiliateDashTask: boolean
hasLegacyPlatformTask: boolean
}
export type ReconcileFulfillmentModesOptions = {
batchSize?: number
maxBatches?: number
reason?: string
/** 从该 order_item id 之后继续扫(keyset 分页;默认 0 表示从头开始)。 */
afterId?: number
}
export type ReconcileFulfillmentModesResult = {
updatedCount: number
remainingCount: number
batches: number
reason: string
finishedAt: string
/** 本次扫到的最大 order_item id;可作为下一次的 afterId 续跑。 */
lastScannedId: number
}
/**
* 统一履约判定(纯函数,供单测与文档对照)。
*
* 优先级固定:工单 → 自动发货任务 → 历史平台任务 → 人工。
* 核销码、provider、商品来源都不参与判定。
*/
export function classifyFulfillmentMode({
hasWorkOrder,
hasAffiliateDashTask,
hasLegacyPlatformTask,
}: FulfillmentModeDecisionInput): OrderFulfillmentMode {
if (hasWorkOrder) return 'work_order'
if (hasAffiliateDashTask) return 'affiliate_dash'
if (hasLegacyPlatformTask) return 'legacy_platform'
return 'manual'
}
const FULFILLMENT_MODE_CASE_SQL = `
CASE
WHEN EXISTS (
SELECT 1 FROM work_orders wo
WHERE wo.order_item_id = oi.id
) THEN 'work_order'
WHEN EXISTS (
SELECT 1 FROM fulfillment_tasks ft
WHERE ft.order_item_id = oi.id
AND ft.executor_key = 'affiliate_dash'
) THEN 'affiliate_dash'
WHEN EXISTS (
SELECT 1 FROM fulfillment_tasks ft
WHERE ft.order_item_id = oi.id
AND ft.executor_key IN ('kuaishou_ct_assisted', 'kuaishou_feifei')
) THEN 'legacy_platform'
ELSE 'manual'
END
`
/**
* 重新判定指定订单项的履约方式。
*
* 只在模式发生变化时更新 fulfillment_decided_at / fulfillment_reason;
* 同一订单项重复调用是幂等的。
*/
export async function decideOrderItemsFulfillmentModes(
orderItemIds: Array<number | string>,
{ reason = 'order_sync' }: { reason?: string } = {},
): Promise<{ updatedCount: number }> {
const ids = Array.from(
new Set(
orderItemIds
.map((value) => Number(value))
.filter((value) => Number.isInteger(value) && value > 0),
),
)
if (ids.length === 0) return { updatedCount: 0 }
const result = await query<{ fulfillment_mode: OrderFulfillmentMode }>(
`
WITH decided AS (
SELECT oi.id, ${FULFILLMENT_MODE_CASE_SQL} AS mode
FROM order_items oi
WHERE oi.id = ANY($1::bigint[])
)
UPDATE order_items oi
SET
fulfillment_mode = d.mode,
fulfillment_decided_at = NOW(),
fulfillment_reason = $2
FROM decided d
WHERE oi.id = d.id
AND (
oi.fulfillment_mode IS NULL
OR oi.fulfillment_mode IS DISTINCT FROM d.mode
)
RETURNING oi.fulfillment_mode
`,
[ids, String(reason || 'order_sync')],
)
return { updatedCount: result.rowCount || 0 }
}
/**
* 批处理游标推进。
*
* keyset 分页必须保证单调前进,否则同一批数据会被反复取到、陷入死循环。
* selectedCount < batchSize 表示已经扫到底;lastScannedId 没有前进同样视为结束。
*/
export function resolveReconcileCursor(input: {
cursor: number
selectedCount: number
lastScannedId: number
batchSize: number
}): { nextCursor: number; done: boolean } {
const nextCursor = Math.max(input.cursor, input.lastScannedId)
const done = input.selectedCount < input.batchSize || nextCursor <= input.cursor
return { nextCursor, done }
}
/**
* 兜底归集:分批处理 fulfillment_mode 为 NULL 的订单项。
*
* 定时任务只需处理新单;历史回填用同一个函数,把 maxBatches 调大即可。
* 走 keyset(id > 上一批最大 id),每批只扫索引里剩下的未判定行,
* 不会像 OFFSET 那样反复从头扫已回填的数据。
*/
export async function reconcilePendingFulfillmentModes({
batchSize = 2000,
maxBatches = 10,
reason = 'reconcile',
afterId = 0,
}: ReconcileFulfillmentModesOptions = {}): Promise<ReconcileFulfillmentModesResult> {
const normalizedBatchSize = clampInteger(batchSize, 2000, 1, 50_000)
const normalizedMaxBatches = clampInteger(maxBatches, 10, 1, 10_000)
const normalizedReason = String(reason || 'reconcile').trim() || 'reconcile'
let cursor = Math.max(0, Math.trunc(Number(afterId) || 0))
let updatedCount = 0
let batches = 0
for (let index = 0; index < normalizedMaxBatches; index += 1) {
// keyset:每批从上一批最大 id 之后继续,避免每次从头扫已回填过的行。
// 返回 pending 的数量与最大 id,用于判断是否扫到底、以及下一批的起点。
const result = await query<{
updated_count: number
selected_count: number
last_id: number | string | null
}>(
`
WITH pending AS (
SELECT oi.id
FROM order_items oi
WHERE oi.fulfillment_mode IS NULL
AND oi.id > $3
ORDER BY oi.id
LIMIT $1
),
decided AS (
SELECT oi.id, ${FULFILLMENT_MODE_CASE_SQL} AS mode
FROM order_items oi
JOIN pending p ON p.id = oi.id
),
updated AS (
UPDATE order_items oi
SET
fulfillment_mode = d.mode,
fulfillment_decided_at = NOW(),
fulfillment_reason = $2
FROM decided d
WHERE oi.id = d.id
AND oi.fulfillment_mode IS NULL
RETURNING oi.id
)
SELECT
(SELECT COUNT(*)::int FROM updated) AS updated_count,
(SELECT COUNT(*)::int FROM pending) AS selected_count,
(SELECT COALESCE(MAX(id), 0)::bigint FROM pending) AS last_id
`,
[normalizedBatchSize, normalizedReason, cursor],
)
const row = result.rows[0]
const step = resolveReconcileCursor({
cursor,
selectedCount: Number(row?.selected_count || 0),
lastScannedId: Number(row?.last_id || 0),
batchSize: normalizedBatchSize,
})
updatedCount += Number(row?.updated_count || 0)
batches += 1
cursor = step.nextCursor
if (step.done) break
}
const remaining = await query<{ count: number }>(
`SELECT COUNT(*)::int AS count FROM order_items WHERE fulfillment_mode IS NULL`,
)
return {
updatedCount,
remainingCount: Number(remaining.rows[0]?.count || 0),
batches,
reason: normalizedReason,
finishedAt: nowIso(),
lastScannedId: cursor,
}
}
export async function getFulfillmentModeCounts(): Promise<
Array<{ fulfillmentMode: OrderFulfillmentMode | null; count: number }>
> {
const result = await query<{ fulfillment_mode: OrderFulfillmentMode | null; count: number }>(
`
SELECT fulfillment_mode, COUNT(*)::int AS count
FROM order_items
GROUP BY fulfillment_mode
ORDER BY COUNT(*) DESC
`,
)
return result.rows.map((row) => ({
fulfillmentMode: row.fulfillment_mode || null,
count: Number(row.count || 0),
}))
}
function clampInteger(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))
}
@@ -41,7 +41,30 @@ test('normalizeScheduledJobsConfig ensures default jobs exist', () => {
assert.ok(ids.includes('deposit-unfreeze'))
assert.ok(ids.includes('data-retention'))
assert.ok(ids.includes('pdd_order_sync'))
assert.ok(ids.includes('cost-finance-sync'))
// 旧财务闭环作业已下线,不再作为默认任务注入。
assert.equal(ids.includes('cost-finance-sync'), false)
assert.ok(ids.includes('channel-assign'))
assert.ok(ids.includes('ledger-post'))
})
test('旧的 cost_finance_sync 任务已彻底移除,不再出现在配置里', () => {
const config = normalizeScheduledJobsConfig({
enabled: true,
jobs: [
{
id: 'cost-finance-sync',
type: 'cost_finance_sync',
enabled: true,
intervalSeconds: 900,
config: { days: 7 },
},
],
})
assert.equal(
config.jobs.some((item) => item.type === 'cost_finance_sync'),
false,
)
})
test('normalizeScheduledJobsConfig 收录 v2 六个管道作业并保持各自配置', () => {
@@ -10,7 +10,6 @@ const WORK_ORDER_TIMEOUT_JOB_ID = 'work-order-timeout'
const DEPOSIT_UNFREEZE_JOB_ID = 'deposit-unfreeze'
const DATA_RETENTION_JOB_ID = 'data-retention'
const PDD_ORDER_SYNC_JOB_ID = 'pdd_order_sync'
const COST_FINANCE_SYNC_JOB_ID = 'cost-finance-sync'
/** v2 管道作业:各自只负责一层,可单独启停。 */
const CHANNEL_ASSIGN_JOB_ID = 'channel-assign'
@@ -84,7 +83,6 @@ export function normalizeScheduledJobsConfig(rawValue: unknown) {
const hasDepositUnfreeze = jobs.some((item) => item.id === DEPOSIT_UNFREEZE_JOB_ID)
const hasDataRetention = jobs.some((item) => item.id === DATA_RETENTION_JOB_ID)
const hasPddOrderSync = jobs.some((item) => item.id === PDD_ORDER_SYNC_JOB_ID)
const hasCostFinanceSync = jobs.some((item) => item.id === COST_FINANCE_SYNC_JOB_ID)
const hasChannelAssign = jobs.some((item) => item.id === CHANNEL_ASSIGN_JOB_ID)
const hasCarrierDispatch = jobs.some((item) => item.id === CARRIER_DISPATCH_JOB_ID)
const hasProductNormalize = jobs.some((item) => item.id === PRODUCT_NORMALIZE_JOB_ID)
@@ -104,9 +102,8 @@ export function normalizeScheduledJobsConfig(rawValue: unknown) {
if (!hasPddOrderSync) {
jobs.push(createDefaultPddOrderSyncJob())
}
if (!hasCostFinanceSync) {
jobs.push(createDefaultCostFinanceSyncJob())
}
// 旧作业 cost_finance_sync 已下线:v2 管道接管,且它写旧字段会覆盖新口径。
// 保留配置项仅用于兼容读取,运行期强制停用。
// v2 管道:老配置里没有这些任务时自动补齐,保证升级后无需手工添加。
if (!hasChannelAssign) jobs.push(createDefaultChannelAssignJob())
if (!hasCarrierDispatch) jobs.push(createDefaultCarrierDispatchJob())
@@ -143,9 +140,6 @@ function normalizeScheduledJob(rawValue: unknown) {
if (type === 'pdd_order_sync') {
return normalizePddOrderSyncJob(rawValue)
}
if (type === 'cost_finance_sync') {
return normalizeCostFinanceSyncJob(rawValue)
}
if (type === 'channel_assign') {
return normalizeChannelAssignJob(rawValue)
}
@@ -182,20 +176,6 @@ function normalizePddOrderSyncJob(rawValue: JsonObject) {
}
}
function normalizeCostFinanceSyncJob(rawValue: JsonObject) {
const config = isPlainObject(rawValue.config) ? rawValue.config : {}
return {
id: COST_FINANCE_SYNC_JOB_ID,
type: 'cost_finance_sync',
enabled: rawValue.enabled !== false,
intervalSeconds: normalizeRangeInteger(rawValue.intervalSeconds, 300, 60, 3600),
config: {
days: normalizeRangeInteger(config.days, 7, 1, 90),
},
}
}
function normalizeDepositUnfreezeJob(rawValue: JsonObject) {
const config = isPlainObject(rawValue.config) ? rawValue.config : {}
@@ -242,7 +222,6 @@ function createDefaultScheduledJobsConfig() {
createDefaultDepositUnfreezeJob(),
createDefaultDataRetentionJob(),
createDefaultPddOrderSyncJob(),
createDefaultCostFinanceSyncJob(),
createDefaultChannelAssignJob(),
createDefaultCarrierDispatchJob(),
createDefaultProductNormalizeJob(),
@@ -406,21 +385,6 @@ function createDefaultPddOrderSyncJob() {
}
}
function createDefaultCostFinanceSyncJob() {
return {
id: COST_FINANCE_SYNC_JOB_ID,
type: 'cost_finance_sync',
enabled: true,
// 一次同步要跑 6 条全窗口聚合(41 万单实测 30–60 秒)。5 分钟一次会让
// 数据库长期处在 10–20% 的同步负载下,后台页面首屏被拖慢;财务事实
// 15 分钟刷新已足够,需要更快可在后台定时任务里改回 300。
intervalSeconds: 900,
config: {
days: 7,
},
}
}
function createDefaultDataRetentionJob() {
return {
id: DATA_RETENTION_JOB_ID,
@@ -1,108 +0,0 @@
import type { JsonObject } from '../../types/json.js'
import {
syncActualWorkOrderCostSnapshots,
syncEstimatedOrderItemCostSnapshots,
} from '../cost-center/cost-snapshot-sync-service.js'
import { normalizeManualProducts } from '../cost-center/manual-product-normalization-service.js'
import { rematchManualFulfillments } from '../cost-center/manual-fulfillment-config-service.js'
import { rebuildSalesDailyFacts } from '../finance/sales-facts-service.js'
import { syncFinanceLedgerFromFacts } from '../finance/finance-ledger-service.js'
import { reconcilePendingFulfillmentModes } from '../fulfillment/fulfillment-mode-service.js'
/**
* 财务闭环同步:履约方式兜底 -> 人工商品归一 -> 预估成本快照 -> 工单实际快照 -> 销售事实重算。
* 只处理最近 N 天,避免定时任务扫全量历史。
*/
export async function runCostFinanceSyncJob(job: JsonObject = {}) {
const config = job.config && typeof job.config === 'object' ? (job.config as JsonObject) : {}
const days = clampInteger(config.days, 30, 1, 90)
const fulfillment = await reconcilePendingFulfillmentModes({
batchSize: 2000,
maxBatches: 10,
reason: 'cost_finance_sync',
})
// 人工单按内部 SKU 归一;异常收口交给预估同步统一处理,这里只补商品维度。
const manualProducts = await normalizeManualProducts({
dateFrom: shiftDays(-(days - 1)),
dateTo: today(),
resolveExceptions: false,
})
// 人工 SKU 配置可能在订单之后才补:每次同步都按配置回填未匹配订单项,
// 保证「配置好人工之后的新单」自动挂上渠道,不依赖管理员手动点「重新匹配」。
const manualMatches = await rematchManualFulfillments({
onlyUnmatched: true,
dateFrom: shiftDays(-(days - 1)),
dateTo: today(),
})
const estimated = await syncEstimatedOrderItemCostSnapshots({
dateFrom: shiftDays(-(days - 1)),
dateTo: today(),
})
const actual = await syncActualWorkOrderCostSnapshots({
dateFrom: shiftDays(-(days - 1)),
dateTo: today(),
})
const facts = await rebuildSalesDailyFacts({
dateFrom: shiftDays(-(days - 1)),
dateTo: today(),
})
// 影子总账按日同步销售事实(幂等;已关账期间会跳过)。
const ledger = await syncFinanceLedgerFromFacts({
dateFrom: shiftDays(-(days - 1)),
dateTo: today(),
})
return {
status: 'ok',
message:
`履约方式 ${fulfillment.updatedCount} 项 / ` +
`人工商品 ${manualProducts.updatedItemCount} 项 / ` +
`人工渠道匹配 ${manualMatches.updatedCount} 项 / ` +
`成本快照 ${estimated.createdCount} 商品预估 / ` +
`${actual.directRewardCount + actual.upgradedCount} 工单实际 / ` +
`${actual.estimatedRewardCount} 工单在途,销售事实 ${facts.insertedCount} 行,` +
`总账凭证 ${ledger.created} 条`,
days,
fulfillmentModeUpdated: fulfillment.updatedCount,
fulfillmentModeRemaining: fulfillment.remainingCount,
manualItemsNormalized: manualProducts.updatedItemCount,
manualProductsCreated: manualProducts.createdProductCount,
manualItemsMatched: manualMatches.updatedCount,
estimatedCreated: estimated.createdCount,
actualCreated: actual.directRewardCount + actual.upgradedCount,
estimatedRewardCreated: actual.estimatedRewardCount,
exceptionsCreated: estimated.exceptionCreatedCount,
factsInserted: facts.insertedCount,
ledgerEntriesCreated: ledger.created,
ledgerSkippedClosedPeriods: ledger.skipped,
dateFrom: facts.range.fromDate,
dateTo: facts.range.toDate,
}
}
/** 模块级只构造一次:`new Intl.DateTimeFormat()` 每次约 0.5ms,逐条调用会线性放大。 */
const SHANGHAI_DATE_FORMATTER = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
function today() {
return SHANGHAI_DATE_FORMATTER.format(new Date())
}
function shiftDays(days: number) {
const [year, month, day] = today().split('-').map(Number)
const date = new Date(Date.UTC(year || 1970, (month || 1) - 1, day || 1))
date.setUTCDate(date.getUTCDate() + days)
return date.toISOString().slice(0, 10)
}
function clampInteger(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))
}
@@ -6,7 +6,6 @@ import { runDepositUnfreezeJob } from './deposit-unfreeze-job.js'
import { runWorkOrderTimeoutJob } from './work-order-timeout-job.js'
import { runDataRetentionJob } from './data-retention-job.js'
import { runPddOrderSyncJob } from './pdd-order-sync-job.js'
import { runCostFinanceSyncJob } from './cost-finance-sync-job.js'
import {
runCarrierDispatchJob,
runChannelAssignJob,
@@ -213,9 +212,6 @@ function dispatchJob(job: JsonObject) {
if (job.type === 'pdd_order_sync') {
return runPddOrderSyncJob(job)
}
if (job.type === 'cost_finance_sync') {
return runCostFinanceSyncJob(job)
}
if (job.type === 'channel_assign') {
return runChannelAssignJob(job)
}
@@ -45,7 +45,7 @@ import {
} from './mappers.js'
import { getWorkerProductMatchConfig } from './worker-product-match-config-service.js'
import { registerWorkOrderShopName } from './work-order-shop-options-cache.js'
import { decideOrderItemsFulfillmentModes } from '../fulfillment/fulfillment-mode-service.js'
import { assignPendingOrderItemChannels } from '../fulfillment/channel-assign-service.js'
export async function syncWorkerOrdersForSourceOrder(
order: OrderRow,
@@ -308,12 +308,9 @@ export async function syncWorkerOrdersForSourceOrder(
}
}
// 订单处理完成后统一落履约事实;失败不阻断下单,由财务定时任务兜底归集。
// 订单处理完成后立即跑一次渠道判定;失败不阻断下单,由定时任务兜底。
try {
await decideOrderItemsFulfillmentModes(
orderItems.map((item) => Number(item.id)),
{ reason: options.source || 'source_order' },
)
await assignPendingOrderItemChannels({ batchSize: 200, maxBatches: 1 })
} catch (error) {
logWarn('[worker-order-consumer]', '履约方式归集失败,等待定时任务兜底', {
orderId: order.id,