feat(finance): 接单模板发单金额落规则级成本版本

此前接单平台成本版本只有 sys_work_order_reward 的全局 0 元占位,662 个模板的发单金额从未进入 cost_versions。\n\n现在为 662 个模板各生成一条规则级版本:634 条固定发单金额 + 28 条按件单价;全局 0 元版本仅作“按工单实际结算”占位。成本版本页新增“用户实付均价”和“平均实发/项”,支持按模板名称/规则键搜索,按商品筛选也能命中规则版本。\n\n历史工单快照关联到对应模板版本;模板保存时自动同步单条成本版本;删除模板会先把历史版本脱钩停用,保留快照可追溯。\n\n新增迁移 112 覆盖索引,用户实付聚合从约 1.1s 降到约 150ms。
This commit is contained in:
yml2213
2026-10-22 19:29:53 +08:00
parent 72e1141c8a
commit 7c8832078f
12 changed files with 547 additions and 43 deletions
@@ -0,0 +1,14 @@
-- 112_cost_reporting_indexes.sql —— 成本版本页按接单规则汇总用户实付。
--
-- 背景:成本版本列表需要同时展示「用户实付均价」和「模板发单金额」。
-- 原查询按 work_orders.product_rule_id 聚合约 11 万工单 + 31 万订单项,
-- 没有覆盖索引时需要 1 秒以上;以下两个覆盖索引把聚合降到约 150 毫秒。
CREATE INDEX IF NOT EXISTS idx_work_orders_rule_cover
ON work_orders (product_rule_id)
INCLUDE (order_item_id)
WHERE order_item_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_order_items_revenue_cover
ON order_items (id)
INCLUDE (order_id, revenue_amount_fen);
@@ -312,6 +312,25 @@ export async function syncOpenWorkOrderPriceFromRule(input: {
})
}
export async function detachWorkProductRuleCostVersions(
ruleId: number,
label: string,
): Promise<{ updated: number }> {
const result = await query(
`
UPDATE cost_versions
SET work_product_rule_id = NULL,
status = 'retired',
effective_to = COALESCE(effective_to, NOW()),
change_reason = LEFT('已删除模板 ' || $2 || ';' || change_reason, 500),
updated_at = NOW()
WHERE work_product_rule_id = $1
`,
[ruleId, label],
)
return { updated: result.rowCount || 0 }
}
export async function deleteWorkProductRule(
ruleId: number | string,
): Promise<{ deleted: boolean }> {
@@ -28,6 +28,7 @@ import {
resolveAdminCostException,
retireAdminCostVersion,
syncAdminAffiliateDashProducts,
syncAdminWorkOrderTemplateCostVersions,
updateAdminChannelMapping,
updateAdminCostItem,
updateAdminProduct,
@@ -150,6 +151,22 @@ router.post(
}),
)
router.post(
'/cost-center/work-order-template-costs/sync',
requireAdminPermission('finance.config'),
createJsonHandler(() => syncAdminWorkOrderTemplateCostVersions(), {
successMessage: '接单模板发单金额已同步',
errorMessage: '同步接单模板发单金额失败',
scope: '[admin/cost-center/work-order-template-costs/sync]',
audit: (_req, data) => ({
action: 'cost_work_order_template_costs_synced',
targetType: 'cost_versions',
targetId: '',
data: data as Record<string, unknown>,
}),
}),
)
router.get(
'/cost-center/products',
requireAdminPermission('finance.view'),
@@ -1,5 +1,6 @@
import { Router } from 'express'
import { setHttpCache } from '../../../middleware/http-cache.js'
import { syncAdminWorkOrderTemplateCostVersions } from '../../../services/admin/admin-cost-center-service.js'
import {
deleteAdminWorkCategory,
@@ -147,17 +148,25 @@ router.get(
router.post(
'/worker-platform/product-rules',
requireAdminRoles(['admin', 'operator']),
createJsonHandler((req) => saveAdminWorkProductRule(req.body || {}), {
successMessage: '接单物品规则已保存',
errorMessage: '保存接单物品规则失败',
scope: '[admin/worker-platform/product-rules]',
audit: (_req, data) => ({
action: 'work_product_rule_saved',
targetType: 'work_product_rule',
targetId: String((data as { rule?: { ruleKey?: string } })?.rule?.ruleKey || ''),
data: data && typeof data === 'object' ? (data as Record<string, unknown>) : {},
}),
}),
createJsonHandler(
async (req) => {
const result = await saveAdminWorkProductRule(req.body || {})
const ruleId = Number((result as { rule?: { id?: number } })?.rule?.id || 0)
const templateCost = ruleId ? await syncAdminWorkOrderTemplateCostVersions(ruleId) : null
return { ...result, templateCost }
},
{
successMessage: '接单物品规则已保存',
errorMessage: '保存接单物品规则失败',
scope: '[admin/worker-platform/product-rules]',
audit: (_req, data) => ({
action: 'work_product_rule_saved',
targetType: 'work_product_rule',
targetId: String((data as { rule?: { ruleKey?: string } })?.rule?.ruleKey || ''),
data: data && typeof data === 'object' ? (data as Record<string, unknown>) : {},
}),
},
),
)
router.post(
@@ -51,6 +51,8 @@ const COST_VERSION_SOURCES = new Set(['manual', 'upstream_sync', 'import'])
const COST_VERSION_STATUSES = new Set(['draft', 'active', 'retired'])
const COST_EXCEPTION_STATUSES = new Set(['open', 'resolved', 'ignored'])
const PROVIDERS = ['91kaquan', 'pdd', 'douyin_mashida']
/** 首次把接单模板发单金额落成成本版本时使用的基线时间。 */
const WORK_ORDER_TEMPLATE_COST_BASELINE = '2026-07-10T00:00:00+08:00'
const PRODUCT_CATEGORY_JOINS = `
LEFT JOIN LATERAL (
SELECT wc.id, wc.name, wc.parent_id
@@ -180,6 +182,14 @@ type CostVersionRow = {
canonical_sku: string | null
canonical_name: string | null
work_product_rule_id: number | null
work_product_rule_name: string | null
work_product_rule_key: string | null
rule_reward_amount: number | null
rule_unit_price_fen: number | null
revenue_item_count: number | null
revenue_fen: number | null
reward_avg_fen: number | null
reward_fen: number | null
amount_fen: number
rate_bps: number
effective_from: string
@@ -466,7 +476,20 @@ export async function listAdminProducts(payload: JsonObject = {}) {
root.id AS root_category_id,
root.name AS root_category_name,
(SELECT COUNT(*)::int FROM channel_product_mappings m WHERE m.canonical_product_id = p.id) AS mapping_count,
(SELECT COUNT(*)::int FROM cost_versions v WHERE v.canonical_product_id = p.id AND v.status = 'active') AS cost_version_count,
(
SELECT COUNT(*)::int
FROM cost_versions v
WHERE v.status = 'active'
AND v.canonical_product_id = p.id
) + (
SELECT COUNT(*)::int
FROM cost_versions v
JOIN work_product_rules wpr ON wpr.id = v.work_product_rule_id
WHERE v.status = 'active'
AND v.scope_type = 'rule'
AND p.canonical_sku LIKE 'tpl:%'
AND wpr.rule_key = substring(p.canonical_sku from 5)
) AS cost_version_count,
COALESCE(aff.task_count, 0) AS affiliate_task_count,
aff.last_fulfilled_at AS affiliate_last_fulfilled_at
FROM product_catalog p
@@ -1457,6 +1480,7 @@ export async function applyAdminTemplateProductBootstrap(
await backfillAdminNormalizedQuantities({ limit: 50000, all: true })
const mappingBackfill = await backfillAdminProductMappings({ limit: 20000, all: true })
mappedOrderItemCount = mappingBackfill.updatedCount
const templateCosts = await syncAdminWorkOrderTemplateCostVersions()
return {
templateCount: data.templates.length,
@@ -1469,6 +1493,9 @@ export async function applyAdminTemplateProductBootstrap(
resetOrderItemCount: resetResult.resetOrderItemCount,
mappingConflictCount: plan.conflicts.length,
mappingConflictItemCount: plan.unresolvedItemCount,
templateCostVersionCount: templateCosts.activeVersionCount,
templateCostCreatedCount: templateCosts.createdCount,
templateCostRetiredCount: templateCosts.retiredCount,
}
}
@@ -1907,6 +1934,197 @@ export async function backfillAdminWorkOrderProductSkus() {
}
}
type WorkOrderTemplateCostSyncRow = {
rule_count: number
unit_price_rule_count: number
active_version_count: number
}
/**
* 把接单模板的发单金额同步成规则级成本版本。
*
* 固定金额模板取 `reward_amount`;按件模板取 `unit_price_fen`,数量由订单 SKU
* 决定。全局 0 元版本只作为“按工单实际结算”的占位,不再代表模板价格。
*/
export async function syncAdminWorkOrderTemplateCostVersions(ruleId?: number) {
const now = nowIso()
const scopedRuleId = ruleId && Number.isFinite(ruleId) ? Number(ruleId) : null
const retired = await query(
`
WITH target AS (
SELECT
wpr.id AS rule_id,
CASE
WHEN wpr.unit_price_fen > 0 AND wpr.reward_amount = 0 THEN wpr.unit_price_fen
ELSE wpr.reward_amount
END AS amount_fen
FROM work_product_rules wpr
WHERE ($2::bigint IS NULL OR wpr.id = $2)
),
sys AS (
SELECT id FROM cost_items
WHERE code = 'sys_work_order_reward' AND status = 'active'
),
changed AS (
SELECT v.id
FROM cost_versions v
JOIN sys si ON si.id = v.cost_item_id
JOIN target t ON t.rule_id = v.work_product_rule_id
WHERE v.scope_type = 'rule'
AND v.status = 'active'
AND v.amount_fen <> t.amount_fen
)
UPDATE cost_versions v
SET status = 'retired',
effective_to = $1,
change_reason = '模板发单金额变更',
updated_at = $1
FROM changed c
WHERE v.id = c.id
RETURNING v.id
`,
[now, scopedRuleId],
)
const inserted = await query<{ id: number }>(
`
WITH target AS (
SELECT
wpr.id AS rule_id,
CASE
WHEN wpr.unit_price_fen > 0 AND wpr.reward_amount = 0 THEN wpr.unit_price_fen
ELSE wpr.reward_amount
END AS amount_fen
FROM work_product_rules wpr
WHERE ($3::bigint IS NULL OR wpr.id = $3)
),
sys AS (
SELECT id FROM cost_items
WHERE code = 'sys_work_order_reward' AND status = 'active'
),
missing AS (
SELECT
t.rule_id,
t.amount_fen,
si.id AS cost_item_id,
EXISTS (
SELECT 1 FROM cost_versions h
WHERE h.cost_item_id = si.id
AND h.scope_type = 'rule'
AND h.work_product_rule_id = t.rule_id
) AS has_history
FROM target t
CROSS JOIN sys si
WHERE t.amount_fen > 0
AND NOT EXISTS (
SELECT 1 FROM cost_versions v
WHERE v.cost_item_id = si.id
AND v.scope_type = 'rule'
AND v.work_product_rule_id = t.rule_id
AND v.status = 'active'
)
)
INSERT INTO cost_versions (
cost_item_id, scope_type, provider, shop_id, canonical_product_id,
work_product_rule_id, amount_fen, rate_bps, effective_from, effective_to,
source, status, change_reason, created_by_admin_user_id,
approved_by_admin_user_id, created_at, updated_at
)
SELECT
m.cost_item_id,
'rule',
'',
'',
NULL,
m.rule_id,
m.amount_fen,
0,
CASE WHEN m.has_history THEN $1::timestamptz ELSE $2::timestamptz END,
NULL,
'import',
'active',
CASE
WHEN wpr.unit_price_fen > 0 AND wpr.reward_amount = 0
THEN '模板按件发单单价(数量取订单 SKU)'
ELSE '模板固定发单金额'
END,
NULL,
NULL,
$1,
$1
FROM missing m
JOIN work_product_rules wpr ON wpr.id = m.rule_id
ON CONFLICT DO NOTHING
RETURNING id
`,
[now, WORK_ORDER_TEMPLATE_COST_BASELINE, scopedRuleId],
)
const linked = await query(
`
WITH rule_version AS (
SELECT DISTINCT ON (v.work_product_rule_id)
v.work_product_rule_id,
v.id AS cost_version_id,
v.cost_item_id
FROM cost_versions v
JOIN cost_items ci ON ci.id = v.cost_item_id
AND ci.code = 'sys_work_order_reward'
WHERE v.scope_type = 'rule'
AND v.status = 'active'
AND ($1::bigint IS NULL OR v.work_product_rule_id = $1)
ORDER BY v.work_product_rule_id, v.effective_from DESC, v.id DESC
)
UPDATE cost_snapshots cs
SET cost_version_id = rv.cost_version_id
FROM work_orders wo
JOIN rule_version rv ON rv.work_product_rule_id = wo.product_rule_id
WHERE cs.order_item_id = wo.order_item_id
AND cs.cost_item_id = rv.cost_item_id
AND cs.superseded_at IS NULL
AND cs.cost_version_id <> rv.cost_version_id
AND EXISTS (
SELECT 1 FROM cost_versions gv
WHERE gv.id = cs.cost_version_id AND gv.scope_type = 'global'
)
AND ($1::bigint IS NULL OR wo.product_rule_id = $1)
`,
[scopedRuleId],
)
const summary = await query<WorkOrderTemplateCostSyncRow>(
`
SELECT
COUNT(*)::int AS rule_count,
COUNT(*) FILTER (
WHERE wpr.unit_price_fen > 0 AND wpr.reward_amount = 0
)::int AS unit_price_rule_count,
COUNT(v.id) FILTER (
WHERE v.scope_type = 'rule' AND v.status = 'active'
)::int AS active_version_count
FROM work_product_rules wpr
LEFT JOIN cost_versions v
ON v.cost_item_id = (
SELECT id FROM cost_items WHERE code = 'sys_work_order_reward' LIMIT 1
)
AND v.work_product_rule_id = wpr.id
WHERE ($1::bigint IS NULL OR wpr.id = $1)
`,
[scopedRuleId],
)
const row = summary.rows[0]
return {
ruleId: scopedRuleId,
ruleCount: Number(row?.rule_count || 0),
unitPriceRuleCount: Number(row?.unit_price_rule_count || 0),
activeVersionCount: Number(row?.active_version_count || 0),
createdCount: inserted.rows.length,
retiredCount: retired.rowCount || 0,
linkedSnapshotCount: linked.rowCount || 0,
}
}
type TemplateBootstrapTemplateRow = {
id: number
rule_key: string
@@ -2697,7 +2915,20 @@ export async function listAdminCostVersions(payload: JsonObject = {}) {
const productId = normalizeOptionalId(payload.canonicalProductId)
if (productId) {
params.push(productId)
clauses.push(`v.canonical_product_id = $${params.length}`)
clauses.push(
`(
v.canonical_product_id = $${params.length}
OR (
v.scope_type = 'rule'
AND v.work_product_rule_id IN (
SELECT wpr.id
FROM work_product_rules wpr
JOIN product_catalog pp ON pp.canonical_sku = 'tpl:' || wpr.rule_key
WHERE pp.id = $${params.length}
)
)
)`,
)
}
const provider = text(payload.provider)
if (provider) {
@@ -2721,7 +2952,8 @@ export async function listAdminCostVersions(payload: JsonObject = {}) {
params.push(`%${keyword}%`)
clauses.push(
`(ci.code ILIKE $${params.length} OR ci.name ILIKE $${params.length}
OR p.canonical_sku ILIKE $${params.length} OR p.canonical_name ILIKE $${params.length})`,
OR p.canonical_sku ILIKE $${params.length} OR p.canonical_name ILIKE $${params.length}
OR wpr.product_name ILIKE $${params.length} OR wpr.rule_key ILIKE $${params.length})`,
)
}
@@ -2732,6 +2964,7 @@ export async function listAdminCostVersions(payload: JsonObject = {}) {
FROM cost_versions v
JOIN cost_items ci ON ci.id = v.cost_item_id
LEFT JOIN product_catalog p ON p.id = v.canonical_product_id
LEFT JOIN work_product_rules wpr ON wpr.id = v.work_product_rule_id
${where}
`,
params,
@@ -2746,12 +2979,44 @@ export async function listAdminCostVersions(payload: JsonObject = {}) {
ci.carrier_type AS cost_item_carrier_type,
ci.pricing_mode,
p.canonical_sku,
p.canonical_name
p.canonical_name,
wpr.product_name AS work_product_rule_name,
wpr.rule_key AS work_product_rule_key,
wpr.reward_amount AS rule_reward_amount,
wpr.unit_price_fen AS rule_unit_price_fen,
rev.revenue_item_count,
rev.revenue_fen,
rev.reward_avg_fen,
rev.reward_fen
FROM cost_versions v
JOIN cost_items ci ON ci.id = v.cost_item_id
LEFT JOIN product_catalog p ON p.id = v.canonical_product_id
LEFT JOIN work_product_rules wpr ON wpr.id = v.work_product_rule_id
LEFT JOIN (
SELECT
wo.product_rule_id,
COUNT(*)::int AS revenue_item_count,
SUM(COALESCE(oi.revenue_amount_fen, 0))::bigint AS revenue_fen,
ROUND(AVG(wo.reward_amount))::bigint AS reward_avg_fen,
SUM(wo.reward_amount)::bigint AS reward_fen
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 o.pay_status IN ('paid', 'refunded')
GROUP BY wo.product_rule_id
) rev ON rev.product_rule_id = v.work_product_rule_id
${where}
ORDER BY v.status ASC, v.effective_from DESC, v.id DESC
ORDER BY
v.status ASC,
CASE
WHEN ci.code = 'sys_work_order_reward'
AND v.scope_type = 'global'
AND v.amount_fen = 0
THEN 1 ELSE 0
END ASC,
COALESCE(rev.revenue_item_count, 0) DESC,
v.effective_from DESC,
v.id DESC
LIMIT $${listParams.length - 1} OFFSET $${listParams.length}
`,
listParams,
@@ -3728,6 +3993,19 @@ function mapCostItem(row: CostItemRow) {
}
function mapCostVersion(row: CostVersionRow) {
const revenueItemCount = Number(row.revenue_item_count || 0)
const revenueFen = Number(row.revenue_fen || 0)
const rewardAvgFen = Number(row.reward_avg_fen || 0)
const rewardFen = Number(row.reward_fen || 0)
const ruleUnitPriceFen = Number(row.rule_unit_price_fen || 0)
const ruleRewardAmountFen = Number(row.rule_reward_amount || 0)
const amountKind =
row.cost_item_code === 'sys_work_order_reward' &&
row.scope_type === 'rule' &&
ruleUnitPriceFen > 0 &&
ruleRewardAmountFen === 0
? 'unit_price'
: 'fixed'
return {
id: Number(row.id),
costItemId: Number(row.cost_item_id),
@@ -3742,6 +4020,20 @@ function mapCostVersion(row: CostVersionRow) {
canonicalSku: row.canonical_sku || '',
canonicalName: row.canonical_name || '',
workProductRuleId: row.work_product_rule_id ? Number(row.work_product_rule_id) : null,
workProductRuleName: row.work_product_rule_name || '',
workProductRuleKey: row.work_product_rule_key || '',
ruleRewardAmountFen,
ruleUnitPriceFen,
revenueItemCount,
revenueFen,
revenueAvgFen: revenueItemCount > 0 ? Math.round(revenueFen / revenueItemCount) : 0,
rewardAvgFen,
rewardFen,
amountKind,
isSystemPlaceholder:
row.cost_item_code === 'sys_work_order_reward' &&
row.scope_type === 'global' &&
Number(row.amount_fen) === 0,
amountFen: Number(row.amount_fen),
rateBps: Number(row.rate_bps),
effectiveFrom: row.effective_from,
@@ -382,6 +382,7 @@ async function syncDirectWorkOrderRewardSnapshots(
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
@@ -402,7 +403,8 @@ async function syncDirectWorkOrderRewardSnapshots(
)
SELECT
'order_item', a.order_item_id, oi.order_id, a.order_item_id, a.work_order_id,
'work_order', si.cost_item_id, si.cost_version_id, oi.canonical_product_id,
'work_order', 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',
@@ -414,6 +416,18 @@ async function syncDirectWorkOrderRewardSnapshots(
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'
@@ -508,6 +522,7 @@ async function syncCommittedWorkOrderRewardSnapshots(
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
@@ -531,7 +546,8 @@ async function syncCommittedWorkOrderRewardSnapshots(
)
SELECT
'order_item', l.order_item_id, oi.order_id, l.order_item_id, l.work_order_id,
'work_order', si.cost_item_id, si.cost_version_id, oi.canonical_product_id,
'work_order', 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',
@@ -543,6 +559,18 @@ async function syncCommittedWorkOrderRewardSnapshots(
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
@@ -19,6 +19,7 @@ import {
import {
countWorkProductRulesByCategory,
deleteWorkProductRule,
detachWorkProductRuleCostVersions,
getWorkProductRuleById,
listOpenWorkOrdersByProductRuleId,
listWorkProductRulesPage,
@@ -615,6 +616,17 @@ export async function deleteAdminWorkProductRule(ruleId: number | string) {
errorCode: 'work_product_rule_id_invalid',
})
}
const rule = await getWorkProductRuleById(normalizedRuleId)
if (!rule) {
throw createHttpError('物品规则不存在或已删除', {
statusCode: 404,
errorCode: 'work_product_rule_not_found',
})
}
const detached = await detachWorkProductRuleCostVersions(
normalizedRuleId,
`${rule.product_name} / ${rule.rule_key}`,
)
const { deleted } = await deleteWorkProductRule(normalizedRuleId)
if (!deleted) {
throw createHttpError('物品规则不存在或已删除', {
@@ -622,5 +634,5 @@ export async function deleteAdminWorkProductRule(ruleId: number | string) {
errorCode: 'work_product_rule_not_found',
})
}
return { deleted }
return { deleted, retiredCostVersionCount: detached.updated }
}