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 }
}
@@ -65,6 +65,7 @@ import {
resolveAdminCostException,
retireAdminCostVersion,
syncAdminAffiliateDashProducts,
syncAdminWorkOrderTemplateCostVersions,
updateAdminChannelProductMapping,
updateAdminCostItem,
updateAdminProductCatalog,
@@ -370,6 +371,7 @@ export default function AdminCostCenterPage() {
const [syncingAffiliate, setSyncingAffiliate] = useState(false)
const [backfillingAffiliateSkus, setBackfillingAffiliateSkus] = useState(false)
const [backfillingWorkOrderSkus, setBackfillingWorkOrderSkus] = useState(false)
const [syncingTemplateCosts, setSyncingTemplateCosts] = useState(false)
const versionScopeType = Form.useWatch('scopeType', versionForm)
const versionCostItemId = Form.useWatch('costItemId', versionForm)
@@ -640,7 +642,7 @@ export default function AdminCostCenterPage() {
? `;保留 ${applied.mappingConflictCount} 组冲突待人工处理`
: ''
message.success(
`已新建 ${applied.createdProductCount} 个商品、${applied.createdMappingCount} 条映射,回填 ${applied.mappedOrderItemCount} 个订单项${rebuildText}${conflictText}`,
`已新建 ${applied.createdProductCount} 个商品、${applied.createdMappingCount} 条映射,同步 ${applied.templateCostVersionCount} 条模板发单金额,回填 ${applied.mappedOrderItemCount} 个订单项${rebuildText}${conflictText}`,
)
refreshAll()
} catch (error) {
@@ -723,6 +725,22 @@ export default function AdminCostCenterPage() {
}
}
async function syncTemplateCosts() {
setSyncingTemplateCosts(true)
try {
const response = await syncAdminWorkOrderTemplateCostVersions()
const result = response.data
message.success(
`已同步 ${result.activeVersionCount} 条模板发单金额(新建 ${result.createdCount},停用 ${result.retiredCount}),关联 ${result.linkedSnapshotCount} 条成本快照`,
)
refreshAll()
} catch (error) {
message.error(error instanceof Error ? error.message : '同步接单模板发单金额失败')
} finally {
setSyncingTemplateCosts(false)
}
}
function openEditProduct(item: ProductCatalogItem) {
setEditingProduct(item)
productForm.setFieldsValue({
@@ -1201,9 +1219,11 @@ export default function AdminCostCenterPage() {
title: '成本版本',
dataIndex: 'costVersionCount',
width: 110,
render: (value: number) =>
render: (value: number, row) =>
value > 0 ? (
<Typography.Text type="success">{value} 个版本</Typography.Text>
) : row.carrierType === 'work_order' ? (
<Tag color="blue">按工单实际结算</Tag>
) : (
<Typography.Text type="secondary">未配置</Typography.Text>
),
@@ -1515,22 +1535,73 @@ export default function AdminCostCenterPage() {
title: '范围',
dataIndex: 'scopeType',
width: 200,
ellipsis: true,
render: (value: string, row) => {
if (value === 'global') return '全局'
if (value === 'global') {
return row.isSystemPlaceholder ? '全局(系统占位)' : '全局'
}
if (value === 'provider') return providerLabel(row.provider)
if (value === 'shop') return `${providerLabel(row.provider)} / ${row.shopId}`
if (value === 'product') return row.canonicalName || `商品 #${row.canonicalProductId}`
return `规则 #${row.workProductRuleId}`
const ruleLabel =
row.workProductRuleName ||
(row.workProductRuleId ? `规则 #${row.workProductRuleId}` : '历史模板版本')
const ruleSub =
row.workProductRuleKey ||
(row.workProductRuleId ? '' : row.changeReason || '模板已删除,历史版本保留')
return (
<Space direction="vertical" size={0} style={{ width: '100%' }}>
<Typography.Text ellipsis={{ tooltip: ruleLabel }}>{ruleLabel}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }} ellipsis>
{ruleSub}
</Typography.Text>
</Space>
)
},
},
{
title: '金额/比例',
width: 130,
title: '用户实付均价',
width: 160,
render: (_, row) =>
row.pricingMode === 'percent_of_revenue'
? formatRate(row.rateBps)
: formatMoney(row.amountFen),
row.revenueItemCount > 0 ? (
<Space direction="vertical" size={0}>
<span>{formatMoney(row.revenueAvgFen)}</span>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{row.revenueItemCount} 项 / 合计 {formatMoney(row.revenueFen)}
</Typography.Text>
</Space>
) : (
'-'
),
},
{
title: activeCarrier === 'work_order' ? '发单金额/比例' : '金额/比例',
width: 170,
render: (_, row) => {
if (row.isSystemPlaceholder) return <Tag>按工单实际结算</Tag>
if (row.pricingMode === 'percent_of_revenue') return formatRate(row.rateBps)
if (row.amountKind === 'unit_price') {
return (
<Space direction="vertical" size={0}>
<span>{formatMoney(row.amountFen)} / 件</span>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{row.revenueItemCount > 0
? `平均实发 ${formatMoney(row.rewardAvgFen)} / 项`
: '暂无成交'}
</Typography.Text>
</Space>
)
}
return (
<Space direction="vertical" size={0}>
<span>{formatMoney(row.amountFen)}</span>
{row.revenueItemCount > 0 ? (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
平均实发 {formatMoney(row.rewardAvgFen)} / 项
</Typography.Text>
) : null}
</Space>
)
},
},
{
title: '生效时间',
@@ -1538,9 +1609,13 @@ export default function AdminCostCenterPage() {
width: 190,
render: (value: string, row) => (
<Space direction="vertical" size={0}>
<span>{formatAdminDateTime(value)}</span>
<span>{row.isSystemPlaceholder ? '系统占位' : formatAdminDateTime(value)}</span>
<Typography.Text type="secondary">
{row.effectiveTo ? `至 ${formatAdminDateTime(row.effectiveTo)}` : '长期'}
{row.isSystemPlaceholder
? '按工单实际结算'
: row.effectiveTo
? `至 ${formatAdminDateTime(row.effectiveTo)}`
: '长期'}
</Typography.Text>
</Space>
),
@@ -2270,13 +2345,24 @@ export default function AdminCostCenterPage() {
}
extra={
canConfig ? (
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => openCreateVersion()}
>
新建版本
</Button>
<Space>
{activeCarrier === 'all' || activeCarrier === 'work_order' ? (
<Button
icon={<SyncOutlined />}
loading={syncingTemplateCosts}
onClick={() => void syncTemplateCosts()}
>
同步模板发单金额
</Button>
) : null}
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => openCreateVersion()}
>
新建版本
</Button>
</Space>
) : null
}
>
@@ -2285,7 +2371,7 @@ export default function AdminCostCenterPage() {
showIcon
style={{ marginBottom: 12 }}
message="成本版本 = 给成本项定价:一条版本只表达「多少钱 + 何时生效 + 对谁适用」"
description="按件/按单/按比例计价;范围优先级 接单规则 > 标准商品 > 店铺 > 来源 > 全局。改价请新建版本,不要改已生效版本;每个成本项建议只保留一个生效版本。"
description="接单平台按模板逐条建规则级版本;同一适用范围只保留一个生效版本,改价请新增版本。全局 0 元版本只是「按工单实际结算」的系统占位。"
/>
<Space style={{ marginBottom: 16 }} wrap>
<Input
@@ -2322,7 +2408,7 @@ export default function AdminCostCenterPage() {
columns={versionColumns}
dataSource={versions}
loading={versionsQuery.isLoading}
scroll={{ x: 1100 }}
scroll={{ x: 1340 }}
pagination={buildAdminTablePagination({
current: versionPage,
pageSize: versionPageSize,
@@ -38,6 +38,17 @@ export function applyAdminTemplateProductBootstrap() {
)
}
export function syncAdminWorkOrderTemplateCostVersions() {
return apiPost<{
ruleCount: number
unitPriceRuleCount: number
activeVersionCount: number
createdCount: number
retiredCount: number
linkedSnapshotCount: number
}>('/api/v1/admin/cost-center/work-order-template-costs/sync')
}
export function backfillAdminWorkOrderProductSkus() {
return apiPost<{
updatedCount: number
@@ -92,6 +92,17 @@ export interface CostVersion {
canonicalSku: string
canonicalName: string
workProductRuleId: number | null
workProductRuleName: string
workProductRuleKey: string
ruleRewardAmountFen: number
ruleUnitPriceFen: number
revenueItemCount: number
revenueFen: number
revenueAvgFen: number
rewardAvgFen: number
rewardFen: number
amountKind: 'fixed' | 'unit_price'
isSystemPlaceholder: boolean
amountFen: number
rateBps: number
effectiveFrom: string
@@ -195,6 +206,9 @@ export interface TemplateProductBootstrapResult {
resetOrderItemCount: number
mappingConflictCount: number
mappingConflictItemCount: number
templateCostVersionCount: number
templateCostCreatedCount: number
templateCostRetiredCount: number
}
export interface CostLine {
+1 -1
View File
@@ -55,7 +55,7 @@
| 客服备用金台账 | **已实施 P0** | `fund_accounts` / `fund_entries` / `manual_expenses` / `fund_reconciliations`;后台页面、权限、审计已完成 |
| 后台用户中文显示名 | **已实施** | `admin_users.display_name`,备用金姓名触发器同步;审计、登录记录、任务处理人优先显示中文名 |
| 商品主数据 + 渠道 SKU 映射 | **已实施 P0(dev 分支)** | 迁移 `103`(映射消歧在 `106`);`product_catalog` / `channel_product_mappings`,后台「商品与成本」页支持维护、历史订单项回填与按载体分区 |
| 成本中心 | **已实施 P0(dev 分支)** | 迁移 `104/105/107/108/110`;`cost_items` / `cost_versions` / `cost_snapshots` / `cost_exceptions` 支持版本、预览、快照、异常队列、自动发货价格导入;自动发货订单项按履约任务 SKU 直接归一为 `aff:<skuCode>`(不建渠道映射),接单平台按工单模板回填并补齐长尾商品/映射,人工按支出登记写入实际快照 |
| 成本中心 | **已实施 P0(dev 分支)** | 迁移 `104/105/107/108/110/112`;`cost_items` / `cost_versions` / `cost_snapshots` / `cost_exceptions` 支持版本、预览、快照、异常队列、自动发货价格导入;自动发货订单项按履约任务 SKU 直接归一为 `aff:<skuCode>`(不建渠道映射),接单平台按工单模板回填商品并为 662 个模板生成规则级发单金额版本,人工按支出登记写入实际快照 |
| 销售事实 / 订单毛利 | **已实施 P0(dev 分支)** | 迁移 `109`;`sales_daily_facts` 按店铺×日期×标准商品×载体汇总,未归一商品单独成行;后台新增「销售毛利」页,成本缺失会显式告警 |
| 公司人工支出登记 | **已实施 P0(dev 分支)** | 迁移 `111`;`manual_expense_records` / `manual_expense_events`,后台「人工支出」页支持登记、审批、驳回、报销;与客服备用金 `manual_expenses` 通过 `fund_expense_id` 关联 |
| 影子总账 | 未实施 | 现有财务页仍是 `UNION ALL` 业务事件拼装,不是复式总账 |
+4 -2
View File
@@ -144,7 +144,7 @@ product_catalog
接单平台与人工客服各有一个系统成本项(迁移 `110_system_cost_items.sql`):
- `sys_work_order_reward`:已验收工单按 `work_orders.reward_amount` 写 `actual`;未验收但已确定报酬的工单先按同一金额写 `estimated`(metadata `source=work_order_reward_committed`),验收后升级为 `actual`;取消工单不计成本。版本金额 0 只是占位,不进入商品预估。本地全量已回填 95,870 条 `actual` + 21,217 条 `estimated`。
- `sys_work_order_reward`:662 个接单模板各生成一条规则级成本版本(634 条固定发单金额 + 28 条按件发单单价),全局 0 元版本只作「按工单实际结算」的占位。已验收工单按 `work_orders.reward_amount` 写 `actual`;未验收但已确定报酬的工单先按同一金额写 `estimated`(metadata `source=work_order_reward_committed`),验收后升级为 `actual`;取消工单不计成本。本地全量已回填 95,870 条 `actual` + 21,217 条 `estimated`,其中 111,831 条快照已关联到对应模板成本版本。
- `sys_manual_service`:人工支出登记通过后按登记金额写 `actual` 快照;成本版本先为 `draft` 占位,不参与预估,但允许人工支出使用实际金额入账。财务填写正式按单单价后可将版本改为 `active`。
### 3.2 渠道 SKU 映射
@@ -407,10 +407,12 @@ orders + order_items + channel_product_mappings + cost_snapshots
- 模板改价必须生成 `cost_versions`,不能直接改历史工单。
- 工单实际结算金额写入 `cost_snapshot(actual)`;未验收但已确定报酬的工单先写 `actual` 以外的 `estimated`,验收后升级。
- 商品归一入口:后台「接单平台」→「按工单回填商品」,接口 `POST /api/v1/admin/cost-center/work-order-products/backfill`。优先按工单真实使用的 `work_orders.product_rule_id` 指向 `tpl:<rule_key>`;仍无法归一的 341 个长尾订单项,再按 `provider + shop_id + sku_code + normalized_sku_name` 自动补 45 个 `wo:` 标准商品和 115 条 `sku_code_name` 渠道映射。两次回填后接单平台归一率 100%。
- 成本版本入口:后台「接单平台」→「成本版本」→「同步模板发单金额」,接口 `POST /api/v1/admin/cost-center/work-order-template-costs/sync`。662 个模板各生成一条规则级版本(634 固定金额 + 28 按件单价),页面同时显示该模板的「用户实付均价」,用于对比用户付款与发单金额;全局 0 元版本只作实际结算占位。
- 成本异常:966 个订单项没有成本快照(601 个已取消工单 + 365 个零报酬未验收工单),保留在异常队列;取消/零报酬不等于 0 成本,不自动写 0。
- 删除模板:历史成本版本会脱钩并停用,`cost_snapshots` 继续引用原版本,保证历史毛利可追溯;不会因为外键把模板卡死。
- 影子总账负责收入/成本/押金/提现/售后的复式记录;成本中心负责“金额是多少、用了哪个版本”。
本地实测(2026-10-22):接单平台 118,053 个订单项全部归一;未归一收入 ¥775,641.19(14.54%)→ ¥0.00。成本 95,870 条 `actual`(¥3,773,401.32)+ 21,217 条 `estimated`(¥754,912.45),合计 ¥4,528,313.77;销售收入 ¥5,335,734.45,毛利率 15.13%。
本地实测(2026-10-22):接单平台 118,053 个订单项全部归一;未归一收入 ¥775,641.19(14.54%)→ ¥0.00。成本 95,870 条 `actual`(¥3,773,401.32)+ 21,217 条 `estimated`(¥754,912.45),合计 ¥4,528,313.77;销售收入 ¥5,335,734.45,毛利率 15.13%。成本版本从 39 条增至 701 条(38 自动发货商品 + 662 模板规则 + 1 全局占位)。
生产数据提示:91/PDD/抖音已验收工单的报酬/订单金额分别约 86%/80%/96.5%。在收入口径未确认前,这个比例只能作为量级参考,不能直接当毛利率。