refactor(finance)!: 强制渠道模型,移除旧履约方式与同步链路
- 删除 fulfillment_mode 相关的旧同步/回填链路与定时任务 - 删除 cost_finance_sync、成本快照同步、人工商品归一等遗留服务 - 迁移 113 直接删除 order_items.fulfillment_mode 及其索引约束 - 断代脚本自带归档步骤,不再依赖独立回填脚本 - 退款/事实/总账统一走渠道版销售事实
This commit is contained in:
@@ -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 })
|
||||
|
||||
|
||||
Reference in New Issue
Block a user