diff --git a/apps/backend/src/db/migrations/112_cost_reporting_indexes.sql b/apps/backend/src/db/migrations/112_cost_reporting_indexes.sql new file mode 100644 index 00000000..f1f160c2 --- /dev/null +++ b/apps/backend/src/db/migrations/112_cost_reporting_indexes.sql @@ -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); diff --git a/apps/backend/src/repositories/worker-platform/work-product-rule-repo.ts b/apps/backend/src/repositories/worker-platform/work-product-rule-repo.ts index 3f47f897..d5e25077 100644 --- a/apps/backend/src/repositories/worker-platform/work-product-rule-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-product-rule-repo.ts @@ -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 }> { diff --git a/apps/backend/src/routes/admin/cost-center.ts b/apps/backend/src/routes/admin/cost-center.ts index 7f28f61f..ca12c41b 100644 --- a/apps/backend/src/routes/admin/cost-center.ts +++ b/apps/backend/src/routes/admin/cost-center.ts @@ -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, + }), + }), +) + router.get( '/cost-center/products', requireAdminPermission('finance.view'), diff --git a/apps/backend/src/routes/admin/worker-platform/config.ts b/apps/backend/src/routes/admin/worker-platform/config.ts index 5b6fb9e5..1c997f1b 100644 --- a/apps/backend/src/routes/admin/worker-platform/config.ts +++ b/apps/backend/src/routes/admin/worker-platform/config.ts @@ -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) : {}, - }), - }), + 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) : {}, + }), + }, + ), ) router.post( diff --git a/apps/backend/src/services/admin/admin-cost-center-service.ts b/apps/backend/src/services/admin/admin-cost-center-service.ts index 5ca5a1da..7d131217 100644 --- a/apps/backend/src/services/admin/admin-cost-center-service.ts +++ b/apps/backend/src/services/admin/admin-cost-center-service.ts @@ -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( + ` + 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, diff --git a/apps/backend/src/services/cost-center/cost-snapshot-sync-service.ts b/apps/backend/src/services/cost-center/cost-snapshot-sync-service.ts index 947f1e64..a5a5c4f9 100644 --- a/apps/backend/src/services/cost-center/cost-snapshot-sync-service.ts +++ b/apps/backend/src/services/cost-center/cost-snapshot-sync-service.ts @@ -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 diff --git a/apps/backend/src/services/worker-platform/admin-worker-catalog-service.ts b/apps/backend/src/services/worker-platform/admin-worker-catalog-service.ts index f186cbad..0a8528d4 100644 --- a/apps/backend/src/services/worker-platform/admin-worker-catalog-service.ts +++ b/apps/backend/src/services/worker-platform/admin-worker-catalog-service.ts @@ -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 } } diff --git a/apps/frontend/src/pages/admin/AdminCostCenterPage.tsx b/apps/frontend/src/pages/admin/AdminCostCenterPage.tsx index 0a616037..c08245ce 100644 --- a/apps/frontend/src/pages/admin/AdminCostCenterPage.tsx +++ b/apps/frontend/src/pages/admin/AdminCostCenterPage.tsx @@ -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 ? ( {value} 个版本 + ) : row.carrierType === 'work_order' ? ( + 按工单实际结算 ) : ( 未配置 ), @@ -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 ( + + {ruleLabel} + + {ruleSub} + + + ) }, }, { - title: '金额/比例', - width: 130, + title: '用户实付均价', + width: 160, render: (_, row) => - row.pricingMode === 'percent_of_revenue' - ? formatRate(row.rateBps) - : formatMoney(row.amountFen), + row.revenueItemCount > 0 ? ( + + {formatMoney(row.revenueAvgFen)} + + {row.revenueItemCount} 项 / 合计 {formatMoney(row.revenueFen)} + + + ) : ( + '-' + ), + }, + { + title: activeCarrier === 'work_order' ? '发单金额/比例' : '金额/比例', + width: 170, + render: (_, row) => { + if (row.isSystemPlaceholder) return 按工单实际结算 + if (row.pricingMode === 'percent_of_revenue') return formatRate(row.rateBps) + if (row.amountKind === 'unit_price') { + return ( + + {formatMoney(row.amountFen)} / 件 + + {row.revenueItemCount > 0 + ? `平均实发 ${formatMoney(row.rewardAvgFen)} / 项` + : '暂无成交'} + + + ) + } + return ( + + {formatMoney(row.amountFen)} + {row.revenueItemCount > 0 ? ( + + 平均实发 {formatMoney(row.rewardAvgFen)} / 项 + + ) : null} + + ) + }, }, { title: '生效时间', @@ -1538,9 +1609,13 @@ export default function AdminCostCenterPage() { width: 190, render: (value: string, row) => ( - {formatAdminDateTime(value)} + {row.isSystemPlaceholder ? '系统占位' : formatAdminDateTime(value)} - {row.effectiveTo ? `至 ${formatAdminDateTime(row.effectiveTo)}` : '长期'} + {row.isSystemPlaceholder + ? '按工单实际结算' + : row.effectiveTo + ? `至 ${formatAdminDateTime(row.effectiveTo)}` + : '长期'} ), @@ -2270,13 +2345,24 @@ export default function AdminCostCenterPage() { } extra={ canConfig ? ( - + + {activeCarrier === 'all' || activeCarrier === 'work_order' ? ( + + ) : null} + + ) : null } > @@ -2285,7 +2371,7 @@ export default function AdminCostCenterPage() { showIcon style={{ marginBottom: 12 }} message="成本版本 = 给成本项定价:一条版本只表达「多少钱 + 何时生效 + 对谁适用」" - description="按件/按单/按比例计价;范围优先级 接单规则 > 标准商品 > 店铺 > 来源 > 全局。改价请新建版本,不要改已生效版本;每个成本项建议只保留一个生效版本。" + description="接单平台按模板逐条建规则级版本;同一适用范围只保留一个生效版本,改价请新增版本。全局 0 元版本只是「按工单实际结算」的系统占位。" /> ('/api/v1/admin/cost-center/work-order-template-costs/sync') +} + export function backfillAdminWorkOrderProductSkus() { return apiPost<{ updatedCount: number diff --git a/apps/frontend/src/types/admin/cost-center.ts b/apps/frontend/src/types/admin/cost-center.ts index e8aa8933..10990c6a 100644 --- a/apps/frontend/src/types/admin/cost-center.ts +++ b/apps/frontend/src/types/admin/cost-center.ts @@ -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 { diff --git a/docs/README.md b/docs/README.md index c0265566..443f78b3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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:`(不建渠道映射),接单平台按工单模板回填并补齐长尾商品/映射,人工按支出登记写入实际快照 | +| 成本中心 | **已实施 P0(dev 分支)** | 迁移 `104/105/107/108/110/112`;`cost_items` / `cost_versions` / `cost_snapshots` / `cost_exceptions` 支持版本、预览、快照、异常队列、自动发货价格导入;自动发货订单项按履约任务 SKU 直接归一为 `aff:`(不建渠道映射),接单平台按工单模板回填商品并为 662 个模板生成规则级发单金额版本,人工按支出登记写入实际快照 | | 销售事实 / 订单毛利 | **已实施 P0(dev 分支)** | 迁移 `109`;`sales_daily_facts` 按店铺×日期×标准商品×载体汇总,未归一商品单独成行;后台新增「销售毛利」页,成本缺失会显式告警 | | 公司人工支出登记 | **已实施 P0(dev 分支)** | 迁移 `111`;`manual_expense_records` / `manual_expense_events`,后台「人工支出」页支持登记、审批、驳回、报销;与客服备用金 `manual_expenses` 通过 `fund_expense_id` 关联 | | 影子总账 | 未实施 | 现有财务页仍是 `UNION ALL` 业务事件拼装,不是复式总账 | diff --git a/docs/成本中心与三块履约模型.md b/docs/成本中心与三块履约模型.md index b7c4ac52..af6c4f52 100644 --- a/docs/成本中心与三块履约模型.md +++ b/docs/成本中心与三块履约模型.md @@ -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:`;仍无法归一的 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%。在收入口径未确认前,这个比例只能作为量级参考,不能直接当毛利率。