perf(finance): 消除定时同步与后台页面的重查询

- 新增 order_items.manual_sku_key 生成列(迁移 106),统一内部 SKU 口径(skuNick → outSkuSn → sku_code → sku_name,去空格)
- 人工渠道匹配 8.4s → 62ms;人工商品归一写回 7.0s → 307ms
- 销售事实重算改为物化订单集合 + 预聚合成本/退款,6.9s → 0.5s 内
- 成本异常写入去掉逐项 LATERAL 工单探测,收口按最近 7 天窗口,7–8s → 0.5s 内
- 销售毛利订单统计去掉重复 NOT EXISTS,9.4s → 0.4s
- 接单平台店铺下拉改 EXISTS 半连接,3.1s → 0.3s
- cost-finance-sync 默认间隔 5 分钟 → 15 分钟;完整同步 51.2s → 34.9s
This commit is contained in:
yml2213
2026-10-28 13:41:11 +08:00
parent 08318ef8d9
commit 915ce017f3
8 changed files with 145 additions and 82 deletions
@@ -79,13 +79,14 @@ test('人工确认必须单事务:扣款与快照同事务,且使用 with-cl
})
test('人工确认前必须自动匹配履约配置,配置保存/定时任务也要回填', async () => {
const [manualSource, configSource, jobSource] = await Promise.all([
const [manualSource, configSource, jobSource, migrationSource] = await Promise.all([
readFile(new URL('./manual-fulfillment-service.ts', import.meta.url), 'utf8'),
readFile(
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('../../db/migrations/106_manual_sku_key.sql', import.meta.url), 'utf8'),
])
assert.ok(
manualSource.includes('matchManualFulfillmentConfigWithClient(client, orderItemId, now)'),
@@ -104,8 +105,15 @@ test('人工确认前必须自动匹配履约配置,配置保存/定时任务
'定时同步必须自动回填未匹配的人工订单项,并限制在最近窗口',
)
assert.ok(
manualSource.includes('NULLIF(oi.sku_code') && configSource.includes('NULLIF(oi.sku_code'),
'SKU 匹配必须有 sku_code / sku_name 兜底,不能只认 spec_json',
configSource.includes('manual_sku_key'),
'人工 SKU 匹配必须统一走 order_items.manual_sku_key 生成列',
)
assert.ok(
migrationSource.includes("spec_json->>'skuNick'") &&
migrationSource.includes("spec_json->>'outSkuSn'") &&
migrationSource.includes('sku_code') &&
migrationSource.includes('sku_name'),
'生成列必须保留 sku_code / sku_name 兜底',
)
})
@@ -76,9 +76,63 @@ export async function rebuildSalesDailyFacts(
const inserted = await client.query(
`
WITH item_base AS (
WITH scoped_orders AS MATERIALIZED (
-- 先物化 7 天窗口内的订单集合,避免 planner 对每个订单项做一次
-- orders 随机回表;后续全部走 hash/merge join。
SELECT
(COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date AS stat_date,
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,
@@ -99,36 +153,10 @@ export async function rebuildSalesDailyFacts(
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 order_items oi
JOIN orders o ON o.id = oi.order_id
LEFT JOIN LATERAL (
SELECT
SUM(cs.total_amount_fen)::bigint AS cost_fen,
COUNT(*)::int AS snapshot_count,
COUNT(*) FILTER (WHERE cs.snapshot_stage = 'actual')::int AS actual_count
FROM cost_snapshots cs
WHERE cs.order_item_id = oi.id
AND cs.superseded_at IS NULL
) cost ON TRUE
LEFT JOIN LATERAL (
SELECT
SUM(r.amount_fen)::bigint AS refund_fen,
COUNT(*)::int AS refund_count
FROM order_refunds r
WHERE r.order_item_id = oi.id
AND r.status = 'confirmed'
) refunds ON TRUE
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'
)
)
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,
@@ -297,19 +325,23 @@ export async function getSalesProfitOverview(input: SalesFactsInput = {}) {
orders_without_items: number
revenue_without_items: number
}>(
// 原来两个 FILTER 各写一次 NOT EXISTS,等价于对每个订单做两轮逐行探测
// (30 天 / 16 万单实测 108 万 buffers、9.4 秒)。改成一次 DISTINCT 扫描
// order_id 后做 hash join,只扫一遍索引,实测 10.9 万 buffers、0.4 秒。
`
SELECT
COUNT(*)::int AS order_count,
COUNT(*) FILTER (
WHERE NOT EXISTS (SELECT 1 FROM order_items oi WHERE oi.order_id = orders.id)
)::int AS orders_without_items,
COALESCE(SUM(total_amount) FILTER (
WHERE NOT EXISTS (SELECT 1 FROM order_items oi WHERE oi.order_id = orders.id)
), 0)::bigint AS revenue_without_items
FROM orders
WHERE pay_status IN ('paid', 'refunded')
AND (COALESCE(paid_at, created_at) AT TIME ZONE 'Asia/Shanghai')::date
BETWEEN $1::date AND $2::date
COUNT(*) FILTER (WHERE oi.order_id IS NULL)::int AS orders_without_items,
COALESCE(SUM(s.total_amount) FILTER (WHERE oi.order_id IS NULL), 0)::bigint
AS revenue_without_items
FROM (
SELECT o.id, o.total_amount
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
) s
LEFT JOIN (SELECT DISTINCT order_id FROM order_items) oi ON oi.order_id = s.id
`,
[range.fromDate, range.toDate],
),