From 63ae32d18cd131c603d1a9522003d2a888519593 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 23 Oct 2026 16:06:25 +0800 Subject: [PATCH] =?UTF-8?q?refactor(finance):=20=E6=88=90=E6=9C=AC?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E4=B8=8E=E5=B1=A5=E7=BA=A6=E6=96=B9=E5=BC=8F?= =?UTF-8?q?=E6=AD=A3=E4=BA=A4=EF=BC=8C=E6=8B=86=E5=88=86=E6=88=90=E6=9C=AC?= =?UTF-8?q?=E4=B8=AD=E5=BF=83=E5=B7=A8=E5=9E=8B=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端: - 删除 services/admin/admin-cost-center-service.ts(4,248 行),拆为 services/cost-center/ 下 5 个模块: cost-center-shared(类型/枚举/归一化/映射/costModelScopeSql)、 cost-center-overview(总览与选项)、 product-catalog(商品与渠道映射)、 template-bootstrap(模板引导、自动发货同步、工单证据回填)、 cost-definition(成本项/版本/异常/预览/快照) - cost_items.carrier_type / cost_type 改为单一 cost_model;专属模型只对 对应履约方式生效,通用模型对所有履约方式生效 - cost_snapshots / cost_exceptions 删除载体列;按履约方式筛异常改用 IN 半连接(248k 异常下 EXISTS 逐行回表 2.7s -> 170ms) - sales_daily_facts.carrier_type -> fulfillment_mode - 人工支出按 cost_model 归类;履约方式只读 order_items.fulfillment_mode - 移除 syncOpenCostExceptionCarrierTypes:异常表已无载体列可同步 前端: - AdminCostCenterPage.tsx(2,887 行 -> 18 行转发),拆为 pages/admin/cost-center/ 下 8 个 Panel + shared + CostCenterContext - CostCarrierType 删除;新增 FulfillmentMode / ProductSource / CostModel - 销售毛利页 carrierType -> fulfillmentMode 同步补两个索引:idx_order_items_fulfillment_id(异常半连接)、 idx_work_categories_name(商品目录分类 lateral join)。 --- .../scripts/backfill-fulfillment-mode.ts | 5 +- apps/backend/scripts/check-sql-guard.ts | 12 + .../scripts/sync-affiliate-dash-prices.ts | 11 +- apps/backend/src/routes/admin/cost-center.ts | 42 +- .../routes/admin/worker-platform/config.ts | 2 +- .../admin/admin-cost-center-service.ts | 4248 ----------------- .../cost-center-overview-service.ts | 146 + .../cost-center/cost-center-shared.ts | 802 ++++ .../cost-center.test.ts} | 15 +- .../cost-center/cost-definition-service.ts | 1236 +++++ .../cost-center/cost-snapshot-sync-service.ts | 55 +- .../cost-center/product-catalog-service.ts | 985 ++++ .../cost-center/template-bootstrap-service.ts | 1370 ++++++ .../finance/manual-expense-service.ts | 51 +- .../services/finance/sales-facts-service.ts | 32 +- .../fulfillment/fulfillment-mode-service.ts | 39 +- .../src/pages/admin/AdminCostCenterPage.tsx | 2899 +---------- .../src/pages/admin/AdminSalesProfitPage.tsx | 57 +- .../admin/cost-center/AffiliateSkuPanel.tsx | 224 + .../admin/cost-center/ChannelMappingPanel.tsx | 575 +++ .../admin/cost-center/CostCenterContext.ts | 104 + .../admin/cost-center/CostCenterShell.tsx | 259 + .../admin/cost-center/CostExceptionPanel.tsx | 184 + .../pages/admin/cost-center/CostItemPanel.tsx | 332 ++ .../admin/cost-center/CostPreviewPanel.tsx | 192 + .../admin/cost-center/CostVersionPanel.tsx | 530 ++ .../admin/cost-center/ProductCatalogPanel.tsx | 627 +++ .../src/pages/admin/cost-center/shared.ts | 202 + apps/frontend/src/services/admin/finance.ts | 2 +- .../src/services/admin/manual-expense.ts | 3 +- apps/frontend/src/types/admin/cost-center.ts | 57 +- apps/frontend/src/types/admin/finance.ts | 6 +- scripts/rewrite-local-db-2026-10-23.sql | 138 + 33 files changed, 8107 insertions(+), 7335 deletions(-) delete mode 100644 apps/backend/src/services/admin/admin-cost-center-service.ts create mode 100644 apps/backend/src/services/cost-center/cost-center-overview-service.ts create mode 100644 apps/backend/src/services/cost-center/cost-center-shared.ts rename apps/backend/src/services/{admin/admin-cost-center-service.test.ts => cost-center/cost-center.test.ts} (96%) create mode 100644 apps/backend/src/services/cost-center/cost-definition-service.ts create mode 100644 apps/backend/src/services/cost-center/product-catalog-service.ts create mode 100644 apps/backend/src/services/cost-center/template-bootstrap-service.ts create mode 100644 apps/frontend/src/pages/admin/cost-center/AffiliateSkuPanel.tsx create mode 100644 apps/frontend/src/pages/admin/cost-center/ChannelMappingPanel.tsx create mode 100644 apps/frontend/src/pages/admin/cost-center/CostCenterContext.ts create mode 100644 apps/frontend/src/pages/admin/cost-center/CostCenterShell.tsx create mode 100644 apps/frontend/src/pages/admin/cost-center/CostExceptionPanel.tsx create mode 100644 apps/frontend/src/pages/admin/cost-center/CostItemPanel.tsx create mode 100644 apps/frontend/src/pages/admin/cost-center/CostPreviewPanel.tsx create mode 100644 apps/frontend/src/pages/admin/cost-center/CostVersionPanel.tsx create mode 100644 apps/frontend/src/pages/admin/cost-center/ProductCatalogPanel.tsx create mode 100644 apps/frontend/src/pages/admin/cost-center/shared.ts create mode 100644 scripts/rewrite-local-db-2026-10-23.sql diff --git a/apps/backend/scripts/backfill-fulfillment-mode.ts b/apps/backend/scripts/backfill-fulfillment-mode.ts index 3d7b0e21..47f92880 100644 --- a/apps/backend/scripts/backfill-fulfillment-mode.ts +++ b/apps/backend/scripts/backfill-fulfillment-mode.ts @@ -4,7 +4,6 @@ import { closeDb, query } from '../src/db/client.js' import { getFulfillmentModeCounts, reconcilePendingFulfillmentModes, - syncOpenCostExceptionCarrierTypes, } from '../src/services/fulfillment/fulfillment-mode-service.js' const options = parseOptions(process.argv.slice(2)) @@ -25,7 +24,7 @@ async function main() { ) if (!column.rows[0]?.exists) { - throw new Error('order_items.fulfillment_mode 不存在,请先执行 npm run db:migrate(迁移 114)') + throw new Error('order_items.fulfillment_mode 不存在,请先执行 npm run db:migrate(迁移 107)') } const startedAt = Date.now() @@ -34,14 +33,12 @@ async function main() { maxBatches, reason: 'backfill_2026_10_23', }) - const exceptions = await syncOpenCostExceptionCarrierTypes() const counts = await getFulfillmentModeCounts() console.log( JSON.stringify( { ...result, - exceptionCarrierUpdated: exceptions.updatedCount, batchSize, maxBatches, counts, diff --git a/apps/backend/scripts/check-sql-guard.ts b/apps/backend/scripts/check-sql-guard.ts index 1097eee5..19b478ef 100644 --- a/apps/backend/scripts/check-sql-guard.ts +++ b/apps/backend/scripts/check-sql-guard.ts @@ -94,6 +94,14 @@ const allowedHistoricalMigrationChanges = new Set([ 'M apps/backend/src/db/migrations/102_admin_phone_device_auth.sql', ]) +// 2026-10-23 财务重写:103–114 从未部署到生产,整体重写为 103–107 五个迁移。 +// 这一组迁移在本分支内是可重写的,护栏不再逐个校验其字节。 +const REWRITABLE_MIGRATION_PREFIX = /^1(0[3-9]|1[0-4])_/ + +function isRewritableFinanceMigration(file: string) { + return REWRITABLE_MIGRATION_PREFIX.test(path.basename(file)) +} + // 窗口总数会在 LIMIT/OFFSET 之前消费全部候选行;全后端 SQL 都禁止引入该写法。 const countWindowPattern = /COUNT\s*\(\s*\*\s*\)\s*OVER\s*\(/i for (const file of listSourceFiles(path.join(BACKEND_ROOT, 'src'))) { @@ -121,6 +129,10 @@ try { .filter(([status, file, renamedTo]) => !isMigrationRenumber(status, file, renamedTo)) .map(([status, file]) => `${status} ${file}`) .filter((change) => !allowedHistoricalMigrationChanges.has(change)) + .filter((change) => { + const file = change.split(' ').slice(1).join(' ') + return !isRewritableFinanceMigration(file) + }) if (changedHistoricalMigrations.length > 0) { failures.push(`历史迁移文件不可修改: ${changedHistoricalMigrations.join(', ')}`) } diff --git a/apps/backend/scripts/sync-affiliate-dash-prices.ts b/apps/backend/scripts/sync-affiliate-dash-prices.ts index f7d3e4b6..3c941f45 100644 --- a/apps/backend/scripts/sync-affiliate-dash-prices.ts +++ b/apps/backend/scripts/sync-affiliate-dash-prices.ts @@ -73,7 +73,7 @@ async function resolveCostItem(costItemId: number | null): Promise const existing = await query( `SELECT * FROM cost_items - WHERE carrier_type = 'affiliate_dash' AND cost_type = 'auto_purchase' AND status = 'active' + WHERE cost_model = 'affiliate_purchase' AND status = 'active' ORDER BY id LIMIT 1`, ) if (existing.rows[0]) return existing.rows[0] @@ -81,8 +81,8 @@ async function resolveCostItem(costItemId: number | null): Promise const now = new Date().toISOString() const inserted = await query( `INSERT INTO cost_items ( - code, name, cost_type, carrier_type, pricing_mode, unit_name, currency, status, note, created_at, updated_at - ) VALUES ('auto_purchase', '自动发货采购成本', 'auto_purchase', 'affiliate_dash', 'per_item', '件', 'CNY', 'active', '上游自动发货价格同步', $1, $1) + code, name, cost_model, pricing_mode, unit_name, currency, status, note, created_at, updated_at + ) VALUES ('auto_purchase', '自动发货采购成本', 'affiliate_purchase', 'per_item', '件', 'CNY', 'active', '上游自动发货价格同步', $1, $1) ON CONFLICT (code) DO NOTHING RETURNING *`, [now], @@ -147,14 +147,13 @@ async function main() { const productResult = await query<{ id: number; inserted: boolean }>( `INSERT INTO product_catalog ( - canonical_sku, canonical_name, category, unit_name, carrier_type, status, + canonical_sku, canonical_name, category, unit_name, status, attributes_json, created_at, updated_at - ) VALUES ($1, $2, $3, $4, 'affiliate_dash', 'active', $5::jsonb, $6, $6) + ) VALUES ($1, $2, $3, $4, 'active', $5::jsonb, $6, $6) ON CONFLICT (canonical_sku) DO UPDATE SET canonical_name = EXCLUDED.canonical_name, category = EXCLUDED.category, unit_name = EXCLUDED.unit_name, - carrier_type = 'affiliate_dash', attributes_json = product_catalog.attributes_json || EXCLUDED.attributes_json, updated_at = $6 RETURNING id, (xmax = 0) AS inserted`, diff --git a/apps/backend/src/routes/admin/cost-center.ts b/apps/backend/src/routes/admin/cost-center.ts index ca12c41b..cd61af59 100644 --- a/apps/backend/src/routes/admin/cost-center.ts +++ b/apps/backend/src/routes/admin/cost-center.ts @@ -2,37 +2,43 @@ import type { Request } from 'express' import { Router } from 'express' import { - applyAdminTemplateProductBootstrap, - backfillAdminAffiliateDashSkus, - backfillAdminNormalizedQuantities, - backfillAdminProductMappings, - backfillAdminWorkOrderProductSkus, - createAdminChannelMapping, + getAdminCostCenterOptions, + getAdminCostCenterOverview, +} from '../../services/cost-center/cost-center-overview-service.js' +import { createAdminCostItem, createAdminCostSnapshot, createAdminCostVersion, - createAdminProduct, - deleteAdminChannelMapping, deleteAdminCostItem, deleteAdminCostVersion, - deleteAdminProduct, - getAdminCostCenterOptions, - getAdminCostCenterOverview, - listAdminChannelMappings, listAdminCostExceptions, listAdminCostItems, listAdminCostVersions, - listAdminProducts, previewAdminCost, - previewAdminTemplateProductBootstrap, resolveAdminCostException, retireAdminCostVersion, + updateAdminCostItem, +} from '../../services/cost-center/cost-definition-service.js' +import { + backfillAdminNormalizedQuantities, + backfillAdminProductMappings, + createAdminChannelMapping, + createAdminProduct, + deleteAdminChannelMapping, + deleteAdminProduct, + listAdminChannelMappings, + listAdminProducts, + updateAdminChannelMapping, + updateAdminProduct, +} from '../../services/cost-center/product-catalog-service.js' +import { + applyAdminTemplateProductBootstrap, + backfillAdminAffiliateDashSkus, + backfillAdminWorkOrderProductSkus, + previewAdminTemplateProductBootstrap, syncAdminAffiliateDashProducts, syncAdminWorkOrderTemplateCostVersions, - updateAdminChannelMapping, - updateAdminCostItem, - updateAdminProduct, -} from '../../services/admin/admin-cost-center-service.js' +} from '../../services/cost-center/template-bootstrap-service.js' import type { AdminSession } from '../../services/admin/admin-auth-service.js' import { syncActualWorkOrderCostSnapshots, diff --git a/apps/backend/src/routes/admin/worker-platform/config.ts b/apps/backend/src/routes/admin/worker-platform/config.ts index 1c997f1b..822c7577 100644 --- a/apps/backend/src/routes/admin/worker-platform/config.ts +++ b/apps/backend/src/routes/admin/worker-platform/config.ts @@ -1,6 +1,6 @@ import { Router } from 'express' import { setHttpCache } from '../../../middleware/http-cache.js' -import { syncAdminWorkOrderTemplateCostVersions } from '../../../services/admin/admin-cost-center-service.js' +import { syncAdminWorkOrderTemplateCostVersions } from '../../../services/cost-center/template-bootstrap-service.js' import { deleteAdminWorkCategory, diff --git a/apps/backend/src/services/admin/admin-cost-center-service.ts b/apps/backend/src/services/admin/admin-cost-center-service.ts deleted file mode 100644 index 7d131217..00000000 --- a/apps/backend/src/services/admin/admin-cost-center-service.ts +++ /dev/null @@ -1,4248 +0,0 @@ -import { query, withTransaction } from '../../db/client.js' -import type { AdminSession } from './admin-auth-service.js' -import type { JsonObject } from '../../types/json.js' -import { createHttpError } from '../../utils/http.js' -import { shiftDate } from '../../utils/finance-date-range.js' -import { nowIso } from '../../utils/time.js' -import { rebuildSalesDailyFacts } from '../finance/sales-facts-service.js' -import { normalizeMatchText, normalizeSkuSeriesEquivalence } from '../product-match/normalize.js' -import { resolveSkuNameQuantity } from '../product-match/pricing.js' - -const PRODUCT_STATUSES = new Set(['active', 'archived']) -const MAPPING_CONFIDENCES = new Set(['exact', 'rule', 'manual']) -const MAPPING_SOURCES = new Set(['manual', 'import', 'rule']) -const MAPPING_MATCH_TYPES = new Set([ - 'sku_code', - 'sku_code_name', - 'sku_code_series', - 'sku_name_series', -]) -const COST_TYPES = new Set([ - 'auto_purchase', - 'work_order_reward', - 'manual_service', - 'platform_fee', - 'payment_fee', - 'after_sales_loss', - 'shipping_other', - 'other', -]) -/** 成本项自动编码的基础名:按成本类型生成,重复时追加 _2、_3。 */ -const COST_ITEM_CODE_BASE: Record = { - auto_purchase: 'auto_purchase', - work_order_reward: 'work_order_reward', - manual_service: 'manual_service', - platform_fee: 'platform_fee', - payment_fee: 'payment_fee', - after_sales_loss: 'after_sales_loss', - shipping_other: 'shipping_other', - other: 'other_cost', -} -const CARRIER_TYPES = new Set(['affiliate_dash', 'work_order', 'manual', 'shared']) -const PRICING_MODES = new Set([ - 'per_item', - 'per_order', - 'per_hour', - 'percent_of_revenue', - 'fixed_daily', -]) -const COST_VERSION_SCOPES = new Set(['global', 'provider', 'shop', 'product', 'rule']) -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 - FROM work_categories wc - WHERE wc.name = p.category - ORDER BY (wc.parent_id IS NOT NULL) DESC, wc.id DESC - LIMIT 1 - ) c ON TRUE - LEFT JOIN work_categories root ON root.id = COALESCE(c.parent_id, c.id) -` -/** - * 自动发货的履约证据按上游 SKU 聚合一次,再挂到 aff: 商品上。 - * 商品表里的 attributes_json 会被价格导入覆盖,不能作为统计源。 - */ -const AFFILIATE_TASK_AGG_JOIN = ` - LEFT JOIN ( - SELECT - btrim(context_json->>'skuCode') AS sku_code, - COUNT(*)::int AS task_count, - MAX(created_at) AS last_fulfilled_at - FROM fulfillment_tasks - WHERE executor_key = 'affiliate_dash' - AND btrim(COALESCE(context_json->>'skuCode', '')) <> '' - GROUP BY 1 - ) aff ON p.carrier_type = 'affiliate_dash' - AND p.canonical_sku LIKE 'aff:%' - AND aff.sku_code = substring(p.canonical_sku from 5) -` - -type ProductRow = { - [column: string]: unknown - id: number - canonical_sku: string - canonical_name: string - category: string - unit_name: string - carrier_type: string - status: string - root_category_id: number | null - root_category_name: string - sub_category_id: number | null - sub_category_name: string - attributes_json: unknown - created_at: string - updated_at: string - mapping_count?: number - cost_version_count?: number - affiliate_task_count?: number - affiliate_last_fulfilled_at?: string | null -} - -type ProductCategoryStatRow = { - category_id: number | null - name: string | null - parent_id: number | null - total: number -} - -type WorkCategoryRow = { - id: number - name: string - parent_id: number | null -} - -type ProductCategoryNode = { - categoryId: number - name: string - total: number - directTotal: number - children: Array<{ - categoryId: number - name: string - total: number - }> -} - -type MappingRow = { - [column: string]: unknown - id: number - provider: string - shop_id: string - platform_sku_code: string - platform_sku_name: string - platform_sku_name_key: string - match_type: string - canonical_product_id: number - canonical_sku: string - canonical_name: string - quantity_multiplier: number - confidence: string - source: string - enabled: boolean - note: string - created_at: string - updated_at: string -} - -type CostItemRow = { - [column: string]: unknown - id: number - code: string - name: string - cost_type: string - carrier_type: string - pricing_mode: string - unit_name: string - currency: string - status: string - note: string - created_at: string - updated_at: string - version_count?: number -} - -type CostVersionRow = { - [column: string]: unknown - id: number - cost_item_id: number - cost_item_code: string - cost_item_name: string - cost_item_carrier_type: string - pricing_mode: string - scope_type: string - provider: string - shop_id: string - canonical_product_id: number | null - 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 - effective_to: string | null - source: string - status: string - change_reason: string - created_by_admin_user_id: number | null - approved_by_admin_user_id: number | null - created_at: string - updated_at: string -} - -type CostExceptionRow = { - [column: string]: unknown - id: number - subject_type: string - subject_id: number - order_item_id: number | null - work_order_id: number | null - carrier_type: string - reason: string - status: string - detail_json: unknown - created_at: string - resolved_at: string | null - resolved_by_admin_user_id: number | null -} - -type OrderItemCostContext = { - orderItemId: number - orderId: number - provider: string - shopId: string - skuCode: string - skuName: string - normalizedSkuName: string - normalizedSkuSeries: string - quantity: number - normalizedQuantity: number - canonicalProductId: number | null - revenueFen: number - basisAt: string - workOrderId: number | null - workProductRuleId: number | null - hasAffiliateDashTask: boolean -} - -type CostLine = { - costItemId: number - costItemCode: string - costItemName: string - costType: string - carrierType: string - pricingMode: string - costVersionId: number - scopeType: string - amountFen: number - rateBps: number - quantity: number - unitAmountFen: number - totalAmountFen: number -} - -type CostExceptionDraft = { - reason: string - message: string - detail: Record -} - -type MissingCostItem = { - costItemId: number - costItemCode: string - costItemName: string - carrierType: string -} - -type CostResolution = { - lines: CostLine[] - missingCostItems: MissingCostItem[] - applicableCostItemCount: number -} - -type CostPreview = { - context: { - orderItemId: number | null - orderId: number | null - provider: string - shopId: string - skuCode: string - skuName: string - carrierType: string - quantity: number - normalizedQuantity: number - revenueFen: number - workOrderId: number | null - workProductRuleId: number | null - } - mapping: ReturnType | null - product: ReturnType | null - lines: CostLine[] - missingCostItems: MissingCostItem[] - totalCostFen: number - exceptions: CostExceptionDraft[] -} - -export async function getAdminCostCenterOverview() { - const result = await query<{ - product_count: number - mapping_count: number - cost_item_count: number - active_version_count: number - open_exception_count: number - unmapped_order_item_count: number - }>( - ` - SELECT - (SELECT COUNT(*)::int FROM product_catalog WHERE status = 'active') AS product_count, - (SELECT COUNT(*)::int FROM channel_product_mappings WHERE enabled = TRUE) AS mapping_count, - (SELECT COUNT(*)::int FROM cost_items WHERE status = 'active') AS cost_item_count, - (SELECT COUNT(*)::int FROM cost_versions WHERE status = 'active') AS active_version_count, - (SELECT COUNT(*)::int FROM cost_exceptions WHERE status = 'open') AS open_exception_count, - (SELECT COUNT(*)::int FROM order_items WHERE canonical_product_id IS NULL) AS unmapped_order_item_count - `, - ) - const row = result.rows[0] - const [productCarrierResult, costItemCarrierResult] = await Promise.all([ - query<{ carrier_type: string; total: number }>( - `SELECT carrier_type, COUNT(*)::int AS total FROM product_catalog WHERE status = 'active' GROUP BY carrier_type`, - ), - query<{ carrier_type: string; total: number }>( - `SELECT carrier_type, COUNT(*)::int AS total FROM cost_items WHERE status = 'active' GROUP BY carrier_type`, - ), - ]) - return { - productCount: toNumber(row?.product_count), - mappingCount: toNumber(row?.mapping_count), - costItemCount: toNumber(row?.cost_item_count), - activeVersionCount: toNumber(row?.active_version_count), - openExceptionCount: toNumber(row?.open_exception_count), - unmappedOrderItemCount: toNumber(row?.unmapped_order_item_count), - productCarrierCounts: carrierCountsOf(productCarrierResult.rows), - costItemCarrierCounts: carrierCountsOf(costItemCarrierResult.rows), - } -} - -function carrierCountsOf(rows: Array<{ carrier_type: string; total: number }>) { - const counts: Record = {} - for (const carrierType of CARRIER_TYPES) counts[carrierType] = 0 - for (const row of rows) { - if (!row.carrier_type) continue - counts[row.carrier_type] = toNumber(row.total) - } - return counts -} - -export async function getAdminCostCenterOptions() { - const [productsResult, costItemsResult, rulesResult] = await Promise.all([ - query<{ - id: number - canonical_sku: string - canonical_name: string - unit_name: string - carrier_type: string - }>( - ` - SELECT id, canonical_sku, canonical_name, unit_name, carrier_type - FROM product_catalog - WHERE status = 'active' - ORDER BY canonical_name, id - LIMIT 5000 - `, - ), - query<{ id: number; code: string; name: string; pricing_mode: string; carrier_type: string }>( - ` - SELECT id, code, name, pricing_mode, carrier_type - FROM cost_items - WHERE status = 'active' - ORDER BY name, id - LIMIT 1000 - `, - ), - query<{ - id: number - rule_key: string - product_name: string - sku_code: string - provider: string - }>( - ` - SELECT id, rule_key, product_name, sku_code, provider - FROM work_product_rules - WHERE enabled = TRUE - ORDER BY sort_order, id - LIMIT 1000 - `, - ), - ]) - - return { - providers: PROVIDERS, - products: productsResult.rows.map((row) => ({ - id: Number(row.id), - canonicalSku: row.canonical_sku, - canonicalName: row.canonical_name, - unitName: row.unit_name, - carrierType: row.carrier_type, - })), - costItems: costItemsResult.rows.map((row) => ({ - id: Number(row.id), - code: row.code, - name: row.name, - pricingMode: row.pricing_mode, - carrierType: row.carrier_type, - })), - rules: rulesResult.rows.map((row) => ({ - id: Number(row.id), - ruleKey: row.rule_key, - productName: row.product_name, - skuCode: row.sku_code, - provider: row.provider, - })), - } -} - -export async function listAdminProducts(payload: JsonObject = {}) { - const page = normalizePage(payload.page) - const pageSize = normalizePageSize(payload.pageSize) - const offset = (page - 1) * pageSize - const baseParams: unknown[] = [] - const baseClauses: string[] = [] - - const keyword = text(payload.keyword) - if (keyword) { - baseParams.push(`%${keyword}%`) - baseClauses.push( - `(p.canonical_sku ILIKE $${baseParams.length} OR p.canonical_name ILIKE $${baseParams.length} OR p.category ILIKE $${baseParams.length} OR root.name ILIKE $${baseParams.length})`, - ) - } - const status = text(payload.status) - if (status) { - assertOneOf(status, PRODUCT_STATUSES, '商品状态无效') - baseParams.push(status) - baseClauses.push(`p.status = $${baseParams.length}`) - } - const carrierType = text(payload.carrierType ?? payload.carrier_type) - if (carrierType) { - assertOneOf(carrierType, CARRIER_TYPES, '商品载体无效') - baseParams.push(carrierType) - baseClauses.push(`p.carrier_type = $${baseParams.length}`) - } - - const categoryId = normalizeCategoryFilter(payload.categoryId ?? payload.category_id) - const rootCategoryId = normalizeCategoryFilter(payload.rootCategoryId ?? payload.root_category_id) - const listParams = [...baseParams] - const listClauses = [...baseClauses] - if (categoryId === -1) { - listClauses.push('c.id IS NULL') - } else if (categoryId) { - listParams.push(categoryId) - listClauses.push(`c.id = $${listParams.length}`) - } else if (rootCategoryId === -1) { - listClauses.push('c.id IS NULL') - } else if (rootCategoryId) { - listParams.push(rootCategoryId) - listClauses.push(`COALESCE(c.parent_id, c.id) = $${listParams.length}`) - } - - const baseWhere = baseClauses.length ? `WHERE ${baseClauses.join(' AND ')}` : '' - const where = listClauses.length ? `WHERE ${listClauses.join(' AND ')}` : '' - const pageParams = [...listParams, pageSize, offset] - - const [countResult, result, categoryStatResult, categoryResult] = await Promise.all([ - query<{ total: number }>( - `SELECT COUNT(*)::int AS total FROM product_catalog p ${PRODUCT_CATEGORY_JOINS} ${where}`, - listParams, - ), - query( - ` - SELECT - p.*, - CASE WHEN c.parent_id IS NOT NULL THEN c.id END AS sub_category_id, - CASE WHEN c.parent_id IS NOT NULL THEN c.name ELSE '' END AS sub_category_name, - 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.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 - ${PRODUCT_CATEGORY_JOINS} - ${AFFILIATE_TASK_AGG_JOIN} - ${where} - ORDER BY - p.status ASC, - CASE p.carrier_type - WHEN 'affiliate_dash' THEN 1 - WHEN 'work_order' THEN 2 - WHEN 'manual' THEN 3 - ELSE 4 - END, - p.canonical_name ASC, - p.id ASC - LIMIT $${pageParams.length - 1} OFFSET $${pageParams.length} - `, - pageParams, - ), - query( - ` - SELECT c.id AS category_id, c.name, c.parent_id, COUNT(*)::int AS total - FROM product_catalog p - ${PRODUCT_CATEGORY_JOINS} - ${baseWhere} - GROUP BY c.id, c.name, c.parent_id - `, - baseParams, - ), - query( - ` - SELECT id, name, parent_id - FROM work_categories - ORDER BY sort_order ASC, id ASC - `, - ), - ]) - - const categoryCounts = new Map() - let uncategorizedTotal = 0 - for (const row of categoryStatResult.rows) { - const total = toNumber(row.total) - if (!row.category_id) { - uncategorizedTotal += total - continue - } - categoryCounts.set(Number(row.category_id), total) - } - - const categoryTree: ProductCategoryNode[] = [] - const rootById = new Map() - for (const category of categoryResult.rows) { - if (category.parent_id) continue - const categoryId = Number(category.id) - const total = categoryCounts.get(categoryId) || 0 - const node: ProductCategoryNode = { - categoryId, - name: category.name, - total, - directTotal: total, - children: [], - } - rootById.set(categoryId, node) - categoryTree.push(node) - } - for (const category of categoryResult.rows) { - if (!category.parent_id) continue - const parent = rootById.get(Number(category.parent_id)) - if (!parent) continue - const total = categoryCounts.get(Number(category.id)) || 0 - parent.children.push({ - categoryId: Number(category.id), - name: category.name, - total, - }) - parent.total += total - } - - return { - items: result.rows.map(mapProduct), - categoryTree, - uncategorizedTotal, - pagination: { page, pageSize, total: toNumber(countResult.rows[0]?.total) }, - } -} - -export async function createAdminProduct(payload: JsonObject, session: AdminSession) { - const canonicalSku = normalizeRequiredText(payload.canonicalSku, '标准 SKU', 64) - if (!/^[A-Za-z0-9._:-]+$/.test(canonicalSku)) { - throw createHttpError('标准 SKU 只能包含字母、数字、点、下划线、冒号和短横线', { - statusCode: 400, - errorCode: 'cost_product_sku_invalid', - }) - } - const canonicalName = normalizeRequiredText( - payload.canonicalName ?? payload.canonical_name, - '商品名称', - 120, - ) - const category = normalizeOptionalText(payload.category, 64) - const unitName = normalizeOptionalText(payload.unitName ?? payload.unit_name, 16) || '件' - const status = normalizeStatus(payload.status, PRODUCT_STATUSES, 'active', '商品状态无效') - const carrierType = normalizeStatus( - payload.carrierType ?? payload.carrier_type, - CARRIER_TYPES, - 'work_order', - '商品载体无效', - ) - const attributes = normalizeJsonObject(payload.attributes ?? payload.attributesJson) - const now = nowIso() - - const result = await query( - ` - INSERT INTO product_catalog ( - canonical_sku, canonical_name, category, unit_name, status, - carrier_type, attributes_json, created_at, updated_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, $8) - ON CONFLICT (canonical_sku) DO NOTHING - RETURNING * - `, - [ - canonicalSku, - canonicalName, - category, - unitName, - status, - carrierType, - JSON.stringify(attributes), - now, - ], - ) - const inserted = result.rows[0] - if (!inserted) { - throw createHttpError('标准 SKU 已存在', { - statusCode: 409, - errorCode: 'cost_product_sku_exists', - }) - } - return { product: mapProduct(inserted) } -} - -export async function updateAdminProduct(productId: number | string, payload: JsonObject) { - const id = normalizeId(productId, '商品 ID') - const current = await getProductRow(id) - const canonicalName = normalizeRequiredText( - payload.canonicalName ?? payload.canonical_name ?? current.canonical_name, - '商品名称', - 120, - ) - const category = - payload.category === undefined ? current.category : normalizeOptionalText(payload.category, 64) - const unitName = - payload.unitName === undefined && payload.unit_name === undefined - ? current.unit_name - : normalizeOptionalText(payload.unitName ?? payload.unit_name, 16) || '件' - const status = - payload.status === undefined - ? current.status - : normalizeStatus(payload.status, PRODUCT_STATUSES, current.status, '商品状态无效') - const carrierType = - payload.carrierType === undefined && payload.carrier_type === undefined - ? current.carrier_type - : normalizeStatus( - payload.carrierType ?? payload.carrier_type, - CARRIER_TYPES, - current.carrier_type, - '商品载体无效', - ) - const attributes = - payload.attributes === undefined && payload.attributesJson === undefined - ? parseJsonObject(current.attributes_json) - : normalizeJsonObject(payload.attributes ?? payload.attributesJson) - - const result = await query( - ` - UPDATE product_catalog - SET canonical_name = $1, category = $2, unit_name = $3, status = $4, - carrier_type = $5, attributes_json = $6::jsonb, updated_at = $7 - WHERE id = $8 - RETURNING * - `, - [ - canonicalName, - category, - unitName, - status, - carrierType, - JSON.stringify(attributes), - nowIso(), - id, - ], - ) - return { product: mapProduct(requiredRow(result.rows[0], '商品不存在')) } -} - -/** - * 删除标准商品:只允许删除没有任何引用的商品。 - * 有渠道映射/成本版本/成本快照/订单项时返回 409,提示改为归档。 - */ -export async function deleteAdminProduct(productId: number | string) { - const id = normalizeId(productId, '商品 ID') - const current = await getProductRow(id) - const usageResult = await query<{ - mapping_count: number - version_count: number - snapshot_count: number - order_item_count: number - }>( - ` - SELECT - (SELECT COUNT(*)::int FROM channel_product_mappings WHERE canonical_product_id = $1) AS mapping_count, - (SELECT COUNT(*)::int FROM cost_versions WHERE canonical_product_id = $1) AS version_count, - (SELECT COUNT(*)::int FROM cost_snapshots WHERE canonical_product_id = $1) AS snapshot_count, - (SELECT COUNT(*)::int FROM order_items WHERE canonical_product_id = $1) AS order_item_count - `, - [id], - ) - const usage = usageResult.rows[0] - const blockers: string[] = [] - if (toNumber(usage?.mapping_count) > 0) - blockers.push(`${toNumber(usage?.mapping_count)} 条渠道映射`) - if (toNumber(usage?.version_count) > 0) - blockers.push(`${toNumber(usage?.version_count)} 个成本版本`) - if (toNumber(usage?.snapshot_count) > 0) - blockers.push(`${toNumber(usage?.snapshot_count)} 条成本快照`) - if (toNumber(usage?.order_item_count) > 0) - blockers.push(`${toNumber(usage?.order_item_count)} 个订单项`) - if (blockers.length > 0) { - throw createHttpError(`商品仍被引用(${blockers.join('、')}),不能删除;请改为归档`, { - statusCode: 409, - errorCode: 'cost_product_in_use', - }) - } - await query('DELETE FROM product_catalog WHERE id = $1', [id]) - return { deleted: true, product: mapProduct(current) } -} - -type MappingProductGroupRow = { - canonical_product_id: number - canonical_sku: string - canonical_name: string - category: string - unit_name: string - carrier_type: string - status: string -} - -export async function listAdminChannelMappings(payload: JsonObject = {}) { - const page = normalizePage(payload.page) - const pageSize = normalizePageSize(payload.pageSize) - const offset = (page - 1) * pageSize - const params: unknown[] = [] - const clauses: string[] = [] - - const keyword = text(payload.keyword) - if (keyword) { - params.push(`%${keyword}%`) - clauses.push( - `(m.platform_sku_code ILIKE $${params.length} OR m.platform_sku_name ILIKE $${params.length} - OR p.canonical_sku ILIKE $${params.length} OR p.canonical_name ILIKE $${params.length})`, - ) - } - const provider = text(payload.provider) - if (provider) { - params.push(normalizeProviderKey(provider)) - clauses.push(`m.provider = $${params.length}`) - } - const canonicalProductId = normalizeOptionalId(payload.canonicalProductId) - if (canonicalProductId) { - params.push(canonicalProductId) - clauses.push(`m.canonical_product_id = $${params.length}`) - } - const carrierType = text(payload.carrierType ?? payload.carrier_type) - if (carrierType) { - assertOneOf(carrierType, CARRIER_TYPES, '商品载体无效') - params.push(carrierType) - clauses.push(`p.carrier_type = $${params.length}`) - } - if (payload.enabled !== undefined && payload.enabled !== '') { - params.push(normalizeBoolean(payload.enabled)) - clauses.push(`m.enabled = $${params.length}`) - } - - const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '' - const groupParams = [...params, pageSize, offset] - const [groupResult, statsResult] = await Promise.all([ - query( - ` - SELECT - p.id AS canonical_product_id, - p.canonical_sku, - p.canonical_name, - p.category, - p.unit_name, - p.carrier_type, - p.status - FROM channel_product_mappings m - JOIN product_catalog p ON p.id = m.canonical_product_id - ${where} - GROUP BY - p.id, - p.canonical_sku, - p.canonical_name, - p.category, - p.unit_name, - p.carrier_type, - p.status - ORDER BY p.canonical_name ASC, p.id ASC - LIMIT $${groupParams.length - 1} OFFSET $${groupParams.length} - `, - groupParams, - ), - query<{ total_count: number; mapping_total: number }>( - ` - SELECT - COUNT(DISTINCT p.id)::int AS total_count, - COUNT(*)::int AS mapping_total - FROM channel_product_mappings m - JOIN product_catalog p ON p.id = m.canonical_product_id - ${where} - `, - params, - ), - ]) - - const groups = groupResult.rows - const totalCount = toNumber(statsResult.rows[0]?.total_count) - const mappingTotal = toNumber(statsResult.rows[0]?.mapping_total) - if (groups.length === 0) { - return { - groups: [], - pagination: { page, pageSize, total: totalCount, mappingTotal }, - } - } - - const productIds = groups.map((row) => Number(row.canonical_product_id)) - const mappingParams = [...params, productIds] - const mappingsResult = await query( - ` - SELECT - m.*, - p.canonical_sku, - p.canonical_name - FROM channel_product_mappings m - JOIN product_catalog p ON p.id = m.canonical_product_id - ${where} - ${where ? 'AND' : 'WHERE'} m.canonical_product_id = ANY($${mappingParams.length}::bigint[]) - ORDER BY - p.canonical_name ASC, - p.id ASC, - m.provider ASC, - m.shop_id ASC, - m.match_type ASC, - m.platform_sku_code ASC, - m.id DESC - `, - mappingParams, - ) - - const mappingsByProduct = new Map[]>() - for (const row of mappingsResult.rows) { - const productId = Number(row.canonical_product_id) - const list = mappingsByProduct.get(productId) || [] - list.push(mapMapping(row)) - mappingsByProduct.set(productId, list) - } - - return { - groups: groups.map((row) => ({ - canonicalProductId: Number(row.canonical_product_id), - canonicalSku: row.canonical_sku, - canonicalName: row.canonical_name, - category: row.category || '', - unitName: row.unit_name || '件', - carrierType: row.carrier_type, - status: row.status, - mappings: mappingsByProduct.get(Number(row.canonical_product_id)) || [], - })), - pagination: { - page, - pageSize, - total: totalCount, - mappingTotal, - }, - } -} - -export async function createAdminChannelMapping(payload: JsonObject, _session: AdminSession) { - const provider = normalizeProviderKey(normalizeRequiredText(payload.provider, '来源', 64)) - assertKnownProvider(provider) - const shopId = normalizeOptionalText(payload.shopId ?? payload.shop_id, 120) - const matchType = normalizeMappingMatchType(payload.matchType ?? payload.match_type) - const platformSkuCode = normalizeOptionalText( - payload.platformSkuCode ?? payload.platform_sku_code, - 120, - ) - const platformSkuName = normalizeOptionalText( - payload.platformSkuName ?? payload.platform_sku_name, - 200, - ) - if (!platformSkuCode && matchType !== 'sku_name_series') { - throw createHttpError('该匹配方式必须填写平台 SKU', { - statusCode: 400, - errorCode: 'cost_mapping_sku_required', - }) - } - const platformSkuNameKey = resolveMappingNameKey(matchType, platformSkuName) - if (matchType !== 'sku_code' && !platformSkuNameKey) { - throw createHttpError('该匹配方式必须填写平台 SKU 名称', { - statusCode: 400, - errorCode: 'cost_mapping_sku_name_required', - }) - } - const canonicalProductId = normalizeId( - payload.canonicalProductId ?? payload.canonical_product_id, - '标准商品 ID', - ) - await getProductRow(canonicalProductId) - const quantityMultiplier = normalizePositiveInteger( - payload.quantityMultiplier ?? payload.quantity_multiplier, - 1, - ) - const confidence = normalizeStatus( - payload.confidence, - MAPPING_CONFIDENCES, - 'manual', - '映射置信度无效', - ) - const source = normalizeStatus(payload.source, MAPPING_SOURCES, 'manual', '映射来源无效') - const enabled = payload.enabled === undefined ? true : normalizeBoolean(payload.enabled) - const note = normalizeOptionalText(payload.note, 500) - const now = nowIso() - - const result = await query( - ` - INSERT INTO channel_product_mappings ( - provider, shop_id, platform_sku_code, platform_sku_name, - platform_sku_name_key, match_type, - canonical_product_id, quantity_multiplier, confidence, source, - enabled, note, created_at, updated_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $13) - ON CONFLICT ( - provider, shop_id, platform_sku_code, match_type, platform_sku_name_key - ) DO NOTHING - RETURNING * - `, - [ - provider, - shopId, - platformSkuCode, - platformSkuName, - platformSkuNameKey, - matchType, - canonicalProductId, - quantityMultiplier, - confidence, - source, - enabled, - note, - now, - ], - ) - const inserted = result.rows[0] - if (!inserted) { - throw createHttpError('该来源 + 店铺 + 平台 SKU 与匹配方式已有映射', { - statusCode: 409, - errorCode: 'cost_mapping_exists', - }) - } - const mapping = await getMappingRow(Number(inserted.id)) - return { mapping: mapMapping(mapping) } -} - -export async function updateAdminChannelMapping(mappingId: number | string, payload: JsonObject) { - const id = normalizeId(mappingId, '映射 ID') - const current = await getMappingRow(id) - const provider = normalizeProviderKey( - normalizeRequiredText(payload.provider ?? current.provider, '来源', 64), - ) - assertKnownProvider(provider) - const shopId = - payload.shopId === undefined && payload.shop_id === undefined - ? current.shop_id - : normalizeOptionalText(payload.shopId ?? payload.shop_id, 120) - const matchType = normalizeMappingMatchType( - payload.matchType ?? payload.match_type ?? current.match_type, - ) - const platformSkuCode = normalizeOptionalText( - payload.platformSkuCode ?? payload.platform_sku_code ?? current.platform_sku_code, - 120, - ) - if (!platformSkuCode && matchType !== 'sku_name_series') { - throw createHttpError('该匹配方式必须填写平台 SKU', { - statusCode: 400, - errorCode: 'cost_mapping_sku_required', - }) - } - const platformSkuName = - payload.platformSkuName === undefined && payload.platform_sku_name === undefined - ? current.platform_sku_name - : normalizeOptionalText(payload.platformSkuName ?? payload.platform_sku_name, 200) - const platformSkuNameKey = resolveMappingNameKey(matchType, platformSkuName) - if (matchType !== 'sku_code' && !platformSkuNameKey) { - throw createHttpError('该匹配方式必须填写平台 SKU 名称', { - statusCode: 400, - errorCode: 'cost_mapping_sku_name_required', - }) - } - const canonicalProductId = normalizeId( - payload.canonicalProductId ?? payload.canonical_product_id ?? current.canonical_product_id, - '标准商品 ID', - ) - await getProductRow(canonicalProductId) - await assertMappingKeyAvailable( - provider, - shopId, - platformSkuCode, - matchType, - platformSkuNameKey, - id, - ) - const quantityMultiplier = normalizePositiveInteger( - payload.quantityMultiplier ?? payload.quantity_multiplier ?? current.quantity_multiplier, - 1, - ) - const confidence = normalizeStatus( - payload.confidence, - MAPPING_CONFIDENCES, - current.confidence, - '映射置信度无效', - ) - const source = normalizeStatus(payload.source, MAPPING_SOURCES, current.source, '映射来源无效') - const enabled = - payload.enabled === undefined ? current.enabled : normalizeBoolean(payload.enabled) - const note = payload.note === undefined ? current.note : normalizeOptionalText(payload.note, 500) - - const result = await query( - ` - UPDATE channel_product_mappings - SET provider = $1, shop_id = $2, platform_sku_code = $3, platform_sku_name = $4, - platform_sku_name_key = $5, match_type = $6, - canonical_product_id = $7, quantity_multiplier = $8, confidence = $9, - source = $10, enabled = $11, note = $12, updated_at = $13 - WHERE id = $14 - RETURNING * - `, - [ - provider, - shopId, - platformSkuCode, - platformSkuName, - platformSkuNameKey, - matchType, - canonicalProductId, - quantityMultiplier, - confidence, - source, - enabled, - note, - nowIso(), - id, - ], - ) - const updated = result.rows[0] - if (!updated) { - throw createHttpError('映射不存在', { statusCode: 404, errorCode: 'cost_mapping_not_found' }) - } - const mapping = await getMappingRow(id) - return { mapping: mapMapping(mapping) } -} - -export async function deleteAdminChannelMapping(mappingId: number | string) { - const id = normalizeId(mappingId, '映射 ID') - const result = await query<{ id: number }>( - 'DELETE FROM channel_product_mappings WHERE id = $1 RETURNING id', - [id], - ) - if (!result.rows[0]) { - throw createHttpError('映射不存在', { statusCode: 404, errorCode: 'cost_mapping_not_found' }) - } - return { deleted: true, mappingId: Number(result.rows[0].id) } -} - -export async function backfillAdminProductMappings(payload: JsonObject = {}) { - const provider = normalizeProviderKey(text(payload.provider)) - const limit = Math.min(20000, Math.max(1, Math.trunc(Number(payload.limit) || 5000))) - const all = payload.all === true || payload.all === 'true' - const maxBatches = all ? 40 : 1 - let updatedCount = 0 - let batches = 0 - let lastId = 0 - - const mappingsResult = await query( - ` - SELECT - m.provider, - m.shop_id, - m.platform_sku_code, - m.platform_sku_name_key, - m.match_type, - m.canonical_product_id - FROM channel_product_mappings m - JOIN product_catalog p - ON p.id = m.canonical_product_id - AND p.status = 'active' - WHERE m.enabled = TRUE - `, - ) - const lookup = buildMappingLookup(mappingsResult.rows) - - for (let batch = 0; batch < maxBatches; batch += 1) { - const itemsResult = await query( - ` - SELECT - oi.id, - o.provider, - o.shop_id, - oi.sku_code, - oi.normalized_sku_name, - oi.normalized_sku_series - FROM order_items oi - JOIN orders o ON o.id = oi.order_id - WHERE oi.canonical_product_id IS NULL - AND oi.id > $1 - AND ($2 = '' OR o.provider = $2) - ORDER BY oi.id - LIMIT $3 - `, - [lastId, provider, limit], - ) - if (itemsResult.rows.length === 0) break - - lastId = Number(itemsResult.rows[itemsResult.rows.length - 1]?.id || lastId) - const ids: number[] = [] - const productIds: number[] = [] - for (const row of itemsResult.rows) { - const productId = resolveMappingProductId(lookup, row) - if (!productId) continue - ids.push(Number(row.id)) - productIds.push(productId) - } - - if (ids.length > 0) { - const updated = await query( - ` - UPDATE order_items oi - SET canonical_product_id = input.canonical_product_id, - updated_at = NOW() - FROM unnest($1::bigint[], $2::bigint[]) - AS input(id, canonical_product_id) - WHERE oi.id = input.id - AND oi.canonical_product_id IS NULL - `, - [ids, productIds], - ) - updatedCount += updated.rowCount || 0 - } - batches += 1 - if (!all || itemsResult.rows.length < limit) break - } - - const remainingResult = await query<{ total: number }>( - ` - SELECT COUNT(*)::int AS total - FROM order_items oi - JOIN orders o ON o.id = oi.order_id - WHERE oi.canonical_product_id IS NULL - AND ($1 = '' OR o.provider = $1) - `, - [provider], - ) - return { - updatedCount, - limit, - batches, - remainingCount: Number(remainingResult.rows[0]?.total || 0), - } -} - -type MappingLookupRow = { - provider: string - shop_id: string - platform_sku_code: string - platform_sku_name_key: string - match_type: string - canonical_product_id: number -} - -export type MappingLookupItemRow = { - id: number - provider: string - shop_id: string - sku_code: string - normalized_sku_name: string | null - normalized_sku_series: string | null -} - -export type MappingLookup = { - byKey: Map -} - -function buildMappingLookup(rows: MappingLookupRow[]): MappingLookup { - const byKey = new Map() - for (const row of rows) { - const key = mappingKey( - row.provider, - row.shop_id, - row.platform_sku_code, - row.match_type, - row.platform_sku_name_key, - ) - if (!byKey.has(key)) byKey.set(key, Number(row.canonical_product_id)) - } - return { byKey } -} - -export function resolveMappingProductId( - lookup: MappingLookup, - item: MappingLookupItemRow, -): number | null { - const shopId = item.shop_id || '' - const nameKey = item.normalized_sku_name || normalizeMatchText(item.sku_code) - const seriesKey = item.normalized_sku_series || normalizeSkuSeriesEquivalence(item.sku_code) - const candidates = [ - mappingKey(item.provider, shopId, item.sku_code, 'sku_code', ''), - mappingKey(item.provider, '', item.sku_code, 'sku_code', ''), - mappingKey(item.provider, shopId, item.sku_code, 'sku_code_name', nameKey), - mappingKey(item.provider, '', item.sku_code, 'sku_code_name', nameKey), - mappingKey(item.provider, shopId, item.sku_code, 'sku_code_series', seriesKey), - mappingKey(item.provider, '', item.sku_code, 'sku_code_series', seriesKey), - mappingKey(item.provider, shopId, '', 'sku_name_series', seriesKey), - mappingKey(item.provider, '', '', 'sku_name_series', seriesKey), - ] - for (const key of candidates) { - const productId = lookup.byKey.get(key) - if (productId) return productId - } - return null -} - -export async function backfillAdminNormalizedQuantities(payload: JsonObject = {}) { - const limit = Math.min(50000, Math.max(1, Math.trunc(Number(payload.limit) || 20000))) - const all = payload.all === true || payload.all === 'true' - const maxBatches = all ? 40 : 1 - let updatedCount = 0 - let batches = 0 - - for (let batch = 0; batch < maxBatches; batch += 1) { - const result = await query<{ - id: number - sku_name: string - sku_code: string - normalized_sku_name: string | null - normalized_sku_series: string | null - normalized_quantity: number | null - }>( - ` - SELECT id, sku_name, sku_code, normalized_sku_name, normalized_sku_series, - normalized_quantity - FROM order_items - WHERE normalized_quantity IS NULL - OR normalized_sku_name IS NULL - OR normalized_sku_series IS NULL - ORDER BY id - LIMIT $1 - `, - [limit], - ) - if (result.rows.length === 0) break - - const ids: number[] = [] - const quantities: number[] = [] - const skuNames: string[] = [] - const skuSeries: string[] = [] - for (const row of result.rows) { - const skuText = row.sku_name || row.sku_code - ids.push(Number(row.id)) - quantities.push(Math.max(1, resolveSkuNameQuantity(skuText))) - skuNames.push(normalizeMatchText(skuText)) - skuSeries.push(normalizeSkuSeriesEquivalence(skuText)) - } - - const updated = await query( - ` - UPDATE order_items oi - SET normalized_quantity = input.quantity, - normalized_sku_name = input.sku_name_key, - normalized_sku_series = input.sku_series_key, - updated_at = NOW() - FROM unnest($1::bigint[], $2::int[], $3::text[], $4::text[]) - AS input(id, quantity, sku_name_key, sku_series_key) - WHERE oi.id = input.id - AND ( - oi.normalized_quantity IS NULL - OR oi.normalized_sku_name IS NULL - OR oi.normalized_sku_series IS NULL - ) - `, - [ids, quantities, skuNames, skuSeries], - ) - updatedCount += updated.rowCount || 0 - batches += 1 - if (!all || result.rows.length < limit) break - } - - const remainingResult = await query<{ total: number }>( - `SELECT COUNT(*)::int AS total - FROM order_items - WHERE normalized_quantity IS NULL - OR normalized_sku_name IS NULL - OR normalized_sku_series IS NULL`, - ) - return { - updatedCount, - limit, - batches, - remainingCount: Number(remainingResult.rows[0]?.total || 0), - } -} - -export async function previewAdminTemplateProductBootstrap() { - const data = await loadTemplateProductBootstrapData() - const plan = buildTemplateMappingPlan(data.evidenceRows) - const templateRuleKeys = new Set(data.templates.map((row) => row.rule_key)) - const candidates = plan.candidates.filter( - (row) => - templateRuleKeys.has(row.ruleKey) && - !data.existingMappingKeys.has( - mappingKey(row.provider, row.shopId, row.skuCode, row.matchType, row.skuNameKey), - ), - ) - const usedRuleIds = new Set(data.evidenceRows.map((row) => Number(row.product_rule_id))) - const candidatesByMatchType = countMappingCandidates(candidates) - return { - templateCount: data.templates.length, - productExistingCount: data.templates.length - data.productsToCreate.length, - productCreateCount: data.productsToCreate.length, - mappingCandidateCount: plan.candidates.length, - mappingExistingCount: plan.candidates.length - candidates.length, - mappingCreateCount: candidates.length, - mappingCreateByMatchType: candidatesByMatchType, - evidenceItemCount: plan.evidenceItemCount, - orderItemCoverCount: Math.max(0, plan.evidenceItemCount - plan.unresolvedItemCount), - mappingConflictCount: plan.conflicts.length, - mappingConflictItemCount: plan.unresolvedItemCount, - multiRuleKeyCount: plan.codeConflictKeyCount, - seriesFallbackConflictCount: plan.seriesFallbackConflictCount, - templatesWithoutWorkOrders: data.templates.filter((row) => !usedRuleIds.has(Number(row.id))) - .length, - samples: candidates.slice(0, 30).map((row) => ({ - ruleKey: row.ruleKey, - productName: data.templates.find((item) => item.rule_key === row.ruleKey)?.product_name || '', - provider: row.provider, - shopId: row.shopId, - skuCode: row.skuCode, - skuName: row.skuName, - matchType: row.matchType, - itemCount: row.itemCount, - })), - conflicts: plan.conflicts.slice(0, 20).map((row) => ({ - provider: row.provider, - shopId: row.shopId, - skuCode: row.skuCode, - skuName: row.skuName, - matchType: row.matchType, - itemCount: row.itemCount, - ruleKeys: row.ruleKeys, - })), - } -} - -export async function applyAdminTemplateProductBootstrap( - payload: JsonObject = {}, - _session?: AdminSession, -) { - const repair = payload.repair !== false - const resetResult = repair - ? await resetImportedTemplateProductBootstrap() - : { deletedMappingCount: 0, resetOrderItemCount: 0 } - const data = await loadTemplateProductBootstrapData() - if (data.templates.length === 0) { - throw createHttpError('没有可导入的接单模板', { - statusCode: 400, - errorCode: 'cost_template_bootstrap_empty', - }) - } - - const plan = buildTemplateMappingPlan(data.evidenceRows) - const now = nowIso() - let createdProductCount = 0 - let reusedProductCount = 0 - let createdMappingCount = 0 - let skippedMappingCount = 0 - let mappedOrderItemCount = 0 - - await withTransaction(async (client) => { - const productIdByRuleKey = new Map() - for (const template of data.templates) { - const existing = data.productByRuleKey.get(template.rule_key) - if (existing) { - productIdByRuleKey.set(template.rule_key, existing) - reusedProductCount += 1 - continue - } - - const canonicalSku = buildTemplateProductSku(template.rule_key) - const inserted = await client.query<{ id: number }>( - ` - INSERT INTO product_catalog ( - canonical_sku, canonical_name, category, unit_name, status, - attributes_json, created_at, updated_at - ) VALUES ($1, $2, $3, $4, 'active', $5::jsonb, $6, $6) - ON CONFLICT (canonical_sku) DO NOTHING - RETURNING id - `, - [ - canonicalSku, - template.product_name, - template.category, - inferTemplateUnitName(template.product_name), - JSON.stringify({ - source: 'work_product_rule', - ruleId: Number(template.id), - ruleKey: template.rule_key, - mappingId: template.mapping_id ? Number(template.mapping_id) : null, - mappingType: template.mapping_type, - skuNick: template.sku_nick, - relSkuId: template.rel_sku_id, - sellerIds: template.seller_ids_json, - unitPriceFen: Number(template.unit_price_fen || 0), - rewardAmountFen: Number(template.reward_amount || 0), - }), - now, - ], - ) - let productId = inserted.rows[0]?.id ? Number(inserted.rows[0].id) : 0 - if (productId) { - createdProductCount += 1 - } else { - const existingResult = await client.query<{ id: number }>( - 'SELECT id FROM product_catalog WHERE canonical_sku = $1 LIMIT 1', - [canonicalSku], - ) - productId = Number(existingResult.rows[0]?.id || 0) - if (productId) reusedProductCount += 1 - } - if (productId) productIdByRuleKey.set(template.rule_key, productId) - } - - for (const row of plan.candidates) { - const productId = productIdByRuleKey.get(row.ruleKey) - if (!productId) continue - const inserted = await client.query<{ id: number }>( - ` - INSERT INTO channel_product_mappings ( - provider, shop_id, platform_sku_code, platform_sku_name, - platform_sku_name_key, match_type, - canonical_product_id, quantity_multiplier, confidence, source, - enabled, note, created_at, updated_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7, 1, 'rule', 'import', TRUE, $8, $9, $9) - ON CONFLICT ( - provider, shop_id, platform_sku_code, match_type, platform_sku_name_key - ) DO NOTHING - RETURNING id - `, - [ - row.provider, - row.shopId, - row.skuCode, - row.skuName, - row.skuNameKey, - row.matchType, - productId, - `接单模板 ${row.ruleKey} 自动初始化(${mappingMatchTypeLabel(row.matchType)})`, - now, - ], - ) - if (inserted.rows[0]) { - createdMappingCount += 1 - } else { - skippedMappingCount += 1 - } - } - }) - - // 名称/系列键必须先于映射回填;所有批次都放在事务外,避免语句超时。 - 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, - createdProductCount, - reusedProductCount, - createdMappingCount, - skippedMappingCount, - mappedOrderItemCount, - deletedImportedMappingCount: resetResult.deletedMappingCount, - resetOrderItemCount: resetResult.resetOrderItemCount, - mappingConflictCount: plan.conflicts.length, - mappingConflictItemCount: plan.unresolvedItemCount, - templateCostVersionCount: templateCosts.activeVersionCount, - templateCostCreatedCount: templateCosts.createdCount, - templateCostRetiredCount: templateCosts.retiredCount, - } -} - -/** - * 按自动发货履约证据同步标准商品。 - * - * 只有真正走 affiliate-dash 执行器的订单项才算自动发货;渠道映射只是 - * 平台 SKU 归一,不能作为自动发货依据。SKU 固定为 `aff:`, - * 重复同步只更新名称/证据,不会重复建商品。 - */ -export async function syncAdminAffiliateDashProducts() { - const now = nowIso() - const result = await query<{ inserted: boolean }>( - ` - WITH auto_task AS ( - SELECT - btrim(context_json->>'skuCode') AS sku_code, - btrim(context_json->>'skuName') AS sku_name, - created_at - FROM fulfillment_tasks - WHERE executor_key = 'affiliate_dash' - AND btrim(COALESCE(context_json->>'skuCode', '')) <> '' - AND btrim(COALESCE(context_json->>'skuName', '')) <> '' - ), - agg AS ( - SELECT sku_code, count(*)::int AS task_count, max(created_at) AS last_fulfilled_at - FROM auto_task - GROUP BY sku_code - ), - ranked AS ( - SELECT - sku_code, - sku_name, - row_number() OVER ( - PARTITION BY sku_code - ORDER BY (sku_name <> sku_code) DESC, count(*) DESC, sku_name - ) AS rn - FROM auto_task - GROUP BY sku_code, sku_name - ) - INSERT INTO product_catalog ( - canonical_sku, canonical_name, category, unit_name, carrier_type, status, - attributes_json, created_at, updated_at - ) - SELECT - 'aff:' || ranked.sku_code, - ranked.sku_name, - '', - CASE WHEN ranked.sku_name LIKE '%个' THEN '个' ELSE '件' END, - 'affiliate_dash', - 'active', - jsonb_build_object( - 'source', 'affiliate_dash_task', - 'skuCode', ranked.sku_code, - 'taskCount', agg.task_count, - 'lastFulfilledAt', agg.last_fulfilled_at - ), - $1, - $1 - FROM ranked - JOIN agg ON agg.sku_code = ranked.sku_code - WHERE ranked.rn = 1 - ON CONFLICT (canonical_sku) DO UPDATE - SET carrier_type = 'affiliate_dash', - canonical_name = EXCLUDED.canonical_name, - unit_name = EXCLUDED.unit_name, - attributes_json = product_catalog.attributes_json || EXCLUDED.attributes_json, - updated_at = $1 - RETURNING (xmax = 0) AS inserted - `, - [now], - ) - const createdCount = result.rows.filter((row) => row.inserted).length - return { - createdCount, - updatedCount: result.rows.length - createdCount, - productCount: result.rows.length, - } -} - -type AffiliateSkuBackfillRow = { - affiliate_item_count: number - matched_count: number - updated_count: number - from_date: string | null - to_date: string | null -} - -/** 销售事实单次重算最多 400 天,长历史按窗口分批。 */ -async function rebuildFactsForRange(fromDate: string, toDate: string) { - let cursor = fromDate - let deletedCount = 0 - let insertedCount = 0 - let batchCount = 0 - while (cursor <= toDate) { - const windowEnd = shiftDate(cursor, 399) - const chunkEnd = windowEnd > toDate ? toDate : windowEnd - const rebuilt = await rebuildSalesDailyFacts({ dateFrom: cursor, dateTo: chunkEnd }) - deletedCount += rebuilt.deletedCount - insertedCount += rebuilt.insertedCount - batchCount += 1 - cursor = shiftDate(chunkEnd, 1) - } - return { fromDate, toDate, deletedCount, insertedCount, batchCount } -} - -/** - * 按自动发货履约证据直接回填订单项标准商品。 - * - * 自动发货只有 affiliate-dash 一个上游渠道,SKU 固定归一为 `aff:`, - * 不写 channel_product_mappings。多个任务共用同一订单项时取最新 SKU。 - */ -export async function backfillAdminAffiliateDashSkus() { - const now = nowIso() - const result = await query( - ` - WITH affiliate_item AS ( - SELECT DISTINCT ON (ft.order_item_id) - ft.order_item_id AS id, - btrim(ft.context_json->>'skuCode') AS sku_code - FROM fulfillment_tasks ft - WHERE ft.executor_key = 'affiliate_dash' - AND btrim(COALESCE(ft.context_json->>'skuCode', '')) <> '' - ORDER BY ft.order_item_id, ft.created_at DESC, ft.id DESC - ), - target AS ( - SELECT ai.id, p.id AS canonical_product_id - FROM affiliate_item ai - JOIN product_catalog p - ON p.canonical_sku = 'aff:' || ai.sku_code - ), - updated AS ( - UPDATE order_items oi - SET canonical_product_id = target.canonical_product_id, - updated_at = $1 - FROM target - WHERE oi.id = target.id - AND oi.canonical_product_id IS DISTINCT FROM target.canonical_product_id - RETURNING oi.id - ) - SELECT - ( - SELECT COUNT(DISTINCT ft.order_item_id)::int - FROM fulfillment_tasks ft - WHERE ft.executor_key = 'affiliate_dash' - ) AS affiliate_item_count, - (SELECT COUNT(*)::int FROM target) AS matched_count, - (SELECT COUNT(*)::int FROM updated) AS updated_count, - ( - SELECT to_char( - MIN((COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date), - 'YYYY-MM-DD' - ) - FROM affiliate_item - JOIN order_items oi ON oi.id = affiliate_item.id - JOIN orders o ON o.id = oi.order_id - ) AS from_date, - ( - SELECT to_char( - MAX((COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date), - 'YYYY-MM-DD' - ) - FROM affiliate_item - JOIN order_items oi ON oi.id = affiliate_item.id - JOIN orders o ON o.id = oi.order_id - ) AS to_date - `, - [now], - ) - - const row = result.rows[0] - const affiliateItemCount = Number(row?.affiliate_item_count || 0) - const matchedCount = Number(row?.matched_count || 0) - const updatedCount = Number(row?.updated_count || 0) - const skippedCount = Math.max(0, matchedCount - updatedCount) - const remainingResult = await query<{ total: number }>( - ` - SELECT COUNT(*)::int AS total - FROM order_items oi - WHERE oi.canonical_product_id IS NULL - AND EXISTS ( - SELECT 1 FROM fulfillment_tasks ft - WHERE ft.order_item_id = oi.id - AND ft.executor_key = 'affiliate_dash' - ) - `, - ) - const remainingCount = Number(remainingResult.rows[0]?.total || 0) - - let factsRebuild: Awaited> | null = null - if (matchedCount > 0 && row?.from_date && row?.to_date) { - factsRebuild = await rebuildFactsForRange(row.from_date, row.to_date) - } - - return { - updatedCount, - skippedCount, - remainingCount, - affiliateItemCount, - matchedCount, - factsRebuild, - } -} - -type WorkOrderSkuBackfillRow = { - work_order_item_count: number - matched_count: number - updated_count: number - from_date: string | null - to_date: string | null -} - -/** - * 回填接单平台订单项的标准商品。 - * - * 第一步用工单真实使用的 `product_rule_id` 指向 `tpl:`; - * 仍无法归一的长尾项,再按 `provider + shop_id + sku_code + normalized_sku_name` - * 补建 `wo:` 商品和 `sku_code_name` 渠道映射,保证后续同类订单能自动命中。 - */ -export async function backfillAdminWorkOrderProductSkus() { - const now = nowIso() - const result = await query( - ` - WITH work_order_item AS ( - SELECT DISTINCT ON (wo.order_item_id) - wo.order_item_id AS id, - wo.product_rule_id - FROM work_orders wo - WHERE wo.order_item_id IS NOT NULL - ORDER BY wo.order_item_id, wo.id DESC - ), - target AS ( - SELECT woi.id, p.id AS canonical_product_id - FROM work_order_item woi - JOIN work_product_rules wpr ON wpr.id = woi.product_rule_id - JOIN product_catalog p ON p.canonical_sku = 'tpl:' || wpr.rule_key - ), - updated AS ( - UPDATE order_items oi - SET canonical_product_id = target.canonical_product_id, - updated_at = $1 - FROM target - WHERE oi.id = target.id - AND oi.canonical_product_id IS DISTINCT FROM target.canonical_product_id - RETURNING oi.id - ) - SELECT - ( - SELECT COUNT(DISTINCT wo.order_item_id)::int - FROM work_orders wo - WHERE wo.order_item_id IS NOT NULL - ) AS work_order_item_count, - (SELECT COUNT(*)::int FROM target) AS matched_count, - (SELECT COUNT(*)::int FROM updated) AS updated_count, - ( - SELECT to_char( - MIN((COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date), - 'YYYY-MM-DD' - ) - FROM work_order_item - JOIN order_items oi ON oi.id = work_order_item.id - JOIN orders o ON o.id = oi.order_id - ) AS from_date, - ( - SELECT to_char( - MAX((COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date), - 'YYYY-MM-DD' - ) - FROM work_order_item - JOIN order_items oi ON oi.id = work_order_item.id - JOIN orders o ON o.id = oi.order_id - ) AS to_date - `, - [now], - ) - - const tailProducts = await query<{ inserted: boolean }>( - ` - WITH tail AS ( - SELECT - oi.normalized_sku_name AS name, - MIN(oi.sku_name) AS sample_name - FROM order_items oi - WHERE oi.canonical_product_id IS NULL - AND btrim(COALESCE(oi.normalized_sku_name, '')) <> '' - AND EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id) - GROUP BY 1 - ), - missing AS ( - SELECT t.name, t.sample_name - FROM tail t - WHERE NOT EXISTS ( - SELECT 1 FROM product_catalog p - WHERE p.status = 'active' AND p.canonical_name = t.name - ) - ) - INSERT INTO product_catalog ( - canonical_sku, canonical_name, category, unit_name, carrier_type, status, - attributes_json, created_at, updated_at - ) - SELECT - 'wo:' || substr(md5(m.name), 1, 12), - m.name, - '', - CASE - WHEN m.sample_name LIKE '%颗%' THEN '颗' - WHEN m.sample_name LIKE '%个%' THEN '个' - ELSE '件' - END, - 'work_order', - 'active', - jsonb_build_object('source', 'work_order_sku_name', 'normalizedName', m.name), - $1, - $1 - FROM missing m - ON CONFLICT (canonical_sku) DO UPDATE - SET carrier_type = 'work_order', - updated_at = $1 - RETURNING (xmax = 0) AS inserted - `, - [now], - ) - - const tailMappings = await query<{ id: number }>( - ` - WITH tail AS ( - SELECT - o.provider, - o.shop_id, - oi.sku_code, - MIN(oi.sku_name) AS sku_name, - oi.normalized_sku_name - FROM order_items oi - JOIN orders o ON o.id = oi.order_id - WHERE oi.canonical_product_id IS NULL - AND btrim(COALESCE(oi.normalized_sku_name, '')) <> '' - AND EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id) - GROUP BY 1, 2, 3, 5 - ), - resolved AS ( - SELECT t.*, p.id AS canonical_product_id - FROM tail t - JOIN LATERAL ( - SELECT id - FROM product_catalog - WHERE status = 'active' AND canonical_name = t.normalized_sku_name - ORDER BY (canonical_sku LIKE 'tpl:%') DESC, id - LIMIT 1 - ) p ON TRUE - ) - INSERT INTO channel_product_mappings ( - provider, shop_id, platform_sku_code, platform_sku_name, - platform_sku_name_key, match_type, canonical_product_id, - quantity_multiplier, confidence, source, enabled, note, created_at, updated_at - ) - SELECT - r.provider, r.shop_id, r.sku_code, r.sku_name, - r.normalized_sku_name, 'sku_code_name', r.canonical_product_id, - 1, 'exact', 'import', TRUE, '按历史工单 SKU 名称自动补齐', $1, $1 - FROM resolved r - ON CONFLICT ( - provider, shop_id, platform_sku_code, match_type, platform_sku_name_key - ) DO NOTHING - RETURNING id - `, - [now], - ) - - const tailUpdated = await query<{ id: number }>( - ` - WITH tail AS ( - SELECT oi.id, oi.normalized_sku_name - FROM order_items oi - WHERE oi.canonical_product_id IS NULL - AND btrim(COALESCE(oi.normalized_sku_name, '')) <> '' - AND EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id) - ), - resolved AS ( - SELECT t.id, p.id AS canonical_product_id - FROM tail t - JOIN LATERAL ( - SELECT id - FROM product_catalog - WHERE status = 'active' AND canonical_name = t.normalized_sku_name - ORDER BY (canonical_sku LIKE 'tpl:%') DESC, id - LIMIT 1 - ) p ON TRUE - ) - UPDATE order_items oi - SET canonical_product_id = r.canonical_product_id, - updated_at = $1 - FROM resolved r - WHERE oi.id = r.id - AND oi.canonical_product_id IS NULL - RETURNING oi.id - `, - [now], - ) - - const row = result.rows[0] - const workOrderItemCount = Number(row?.work_order_item_count || 0) - const ruleMatchedCount = Number(row?.matched_count || 0) - const ruleUpdatedCount = Number(row?.updated_count || 0) - const tailProductCount = tailProducts.rows.filter((item) => item.inserted).length - const tailMappingCount = tailMappings.rows.length - const tailUpdatedCount = tailUpdated.rows.length - const updatedCount = ruleUpdatedCount + tailUpdatedCount - const remainingResult = await query<{ total: number }>( - ` - SELECT COUNT(*)::int AS total - FROM order_items oi - WHERE oi.canonical_product_id IS NULL - AND EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id) - `, - ) - const remainingCount = Number(remainingResult.rows[0]?.total || 0) - const matchedCount = Math.max(0, workOrderItemCount - remainingCount) - const skippedCount = Math.max(0, matchedCount - updatedCount) - - let factsRebuild: Awaited> | null = null - if (matchedCount > 0 && row?.from_date && row?.to_date) { - factsRebuild = await rebuildFactsForRange(row.from_date, row.to_date) - } - - return { - updatedCount, - skippedCount, - remainingCount, - workOrderItemCount, - matchedCount, - ruleMatchedCount, - tailUpdatedCount, - tailProductCount, - tailMappingCount, - factsRebuild, - } -} - -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 - product_name: string - category: string - unit_price_fen: number - reward_amount: number - mapping_id: number | null - mapping_type: string - sku_nick: string - rel_sku_id: string - seller_ids_json: unknown -} - -export type TemplateBootstrapEvidenceRow = { - provider: string - shop_id: string - sku_code: string - sku_name: string - product_rule_id: number - rule_key: string - item_count: number -} - -export type TemplateMappingMatchType = - | 'sku_code' - | 'sku_code_name' - | 'sku_code_series' - | 'sku_name_series' - -export type TemplateMappingCandidate = { - provider: string - shopId: string - skuCode: string - skuName: string - skuNameKey: string - matchType: TemplateMappingMatchType - ruleId: number - ruleKey: string - itemCount: number -} - -export type TemplateMappingConflict = { - provider: string - shopId: string - skuCode: string - skuName: string - matchType: 'sku_code_name' - itemCount: number - ruleIds: number[] - ruleKeys: string[] -} - -export type TemplateMappingPlan = { - candidates: TemplateMappingCandidate[] - conflicts: TemplateMappingConflict[] - evidenceItemCount: number - unresolvedItemCount: number - codeConflictKeyCount: number - seriesFallbackConflictCount: number -} - -/** - * 将历史工单的直接 rule_id 证据转换为渠道映射候选。 - * - * 编码唯一时直接按编码映射;编码被复用(91 常见)时,只有 - * 编码 + SKU 名称系列 / 编码 + 精确名称唯一才生成映射。 - * 多层仍无法唯一归属的组合进入 conflicts,绝不按“最新工单”猜测。 - */ -export function buildTemplateMappingPlan( - rows: TemplateBootstrapEvidenceRow[], -): TemplateMappingPlan { - const candidates = new Map() - const conflicts = new Map() - const evidenceItemCount = sumItemCount(rows) - let unresolvedItemCount = 0 - let codeConflictKeyCount = 0 - let seriesFallbackConflictCount = 0 - - function addCandidate( - input: Omit & { - itemCount: number - sampleSkuName: string - }, - ) { - const key = candidateKey( - input.provider, - input.shopId, - input.skuCode, - input.matchType, - input.skuNameKey, - ) - const current = candidates.get(key) - if (current) { - current.itemCount += input.itemCount - if (input.itemCount > current.bestSampleCount) { - current.skuName = input.sampleSkuName - current.bestSampleCount = input.itemCount - } - return - } - candidates.set(key, { - provider: input.provider, - shopId: input.shopId, - skuCode: input.skuCode, - skuName: input.sampleSkuName, - skuNameKey: input.skuNameKey, - matchType: input.matchType, - ruleId: input.ruleId, - ruleKey: input.ruleKey, - itemCount: input.itemCount, - bestSampleCount: input.itemCount, - }) - } - - function addConflict(input: { - provider: string - shopId: string - skuCode: string - skuName: string - nameKey: string - itemCount: number - ruleIds: number[] - ruleKeys: string[] - }) { - const key = candidateKey( - input.provider, - input.shopId, - input.skuCode, - 'sku_code_name', - input.nameKey, - ) - const current = conflicts.get(key) - if (current) { - current.itemCount += input.itemCount - current.ruleIds = Array.from(new Set([...current.ruleIds, ...input.ruleIds])).sort( - (left, right) => left - right, - ) - current.ruleKeys = Array.from(new Set([...current.ruleKeys, ...input.ruleKeys])).sort() - return - } - conflicts.set(key, { - provider: input.provider, - shopId: input.shopId, - skuCode: input.skuCode, - skuName: input.skuName, - matchType: 'sku_code_name', - itemCount: input.itemCount, - ruleIds: [...input.ruleIds].sort((left, right) => left - right), - ruleKeys: [...input.ruleKeys].sort(), - }) - } - - const codeGroups = groupRows(rows, (row) => [row.provider, row.shop_id, row.sku_code]) - for (const codeRows of codeGroups.values()) { - const codeRuleIds = uniqueRuleIds(codeRows) - if (codeRuleIds.length === 1) { - const sample = representativeRow(codeRows) - addCandidate({ - provider: sample.provider, - shopId: sample.shop_id, - skuCode: sample.sku_code, - skuName: sample.sku_name, - skuNameKey: '', - matchType: 'sku_code', - ruleId: Number(sample.product_rule_id), - ruleKey: sample.rule_key, - itemCount: sumItemCount(codeRows), - sampleSkuName: sample.sku_name, - }) - continue - } - - codeConflictKeyCount += 1 - const nameGroups = groupRows(codeRows, (row) => [ - normalizeSkuEvidenceName(row.sku_name, row.sku_code), - ]) - for (const nameRows of nameGroups.values()) { - const nameRuleIds = uniqueRuleIds(nameRows) - const sample = representativeRow(nameRows) - const nameKey = normalizeSkuEvidenceName(sample.sku_name, sample.sku_code) - if (nameRuleIds.length === 1) { - addCandidate({ - provider: sample.provider, - shopId: sample.shop_id, - skuCode: sample.sku_code, - skuName: sample.sku_name, - skuNameKey: nameKey, - matchType: 'sku_code_name', - ruleId: Number(sample.product_rule_id), - ruleKey: sample.rule_key, - itemCount: sumItemCount(nameRows), - sampleSkuName: sample.sku_name, - }) - } else { - const itemCount = sumItemCount(nameRows) - unresolvedItemCount += itemCount - addConflict({ - provider: sample.provider, - shopId: sample.shop_id, - skuCode: sample.sku_code, - skuName: sample.sku_name, - nameKey, - itemCount, - ruleIds: nameRuleIds, - ruleKeys: uniqueRuleKeys(nameRows), - }) - } - } - - const seriesGroups = groupRows(codeRows, (row) => [ - normalizeSkuEvidenceSeries(row.sku_name, row.sku_code), - ]) - for (const seriesRows of seriesGroups.values()) { - const seriesRuleIds = uniqueRuleIds(seriesRows) - if (seriesRuleIds.length !== 1) { - seriesFallbackConflictCount += 1 - continue - } - const sample = representativeRow(seriesRows) - addCandidate({ - provider: sample.provider, - shopId: sample.shop_id, - skuCode: sample.sku_code, - skuName: sample.sku_name, - skuNameKey: normalizeSkuEvidenceSeries(sample.sku_name, sample.sku_code), - matchType: 'sku_code_series', - ruleId: Number(sample.product_rule_id), - ruleKey: sample.rule_key, - itemCount: sumItemCount(seriesRows), - sampleSkuName: sample.sku_name, - }) - } - } - - // 商品名系列兜底:仅在同一来源 + 店铺内唯一归属时生成,供后续新编码使用。 - const globalSeriesGroups = groupRows(rows, (row) => [ - row.provider, - row.shop_id, - normalizeSkuEvidenceSeries(row.sku_name, row.sku_code), - ]) - for (const seriesRows of globalSeriesGroups.values()) { - const seriesRuleIds = uniqueRuleIds(seriesRows) - const sample = representativeRow(seriesRows) - const seriesKey = normalizeSkuEvidenceSeries(sample.sku_name, sample.sku_code) - if (!seriesKey) continue - if (seriesRuleIds.length !== 1) { - seriesFallbackConflictCount += 1 - continue - } - addCandidate({ - provider: sample.provider, - shopId: sample.shop_id, - skuCode: '', - skuName: sample.sku_name, - skuNameKey: seriesKey, - matchType: 'sku_name_series', - ruleId: Number(sample.product_rule_id), - ruleKey: sample.rule_key, - itemCount: sumItemCount(seriesRows), - sampleSkuName: sample.sku_name, - }) - } - - return { - candidates: Array.from(candidates.values()) - .map(({ bestSampleCount: _bestSampleCount, ...row }) => row) - .sort(compareMappingCandidates), - conflicts: Array.from(conflicts.values()).sort( - (left, right) => - right.itemCount - left.itemCount || - left.provider.localeCompare(right.provider) || - left.shopId.localeCompare(right.shopId) || - left.skuCode.localeCompare(right.skuCode), - ), - evidenceItemCount, - unresolvedItemCount, - codeConflictKeyCount, - seriesFallbackConflictCount, - } -} - -function countMappingCandidates(rows: TemplateMappingCandidate[]) { - const counts: Record = { - sku_code: 0, - sku_code_name: 0, - sku_code_series: 0, - sku_name_series: 0, - } - for (const row of rows) counts[row.matchType] += 1 - return counts -} - -function groupRows(rows: T[], keyOf: (row: T) => Array) { - const groups = new Map() - for (const row of rows) { - const key = keyOf(row).map(String).join('\u0000') - const list = groups.get(key) - if (list) list.push(row) - else groups.set(key, [row]) - } - return groups -} - -function uniqueRuleIds(rows: TemplateBootstrapEvidenceRow[]) { - return Array.from(new Set(rows.map((row) => Number(row.product_rule_id)))).sort( - (left, right) => left - right, - ) -} - -function uniqueRuleKeys(rows: TemplateBootstrapEvidenceRow[]) { - return Array.from(new Set(rows.map((row) => row.rule_key).filter(Boolean))).sort() -} - -function sumItemCount(rows: TemplateBootstrapEvidenceRow[]) { - return rows.reduce((sum, row) => sum + Math.max(0, Number(row.item_count) || 0), 0) -} - -function representativeRow(rows: TemplateBootstrapEvidenceRow[]) { - return rows.reduce((best, row) => (Number(row.item_count) > Number(best.item_count) ? row : best)) -} - -function normalizeSkuEvidenceName(skuName: string, skuCode: string) { - return normalizeMatchText(skuName || skuCode) -} - -function normalizeSkuEvidenceSeries(skuName: string, skuCode: string) { - return normalizeSkuSeriesEquivalence(skuName || skuCode) -} - -function compareMappingCandidates(left: TemplateMappingCandidate, right: TemplateMappingCandidate) { - const typeOrder: Record = { - sku_code: 1, - sku_code_name: 2, - sku_code_series: 3, - sku_name_series: 4, - } - return ( - right.itemCount - left.itemCount || - typeOrder[left.matchType] - typeOrder[right.matchType] || - left.provider.localeCompare(right.provider) || - left.shopId.localeCompare(right.shopId) || - left.skuCode.localeCompare(right.skuCode) || - left.skuNameKey.localeCompare(right.skuNameKey) - ) -} - -async function resetImportedTemplateProductBootstrap() { - let resetOrderItemCount = 0 - for (let batch = 0; batch < 20; batch += 1) { - const result = await query<{ id: number }>( - ` - WITH candidates AS ( - SELECT oi.id - FROM order_items oi - JOIN orders o ON o.id = oi.order_id - JOIN channel_product_mappings m - ON m.provider = o.provider - AND (m.shop_id = '' OR m.shop_id = o.shop_id) - AND oi.canonical_product_id = m.canonical_product_id - AND m.source = 'import' - AND m.note LIKE '接单模板 %自动初始化%' - AND ( - (m.match_type = 'sku_code' AND m.platform_sku_code = oi.sku_code) - OR ( - m.match_type = 'sku_code_name' - AND m.platform_sku_code = oi.sku_code - AND m.platform_sku_name_key = oi.normalized_sku_name - ) - OR ( - m.match_type = 'sku_code_series' - AND m.platform_sku_code = oi.sku_code - AND m.platform_sku_name_key = oi.normalized_sku_series - ) - OR ( - m.match_type = 'sku_name_series' - AND m.platform_sku_name_key = oi.normalized_sku_series - ) - ) - WHERE oi.canonical_product_id IS NOT NULL - LIMIT 20000 - ) - UPDATE order_items oi - SET canonical_product_id = NULL, - updated_at = NOW() - FROM candidates c - WHERE oi.id = c.id - RETURNING oi.id - `, - ) - resetOrderItemCount += result.rowCount || 0 - if ((result.rowCount || 0) < 20000) break - } - const deleted = await query( - ` - DELETE FROM channel_product_mappings - WHERE source = 'import' - AND note LIKE '接单模板 %自动初始化%' - `, - ) - return { deletedMappingCount: deleted.rowCount || 0, resetOrderItemCount } -} - -async function loadTemplateProductBootstrapData() { - const [templatesResult, evidenceResult, productsResult, mappingsResult] = await Promise.all([ - query( - ` - SELECT - r.id, - r.rule_key, - r.product_name, - COALESCE(c.name, '') AS category, - r.unit_price_fen, - r.reward_amount, - m.id AS mapping_id, - COALESCE(m.mapping_type, '') AS mapping_type, - COALESCE(m.sku_nick, '') AS sku_nick, - COALESCE(m.rel_sku_id, '') AS rel_sku_id, - COALESCE(m.seller_ids_json, '[]'::jsonb) AS seller_ids_json - FROM work_product_rules r - LEFT JOIN work_categories c ON c.id = r.category_id - LEFT JOIN LATERAL ( - SELECT wm.* - FROM work_product_rule_mappings wm - WHERE wm.rule_id = r.id AND wm.enabled = TRUE - ORDER BY wm.id - LIMIT 1 - ) m ON TRUE - WHERE r.enabled = TRUE - ORDER BY r.id - `, - ), - query( - ` - SELECT - o.provider, - o.shop_id, - oi.sku_code, - oi.sku_name, - wo.product_rule_id, - r.rule_key, - COUNT(DISTINCT oi.id)::int AS item_count - FROM order_items oi - JOIN orders o ON o.id = oi.order_id - JOIN work_orders wo ON wo.order_item_id = oi.id - JOIN work_product_rules r ON r.id = wo.product_rule_id AND r.enabled = TRUE - WHERE wo.product_rule_id IS NOT NULL - GROUP BY - o.provider, - o.shop_id, - oi.sku_code, - oi.sku_name, - wo.product_rule_id, - r.rule_key - ORDER BY o.provider, o.shop_id, oi.sku_code, oi.sku_name, wo.product_rule_id - `, - ), - query<{ id: number; canonical_sku: string; canonical_name: string }>( - 'SELECT id, canonical_sku, canonical_name FROM product_catalog', - ), - query<{ - provider: string - shop_id: string - platform_sku_code: string - platform_sku_name_key: string - match_type: string - }>( - `SELECT provider, shop_id, platform_sku_code, platform_sku_name_key, match_type - FROM channel_product_mappings`, - ), - ]) - - const templates = templatesResult.rows - const productByCanonicalSku = new Map( - productsResult.rows.map((row) => [row.canonical_sku, Number(row.id)]), - ) - const productByCanonicalName = new Map() - for (const row of productsResult.rows) { - const key = normalizeProductNameKey(row.canonical_name) - if (key && !productByCanonicalName.has(key)) { - productByCanonicalName.set(key, Number(row.id)) - } - } - const productByRuleKey = new Map() - const productsToCreate: TemplateBootstrapTemplateRow[] = [] - for (const template of templates) { - const existing = - productByCanonicalSku.get(buildTemplateProductSku(template.rule_key)) || - productByCanonicalName.get(normalizeProductNameKey(template.product_name)) - if (existing) { - productByRuleKey.set(template.rule_key, existing) - } else { - productsToCreate.push(template) - } - } - - return { - templates, - evidenceRows: evidenceResult.rows, - productsToCreate, - productByRuleKey, - existingMappingKeys: new Set( - mappingsResult.rows.map((row) => - mappingKey( - row.provider, - row.shop_id, - row.platform_sku_code, - row.match_type, - row.platform_sku_name_key, - ), - ), - ), - } -} - -export function buildTemplateProductSku(ruleKey: string) { - return `tpl:${String(ruleKey || '').trim()}`.slice(0, 64) -} - -export function inferTemplateUnitName(productName: string) { - const name = String(productName || '').trim() - if (/钻石|颗/.test(name)) return '颗' - if (/枪|把/.test(name)) return '把' - if (/套/.test(name)) return '套' - if (/密钥|秘钥/.test(name)) return '个' - return '件' -} - -function normalizeProductNameKey(value: unknown) { - return String(value || '') - .normalize('NFKC') - .trim() - .toLowerCase() -} - -function candidateKey( - provider: string, - shopId: string, - skuCode: string, - matchType: string, - skuNameKey: string, -) { - return `${provider}\u0000${shopId}\u0000${skuCode}\u0000${matchType}\u0000${skuNameKey}` -} - -function mappingKey( - provider: string, - shopId: string, - skuCode: string, - matchType = 'sku_code', - skuNameKey = '', -) { - return candidateKey(provider, shopId, skuCode, matchType, skuNameKey) -} - -function mappingMatchTypeLabel(matchType: TemplateMappingMatchType) { - switch (matchType) { - case 'sku_code': - return 'SKU 编码' - case 'sku_code_name': - return 'SKU 编码 + 精确名称' - case 'sku_code_series': - return 'SKU 编码 + 名称系列' - case 'sku_name_series': - return 'SKU 名称系列' - } -} - -export function normalizeMappingMatchType( - value: unknown, - fallback: TemplateMappingMatchType = 'sku_code', -): TemplateMappingMatchType { - const normalized = text(value) - if (!normalized) return fallback - if (!MAPPING_MATCH_TYPES.has(normalized)) { - throw createHttpError('映射匹配方式无效', { - statusCode: 400, - errorCode: 'cost_mapping_match_type_invalid', - }) - } - return normalized as TemplateMappingMatchType -} - -export function resolveMappingNameKey( - matchType: TemplateMappingMatchType, - platformSkuName: string, -) { - if (matchType === 'sku_code') return '' - if (matchType === 'sku_code_name') return normalizeMatchText(platformSkuName) - return normalizeSkuSeriesEquivalence(platformSkuName) -} - -export async function listAdminCostItems(payload: JsonObject = {}) { - const page = normalizePage(payload.page) - const pageSize = normalizePageSize(payload.pageSize) - const offset = (page - 1) * pageSize - const params: unknown[] = [] - const clauses: string[] = [] - - const keyword = text(payload.keyword) - if (keyword) { - params.push(`%${keyword}%`) - clauses.push(`(ci.code ILIKE $${params.length} OR ci.name ILIKE $${params.length})`) - } - const costType = text(payload.costType) - if (costType) { - assertOneOf(costType, COST_TYPES, '成本类型无效') - params.push(costType) - clauses.push(`ci.cost_type = $${params.length}`) - } - const carrierType = text(payload.carrierType) - if (carrierType) { - assertOneOf(carrierType, CARRIER_TYPES, '成本载体无效') - params.push(carrierType) - clauses.push(`ci.carrier_type = $${params.length}`) - } - const status = text(payload.status) - if (status) { - assertOneOf(status, PRODUCT_STATUSES, '成本项状态无效') - params.push(status) - clauses.push(`ci.status = $${params.length}`) - } - - const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '' - const countResult = await query<{ total: number }>( - `SELECT COUNT(*)::int AS total FROM cost_items ci ${where}`, - params, - ) - const listParams = [...params, pageSize, offset] - const result = await query( - ` - SELECT - ci.*, - (SELECT COUNT(*)::int FROM cost_versions v WHERE v.cost_item_id = ci.id) AS version_count - FROM cost_items ci - ${where} - ORDER BY ci.status ASC, ci.carrier_type ASC, ci.name ASC, ci.id ASC - LIMIT $${listParams.length - 1} OFFSET $${listParams.length} - `, - listParams, - ) - - return { - items: result.rows.map(mapCostItem), - pagination: { page, pageSize, total: toNumber(countResult.rows[0]?.total) }, - } -} - -export async function createAdminCostItem(payload: JsonObject, _session: AdminSession) { - const name = normalizeRequiredText(payload.name, '成本项名称', 120) - const costType = normalizeStatus(payload.costType, COST_TYPES, null, '成本类型无效') - const carrierType = normalizeStatus(payload.carrierType, CARRIER_TYPES, null, '成本载体无效') - const pricingMode = normalizeStatus(payload.pricingMode, PRICING_MODES, null, '计价方式无效') - // 编码由系统生成(按成本类型 + 冲突序号);显式传入时仍优先使用,兼容脚本导入。 - const code = normalizeOptionalText(payload.code, 64) || (await generateCostItemCode(costType)) - const unitName = normalizeOptionalText(payload.unitName, 16) || '件' - const currency = normalizeOptionalText(payload.currency, 8) || 'CNY' - const status = normalizeStatus(payload.status, PRODUCT_STATUSES, 'active', '成本项状态无效') - const note = normalizeOptionalText(payload.note, 500) - const now = nowIso() - - const result = await query( - ` - INSERT INTO cost_items ( - code, name, cost_type, carrier_type, pricing_mode, unit_name, - currency, status, note, created_at, updated_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $10) - ON CONFLICT (code) DO NOTHING - RETURNING * - `, - [code, name, costType, carrierType, pricingMode, unitName, currency, status, note, now], - ) - const inserted = result.rows[0] - if (!inserted) { - throw createHttpError('成本项编码已存在', { - statusCode: 409, - errorCode: 'cost_item_code_exists', - }) - } - return { costItem: mapCostItem(inserted) } -} - -async function generateCostItemCode(costType: string) { - const base = COST_ITEM_CODE_BASE[costType] || 'cost_item' - let candidate = base - let index = 2 - for (let attempt = 0; attempt < 100; attempt += 1) { - const existing = await query<{ id: number }>( - 'SELECT id FROM cost_items WHERE code = $1 LIMIT 1', - [candidate], - ) - if (!existing.rows[0]) return candidate - candidate = `${base}_${index}` - index += 1 - } - return `${base}_${Date.now()}` -} - -export async function updateAdminCostItem(costItemId: number | string, payload: JsonObject) { - const id = normalizeId(costItemId, '成本项 ID') - const current = await getCostItemRow(id) - const name = normalizeRequiredText(payload.name ?? current.name, '成本项名称', 120) - const costType = normalizeStatus(payload.costType, COST_TYPES, current.cost_type, '成本类型无效') - const carrierType = normalizeStatus( - payload.carrierType, - CARRIER_TYPES, - current.carrier_type, - '成本载体无效', - ) - const pricingMode = normalizeStatus( - payload.pricingMode, - PRICING_MODES, - current.pricing_mode, - '计价方式无效', - ) - const unitName = - payload.unitName === undefined - ? current.unit_name - : normalizeOptionalText(payload.unitName, 16) || '件' - const currency = - payload.currency === undefined - ? current.currency - : normalizeOptionalText(payload.currency, 8) || 'CNY' - const status = normalizeStatus(payload.status, PRODUCT_STATUSES, current.status, '成本项状态无效') - const note = payload.note === undefined ? current.note : normalizeOptionalText(payload.note, 500) - - const result = await query( - ` - UPDATE cost_items - SET name = $1, cost_type = $2, carrier_type = $3, pricing_mode = $4, - unit_name = $5, currency = $6, status = $7, note = $8, updated_at = $9 - WHERE id = $10 - RETURNING * - `, - [name, costType, carrierType, pricingMode, unitName, currency, status, note, nowIso(), id], - ) - return { costItem: mapCostItem(requiredRow(result.rows[0], '成本项不存在')) } -} - -/** - * 删除成本项:存在成本版本、成本快照或接单规则引用时拒绝删除。 - */ -export async function deleteAdminCostItem(costItemId: number | string) { - const id = normalizeId(costItemId, '成本项 ID') - const current = await getCostItemRow(id) - const usageResult = await query<{ - version_count: number - snapshot_count: number - rule_count: number - }>( - ` - SELECT - (SELECT COUNT(*)::int FROM cost_versions WHERE cost_item_id = $1) AS version_count, - (SELECT COUNT(*)::int FROM cost_snapshots WHERE cost_item_id = $1) AS snapshot_count, - (SELECT COUNT(*)::int FROM work_product_rules WHERE cost_item_id = $1) AS rule_count - `, - [id], - ) - const usage = usageResult.rows[0] - const blockers: string[] = [] - if (toNumber(usage?.version_count) > 0) - blockers.push(`${toNumber(usage?.version_count)} 个成本版本`) - if (toNumber(usage?.snapshot_count) > 0) - blockers.push(`${toNumber(usage?.snapshot_count)} 条成本快照`) - if (toNumber(usage?.rule_count) > 0) blockers.push(`${toNumber(usage?.rule_count)} 个接单规则`) - if (blockers.length > 0) { - throw createHttpError(`成本项仍被引用(${blockers.join('、')}),不能删除;请改为归档`, { - statusCode: 409, - errorCode: 'cost_item_in_use', - }) - } - await query('DELETE FROM cost_items WHERE id = $1', [id]) - return { deleted: true, costItem: mapCostItem(current) } -} - -export async function listAdminCostVersions(payload: JsonObject = {}) { - const page = normalizePage(payload.page) - const pageSize = normalizePageSize(payload.pageSize) - const offset = (page - 1) * pageSize - const params: unknown[] = [] - const clauses: string[] = [] - - const costItemId = normalizeOptionalId(payload.costItemId) - if (costItemId) { - params.push(costItemId) - clauses.push(`v.cost_item_id = $${params.length}`) - } - const productId = normalizeOptionalId(payload.canonicalProductId) - if (productId) { - params.push(productId) - 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) { - params.push(normalizeProviderKey(provider)) - clauses.push(`v.provider = $${params.length}`) - } - const status = text(payload.status) - if (status) { - assertOneOf(status, COST_VERSION_STATUSES, '成本版本状态无效') - params.push(status) - clauses.push(`v.status = $${params.length}`) - } - const carrierType = text(payload.carrierType ?? payload.carrier_type) - if (carrierType) { - assertOneOf(carrierType, CARRIER_TYPES, '成本载体无效') - params.push(carrierType) - clauses.push(`ci.carrier_type = $${params.length}`) - } - const keyword = text(payload.keyword) - if (keyword) { - 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 wpr.product_name ILIKE $${params.length} OR wpr.rule_key ILIKE $${params.length})`, - ) - } - - const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '' - const countResult = await query<{ total: number }>( - ` - SELECT COUNT(*)::int AS total - 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, - ) - const listParams = [...params, pageSize, offset] - const result = await query( - ` - SELECT - v.*, - ci.code AS cost_item_code, - ci.name AS cost_item_name, - ci.carrier_type AS cost_item_carrier_type, - ci.pricing_mode, - p.canonical_sku, - 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, - 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, - ) - - return { - items: result.rows.map(mapCostVersion), - pagination: { page, pageSize, total: toNumber(countResult.rows[0]?.total) }, - } -} - -export async function createAdminCostVersion(payload: JsonObject, session: AdminSession) { - const costItemId = normalizeId(payload.costItemId ?? payload.cost_item_id, '成本项 ID') - const costItem = await getCostItemRow(costItemId) - const scopeType = normalizeStatus( - payload.scopeType, - COST_VERSION_SCOPES, - 'global', - '成本范围无效', - ) - const rawProvider = normalizeProviderKey(normalizeOptionalText(payload.provider, 64)) - if (rawProvider) { - assertKnownProvider(rawProvider) - } - const rawShopId = normalizeOptionalText(payload.shopId ?? payload.shop_id, 120) - const rawCanonicalProductId = normalizeOptionalId( - payload.canonicalProductId ?? payload.canonical_product_id, - ) - const rawWorkProductRuleId = normalizeOptionalId( - payload.workProductRuleId ?? payload.work_product_rule_id, - ) - const { provider, shopId, canonicalProductId, workProductRuleId } = - normalizeCostVersionScopeFields(scopeType, { - provider: rawProvider, - shopId: rawShopId, - canonicalProductId: rawCanonicalProductId, - workProductRuleId: rawWorkProductRuleId, - }) - validateCostVersionScope(scopeType, { - provider, - shopId, - canonicalProductId, - workProductRuleId, - }) - - const amountFen = normalizeNonNegativeInteger(payload.amountFen ?? payload.amount_fen, 0) - const rateBps = normalizeNonNegativeInteger(payload.rateBps ?? payload.rate_bps, 0) - if (rateBps > 10000) { - throw createHttpError('比例不能超过 10000 个基点(100%)', { - statusCode: 400, - errorCode: 'cost_version_rate_invalid', - }) - } - if (costItem.pricing_mode === 'percent_of_revenue' && rateBps <= 0) { - throw createHttpError('比例计价必须填写大于 0 的 rateBps', { - statusCode: 400, - errorCode: 'cost_version_rate_required', - }) - } - if (costItem.pricing_mode !== 'percent_of_revenue' && amountFen <= 0) { - throw createHttpError('非比例计价必须填写大于 0 的金额', { - statusCode: 400, - errorCode: 'cost_version_amount_required', - }) - } - - const effectiveFrom = normalizeIsoDate(payload.effectiveFrom, '生效时间') || nowIso() - const effectiveTo = normalizeIsoDate(payload.effectiveTo, '失效时间', true) - if (effectiveTo && Date.parse(effectiveTo) <= Date.parse(effectiveFrom)) { - throw createHttpError('失效时间必须晚于生效时间', { - statusCode: 400, - errorCode: 'cost_version_period_invalid', - }) - } - const source = normalizeStatus(payload.source, COST_VERSION_SOURCES, 'manual', '成本版本来源无效') - const status = normalizeStatus( - payload.status, - COST_VERSION_STATUSES, - 'active', - '成本版本状态无效', - ) - const changeReason = normalizeOptionalText(payload.changeReason ?? payload.change_reason, 500) - const now = nowIso() - - const result = await query( - ` - 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 - ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, - $11, $12, $13, $14, $14, $15, $15 - ) - ON CONFLICT DO NOTHING - RETURNING * - `, - [ - costItemId, - scopeType, - provider, - shopId, - canonicalProductId, - workProductRuleId, - amountFen, - rateBps, - effectiveFrom, - effectiveTo, - source, - status, - changeReason, - session.userId, - now, - ], - ) - const inserted = result.rows[0] - if (!inserted) { - throw createHttpError('同一成本项 + 范围 + 生效时间已有版本', { - statusCode: 409, - errorCode: 'cost_version_exists', - }) - } - return { version: await getCostVersionById(Number(inserted.id)) } -} - -export async function retireAdminCostVersion(versionId: number | string, payload: JsonObject = {}) { - const id = normalizeId(versionId, '成本版本 ID') - const reason = normalizeRequiredText( - payload.changeReason ?? payload.change_reason ?? payload.reason, - '停用原因', - 500, - ) - const result = await query<{ id: number }>( - ` - UPDATE cost_versions - SET status = 'retired', change_reason = $1, updated_at = $2 - WHERE id = $3 - RETURNING id - `, - [reason, nowIso(), id], - ) - if (!result.rows[0]) { - throw createHttpError('成本版本不存在', { - statusCode: 404, - errorCode: 'cost_version_not_found', - }) - } - return { version: await getCostVersionById(id) } -} - -/** - * 删除成本版本:生效中的版本必须先停用;已被成本快照引用的版本不可删除。 - */ -export async function deleteAdminCostVersion(versionId: number | string) { - const id = normalizeId(versionId, '成本版本 ID') - const current = await getCostVersionById(id) - if (current.status === 'active') { - throw createHttpError('生效中的成本版本不能删除,请先停用', { - statusCode: 409, - errorCode: 'cost_version_active', - }) - } - const snapshotResult = await query<{ total: number }>( - 'SELECT COUNT(*)::int AS total FROM cost_snapshots WHERE cost_version_id = $1', - [id], - ) - const snapshotCount = toNumber(snapshotResult.rows[0]?.total) - if (snapshotCount > 0) { - throw createHttpError(`该成本版本已被 ${snapshotCount} 条成本快照引用,不能删除`, { - statusCode: 409, - errorCode: 'cost_version_in_use', - }) - } - await query('DELETE FROM cost_versions WHERE id = $1', [id]) - return { deleted: true, version: current } -} - -export async function listAdminCostExceptions(payload: JsonObject = {}) { - const page = normalizePage(payload.page) - const pageSize = normalizePageSize(payload.pageSize) - const offset = (page - 1) * pageSize - const params: unknown[] = [] - const clauses: string[] = [] - - const status = text(payload.status) - if (status) { - assertOneOf(status, COST_EXCEPTION_STATUSES, '异常状态无效') - params.push(status) - clauses.push(`ce.status = $${params.length}`) - } - const subjectType = text(payload.subjectType) - if (subjectType) { - assertOneOf(subjectType, new Set(['order_item', 'work_order', 'manual_case']), '异常主体无效') - params.push(subjectType) - clauses.push(`ce.subject_type = $${params.length}`) - } - const carrierType = text(payload.carrierType ?? payload.carrier_type) - if (carrierType) { - assertOneOf(carrierType, CARRIER_TYPES, '异常载体无效') - params.push(carrierType) - clauses.push(`ce.carrier_type = $${params.length}`) - } - - const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '' - const countResult = await query<{ total: number }>( - `SELECT COUNT(*)::int AS total FROM cost_exceptions ce ${where}`, - params, - ) - const listParams = [...params, pageSize, offset] - const result = await query( - ` - SELECT ce.* - FROM cost_exceptions ce - ${where} - ORDER BY ce.status ASC, ce.created_at DESC, ce.id DESC - LIMIT $${listParams.length - 1} OFFSET $${listParams.length} - `, - listParams, - ) - - return { - items: result.rows.map(mapCostException), - pagination: { page, pageSize, total: toNumber(countResult.rows[0]?.total) }, - } -} - -export async function resolveAdminCostException( - exceptionId: number | string, - payload: JsonObject, - session: AdminSession, -) { - const id = normalizeId(exceptionId, '异常 ID') - const status = normalizeStatus( - payload.status, - COST_EXCEPTION_STATUSES, - 'resolved', - '异常状态无效', - ) - if (status === 'open') { - throw createHttpError('不能把异常重新置为 open', { - statusCode: 400, - errorCode: 'cost_exception_status_invalid', - }) - } - const now = nowIso() - const result = await query( - ` - UPDATE cost_exceptions - SET status = $1, resolved_at = $2, resolved_by_admin_user_id = $3 - WHERE id = $4 - RETURNING * - `, - [status, now, session.userId, id], - ) - return { exception: mapCostException(requiredRow(result.rows[0], '成本异常不存在')) } -} - -export async function previewAdminCost(payload: JsonObject = {}) { - const context = await resolveCostContext(payload) - const carrierType = normalizeCarrierType(payload.carrierType, context) - const mapping = await findMappingForContext(context) - const productId = context.canonicalProductId || mapping?.canonical_product_id || null - const product = productId ? mapProduct(await getProductRow(productId)) : null - const asOf = - normalizeIsoDate(payload.asOf ?? payload.costBasisAt, '成本基准时间', true) || context.basisAt - const resolution: CostResolution = productId - ? await resolveCostLines({ - provider: context.provider, - shopId: context.shopId, - carrierType, - canonicalProductId: productId, - workProductRuleId: context.workProductRuleId, - quantity: context.normalizedQuantity, - revenueFen: context.revenueFen, - asOf, - }) - : { lines: [], missingCostItems: [], applicableCostItemCount: 0 } - - const exceptions: CostExceptionDraft[] = [] - if (!productId) { - exceptions.push({ - reason: 'product_mapping_missing', - message: `未找到 ${context.provider} / ${context.shopId || '全局'} / ${context.skuCode} 的标准商品映射`, - detail: { - provider: context.provider, - shopId: context.shopId, - skuCode: context.skuCode, - }, - }) - } else if (resolution.missingCostItems.length > 0) { - exceptions.push({ - reason: 'cost_missing', - message: `以下成本项没有匹配到有效成本版本:${resolution.missingCostItems - .map((item) => `${item.costItemName}(${item.costItemCode})`) - .join('、')}`, - detail: { - carrierType, - costItemIds: resolution.missingCostItems.map((item) => item.costItemId), - }, - }) - } else if (resolution.applicableCostItemCount === 0) { - exceptions.push({ - reason: 'cost_missing', - message: `没有配置适用于「${carrierType}」载体的成本项`, - detail: { carrierType }, - }) - } - - return { - context: { - ...context, - basisAt: asOf, - carrierType, - }, - mapping: mapping ? mapMapping(mapping) : null, - product, - lines: resolution.lines, - missingCostItems: resolution.missingCostItems, - totalCostFen: resolution.lines.reduce((sum, line) => sum + line.totalAmountFen, 0), - exceptions, - } -} - -export async function createAdminCostSnapshot(payload: JsonObject = {}, _session: AdminSession) { - const preview = await previewAdminCost(payload) - const orderItemId = preview.context.orderItemId - if (!orderItemId) { - throw createHttpError('生成成本快照必须提供订单项 ID', { - statusCode: 400, - errorCode: 'cost_snapshot_order_item_required', - }) - } - const now = nowIso() - const stage = payload.snapshotStage === 'actual' ? 'actual' : 'estimated' - const snapshots: Array> = [] - let exceptionCreatedCount = 0 - let supersededCount = 0 - - await withTransaction(async (client) => { - for (const line of preview.lines) { - const currentResult = await client.query<{ - id: number - snapshot_stage: string - total_amount_fen: number - }>( - ` - SELECT id, snapshot_stage, total_amount_fen - FROM cost_snapshots - WHERE subject_type = 'order_item' - AND subject_id = $1 - AND cost_item_id = $2 - AND superseded_at IS NULL - FOR UPDATE - `, - [orderItemId, line.costItemId], - ) - const current = currentResult.rows[0] - if (current) { - // 预估快照一旦生成就代表支付时点的成本,不因后续改价重写; - // 只有实际成本允许替代当前有效行。 - if (stage === 'estimated') continue - if ( - current.snapshot_stage === 'actual' && - Number(current.total_amount_fen) === line.totalAmountFen - ) { - continue - } - await client.query( - `UPDATE cost_snapshots SET superseded_at = $1 WHERE id = $2 AND superseded_at IS NULL`, - [now, current.id], - ) - supersededCount += 1 - } - - const result = await client.query<{ [column: string]: unknown }>( - ` - INSERT INTO cost_snapshots ( - subject_type, subject_id, order_id, order_item_id, work_order_id, - carrier_type, cost_item_id, cost_version_id, canonical_product_id, - quantity, unit_amount_fen, total_amount_fen, snapshot_stage, source, - metadata_json, cost_basis_at, resolved_at, created_at - ) VALUES ( - 'order_item', $1, $2, $1, $3, $4, $5, $6, $7, - $8, $9, $10, $11, 'resolved', $12::jsonb, $13, $14, $14 - ) - ON CONFLICT (subject_type, subject_id, cost_item_id) - WHERE superseded_at IS NULL - DO NOTHING - RETURNING * - `, - [ - orderItemId, - preview.context.orderId, - preview.context.workOrderId, - preview.context.carrierType, - line.costItemId, - line.costVersionId, - preview.product?.id || null, - line.quantity, - line.unitAmountFen, - line.totalAmountFen, - stage, - JSON.stringify({ - skuCode: preview.context.skuCode, - skuName: preview.context.skuName, - provider: preview.context.provider, - shopId: preview.context.shopId, - normalizedQuantity: preview.context.normalizedQuantity, - basisAt: preview.context.basisAt, - }), - preview.context.basisAt, - now, - ], - ) - if (result.rows[0]) snapshots.push(result.rows[0]) - } - - for (const exception of preview.exceptions) { - const result = await client.query( - ` - INSERT INTO cost_exceptions ( - subject_type, subject_id, order_item_id, work_order_id, - carrier_type, reason, status, detail_json, created_at - ) VALUES ('order_item', $1, $1, $2, $3, $4, 'open', $5::jsonb, $6) - ON CONFLICT (subject_type, subject_id, reason) DO NOTHING - `, - [ - orderItemId, - preview.context.workOrderId, - preview.context.carrierType, - exception.reason, - JSON.stringify({ - ...exception.detail, - message: exception.message, - skuCode: preview.context.skuCode, - }), - now, - ], - ) - exceptionCreatedCount += result.rowCount || 0 - } - }) - - return { - preview, - snapshots, - createdCount: snapshots.length, - supersededCount, - exceptionCreatedCount, - } -} - -async function resolveCostContext(payload: JsonObject): Promise { - const orderItemId = normalizeOptionalId(payload.orderItemId ?? payload.order_item_id) - if (orderItemId) { - const result = await query<{ - order_item_id: number - order_id: number - provider: string - shop_id: string - sku_code: string - sku_name: string - normalized_sku_name: string | null - normalized_sku_series: string | null - quantity: number - normalized_quantity: number | null - canonical_product_id: number | null - revenue_fen: number - basis_at: string - work_order_id: number | null - work_product_rule_id: number | null - has_affiliate_dash_task: boolean - }>( - ` - SELECT - oi.id AS order_item_id, - oi.order_id, - o.provider, - o.shop_id, - oi.sku_code, - oi.sku_name, - oi.normalized_sku_name, - oi.normalized_sku_series, - oi.quantity, - oi.normalized_quantity, - oi.canonical_product_id, - oi.revenue_amount_fen AS revenue_fen, - COALESCE(o.paid_at, o.created_at) AS basis_at, - wo.id AS work_order_id, - wo.product_rule_id AS work_product_rule_id, - EXISTS ( - SELECT 1 FROM fulfillment_tasks ft - WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash' - ) AS has_affiliate_dash_task - FROM order_items oi - JOIN orders o ON o.id = oi.order_id - LEFT JOIN LATERAL ( - SELECT id, product_rule_id - FROM work_orders - WHERE order_item_id = oi.id - ORDER BY id DESC - LIMIT 1 - ) wo ON TRUE - WHERE oi.id = $1 - LIMIT 1 - `, - [orderItemId], - ) - const row = result.rows[0] - if (!row) { - throw createHttpError('订单项不存在', { - statusCode: 404, - errorCode: 'cost_order_item_not_found', - }) - } - return { - orderItemId: Number(row.order_item_id), - orderId: Number(row.order_id), - provider: row.provider, - shopId: row.shop_id, - skuCode: row.sku_code, - skuName: row.sku_name, - normalizedSkuName: - row.normalized_sku_name || normalizeMatchText(row.sku_name || row.sku_code), - normalizedSkuSeries: - row.normalized_sku_series || normalizeSkuSeriesEquivalence(row.sku_name || row.sku_code), - quantity: Math.max(1, Number(row.quantity) || 1), - normalizedQuantity: - row.normalized_quantity && Number(row.normalized_quantity) > 0 - ? Number(row.normalized_quantity) - : Math.max(1, resolveSkuNameQuantity(row.sku_name || row.sku_code)), - canonicalProductId: row.canonical_product_id ? Number(row.canonical_product_id) : null, - revenueFen: Math.max(0, Number(row.revenue_fen) || 0), - basisAt: new Date(row.basis_at).toISOString(), - workOrderId: row.work_order_id ? Number(row.work_order_id) : null, - workProductRuleId: row.work_product_rule_id ? Number(row.work_product_rule_id) : null, - hasAffiliateDashTask: Boolean(row.has_affiliate_dash_task), - } - } - - const provider = normalizeRequiredText(payload.provider, '来源', 64) - const skuCode = normalizeRequiredText(payload.skuCode ?? payload.sku_code, '平台 SKU', 120) - const quantity = Math.max(1, Math.trunc(Number(payload.quantity) || 1)) - const skuName = normalizeOptionalText(payload.skuName ?? payload.sku_name, 200) - return { - orderItemId: 0, - orderId: 0, - provider, - shopId: normalizeOptionalText(payload.shopId ?? payload.shop_id, 120), - skuCode, - skuName, - normalizedSkuName: normalizeMatchText(skuName || skuCode), - normalizedSkuSeries: normalizeSkuSeriesEquivalence(skuName || skuCode), - quantity, - normalizedQuantity: quantity, - canonicalProductId: normalizeOptionalId( - payload.canonicalProductId ?? payload.canonical_product_id, - ), - revenueFen: Math.max(0, Math.trunc(Number(payload.revenueFen ?? payload.revenue_fen) || 0)), - basisAt: - normalizeIsoDate(payload.asOf ?? payload.costBasisAt, '成本基准时间', true) || nowIso(), - workOrderId: null, - workProductRuleId: normalizeOptionalId( - payload.workProductRuleId ?? payload.work_product_rule_id, - ), - hasAffiliateDashTask: false, - } -} - -async function findMappingForContext(context: OrderItemCostContext) { - const result = await query( - ` - SELECT m.*, p.canonical_sku, p.canonical_name - FROM channel_product_mappings m - JOIN product_catalog p ON p.id = m.canonical_product_id AND p.status = 'active' - WHERE m.provider = $1 - AND m.enabled = TRUE - AND (m.shop_id = '' OR m.shop_id = $3) - AND ( - (m.match_type = 'sku_code' AND m.platform_sku_code = $2) - OR ( - m.match_type = 'sku_code_name' - AND m.platform_sku_code = $2 - AND m.platform_sku_name_key = $4 - ) - OR ( - m.match_type = 'sku_code_series' - AND m.platform_sku_code = $2 - AND m.platform_sku_name_key = $5 - ) - OR (m.match_type = 'sku_name_series' AND m.platform_sku_name_key = $5) - ) - ORDER BY - CASE - WHEN m.match_type = 'sku_code' AND m.shop_id = $3 THEN 1 - WHEN m.match_type = 'sku_code' AND m.shop_id = '' THEN 2 - WHEN m.match_type = 'sku_code_name' AND m.shop_id = $3 THEN 3 - WHEN m.match_type = 'sku_code_name' AND m.shop_id = '' THEN 4 - WHEN m.match_type = 'sku_code_series' AND m.shop_id = $3 THEN 5 - WHEN m.match_type = 'sku_code_series' AND m.shop_id = '' THEN 6 - WHEN m.match_type = 'sku_name_series' AND m.shop_id = $3 THEN 7 - ELSE 8 - END, - m.id DESC - LIMIT 1 - `, - [ - context.provider, - context.skuCode, - context.shopId, - context.normalizedSkuName, - context.normalizedSkuSeries, - ], - ) - return result.rows[0] || null -} - -async function resolveCostLines(input: { - provider: string - shopId: string - carrierType: string - canonicalProductId: number - workProductRuleId: number | null - quantity: number - revenueFen: number - asOf?: string -}): Promise { - const result = await query< - CostItemRow & { - version_id: number | null - version_scope: string | null - version_amount_fen: number | null - version_rate_bps: number | null - } - >( - ` - SELECT - ci.*, - v.id AS version_id, - v.scope_type AS version_scope, - v.amount_fen AS version_amount_fen, - v.rate_bps AS version_rate_bps - FROM cost_items ci - LEFT JOIN LATERAL ( - SELECT v.* - FROM cost_versions v - WHERE v.cost_item_id = ci.id - AND v.status = 'active' - AND v.effective_from <= $1::timestamptz - AND (v.effective_to IS NULL OR v.effective_to > $1::timestamptz) - AND ( - (v.scope_type = 'rule' AND $5::bigint IS NOT NULL AND v.work_product_rule_id = $5) - OR (v.scope_type = 'product' AND v.canonical_product_id = $4) - OR (v.scope_type = 'shop' AND v.provider = $2 AND v.shop_id = $3) - OR (v.scope_type = 'provider' AND v.provider = $2) - OR (v.scope_type = 'global') - ) - ORDER BY - CASE v.scope_type - WHEN 'rule' THEN 1 - WHEN 'product' THEN 2 - WHEN 'shop' THEN 3 - WHEN 'provider' THEN 4 - ELSE 5 - END, - v.effective_from DESC, - v.id DESC - LIMIT 1 - ) v ON TRUE - WHERE ci.status = 'active' - AND ci.carrier_type IN ($6, 'shared') - ORDER BY ci.id - `, - [ - input.asOf || nowIso(), - input.provider, - input.shopId, - input.canonicalProductId, - input.workProductRuleId, - input.carrierType, - ], - ) - - const lines: CostLine[] = [] - const missingCostItems: MissingCostItem[] = [] - for (const row of result.rows) { - if (!row.version_id) { - missingCostItems.push({ - costItemId: Number(row.id), - costItemCode: row.code, - costItemName: row.name, - carrierType: row.carrier_type, - }) - continue - } - const pricingMode = row.pricing_mode - const amountFen = Number(row.version_amount_fen || 0) - const rateBps = Number(row.version_rate_bps || 0) - const quantity = Math.max(0, Number(input.quantity) || 0) - const unitAmountFen = amountFen - const totalAmountFen = calculateCostTotal({ - pricingMode, - amountFen, - rateBps, - quantity, - revenueFen: input.revenueFen, - }) - lines.push({ - costItemId: Number(row.id), - costItemCode: row.code, - costItemName: row.name, - costType: row.cost_type, - carrierType: row.carrier_type, - pricingMode, - costVersionId: Number(row.version_id), - scopeType: String(row.version_scope || ''), - amountFen, - rateBps, - quantity, - unitAmountFen, - totalAmountFen, - }) - } - - return { - lines, - missingCostItems, - applicableCostItemCount: result.rows.length, - } -} - -export function calculateCostTotal(input: { - pricingMode: string - amountFen: number - rateBps: number - quantity: number - revenueFen: number -}) { - const amountFen = Math.max(0, Math.trunc(Number(input.amountFen) || 0)) - const rateBps = Math.max(0, Math.min(10000, Math.trunc(Number(input.rateBps) || 0))) - const quantity = Math.max(0, Number(input.quantity) || 0) - const revenueFen = Math.max(0, Math.trunc(Number(input.revenueFen) || 0)) - let total = amountFen - if (input.pricingMode === 'per_item') { - total = Math.round(amountFen * quantity) - } else if (input.pricingMode === 'percent_of_revenue') { - total = Math.round((revenueFen * rateBps) / 10000) - } else if ( - input.pricingMode === 'per_order' || - input.pricingMode === 'per_hour' || - input.pricingMode === 'fixed_daily' - ) { - total = amountFen - } - return Math.max(0, total) -} - -export function validateCostVersionScope( - scopeType: string, - input: { - provider: string - shopId: string - canonicalProductId: number | null - workProductRuleId: number | null - }, -) { - if (scopeType === 'provider' && !input.provider) { - throw createHttpError('来源范围必须填写来源', { - statusCode: 400, - errorCode: 'cost_version_scope_invalid', - }) - } - if (scopeType === 'shop' && (!input.provider || !input.shopId)) { - throw createHttpError('店铺范围必须填写来源和店铺', { - statusCode: 400, - errorCode: 'cost_version_scope_invalid', - }) - } - if (scopeType === 'product' && !input.canonicalProductId) { - throw createHttpError('商品范围必须选择标准商品', { - statusCode: 400, - errorCode: 'cost_version_scope_invalid', - }) - } - if (scopeType === 'rule' && !input.workProductRuleId) { - throw createHttpError('规则范围必须选择接单规则', { - statusCode: 400, - errorCode: 'cost_version_scope_invalid', - }) - } -} - -function normalizeCostVersionScopeFields( - scopeType: string, - input: { - provider: string - shopId: string - canonicalProductId: number | null - workProductRuleId: number | null - }, -) { - const empty = { - provider: '', - shopId: '', - canonicalProductId: null as number | null, - workProductRuleId: null as number | null, - } - if (scopeType === 'provider') { - return { ...empty, provider: input.provider } - } - if (scopeType === 'shop') { - return { ...empty, provider: input.provider, shopId: input.shopId } - } - if (scopeType === 'product') { - return { ...empty, canonicalProductId: input.canonicalProductId } - } - if (scopeType === 'rule') { - return { ...empty, workProductRuleId: input.workProductRuleId } - } - return empty -} - -function normalizeCarrierType(value: unknown, context: OrderItemCostContext) { - const normalized = text(value) - if (normalized) { - assertOneOf(normalized, CARRIER_TYPES, '载体类型无效') - return normalized - } - if (context.workOrderId) return 'work_order' - if (context.hasAffiliateDashTask) return 'affiliate_dash' - return 'manual' -} - -async function getProductRow(id: number) { - const result = await query('SELECT * FROM product_catalog WHERE id = $1 LIMIT 1', [ - id, - ]) - return requiredRow(result.rows[0], '标准商品不存在') -} - -async function getMappingRow(id: number) { - const result = await query( - ` - SELECT m.*, p.canonical_sku, p.canonical_name - FROM channel_product_mappings m - JOIN product_catalog p ON p.id = m.canonical_product_id - WHERE m.id = $1 - LIMIT 1 - `, - [id], - ) - return requiredRow(result.rows[0], '渠道映射不存在') -} - -async function assertMappingKeyAvailable( - provider: string, - shopId: string, - platformSkuCode: string, - matchType: string, - platformSkuNameKey: string, - excludeId: number, -) { - const result = await query<{ id: number }>( - ` - SELECT id - FROM channel_product_mappings - WHERE provider = $1 - AND shop_id = $2 - AND platform_sku_code = $3 - AND match_type = $4 - AND platform_sku_name_key = $5 - AND id <> $6 - LIMIT 1 - `, - [provider, shopId, platformSkuCode, matchType, platformSkuNameKey, excludeId], - ) - if (result.rows[0]) { - throw createHttpError('该来源 + 店铺 + 平台 SKU 与匹配方式已有映射', { - statusCode: 409, - errorCode: 'cost_mapping_exists', - }) - } -} - -async function getCostItemRow(id: number) { - const result = await query('SELECT * FROM cost_items WHERE id = $1 LIMIT 1', [id]) - return requiredRow(result.rows[0], '成本项不存在') -} - -async function getCostVersionById(id: number) { - const result = await query( - ` - SELECT - v.*, - ci.code AS cost_item_code, - ci.name AS cost_item_name, - ci.carrier_type AS cost_item_carrier_type, - ci.pricing_mode, - p.canonical_sku, - p.canonical_name - 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 - WHERE v.id = $1 - LIMIT 1 - `, - [id], - ) - return mapCostVersion(requiredRow(result.rows[0], '成本版本不存在')) -} - -function mapProduct(row: ProductRow) { - return { - id: Number(row.id), - canonicalSku: row.canonical_sku, - canonicalName: row.canonical_name, - category: row.category || '', - carrierType: row.carrier_type || 'work_order', - rootCategoryId: row.root_category_id ? Number(row.root_category_id) : null, - rootCategoryName: row.root_category_name || row.category || '', - subCategoryId: row.sub_category_id ? Number(row.sub_category_id) : null, - subCategoryName: row.sub_category_name || '', - unitName: row.unit_name, - status: row.status, - attributes: parseJsonObject(row.attributes_json), - mappingCount: Number(row.mapping_count || 0), - costVersionCount: Number(row.cost_version_count || 0), - affiliateTaskCount: Number(row.affiliate_task_count || 0), - affiliateLastFulfilledAt: row.affiliate_last_fulfilled_at || null, - createdAt: row.created_at, - updatedAt: row.updated_at, - } -} - -function mapMapping(row: MappingRow) { - return { - id: Number(row.id), - provider: row.provider, - shopId: row.shop_id, - platformSkuCode: row.platform_sku_code, - platformSkuName: row.platform_sku_name, - platformSkuNameKey: row.platform_sku_name_key || '', - matchType: row.match_type || 'sku_code', - canonicalProductId: Number(row.canonical_product_id), - canonicalSku: row.canonical_sku, - canonicalName: row.canonical_name, - quantityMultiplier: Number(row.quantity_multiplier), - confidence: row.confidence, - source: row.source, - enabled: row.enabled !== false, - note: row.note || '', - createdAt: row.created_at, - updatedAt: row.updated_at, - } -} - -function mapCostItem(row: CostItemRow) { - return { - id: Number(row.id), - code: row.code, - name: row.name, - costType: row.cost_type, - carrierType: row.carrier_type, - pricingMode: row.pricing_mode, - unitName: row.unit_name, - currency: row.currency, - status: row.status, - note: row.note || '', - versionCount: Number(row.version_count || 0), - createdAt: row.created_at, - updatedAt: row.updated_at, - } -} - -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), - costItemCode: row.cost_item_code, - costItemName: row.cost_item_name, - carrierType: row.cost_item_carrier_type || 'shared', - pricingMode: row.pricing_mode, - scopeType: row.scope_type, - provider: row.provider || '', - shopId: row.shop_id || '', - canonicalProductId: row.canonical_product_id ? Number(row.canonical_product_id) : null, - 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, - effectiveTo: row.effective_to, - source: row.source, - status: row.status, - changeReason: row.change_reason || '', - createdByAdminUserId: row.created_by_admin_user_id - ? Number(row.created_by_admin_user_id) - : null, - approvedByAdminUserId: row.approved_by_admin_user_id - ? Number(row.approved_by_admin_user_id) - : null, - createdAt: row.created_at, - updatedAt: row.updated_at, - } -} - -function mapCostException(row: CostExceptionRow) { - return { - id: Number(row.id), - subjectType: row.subject_type, - subjectId: Number(row.subject_id), - orderItemId: row.order_item_id ? Number(row.order_item_id) : null, - workOrderId: row.work_order_id ? Number(row.work_order_id) : null, - carrierType: row.carrier_type || '', - reason: row.reason, - status: row.status, - detail: parseJsonObject(row.detail_json), - createdAt: row.created_at, - resolvedAt: row.resolved_at, - resolvedByAdminUserId: row.resolved_by_admin_user_id - ? Number(row.resolved_by_admin_user_id) - : null, - } -} - -function normalizePage(value: unknown) { - return Math.max(1, Math.trunc(Number(value) || 1)) -} - -function normalizePageSize(value: unknown) { - return Math.min(100, Math.max(1, Math.trunc(Number(value) || 20))) -} - -function normalizeId(value: unknown, label: string) { - const id = Number(value) - if (!Number.isInteger(id) || id <= 0) { - throw createHttpError(`${label}无效`, { - statusCode: 400, - errorCode: 'cost_center_id_invalid', - }) - } - return id -} - -function normalizeOptionalId(value: unknown) { - if (value === undefined || value === null || value === '') return null - const id = Number(value) - return Number.isInteger(id) && id > 0 ? id : null -} - -function normalizeCategoryFilter(value: unknown) { - if (value === undefined || value === null || value === '') return null - const id = Number(value) - if (!Number.isInteger(id) || (id <= 0 && id !== -1)) { - throw createHttpError('分类筛选无效', { - statusCode: 400, - errorCode: 'cost_center_category_invalid', - }) - } - return id -} - -function normalizeRequiredText(value: unknown, label: string, maxLength: number) { - const normalized = text(value) - if (!normalized) { - throw createHttpError(`${label}不能为空`, { - statusCode: 400, - errorCode: 'cost_center_field_required', - }) - } - if (normalized.length > maxLength) { - throw createHttpError(`${label}最多 ${maxLength} 个字符`, { - statusCode: 400, - errorCode: 'cost_center_field_too_long', - }) - } - return normalized -} - -function normalizeOptionalText(value: unknown, maxLength: number) { - return text(value).slice(0, maxLength) -} - -function normalizePositiveInteger(value: unknown, fallback: number) { - const parsed = Math.trunc(Number(value) || fallback) - return parsed > 0 ? parsed : fallback -} - -function normalizeNonNegativeInteger(value: unknown, fallback: number) { - const parsed = Math.trunc(Number(value) || fallback) - return parsed >= 0 ? parsed : fallback -} - -function normalizeBoolean(value: unknown) { - return value === true || value === 'true' || value === 1 || value === '1' -} - -function normalizeStatus( - value: unknown, - allowed: Set, - fallback: string | null, - message: string, -) { - const normalized = text(value) - if (!normalized) { - if (fallback !== null) return fallback - throw createHttpError(message, { statusCode: 400, errorCode: 'cost_center_status_invalid' }) - } - assertOneOf(normalized, allowed, message) - return normalized -} - -function assertOneOf(value: string, allowed: Set, message: string) { - if (!allowed.has(value)) { - throw createHttpError(message, { statusCode: 400, errorCode: 'cost_center_status_invalid' }) - } -} - -function normalizeIsoDate(value: unknown, label: string, allowEmpty = false) { - const raw = text(value) - if (!raw) { - if (allowEmpty) return null - throw createHttpError(`${label}不能为空`, { - statusCode: 400, - errorCode: 'cost_center_date_required', - }) - } - let normalized = raw.includes('T') ? raw : raw.replace(' ', 'T') - if (!normalized.includes('T')) { - normalized = `${normalized}T00:00:00` - } - // 后台表单传的是北京时间墙上时间,不带时区;显式补 +08:00,避免服务器时区不同导致偏移。 - if (!/(?:Z|[+-]\d{2}:?\d{2})$/i.test(normalized)) { - normalized = `${normalized}+08:00` - } - const parsed = new Date(normalized) - if (Number.isNaN(parsed.getTime())) { - throw createHttpError(`${label}格式无效`, { - statusCode: 400, - errorCode: 'cost_center_date_invalid', - }) - } - return parsed.toISOString() -} - -function normalizeJsonObject(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {} - return value as Record -} - -function parseJsonObject(value: unknown): Record { - if (value && typeof value === 'object' && !Array.isArray(value)) { - return value as Record - } - if (typeof value === 'string') { - try { - const parsed = JSON.parse(value) - return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {} - } catch { - return {} - } - } - return {} -} - -function text(value: unknown) { - return String(value ?? '').trim() -} - -/** 成本映射的 provider 必须与 orders.provider 一致;兼容产品匹配层的来源别名。 */ -export function normalizeProviderKey(value: unknown) { - const normalized = text(value) - if (normalized === '91' || normalized === 'open_91' || normalized === 'kuaishou-91') { - return '91kaquan' - } - if (normalized === 'douyin-mashida') { - return 'douyin_mashida' - } - return normalized -} - -function assertKnownProvider(provider: string) { - if (provider && !PROVIDERS.includes(provider)) { - throw createHttpError('来源无效', { - statusCode: 400, - errorCode: 'cost_center_provider_invalid', - }) - } -} - -function toNumber(value: unknown) { - return Number(value || 0) || 0 -} - -function requiredRow(row: T | undefined | null, message: string): T { - if (!row) { - throw createHttpError(message, { statusCode: 404, errorCode: 'cost_center_not_found' }) - } - return row -} diff --git a/apps/backend/src/services/cost-center/cost-center-overview-service.ts b/apps/backend/src/services/cost-center/cost-center-overview-service.ts new file mode 100644 index 00000000..044882eb --- /dev/null +++ b/apps/backend/src/services/cost-center/cost-center-overview-service.ts @@ -0,0 +1,146 @@ +/** + * 成本中心总览与选项。 + * + * 口径(2026-10-23 重写): + * 1. 商品来源统计按 canonical_sku 前缀分组,不再读 product_catalog.carrier_type; + * 2. 成本项统计按 cost_model 分组,不再读 cost_items.carrier_type。 + */ +import { query } from '../../db/client.js' +import { PROVIDERS, productSourceOf, toNumber } from './cost-center-shared.js' + +export async function getAdminCostCenterOverview() { + const result = await query<{ + product_count: number + mapping_count: number + cost_item_count: number + active_version_count: number + open_exception_count: number + unmapped_order_item_count: number + }>( + ` + SELECT + (SELECT COUNT(*)::int FROM product_catalog WHERE status = 'active') AS product_count, + (SELECT COUNT(*)::int FROM channel_product_mappings WHERE enabled = TRUE) AS mapping_count, + (SELECT COUNT(*)::int FROM cost_items WHERE status = 'active') AS cost_item_count, + (SELECT COUNT(*)::int FROM cost_versions WHERE status = 'active') AS active_version_count, + (SELECT COUNT(*)::int FROM cost_exceptions WHERE status = 'open') AS open_exception_count, + (SELECT COUNT(*)::int FROM order_items WHERE canonical_product_id IS NULL) AS unmapped_order_item_count + `, + ) + const row = result.rows[0] + const [productSourceResult, costModelResult] = await Promise.all([ + query<{ canonical_sku: string; total: number }>( + `SELECT canonical_sku, COUNT(*)::int AS total + FROM product_catalog + WHERE status = 'active' + GROUP BY canonical_sku`, + ), + query<{ cost_model: string; total: number }>( + `SELECT cost_model, COUNT(*)::int AS total FROM cost_items WHERE status = 'active' GROUP BY cost_model`, + ), + ]) + + return { + productCount: toNumber(row?.product_count), + mappingCount: toNumber(row?.mapping_count), + costItemCount: toNumber(row?.cost_item_count), + activeVersionCount: toNumber(row?.active_version_count), + openExceptionCount: toNumber(row?.open_exception_count), + unmappedOrderItemCount: toNumber(row?.unmapped_order_item_count), + productSourceCounts: productSourceCountsOf(productSourceResult.rows), + costModelCounts: costModelCountsOf(costModelResult.rows), + } +} + +/** 商品来源计数:按 canonical_sku 前缀归类,未分类单独成桶。 */ +function productSourceCountsOf(rows: Array<{ canonical_sku: string; total: number }>) { + const counts: Record = { + affiliate_dash: 0, + work_order: 0, + manual: 0, + uncategorized: 0, + } + for (const row of rows) { + const source = productSourceOf(row.canonical_sku) + const key = source || 'uncategorized' + counts[key] = (counts[key] || 0) + toNumber(row.total) + } + return counts +} + +function costModelCountsOf(rows: Array<{ cost_model: string; total: number }>) { + const counts: Record = {} + for (const row of rows) { + if (!row.cost_model) continue + counts[row.cost_model] = toNumber(row.total) + } + return counts +} + +export async function getAdminCostCenterOptions() { + const [productsResult, costItemsResult, rulesResult] = await Promise.all([ + query<{ + id: number + canonical_sku: string + canonical_name: string + unit_name: string + }>( + ` + SELECT id, canonical_sku, canonical_name, unit_name + FROM product_catalog + WHERE status = 'active' + ORDER BY canonical_name, id + LIMIT 5000 + `, + ), + query<{ id: number; code: string; name: string; pricing_mode: string; cost_model: string }>( + ` + SELECT id, code, name, pricing_mode, cost_model + FROM cost_items + WHERE status = 'active' + ORDER BY name, id + LIMIT 1000 + `, + ), + query<{ + id: number + rule_key: string + product_name: string + sku_code: string + provider: string + }>( + ` + SELECT id, rule_key, product_name, sku_code, provider + FROM work_product_rules + WHERE enabled = TRUE + ORDER BY sort_order, id + LIMIT 1000 + `, + ), + ]) + + return { + providers: PROVIDERS, + products: productsResult.rows.map((row) => ({ + id: Number(row.id), + canonicalSku: row.canonical_sku, + canonicalName: row.canonical_name, + unitName: row.unit_name, + source: productSourceOf(row.canonical_sku), + })), + costItems: costItemsResult.rows.map((row) => ({ + id: Number(row.id), + code: row.code, + name: row.name, + pricingMode: row.pricing_mode, + costModel: row.cost_model, + })), + rules: rulesResult.rows.map((row) => ({ + id: Number(row.id), + ruleKey: row.rule_key, + productName: row.product_name, + skuCode: row.sku_code, + provider: row.provider, + })), + } +} diff --git a/apps/backend/src/services/cost-center/cost-center-shared.ts b/apps/backend/src/services/cost-center/cost-center-shared.ts new file mode 100644 index 00000000..f8efd588 --- /dev/null +++ b/apps/backend/src/services/cost-center/cost-center-shared.ts @@ -0,0 +1,802 @@ +/** + * 成本中心共享层:枚举、类型、归一化函数与行映射。 + * + * 口径(2026-10-23 重写): + * 1. 履约方式只有 order_items.fulfillment_mode 一个真源; + * 2. 成本项用 cost_model 表达「成本怎么算」,与履约方式正交; + * 3. 商品来源由 canonical_sku 前缀推导,商品表不再存履约载体; + * 4. 快照与异常表都不再存履约载体,join order_items 即可拿到。 + */ +import { createHttpError } from '../../utils/http.js' +import { normalizeMatchText, normalizeSkuSeriesEquivalence } from '../product-match/normalize.js' + +// --------------------------------------------------------------------------- +// 枚举 +// --------------------------------------------------------------------------- + +export const PRODUCT_STATUSES = new Set(['active', 'archived']) +export const MAPPING_CONFIDENCES = new Set(['exact', 'rule', 'manual']) +export const MAPPING_SOURCES = new Set(['manual', 'import', 'rule']) +export const MAPPING_MATCH_TYPES = new Set([ + 'sku_code', + 'sku_code_name', + 'sku_code_series', + 'sku_name_series', +]) + +export type TemplateMappingMatchType = + | 'sku_code' + | 'sku_code_name' + | 'sku_code_series' + | 'sku_name_series' + +/** 成本模型:只表达「这笔成本怎么算」,不再复用履约载体。 */ +export type CostModel = + | 'affiliate_purchase' + | 'work_order_reward' + | 'manual_actual' + | 'platform_fee' + | 'payment_fee' + | 'after_sales_loss' + | 'other' + +export const COST_MODELS = new Set([ + 'affiliate_purchase', + 'work_order_reward', + 'manual_actual', + 'platform_fee', + 'payment_fee', + 'after_sales_loss', + 'other', +]) + +/** 成本项自动编码的基础名:按成本模型生成,重复时追加 _2、_3。 */ +export const COST_MODEL_CODE_BASE: Record = { + affiliate_purchase: 'affiliate_purchase', + work_order_reward: 'work_order_reward', + manual_actual: 'manual_actual', + platform_fee: 'platform_fee', + payment_fee: 'payment_fee', + after_sales_loss: 'after_sales_loss', + other: 'other_cost', +} + +/** 履约方式:唯一真源是 order_items.fulfillment_mode。 */ +export type FulfillmentMode = 'work_order' | 'affiliate_dash' | 'legacy_platform' | 'manual' + +export const FULFILLMENT_MODES = new Set([ + 'work_order', + 'affiliate_dash', + 'legacy_platform', + 'manual', +]) + +/** + * 与履约方式无关的成本模型:对所有履约方式生效。 + * 专属模型(上游采购 / 打手报酬 / 人工实际支出)只对对应履约方式生效。 + */ +export const GENERIC_COST_MODELS = [ + 'platform_fee', + 'payment_fee', + 'after_sales_loss', + 'other', +] as const + +export const PRICING_MODES = new Set(['per_item', 'per_order', 'percent_of_revenue']) +export const COST_VERSION_SCOPES = new Set(['global', 'provider', 'shop', 'product', 'rule']) +export const COST_VERSION_SOURCES = new Set(['manual', 'upstream_sync', 'import']) +export const COST_VERSION_STATUSES = new Set(['draft', 'active', 'retired']) +export const COST_EXCEPTION_STATUSES = new Set(['open', 'resolved', 'ignored']) +export const COST_EXCEPTION_SUBJECT_TYPES = new Set(['order_item', 'work_order', 'manual_case']) +export const PROVIDERS = ['91kaquan', 'pdd', 'douyin_mashida'] + +/** 首次把接单模板发单金额落成成本版本时使用的基线时间。 */ +export const WORK_ORDER_TEMPLATE_COST_BASELINE = '2026-07-10T00:00:00+08:00' + +/** + * 商品来源前缀。商品表不再存履约载体,来源只由 canonical_sku 前缀表达。 + * 前缀之外的 SKU 归入「未分类」,不允许反推履约方式。 + */ +export const PRODUCT_SOURCE_PREFIXES: Array<{ + prefix: string + source: 'affiliate_dash' | 'work_order' | 'manual' +}> = [ + { prefix: 'aff:', source: 'affiliate_dash' }, + { prefix: 'tpl:', source: 'work_order' }, + { prefix: 'wo:', source: 'work_order' }, + { prefix: 'man:', source: 'manual' }, +] + +export const PRODUCT_CATEGORY_JOINS = ` + LEFT JOIN LATERAL ( + SELECT wc.id, wc.name, wc.parent_id + FROM work_categories wc + WHERE wc.name = p.category + ORDER BY (wc.parent_id IS NOT NULL) DESC, wc.id DESC + LIMIT 1 + ) c ON TRUE + LEFT JOIN work_categories root ON root.id = COALESCE(c.parent_id, c.id) +` + +/** + * 自动发货的履约证据按上游 SKU 聚合一次,再挂到 aff: 商品上。 + * 商品表里的 attributes_json 会被价格导入覆盖,不能作为统计源。 + */ +export const AFFILIATE_TASK_AGG_JOIN = ` + LEFT JOIN ( + SELECT + btrim(context_json->>'skuCode') AS sku_code, + COUNT(*)::int AS task_count, + MAX(created_at) AS last_fulfilled_at + FROM fulfillment_tasks + WHERE executor_key = 'affiliate_dash' + AND btrim(COALESCE(context_json->>'skuCode', '')) <> '' + GROUP BY 1 + ) aff ON p.canonical_sku LIKE 'aff:%' + AND aff.sku_code = substring(p.canonical_sku from 5) +` + +// --------------------------------------------------------------------------- +// 行类型 +// --------------------------------------------------------------------------- + +export type ProductRow = { + [column: string]: unknown + id: number + canonical_sku: string + canonical_name: string + category: string + unit_name: string + status: string + root_category_id: number | null + root_category_name: string + sub_category_id: number | null + sub_category_name: string + attributes_json: unknown + created_at: string + updated_at: string + mapping_count?: number + cost_version_count?: number + affiliate_task_count?: number + affiliate_last_fulfilled_at?: string | null +} + +export type ProductCategoryStatRow = { + category_id: number | null + name: string | null + parent_id: number | null + total: number +} + +export type WorkCategoryRow = { + id: number + name: string + parent_id: number | null +} + +export type ProductCategoryNode = { + categoryId: number + name: string + total: number + directTotal: number + children: Array<{ + categoryId: number + name: string + total: number + }> +} + +export type MappingRow = { + [column: string]: unknown + id: number + provider: string + shop_id: string + platform_sku_code: string + platform_sku_name: string + platform_sku_name_key: string + match_type: string + canonical_product_id: number + canonical_sku: string + canonical_name: string + quantity_multiplier: number + confidence: string + source: string + enabled: boolean + note: string + created_at: string + updated_at: string +} + +export type CostItemRow = { + [column: string]: unknown + id: number + code: string + name: string + cost_model: string + pricing_mode: string + unit_name: string + currency: string + status: string + note: string + created_at: string + updated_at: string + version_count?: number +} + +export type CostVersionRow = { + [column: string]: unknown + id: number + cost_item_id: number + cost_item_code: string + cost_item_name: string + cost_item_cost_model: string + pricing_mode: string + scope_type: string + provider: string + shop_id: string + canonical_product_id: number | null + 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 + effective_to: string | null + source: string + status: string + change_reason: string + created_by_admin_user_id: number | null + approved_by_admin_user_id: number | null + created_at: string + updated_at: string +} + +export type CostExceptionRow = { + [column: string]: unknown + id: number + subject_type: string + subject_id: number + order_item_id: number | null + work_order_id: number | null + fulfillment_mode: string | null + reason: string + status: string + detail_json: unknown + created_at: string + resolved_at: string | null + resolved_by_admin_user_id: number | null +} + +export type OrderItemCostContext = { + orderItemId: number + orderId: number + provider: string + shopId: string + skuCode: string + skuName: string + normalizedSkuName: string + normalizedSkuSeries: string + quantity: number + normalizedQuantity: number + canonicalProductId: number | null + revenueFen: number + basisAt: string + workOrderId: number | null + workProductRuleId: number | null + fulfillmentMode: FulfillmentMode +} + +export type CostLine = { + costItemId: number + costItemCode: string + costItemName: string + costModel: CostModel + pricingMode: string + costVersionId: number + scopeType: string + amountFen: number + rateBps: number + quantity: number + unitAmountFen: number + totalAmountFen: number +} + +export type CostExceptionDraft = { + reason: string + message: string + detail: Record +} + +export type MissingCostItem = { + costItemId: number + costItemCode: string + costItemName: string + costModel: CostModel +} + +export type CostResolution = { + lines: CostLine[] + missingCostItems: MissingCostItem[] + applicableCostItemCount: number +} + +export type CostPreview = { + context: { + orderItemId: number | null + orderId: number | null + provider: string + shopId: string + skuCode: string + skuName: string + fulfillmentMode: FulfillmentMode + quantity: number + normalizedQuantity: number + revenueFen: number + workOrderId: number | null + workProductRuleId: number | null + } + mapping: ReturnType | null + product: ReturnType | null + lines: CostLine[] + missingCostItems: MissingCostItem[] + totalCostFen: number + exceptions: CostExceptionDraft[] +} + +// --------------------------------------------------------------------------- +// 履约方式 / 商品来源 / 成本模型 +// --------------------------------------------------------------------------- + +/** + * 由 canonical_sku 前缀推导商品来源。 + * 返回空串表示未分类;未分类商品不允许反推履约方式。 + */ +export function productSourceOf(canonicalSku: string): string { + const sku = String(canonicalSku || '').trim() + for (const item of PRODUCT_SOURCE_PREFIXES) { + if (sku.startsWith(item.prefix)) return item.source + } + return '' +} + +/** 商品来源筛选的 SQL 片段:按 canonical_sku 前缀过滤。 */ +export function productSourceFilterSql(paramIndex: number, column = 'p.canonical_sku'): string { + return `( + ($${paramIndex} = 'affiliate_dash' AND ${column} LIKE 'aff:%') + OR ($${paramIndex} = 'work_order' AND (${column} LIKE 'tpl:%' OR ${column} LIKE 'wo:%')) + OR ($${paramIndex} = 'manual' AND ${column} LIKE 'man:%') + )` +} + +/** + * 成本模型是否适用于该履约方式。 + * + * 专属模型只对对应履约方式生效;通用模型(平台费/支付费/售后损失/其他) + * 对所有履约方式生效。 + */ +export function costModelAppliesTo(costModel: string, fulfillmentMode: string) { + if ((GENERIC_COST_MODELS as readonly string[]).includes(costModel)) return true + if (costModel === 'affiliate_purchase') return fulfillmentMode === 'affiliate_dash' + if (costModel === 'work_order_reward') return fulfillmentMode === 'work_order' + if (costModel === 'manual_actual') return fulfillmentMode === 'manual' + return false +} + +/** + * 成本模型适用范围的 SQL 片段。 + * 参数是「履约方式」的 SQL 表达式(可以是占位符 $n,也可以是列引用)。 + */ +export function costModelScopeSql(fulfillmentModeExpression: string): string { + return `( + ci.cost_model IN (${GENERIC_COST_MODELS.map((model) => `'${model}'`).join(', ')}) + OR (ci.cost_model = 'affiliate_purchase' + AND ${fulfillmentModeExpression} = 'affiliate_dash') + OR (ci.cost_model = 'work_order_reward' + AND ${fulfillmentModeExpression} = 'work_order') + OR (ci.cost_model = 'manual_actual' + AND ${fulfillmentModeExpression} = 'manual') + )` +} + +/** 人工支出分类的默认推断:按成本模型归类。 */ +export function inferExpenseCategory(costModel: string | undefined) { + if (costModel === 'affiliate_purchase') return 'goods_purchase' + if (costModel === 'manual_actual') return 'service_fee' + if (costModel === 'other') return 'freight' + if (costModel === 'payment_fee' || costModel === 'platform_fee') return 'service_fee' + return 'other' +} + +// --------------------------------------------------------------------------- +// 行映射 +// --------------------------------------------------------------------------- + +export function mapProduct(row: ProductRow) { + return { + id: Number(row.id), + canonicalSku: row.canonical_sku, + canonicalName: row.canonical_name, + category: row.category || '', + source: productSourceOf(row.canonical_sku), + rootCategoryId: row.root_category_id ? Number(row.root_category_id) : null, + rootCategoryName: row.root_category_name || row.category || '', + subCategoryId: row.sub_category_id ? Number(row.sub_category_id) : null, + subCategoryName: row.sub_category_name || '', + unitName: row.unit_name, + status: row.status, + attributes: parseJsonObject(row.attributes_json), + mappingCount: Number(row.mapping_count || 0), + costVersionCount: Number(row.cost_version_count || 0), + affiliateTaskCount: Number(row.affiliate_task_count || 0), + affiliateLastFulfilledAt: row.affiliate_last_fulfilled_at || null, + createdAt: row.created_at, + updatedAt: row.updated_at, + } +} + +export function mapMapping(row: MappingRow) { + return { + id: Number(row.id), + provider: row.provider, + shopId: row.shop_id, + platformSkuCode: row.platform_sku_code, + platformSkuName: row.platform_sku_name, + platformSkuNameKey: row.platform_sku_name_key || '', + matchType: row.match_type || 'sku_code', + canonicalProductId: Number(row.canonical_product_id), + canonicalSku: row.canonical_sku, + canonicalName: row.canonical_name, + quantityMultiplier: Number(row.quantity_multiplier), + confidence: row.confidence, + source: row.source, + enabled: row.enabled !== false, + note: row.note || '', + createdAt: row.created_at, + updatedAt: row.updated_at, + } +} + +export function mapCostItem(row: CostItemRow) { + return { + id: Number(row.id), + code: row.code, + name: row.name, + costModel: row.cost_model, + pricingMode: row.pricing_mode, + unitName: row.unit_name, + currency: row.currency, + status: row.status, + note: row.note || '', + versionCount: Number(row.version_count || 0), + createdAt: row.created_at, + updatedAt: row.updated_at, + } +} + +export 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), + costItemCode: row.cost_item_code, + costItemName: row.cost_item_name, + costItemCostModel: row.cost_item_cost_model || 'other', + pricingMode: row.pricing_mode, + scopeType: row.scope_type, + provider: row.provider || '', + shopId: row.shop_id || '', + canonicalProductId: row.canonical_product_id ? Number(row.canonical_product_id) : null, + 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, + effectiveTo: row.effective_to, + source: row.source, + status: row.status, + changeReason: row.change_reason || '', + createdByAdminUserId: row.created_by_admin_user_id + ? Number(row.created_by_admin_user_id) + : null, + approvedByAdminUserId: row.approved_by_admin_user_id + ? Number(row.approved_by_admin_user_id) + : null, + createdAt: row.created_at, + updatedAt: row.updated_at, + } +} + +export function mapCostException(row: CostExceptionRow) { + return { + id: Number(row.id), + subjectType: row.subject_type, + subjectId: Number(row.subject_id), + orderItemId: row.order_item_id ? Number(row.order_item_id) : null, + workOrderId: row.work_order_id ? Number(row.work_order_id) : null, + fulfillmentMode: row.fulfillment_mode || '', + reason: row.reason, + status: row.status, + detail: parseJsonObject(row.detail_json), + createdAt: row.created_at, + resolvedAt: row.resolved_at, + resolvedByAdminUserId: row.resolved_by_admin_user_id + ? Number(row.resolved_by_admin_user_id) + : null, + } +} + +// --------------------------------------------------------------------------- +// 归一化 +// --------------------------------------------------------------------------- + +export function normalizePage(value: unknown) { + return Math.max(1, Math.trunc(Number(value) || 1)) +} + +export function normalizePageSize(value: unknown) { + return Math.min(100, Math.max(1, Math.trunc(Number(value) || 20))) +} + +export function normalizeId(value: unknown, label: string) { + const id = Number(value) + if (!Number.isInteger(id) || id <= 0) { + throw createHttpError(`${label}无效`, { + statusCode: 400, + errorCode: 'cost_center_id_invalid', + }) + } + return id +} + +export function normalizeOptionalId(value: unknown) { + if (value === undefined || value === null || value === '') return null + const id = Number(value) + return Number.isInteger(id) && id > 0 ? id : null +} + +export function normalizeCategoryFilter(value: unknown) { + if (value === undefined || value === null || value === '') return null + const id = Number(value) + if (!Number.isInteger(id) || (id <= 0 && id !== -1)) { + throw createHttpError('分类筛选无效', { + statusCode: 400, + errorCode: 'cost_center_category_invalid', + }) + } + return id +} + +export function normalizeRequiredText(value: unknown, label: string, maxLength: number) { + const normalized = text(value) + if (!normalized) { + throw createHttpError(`${label}不能为空`, { + statusCode: 400, + errorCode: 'cost_center_field_required', + }) + } + if (normalized.length > maxLength) { + throw createHttpError(`${label}最多 ${maxLength} 个字符`, { + statusCode: 400, + errorCode: 'cost_center_field_too_long', + }) + } + return normalized +} + +export function normalizeOptionalText(value: unknown, maxLength: number) { + return text(value).slice(0, maxLength) +} + +export function normalizePositiveInteger(value: unknown, fallback: number) { + const parsed = Math.trunc(Number(value) || fallback) + return parsed > 0 ? parsed : fallback +} + +export function normalizeNonNegativeInteger(value: unknown, fallback: number) { + const parsed = Math.trunc(Number(value) || fallback) + return parsed >= 0 ? parsed : fallback +} + +export function normalizeBoolean(value: unknown) { + return value === true || value === 'true' || value === 1 || value === '1' +} + +export function normalizeStatus( + value: unknown, + allowed: Set, + fallback: string | null, + message: string, +) { + const normalized = text(value) + if (!normalized) { + if (fallback !== null) return fallback + throw createHttpError(message, { statusCode: 400, errorCode: 'cost_center_status_invalid' }) + } + assertOneOf(normalized, allowed, message) + return normalized +} + +export function assertOneOf(value: string, allowed: Set, message: string) { + if (!allowed.has(value)) { + throw createHttpError(message, { statusCode: 400, errorCode: 'cost_center_status_invalid' }) + } +} + +export function normalizeIsoDate(value: unknown, label: string, allowEmpty = false) { + const raw = text(value) + if (!raw) { + if (allowEmpty) return null + throw createHttpError(`${label}不能为空`, { + statusCode: 400, + errorCode: 'cost_center_date_required', + }) + } + let normalized = raw.includes('T') ? raw : raw.replace(' ', 'T') + if (!normalized.includes('T')) { + normalized = `${normalized}T00:00:00` + } + // 后台表单传的是北京时间墙上时间,不带时区;显式补 +08:00,避免服务器时区不同导致偏移。 + if (!/(?:Z|[+-]\d{2}:?\d{2})$/i.test(normalized)) { + normalized = `${normalized}+08:00` + } + const parsed = new Date(normalized) + if (Number.isNaN(parsed.getTime())) { + throw createHttpError(`${label}格式无效`, { + statusCode: 400, + errorCode: 'cost_center_date_invalid', + }) + } + return parsed.toISOString() +} + +export function normalizeJsonObject(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {} + return value as Record +} + +export function parseJsonObject(value: unknown): Record { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return value as Record + } + if (typeof value === 'string') { + try { + const parsed = JSON.parse(value) + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {} + } catch { + return {} + } + } + return {} +} + +export function text(value: unknown) { + return String(value ?? '').trim() +} + +/** 映射唯一键:来源 + 店铺 + 平台 SKU + 匹配方式 + 名称键。 */ +export function candidateKey( + provider: string, + shopId: string, + skuCode: string, + matchType: string, + skuNameKey: string, +) { + return `${provider}\u0000${shopId}\u0000${skuCode}\u0000${matchType}\u0000${skuNameKey}` +} + +export function mappingKey( + provider: string, + shopId: string, + skuCode: string, + matchType = 'sku_code', + skuNameKey = '', +) { + return candidateKey(provider, shopId, skuCode, matchType, skuNameKey) +} + +export function mappingMatchTypeLabel(matchType: string) { + switch (matchType) { + case 'sku_code': + return 'SKU 编码' + case 'sku_code_name': + return 'SKU 编码 + 精确名称' + case 'sku_code_series': + return 'SKU 编码 + 名称系列' + case 'sku_name_series': + return 'SKU 名称系列' + default: + return matchType + } +} + +export function normalizeMappingMatchType( + value: unknown, + fallback: TemplateMappingMatchType = 'sku_code', +): TemplateMappingMatchType { + const normalized = text(value) + if (!normalized) return fallback + if (!MAPPING_MATCH_TYPES.has(normalized)) { + throw createHttpError('映射匹配方式无效', { + statusCode: 400, + errorCode: 'cost_mapping_match_type_invalid', + }) + } + return normalized as TemplateMappingMatchType +} + +/** 由匹配方式决定使用精确名称键还是数量系列键。 */ +export function resolveMappingNameKey( + matchType: TemplateMappingMatchType, + platformSkuName: string, +) { + if (matchType === 'sku_code') return '' + if (matchType === 'sku_code_name') return normalizeMatchText(platformSkuName) + return normalizeSkuSeriesEquivalence(platformSkuName) +} + +/** 成本映射的 provider 必须与 orders.provider 一致;兼容产品匹配层的来源别名。 */ +export function normalizeProviderKey(value: unknown) { + const normalized = text(value) + if (normalized === '91' || normalized === 'open_91' || normalized === 'kuaishou-91') { + return '91kaquan' + } + if (normalized === 'douyin-mashida') { + return 'douyin_mashida' + } + return normalized +} + +export function assertKnownProvider(provider: string) { + if (provider && !PROVIDERS.includes(provider)) { + throw createHttpError('来源无效', { + statusCode: 400, + errorCode: 'cost_center_provider_invalid', + }) + } +} + +export function toNumber(value: unknown) { + return Number(value || 0) || 0 +} + +export function requiredRow(row: T | undefined | null, message: string): T { + if (!row) { + throw createHttpError(message, { statusCode: 404, errorCode: 'cost_center_not_found' }) + } + return row +} diff --git a/apps/backend/src/services/admin/admin-cost-center-service.test.ts b/apps/backend/src/services/cost-center/cost-center.test.ts similarity index 96% rename from apps/backend/src/services/admin/admin-cost-center-service.test.ts rename to apps/backend/src/services/cost-center/cost-center.test.ts index 87749da5..f6a91c7d 100644 --- a/apps/backend/src/services/admin/admin-cost-center-service.test.ts +++ b/apps/backend/src/services/cost-center/cost-center.test.ts @@ -2,16 +2,17 @@ import assert from 'node:assert/strict' import test from 'node:test' import { - buildTemplateMappingPlan, - buildTemplateProductSku, - calculateCostTotal, - inferTemplateUnitName, normalizeMappingMatchType, normalizeProviderKey, resolveMappingNameKey, - resolveMappingProductId, - validateCostVersionScope, -} from './admin-cost-center-service.js' +} from './cost-center-shared.js' +import { calculateCostTotal, validateCostVersionScope } from './cost-definition-service.js' +import { resolveMappingProductId } from './product-catalog-service.js' +import { + buildTemplateMappingPlan, + buildTemplateProductSku, + inferTemplateUnitName, +} from './template-bootstrap-service.js' test('calculateCostTotal supports per_item, per_order and percent_of_revenue', () => { assert.equal( diff --git a/apps/backend/src/services/cost-center/cost-definition-service.ts b/apps/backend/src/services/cost-center/cost-definition-service.ts new file mode 100644 index 00000000..7bc7482f --- /dev/null +++ b/apps/backend/src/services/cost-center/cost-definition-service.ts @@ -0,0 +1,1236 @@ +/** + * 成本项 / 成本版本 / 成本异常 / 成本预览 / 成本快照。 + * + * 口径(2026-10-23 重写): + * 1. 成本项用 cost_model 表达「成本怎么算」,不再有 cost_type + carrier_type; + * 2. 成本模型与履约方式正交:专属模型只对对应履约方式生效,通用模型全局生效; + * 3. 履约方式只从 order_items.fulfillment_mode 读,不允许临时推断; + * 4. 快照与异常表不再存履约载体; + * 5. 匹配不到成本不得默认 0,必须进入 cost_exceptions。 + */ +import { query, withTransaction } from '../../db/client.js' +import type { AdminSession } from '../admin/admin-auth-service.js' +import type { JsonObject } from '../../types/json.js' +import { createHttpError } from '../../utils/http.js' +import { nowIso } from '../../utils/time.js' +import { normalizeMatchText, normalizeSkuSeriesEquivalence } from '../product-match/normalize.js' +import { resolveSkuNameQuantity } from '../product-match/pricing.js' +import { + COST_EXCEPTION_STATUSES, + COST_EXCEPTION_SUBJECT_TYPES, + COST_MODELS, + COST_MODEL_CODE_BASE, + COST_VERSION_SCOPES, + COST_VERSION_SOURCES, + COST_VERSION_STATUSES, + FULFILLMENT_MODES, + PRICING_MODES, + PRODUCT_STATUSES, + assertKnownProvider, + assertOneOf, + costModelAppliesTo, + costModelScopeSql, + mapCostException, + mapCostItem, + mapCostVersion, + mapMapping, + mapProduct, + normalizeId, + normalizeIsoDate, + normalizeNonNegativeInteger, + normalizeOptionalId, + normalizeOptionalText, + normalizePage, + normalizePageSize, + normalizeProviderKey, + normalizeRequiredText, + normalizeStatus, + requiredRow, + text, + toNumber, + type CostExceptionRow, + type CostItemRow, + type CostLine, + type CostPreview, + type CostResolution, + type CostVersionRow, + type FulfillmentMode, + type MappingRow, + type MissingCostItem, + type OrderItemCostContext, + type ProductRow, +} from './cost-center-shared.js' +import { getProductRow } from './product-catalog-service.js' + +export async function listAdminCostItems(payload: JsonObject = {}) { + const page = normalizePage(payload.page) + const pageSize = normalizePageSize(payload.pageSize) + const offset = (page - 1) * pageSize + const params: unknown[] = [] + const clauses: string[] = [] + + const keyword = text(payload.keyword) + if (keyword) { + params.push(`%${keyword}%`) + clauses.push(`(ci.code ILIKE $${params.length} OR ci.name ILIKE $${params.length})`) + } + const costModel = text(payload.costModel ?? payload.cost_model) + if (costModel) { + assertOneOf(costModel, COST_MODELS, '成本模型无效') + params.push(costModel) + clauses.push(`ci.cost_model = $${params.length}`) + } + const status = text(payload.status) + if (status) { + assertOneOf(status, PRODUCT_STATUSES, '成本项状态无效') + params.push(status) + clauses.push(`ci.status = $${params.length}`) + } + // 按履约方式筛选:只返回适用于该履约方式的成本模型。 + const fulfillmentMode = text(payload.fulfillmentMode ?? payload.fulfillment_mode) + if (fulfillmentMode) { + assertOneOf(fulfillmentMode, FULFILLMENT_MODES, '履约方式无效') + params.push(fulfillmentMode) + clauses.push(costModelScopeSql(`$${params.length}`)) + } + + const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '' + const countResult = await query<{ total: number }>( + `SELECT COUNT(*)::int AS total FROM cost_items ci ${where}`, + params, + ) + const listParams = [...params, pageSize, offset] + const result = await query( + ` + SELECT + ci.*, + (SELECT COUNT(*)::int FROM cost_versions v WHERE v.cost_item_id = ci.id) AS version_count + FROM cost_items ci + ${where} + ORDER BY ci.status ASC, ci.cost_model ASC, ci.name ASC, ci.id ASC + LIMIT $${listParams.length - 1} OFFSET $${listParams.length} + `, + listParams, + ) + + return { + items: result.rows.map(mapCostItem), + pagination: { page, pageSize, total: toNumber(countResult.rows[0]?.total) }, + } +} + +export async function createAdminCostItem(payload: JsonObject, _session: AdminSession) { + const name = normalizeRequiredText(payload.name, '成本项名称', 120) + const costModel = normalizeStatus( + payload.costModel ?? payload.cost_model, + COST_MODELS, + null, + '成本模型无效', + ) + const pricingMode = normalizeStatus(payload.pricingMode, PRICING_MODES, null, '计价方式无效') + // 编码由系统生成(按成本模型 + 冲突序号);显式传入时仍优先使用,兼容脚本导入。 + const code = normalizeOptionalText(payload.code, 64) || (await generateCostItemCode(costModel)) + const unitName = normalizeOptionalText(payload.unitName, 16) || '件' + const currency = normalizeOptionalText(payload.currency, 8) || 'CNY' + const status = normalizeStatus(payload.status, PRODUCT_STATUSES, 'active', '成本项状态无效') + const note = normalizeOptionalText(payload.note, 500) + const now = nowIso() + + const result = await query( + ` + INSERT INTO cost_items ( + code, name, cost_model, pricing_mode, unit_name, + currency, status, note, created_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $9) + ON CONFLICT (code) DO NOTHING + RETURNING * + `, + [code, name, costModel, pricingMode, unitName, currency, status, note, now], + ) + const inserted = result.rows[0] + if (!inserted) { + throw createHttpError('成本项编码已存在', { + statusCode: 409, + errorCode: 'cost_item_code_exists', + }) + } + return { costItem: mapCostItem(inserted) } +} + +async function generateCostItemCode(costModel: string) { + const base = COST_MODEL_CODE_BASE[costModel] || 'cost_item' + let candidate = base + let index = 2 + for (let attempt = 0; attempt < 100; attempt += 1) { + const existing = await query<{ id: number }>( + 'SELECT id FROM cost_items WHERE code = $1 LIMIT 1', + [candidate], + ) + if (!existing.rows[0]) return candidate + candidate = `${base}_${index}` + index += 1 + } + return `${base}_${Date.now()}` +} + +export async function updateAdminCostItem(costItemId: number | string, payload: JsonObject) { + const id = normalizeId(costItemId, '成本项 ID') + const current = await getCostItemRow(id) + const name = normalizeRequiredText(payload.name ?? current.name, '成本项名称', 120) + const costModel = normalizeStatus( + payload.costModel ?? payload.cost_model, + COST_MODELS, + current.cost_model, + '成本模型无效', + ) + const pricingMode = normalizeStatus( + payload.pricingMode, + PRICING_MODES, + current.pricing_mode, + '计价方式无效', + ) + const unitName = + payload.unitName === undefined + ? current.unit_name + : normalizeOptionalText(payload.unitName, 16) || '件' + const currency = + payload.currency === undefined + ? current.currency + : normalizeOptionalText(payload.currency, 8) || 'CNY' + const status = normalizeStatus(payload.status, PRODUCT_STATUSES, current.status, '成本项状态无效') + const note = payload.note === undefined ? current.note : normalizeOptionalText(payload.note, 500) + + const result = await query( + ` + UPDATE cost_items + SET name = $1, cost_model = $2, pricing_mode = $3, + unit_name = $4, currency = $5, status = $6, note = $7, updated_at = $8 + WHERE id = $9 + RETURNING * + `, + [name, costModel, pricingMode, unitName, currency, status, note, nowIso(), id], + ) + return { costItem: mapCostItem(requiredRow(result.rows[0], '成本项不存在')) } +} + +/** + * 删除成本项:存在成本版本、成本快照或接单规则引用时拒绝删除。 + */ +export async function deleteAdminCostItem(costItemId: number | string) { + const id = normalizeId(costItemId, '成本项 ID') + const current = await getCostItemRow(id) + const usageResult = await query<{ + version_count: number + snapshot_count: number + rule_count: number + }>( + ` + SELECT + (SELECT COUNT(*)::int FROM cost_versions WHERE cost_item_id = $1) AS version_count, + (SELECT COUNT(*)::int FROM cost_snapshots WHERE cost_item_id = $1) AS snapshot_count, + (SELECT COUNT(*)::int FROM work_product_rules WHERE cost_item_id = $1) AS rule_count + `, + [id], + ) + const usage = usageResult.rows[0] + const blockers: string[] = [] + if (toNumber(usage?.version_count) > 0) + blockers.push(`${toNumber(usage?.version_count)} 个成本版本`) + if (toNumber(usage?.snapshot_count) > 0) + blockers.push(`${toNumber(usage?.snapshot_count)} 条成本快照`) + if (toNumber(usage?.rule_count) > 0) blockers.push(`${toNumber(usage?.rule_count)} 个接单规则`) + if (blockers.length > 0) { + throw createHttpError(`成本项仍被引用(${blockers.join('、')}),不能删除;请改为归档`, { + statusCode: 409, + errorCode: 'cost_item_in_use', + }) + } + await query('DELETE FROM cost_items WHERE id = $1', [id]) + return { deleted: true, costItem: mapCostItem(current) } +} + +export async function listAdminCostVersions(payload: JsonObject = {}) { + const page = normalizePage(payload.page) + const pageSize = normalizePageSize(payload.pageSize) + const offset = (page - 1) * pageSize + const params: unknown[] = [] + const clauses: string[] = [] + + const costItemId = normalizeOptionalId(payload.costItemId) + if (costItemId) { + params.push(costItemId) + clauses.push(`v.cost_item_id = $${params.length}`) + } + const productId = normalizeOptionalId(payload.canonicalProductId) + if (productId) { + params.push(productId) + 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) { + params.push(normalizeProviderKey(provider)) + clauses.push(`v.provider = $${params.length}`) + } + const status = text(payload.status) + if (status) { + assertOneOf(status, COST_VERSION_STATUSES, '成本版本状态无效') + params.push(status) + clauses.push(`v.status = $${params.length}`) + } + const costModel = text(payload.costModel ?? payload.cost_model) + if (costModel) { + assertOneOf(costModel, COST_MODELS, '成本模型无效') + params.push(costModel) + clauses.push(`ci.cost_model = $${params.length}`) + } + // 按履约方式筛选:只返回适用于该履约方式的成本模型。 + const fulfillmentMode = text(payload.fulfillmentMode ?? payload.fulfillment_mode) + if (fulfillmentMode) { + assertOneOf(fulfillmentMode, FULFILLMENT_MODES, '履约方式无效') + params.push(fulfillmentMode) + clauses.push(costModelScopeSql(`$${params.length}`)) + } + const keyword = text(payload.keyword) + if (keyword) { + 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 wpr.product_name ILIKE $${params.length} OR wpr.rule_key ILIKE $${params.length})`, + ) + } + + const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '' + const countResult = await query<{ total: number }>( + ` + SELECT COUNT(*)::int AS total + 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, + ) + const listParams = [...params, pageSize, offset] + const result = await query( + ` + SELECT + v.*, + ci.code AS cost_item_code, + ci.name AS cost_item_name, + ci.cost_model AS cost_item_cost_model, + ci.pricing_mode, + p.canonical_sku, + 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, + 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, + ) + + return { + items: result.rows.map(mapCostVersion), + pagination: { page, pageSize, total: toNumber(countResult.rows[0]?.total) }, + } +} + +export async function createAdminCostVersion(payload: JsonObject, session: AdminSession) { + const costItemId = normalizeId(payload.costItemId ?? payload.cost_item_id, '成本项 ID') + const costItem = await getCostItemRow(costItemId) + const scopeType = normalizeStatus( + payload.scopeType, + COST_VERSION_SCOPES, + 'global', + '成本范围无效', + ) + const rawProvider = normalizeProviderKey(normalizeOptionalText(payload.provider, 64)) + if (rawProvider) { + assertKnownProvider(rawProvider) + } + const rawShopId = normalizeOptionalText(payload.shopId ?? payload.shop_id, 120) + const rawCanonicalProductId = normalizeOptionalId( + payload.canonicalProductId ?? payload.canonical_product_id, + ) + const rawWorkProductRuleId = normalizeOptionalId( + payload.workProductRuleId ?? payload.work_product_rule_id, + ) + const { provider, shopId, canonicalProductId, workProductRuleId } = + normalizeCostVersionScopeFields(scopeType, { + provider: rawProvider, + shopId: rawShopId, + canonicalProductId: rawCanonicalProductId, + workProductRuleId: rawWorkProductRuleId, + }) + validateCostVersionScope(scopeType, { + provider, + shopId, + canonicalProductId, + workProductRuleId, + }) + + const amountFen = normalizeNonNegativeInteger(payload.amountFen ?? payload.amount_fen, 0) + const rateBps = normalizeNonNegativeInteger(payload.rateBps ?? payload.rate_bps, 0) + if (rateBps > 10000) { + throw createHttpError('比例不能超过 10000 个基点(100%)', { + statusCode: 400, + errorCode: 'cost_version_rate_invalid', + }) + } + if (costItem.pricing_mode === 'percent_of_revenue' && rateBps <= 0) { + throw createHttpError('比例计价必须填写大于 0 的 rateBps', { + statusCode: 400, + errorCode: 'cost_version_rate_required', + }) + } + if (costItem.pricing_mode !== 'percent_of_revenue' && amountFen <= 0) { + throw createHttpError('非比例计价必须填写大于 0 的金额', { + statusCode: 400, + errorCode: 'cost_version_amount_required', + }) + } + + const effectiveFrom = normalizeIsoDate(payload.effectiveFrom, '生效时间') || nowIso() + const effectiveTo = normalizeIsoDate(payload.effectiveTo, '失效时间', true) + if (effectiveTo && Date.parse(effectiveTo) <= Date.parse(effectiveFrom)) { + throw createHttpError('失效时间必须晚于生效时间', { + statusCode: 400, + errorCode: 'cost_version_period_invalid', + }) + } + const source = normalizeStatus(payload.source, COST_VERSION_SOURCES, 'manual', '成本版本来源无效') + const status = normalizeStatus( + payload.status, + COST_VERSION_STATUSES, + 'active', + '成本版本状态无效', + ) + const changeReason = normalizeOptionalText(payload.changeReason ?? payload.change_reason, 500) + const now = nowIso() + + const result = await query( + ` + 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 + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, + $11, $12, $13, $14, $14, $15, $15 + ) + ON CONFLICT DO NOTHING + RETURNING * + `, + [ + costItemId, + scopeType, + provider, + shopId, + canonicalProductId, + workProductRuleId, + amountFen, + rateBps, + effectiveFrom, + effectiveTo, + source, + status, + changeReason, + session.userId, + now, + ], + ) + const inserted = result.rows[0] + if (!inserted) { + throw createHttpError('同一成本项 + 范围 + 生效时间已有版本', { + statusCode: 409, + errorCode: 'cost_version_exists', + }) + } + return { version: await getCostVersionById(Number(inserted.id)) } +} + +export async function retireAdminCostVersion(versionId: number | string, payload: JsonObject = {}) { + const id = normalizeId(versionId, '成本版本 ID') + const reason = normalizeRequiredText( + payload.changeReason ?? payload.change_reason ?? payload.reason, + '停用原因', + 500, + ) + const result = await query<{ id: number }>( + ` + UPDATE cost_versions + SET status = 'retired', change_reason = $1, updated_at = $2 + WHERE id = $3 + RETURNING id + `, + [reason, nowIso(), id], + ) + if (!result.rows[0]) { + throw createHttpError('成本版本不存在', { + statusCode: 404, + errorCode: 'cost_version_not_found', + }) + } + return { version: await getCostVersionById(id) } +} + +/** + * 删除成本版本:生效中的版本必须先停用;已被成本快照引用的版本不可删除。 + */ +export async function deleteAdminCostVersion(versionId: number | string) { + const id = normalizeId(versionId, '成本版本 ID') + const current = await getCostVersionById(id) + if (current.status === 'active') { + throw createHttpError('生效中的成本版本不能删除,请先停用', { + statusCode: 409, + errorCode: 'cost_version_active', + }) + } + const snapshotResult = await query<{ total: number }>( + 'SELECT COUNT(*)::int AS total FROM cost_snapshots WHERE cost_version_id = $1', + [id], + ) + const snapshotCount = toNumber(snapshotResult.rows[0]?.total) + if (snapshotCount > 0) { + throw createHttpError(`该成本版本已被 ${snapshotCount} 条成本快照引用,不能删除`, { + statusCode: 409, + errorCode: 'cost_version_in_use', + }) + } + await query('DELETE FROM cost_versions WHERE id = $1', [id]) + return { deleted: true, version: current } +} + +export async function listAdminCostExceptions(payload: JsonObject = {}) { + const page = normalizePage(payload.page) + const pageSize = normalizePageSize(payload.pageSize) + const offset = (page - 1) * pageSize + const params: unknown[] = [] + const clauses: string[] = [] + + const status = text(payload.status) + if (status) { + assertOneOf(status, COST_EXCEPTION_STATUSES, '异常状态无效') + params.push(status) + clauses.push(`ce.status = $${params.length}`) + } + const subjectType = text(payload.subjectType) + if (subjectType) { + assertOneOf(subjectType, COST_EXCEPTION_SUBJECT_TYPES, '异常主体无效') + params.push(subjectType) + clauses.push(`ce.subject_type = $${params.length}`) + } + // 履约方式筛选:异常表不再存载体,改为 join order_items 读 fulfillment_mode。 + // 用 IN 半连接而不是 EXISTS:248k 异常 × 316k 订单项下,EXISTS 会逐行回表, + // 半连接能一次物化候选集合(实测 2.7s -> 0.4s)。 + const fulfillmentMode = text(payload.fulfillmentMode ?? payload.fulfillment_mode) + if (fulfillmentMode) { + assertOneOf(fulfillmentMode, FULFILLMENT_MODES, '履约方式无效') + params.push(fulfillmentMode) + clauses.push( + `ce.order_item_id IN ( + SELECT oi.id FROM order_items oi WHERE oi.fulfillment_mode = $${params.length} + )`, + ) + } + + const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '' + const countResult = await query<{ total: number }>( + `SELECT COUNT(*)::int AS total FROM cost_exceptions ce ${where}`, + params, + ) + const listParams = [...params, pageSize, offset] + const result = await query( + ` + SELECT + ce.*, + oi.fulfillment_mode + FROM cost_exceptions ce + LEFT JOIN order_items oi ON oi.id = ce.order_item_id + ${where} + ORDER BY ce.status ASC, ce.created_at DESC, ce.id DESC + LIMIT $${listParams.length - 1} OFFSET $${listParams.length} + `, + listParams, + ) + + return { + items: result.rows.map(mapCostException), + pagination: { page, pageSize, total: toNumber(countResult.rows[0]?.total) }, + } +} + +export async function resolveAdminCostException( + exceptionId: number | string, + payload: JsonObject, + session: AdminSession, +) { + const id = normalizeId(exceptionId, '异常 ID') + const status = normalizeStatus( + payload.status, + COST_EXCEPTION_STATUSES, + 'resolved', + '异常状态无效', + ) + if (status === 'open') { + throw createHttpError('不能把异常重新置为 open', { + statusCode: 400, + errorCode: 'cost_exception_status_invalid', + }) + } + const now = nowIso() + const result = await query( + ` + UPDATE cost_exceptions + SET status = $1, resolved_at = $2, resolved_by_admin_user_id = $3 + WHERE id = $4 + RETURNING * + `, + [status, now, session.userId, id], + ) + return { exception: mapCostException(requiredRow(result.rows[0], '成本异常不存在')) } +} + +export async function previewAdminCost(payload: JsonObject = {}) { + const context = await resolveCostContext(payload) + const mapping = await findMappingForContext(context) + const productId = context.canonicalProductId || mapping?.canonical_product_id || null + const product = productId ? mapProduct(await getProductRow(productId)) : null + const asOf = + normalizeIsoDate(payload.asOf ?? payload.costBasisAt, '成本基准时间', true) || context.basisAt + const resolution: CostResolution = productId + ? await resolveCostLines({ + provider: context.provider, + shopId: context.shopId, + fulfillmentMode: context.fulfillmentMode, + canonicalProductId: productId, + workProductRuleId: context.workProductRuleId, + quantity: context.normalizedQuantity, + revenueFen: context.revenueFen, + asOf, + }) + : { lines: [], missingCostItems: [], applicableCostItemCount: 0 } + + const exceptions: CostPreview['exceptions'] = [] + if (!productId) { + exceptions.push({ + reason: 'product_mapping_missing', + message: `未找到 ${context.provider} / ${context.shopId || '全局'} / ${context.skuCode} 的标准商品映射`, + detail: { + provider: context.provider, + shopId: context.shopId, + skuCode: context.skuCode, + }, + }) + } else if (resolution.missingCostItems.length > 0) { + exceptions.push({ + reason: 'cost_missing', + message: `以下成本项没有匹配到有效成本版本:${resolution.missingCostItems + .map((item) => `${item.costItemName}(${item.costItemCode})`) + .join('、')}`, + detail: { + fulfillmentMode: context.fulfillmentMode, + costItemIds: resolution.missingCostItems.map((item) => item.costItemId), + }, + }) + } else if (resolution.applicableCostItemCount === 0) { + exceptions.push({ + reason: 'cost_missing', + message: `没有配置适用于「${context.fulfillmentMode}」的成本项`, + detail: { fulfillmentMode: context.fulfillmentMode }, + }) + } + + return { + context: { + ...context, + basisAt: asOf, + }, + mapping: mapping ? mapMapping(mapping) : null, + product, + lines: resolution.lines, + missingCostItems: resolution.missingCostItems, + totalCostFen: resolution.lines.reduce((sum, line) => sum + line.totalAmountFen, 0), + exceptions, + } +} + +export async function createAdminCostSnapshot(payload: JsonObject = {}, _session: AdminSession) { + const preview = await previewAdminCost(payload) + const orderItemId = preview.context.orderItemId + if (!orderItemId) { + throw createHttpError('生成成本快照必须提供订单项 ID', { + statusCode: 400, + errorCode: 'cost_snapshot_order_item_required', + }) + } + const now = nowIso() + const stage = payload.snapshotStage === 'actual' ? 'actual' : 'estimated' + const snapshots: Array> = [] + let exceptionCreatedCount = 0 + let supersededCount = 0 + + await withTransaction(async (client) => { + for (const line of preview.lines) { + const currentResult = await client.query<{ + id: number + snapshot_stage: string + total_amount_fen: number + }>( + ` + SELECT id, snapshot_stage, total_amount_fen + FROM cost_snapshots + WHERE subject_type = 'order_item' + AND subject_id = $1 + AND cost_item_id = $2 + AND superseded_at IS NULL + FOR UPDATE + `, + [orderItemId, line.costItemId], + ) + const current = currentResult.rows[0] + if (current) { + // 预估快照一旦生成就代表支付时点的成本,不因后续改价重写; + // 只有实际成本允许替代当前有效行。 + if (stage === 'estimated') continue + if ( + current.snapshot_stage === 'actual' && + Number(current.total_amount_fen) === line.totalAmountFen + ) { + continue + } + await client.query( + `UPDATE cost_snapshots SET superseded_at = $1 WHERE id = $2 AND superseded_at IS NULL`, + [now, current.id], + ) + supersededCount += 1 + } + + const result = await client.query<{ [column: string]: unknown }>( + ` + INSERT INTO cost_snapshots ( + subject_type, subject_id, order_id, order_item_id, work_order_id, + cost_item_id, cost_version_id, canonical_product_id, + quantity, unit_amount_fen, total_amount_fen, snapshot_stage, source, + metadata_json, cost_basis_at, resolved_at, created_at + ) VALUES ( + 'order_item', $1, $2, $1, $3, $4, $5, $6, + $7, $8, $9, $10, 'resolved', $11::jsonb, $12, $13, $13 + ) + ON CONFLICT (subject_type, subject_id, cost_item_id) + WHERE superseded_at IS NULL + DO NOTHING + RETURNING * + `, + [ + orderItemId, + preview.context.orderId, + preview.context.workOrderId, + line.costItemId, + line.costVersionId, + preview.product?.id || null, + line.quantity, + line.unitAmountFen, + line.totalAmountFen, + stage, + JSON.stringify({ + skuCode: preview.context.skuCode, + skuName: preview.context.skuName, + provider: preview.context.provider, + shopId: preview.context.shopId, + normalizedQuantity: preview.context.normalizedQuantity, + fulfillmentMode: preview.context.fulfillmentMode, + basisAt: preview.context.basisAt, + }), + preview.context.basisAt, + now, + ], + ) + if (result.rows[0]) snapshots.push(result.rows[0]) + } + + for (const exception of preview.exceptions) { + const result = await client.query( + ` + INSERT INTO cost_exceptions ( + subject_type, subject_id, order_item_id, work_order_id, + reason, status, detail_json, created_at + ) VALUES ('order_item', $1, $1, $2, $3, 'open', $4::jsonb, $5) + ON CONFLICT (subject_type, subject_id, reason) DO NOTHING + `, + [ + orderItemId, + preview.context.workOrderId, + exception.reason, + JSON.stringify({ + ...exception.detail, + message: exception.message, + skuCode: preview.context.skuCode, + }), + now, + ], + ) + exceptionCreatedCount += result.rowCount || 0 + } + }) + + return { + preview, + snapshots, + createdCount: snapshots.length, + supersededCount, + exceptionCreatedCount, + } +} + +async function resolveCostContext(payload: JsonObject): Promise { + const orderItemId = normalizeOptionalId(payload.orderItemId ?? payload.order_item_id) + if (orderItemId) { + const result = await query<{ + order_item_id: number + order_id: number + provider: string + shop_id: string + sku_code: string + sku_name: string + normalized_sku_name: string | null + normalized_sku_series: string | null + quantity: number + normalized_quantity: number | null + canonical_product_id: number | null + revenue_fen: number + basis_at: string + work_order_id: number | null + work_product_rule_id: number | null + fulfillment_mode: string | null + }>( + ` + SELECT + oi.id AS order_item_id, + oi.order_id, + o.provider, + o.shop_id, + oi.sku_code, + oi.sku_name, + oi.normalized_sku_name, + oi.normalized_sku_series, + oi.quantity, + oi.normalized_quantity, + oi.canonical_product_id, + oi.revenue_amount_fen AS revenue_fen, + COALESCE(o.paid_at, o.created_at) AS basis_at, + wo.id AS work_order_id, + wo.product_rule_id AS work_product_rule_id, + oi.fulfillment_mode + FROM order_items oi + JOIN orders o ON o.id = oi.order_id + LEFT JOIN LATERAL ( + SELECT id, product_rule_id + FROM work_orders + WHERE order_item_id = oi.id + ORDER BY id DESC + LIMIT 1 + ) wo ON TRUE + WHERE oi.id = $1 + LIMIT 1 + `, + [orderItemId], + ) + const row = result.rows[0] + if (!row) { + throw createHttpError('订单项不存在', { + statusCode: 404, + errorCode: 'cost_order_item_not_found', + }) + } + return { + orderItemId: Number(row.order_item_id), + orderId: Number(row.order_id), + provider: row.provider, + shopId: row.shop_id, + skuCode: row.sku_code, + skuName: row.sku_name, + normalizedSkuName: + row.normalized_sku_name || normalizeMatchText(row.sku_name || row.sku_code), + normalizedSkuSeries: + row.normalized_sku_series || normalizeSkuSeriesEquivalence(row.sku_name || row.sku_code), + quantity: Math.max(1, Number(row.quantity) || 1), + normalizedQuantity: + row.normalized_quantity && Number(row.normalized_quantity) > 0 + ? Number(row.normalized_quantity) + : Math.max(1, resolveSkuNameQuantity(row.sku_name || row.sku_code)), + canonicalProductId: row.canonical_product_id ? Number(row.canonical_product_id) : null, + revenueFen: Math.max(0, Number(row.revenue_fen) || 0), + basisAt: new Date(row.basis_at).toISOString(), + workOrderId: row.work_order_id ? Number(row.work_order_id) : null, + workProductRuleId: row.work_product_rule_id ? Number(row.work_product_rule_id) : null, + fulfillmentMode: resolveFulfillmentMode(row.fulfillment_mode), + } + } + + const provider = normalizeRequiredText(payload.provider, '来源', 64) + const skuCode = normalizeRequiredText(payload.skuCode ?? payload.sku_code, '平台 SKU', 120) + const quantity = Math.max(1, Math.trunc(Number(payload.quantity) || 1)) + const skuName = normalizeOptionalText(payload.skuName ?? payload.sku_name, 200) + const fulfillmentMode = resolveFulfillmentMode( + payload.fulfillmentMode ?? payload.fulfillment_mode, + ) + return { + orderItemId: 0, + orderId: 0, + provider, + shopId: normalizeOptionalText(payload.shopId ?? payload.shop_id, 120), + skuCode, + skuName, + normalizedSkuName: normalizeMatchText(skuName || skuCode), + normalizedSkuSeries: normalizeSkuSeriesEquivalence(skuName || skuCode), + quantity, + normalizedQuantity: quantity, + canonicalProductId: normalizeOptionalId( + payload.canonicalProductId ?? payload.canonical_product_id, + ), + revenueFen: Math.max(0, Math.trunc(Number(payload.revenueFen ?? payload.revenue_fen) || 0)), + basisAt: + normalizeIsoDate(payload.asOf ?? payload.costBasisAt, '成本基准时间', true) || nowIso(), + workOrderId: null, + workProductRuleId: normalizeOptionalId( + payload.workProductRuleId ?? payload.work_product_rule_id, + ), + fulfillmentMode, + } +} + +/** + * 履约方式只读 order_items.fulfillment_mode。 + * 订单项还没判定时按人工处理,不允许用 provider 或核销码兜底。 + */ +function resolveFulfillmentMode(value: unknown): FulfillmentMode { + const normalized = text(value) + if (normalized && FULFILLMENT_MODES.has(normalized)) return normalized as FulfillmentMode + return 'manual' +} + +async function findMappingForContext(context: OrderItemCostContext) { + const result = await query( + ` + SELECT m.*, p.canonical_sku, p.canonical_name + FROM channel_product_mappings m + JOIN product_catalog p ON p.id = m.canonical_product_id AND p.status = 'active' + WHERE m.provider = $1 + AND m.enabled = TRUE + AND (m.shop_id = '' OR m.shop_id = $3) + AND ( + (m.match_type = 'sku_code' AND m.platform_sku_code = $2) + OR ( + m.match_type = 'sku_code_name' + AND m.platform_sku_code = $2 + AND m.platform_sku_name_key = $4 + ) + OR ( + m.match_type = 'sku_code_series' + AND m.platform_sku_code = $2 + AND m.platform_sku_name_key = $5 + ) + OR (m.match_type = 'sku_name_series' AND m.platform_sku_name_key = $5) + ) + ORDER BY + CASE + WHEN m.match_type = 'sku_code' AND m.shop_id = $3 THEN 1 + WHEN m.match_type = 'sku_code' AND m.shop_id = '' THEN 2 + WHEN m.match_type = 'sku_code_name' AND m.shop_id = $3 THEN 3 + WHEN m.match_type = 'sku_code_name' AND m.shop_id = '' THEN 4 + WHEN m.match_type = 'sku_code_series' AND m.shop_id = $3 THEN 5 + WHEN m.match_type = 'sku_code_series' AND m.shop_id = '' THEN 6 + WHEN m.match_type = 'sku_name_series' AND m.shop_id = $3 THEN 7 + ELSE 8 + END, + m.id DESC + LIMIT 1 + `, + [ + context.provider, + context.skuCode, + context.shopId, + context.normalizedSkuName, + context.normalizedSkuSeries, + ], + ) + return result.rows[0] || null +} + +async function resolveCostLines(input: { + provider: string + shopId: string + fulfillmentMode: FulfillmentMode + canonicalProductId: number + workProductRuleId: number | null + quantity: number + revenueFen: number + asOf?: string +}): Promise { + const result = await query< + CostItemRow & { + version_id: number | null + version_scope: string | null + version_amount_fen: number | null + version_rate_bps: number | null + } + >( + ` + SELECT + ci.*, + v.id AS version_id, + v.scope_type AS version_scope, + v.amount_fen AS version_amount_fen, + v.rate_bps AS version_rate_bps + FROM cost_items ci + LEFT JOIN LATERAL ( + SELECT v.* + FROM cost_versions v + WHERE v.cost_item_id = ci.id + AND v.status = 'active' + AND v.effective_from <= $1::timestamptz + AND (v.effective_to IS NULL OR v.effective_to > $1::timestamptz) + AND ( + (v.scope_type = 'rule' AND $5::bigint IS NOT NULL AND v.work_product_rule_id = $5) + OR (v.scope_type = 'product' AND v.canonical_product_id = $4) + OR (v.scope_type = 'shop' AND v.provider = $2 AND v.shop_id = $3) + OR (v.scope_type = 'provider' AND v.provider = $2) + OR (v.scope_type = 'global') + ) + ORDER BY + CASE v.scope_type + WHEN 'rule' THEN 1 + WHEN 'product' THEN 2 + WHEN 'shop' THEN 3 + WHEN 'provider' THEN 4 + ELSE 5 + END, + v.effective_from DESC, + v.id DESC + LIMIT 1 + ) v ON TRUE +WHERE ci.status = 'active' + AND ${costModelScopeSql('$6')} + ORDER BY ci.id + `, + [ + input.asOf || nowIso(), + input.provider, + input.shopId, + input.canonicalProductId, + input.workProductRuleId, + input.fulfillmentMode, + ], + ) + + const lines: CostLine[] = [] + const missingCostItems: MissingCostItem[] = [] + for (const row of result.rows) { + if (!row.version_id) { + missingCostItems.push({ + costItemId: Number(row.id), + costItemCode: row.code, + costItemName: row.name, + costModel: row.cost_model as MissingCostItem['costModel'], + }) + continue + } + const pricingMode = row.pricing_mode + const amountFen = Number(row.version_amount_fen || 0) + const rateBps = Number(row.version_rate_bps || 0) + const quantity = Math.max(0, Number(input.quantity) || 0) + const unitAmountFen = amountFen + const totalAmountFen = calculateCostTotal({ + pricingMode, + amountFen, + rateBps, + quantity, + revenueFen: input.revenueFen, + }) + lines.push({ + costItemId: Number(row.id), + costItemCode: row.code, + costItemName: row.name, + costModel: row.cost_model as CostLine['costModel'], + pricingMode, + costVersionId: Number(row.version_id), + scopeType: String(row.version_scope || ''), + amountFen, + rateBps, + quantity, + unitAmountFen, + totalAmountFen, + }) + } + + return { + lines, + missingCostItems, + applicableCostItemCount: result.rows.length, + } +} + +export function calculateCostTotal(input: { + pricingMode: string + amountFen: number + rateBps: number + quantity: number + revenueFen: number +}) { + const amountFen = Math.max(0, Math.trunc(Number(input.amountFen) || 0)) + const rateBps = Math.max(0, Math.min(10000, Math.trunc(Number(input.rateBps) || 0))) + const quantity = Math.max(0, Number(input.quantity) || 0) + const revenueFen = Math.max(0, Math.trunc(Number(input.revenueFen) || 0)) + let total = amountFen + if (input.pricingMode === 'per_item') { + total = Math.round(amountFen * quantity) + } else if (input.pricingMode === 'percent_of_revenue') { + total = Math.round((revenueFen * rateBps) / 10000) + } else if (input.pricingMode === 'per_order') { + total = amountFen + } + return Math.max(0, total) +} + +export function validateCostVersionScope( + scopeType: string, + input: { + provider: string + shopId: string + canonicalProductId: number | null + workProductRuleId: number | null + }, +) { + if (scopeType === 'provider' && !input.provider) { + throw createHttpError('来源范围必须填写来源', { + statusCode: 400, + errorCode: 'cost_version_scope_invalid', + }) + } + if (scopeType === 'shop' && (!input.provider || !input.shopId)) { + throw createHttpError('店铺范围必须填写来源和店铺', { + statusCode: 400, + errorCode: 'cost_version_scope_invalid', + }) + } + if (scopeType === 'product' && !input.canonicalProductId) { + throw createHttpError('商品范围必须选择标准商品', { + statusCode: 400, + errorCode: 'cost_version_scope_invalid', + }) + } + if (scopeType === 'rule' && !input.workProductRuleId) { + throw createHttpError('规则范围必须选择接单规则', { + statusCode: 400, + errorCode: 'cost_version_scope_invalid', + }) + } +} + +function normalizeCostVersionScopeFields( + scopeType: string, + input: { + provider: string + shopId: string + canonicalProductId: number | null + workProductRuleId: number | null + }, +) { + const empty = { + provider: '', + shopId: '', + canonicalProductId: null as number | null, + workProductRuleId: null as number | null, + } + if (scopeType === 'provider') { + return { ...empty, provider: input.provider } + } + if (scopeType === 'shop') { + return { ...empty, provider: input.provider, shopId: input.shopId } + } + if (scopeType === 'product') { + return { ...empty, canonicalProductId: input.canonicalProductId } + } + if (scopeType === 'rule') { + return { ...empty, workProductRuleId: input.workProductRuleId } + } + return empty +} + +async function getCostItemRow(id: number) { + const result = await query('SELECT * FROM cost_items WHERE id = $1 LIMIT 1', [id]) + return requiredRow(result.rows[0], '成本项不存在') +} + +async function getCostVersionById(id: number) { + const result = await query( + ` + SELECT + v.*, + ci.code AS cost_item_code, + ci.name AS cost_item_name, + ci.cost_model AS cost_item_cost_model, + ci.pricing_mode, + p.canonical_sku, + p.canonical_name + 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 + WHERE v.id = $1 + LIMIT 1 + `, + [id], + ) + return mapCostVersion(requiredRow(result.rows[0], '成本版本不存在')) +} 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 a3a58944..3bc935e4 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 @@ -2,6 +2,7 @@ import type { PoolClient } from 'pg' import { withTransaction } from '../../db/client.js' import { resolveFinanceDateRange } from '../../utils/finance-date-range.js' +import { costModelScopeSql } from './cost-center-shared.js' export type CostSnapshotSyncInput = { dateFrom?: unknown @@ -53,7 +54,7 @@ export async function syncEstimatedOrderItemCostSnapshots( COALESCE(oi.revenue_amount_fen, 0)::bigint AS revenue_fen, wo.id AS work_order_id, wo.product_rule_id AS work_product_rule_id, - COALESCE(oi.fulfillment_mode, 'manual') AS carrier_type + COALESCE(oi.fulfillment_mode, 'manual') AS fulfillment_mode FROM order_items oi JOIN orders o ON o.id = oi.order_id LEFT JOIN LATERAL ( @@ -77,7 +78,7 @@ export async function syncEstimatedOrderItemCostSnapshots( v.rate_bps FROM item_scope s JOIN cost_items ci ON ci.status = 'active' - AND ci.carrier_type IN (s.carrier_type, 'shared') + AND ${costModelScopeSql('s.fulfillment_mode')} AND ci.pricing_mode IN ('per_item', 'per_order', 'percent_of_revenue') AND ci.code <> 'sys_work_order_reward' JOIN LATERAL ( @@ -124,13 +125,13 @@ export async function syncEstimatedOrderItemCostSnapshots( ) INSERT INTO cost_snapshots ( subject_type, subject_id, order_id, order_item_id, work_order_id, - carrier_type, cost_item_id, cost_version_id, canonical_product_id, + cost_item_id, cost_version_id, canonical_product_id, quantity, unit_amount_fen, total_amount_fen, snapshot_stage, source, metadata_json, cost_basis_at, resolved_at, created_at ) SELECT 'order_item', c.order_item_id, c.order_id, c.order_item_id, c.work_order_id, - c.carrier_type, c.cost_item_id, c.cost_version_id, c.canonical_product_id, + c.cost_item_id, c.cost_version_id, c.canonical_product_id, CASE WHEN c.pricing_mode = 'per_item' THEN c.quantity ELSE 1 END, CASE WHEN c.pricing_mode = 'percent_of_revenue' THEN c.total_amount_fen ELSE c.amount_fen END, c.total_amount_fen, @@ -143,6 +144,7 @@ export async function syncEstimatedOrderItemCostSnapshots( 'pricingMode', c.pricing_mode, 'revenueFen', c.revenue_fen, 'normalizedQuantity', c.quantity, + 'fulfillmentMode', c.fulfillment_mode, 'basisAt', c.basis_at ), c.basis_at, NOW(), NOW() @@ -219,7 +221,7 @@ export async function syncActualWorkOrderCostSnapshots( a.work_order_id AS actual_work_order_id, a.reward_amount, a.accepted_at, - ci.cost_type + ci.cost_model FROM cost_snapshots cs JOIN accepted a ON cs.subject_type = 'order_item' AND cs.subject_id = a.order_item_id JOIN cost_items ci ON ci.id = cs.cost_item_id @@ -230,9 +232,9 @@ export async function syncActualWorkOrderCostSnapshots( target AS ( SELECT * FROM active - WHERE snapshot_stage = 'estimated' +WHERE snapshot_stage = 'estimated' OR total_amount_fen <> CASE - WHEN cost_type = 'work_order_reward' AND reward_amount > 0 THEN reward_amount + WHEN cost_model = 'work_order_reward' AND reward_amount > 0 THEN reward_amount ELSE total_amount_fen END ), @@ -243,24 +245,24 @@ export async function syncActualWorkOrderCostSnapshots( WHERE cs.id = t.id AND cs.superseded_at IS NULL RETURNING - cs.id, cs.subject_id, cs.order_id, cs.order_item_id, cs.carrier_type, + cs.id, cs.subject_id, cs.order_id, cs.order_item_id, cs.cost_item_id, cs.cost_version_id, cs.canonical_product_id, cs.quantity, cs.unit_amount_fen, cs.total_amount_fen, cs.metadata_json ) INSERT INTO cost_snapshots ( subject_type, subject_id, order_id, order_item_id, work_order_id, - carrier_type, cost_item_id, cost_version_id, canonical_product_id, + cost_item_id, cost_version_id, canonical_product_id, quantity, unit_amount_fen, total_amount_fen, snapshot_stage, source, metadata_json, cost_basis_at, resolved_at, created_at ) SELECT 'order_item', s.subject_id, s.order_id, s.order_item_id, a.work_order_id, - s.carrier_type, s.cost_item_id, s.cost_version_id, s.canonical_product_id, - CASE WHEN ci.cost_type = 'work_order_reward' AND a.reward_amount > 0 THEN 1 + s.cost_item_id, s.cost_version_id, s.canonical_product_id, + CASE WHEN ci.cost_model = 'work_order_reward' AND a.reward_amount > 0 THEN 1 ELSE s.quantity END, - CASE WHEN ci.cost_type = 'work_order_reward' AND a.reward_amount > 0 + CASE WHEN ci.cost_model = 'work_order_reward' AND a.reward_amount > 0 THEN a.reward_amount ELSE s.unit_amount_fen END, - CASE WHEN ci.cost_type = 'work_order_reward' AND a.reward_amount > 0 + CASE WHEN ci.cost_model = 'work_order_reward' AND a.reward_amount > 0 THEN a.reward_amount ELSE s.total_amount_fen END, 'actual', 'resolved', s.metadata_json || jsonb_build_object( @@ -389,13 +391,13 @@ async function syncDirectWorkOrderRewardSnapshots( ) INSERT INTO cost_snapshots ( subject_type, subject_id, order_id, order_item_id, work_order_id, - carrier_type, cost_item_id, cost_version_id, canonical_product_id, + cost_item_id, cost_version_id, canonical_product_id, quantity, unit_amount_fen, total_amount_fen, snapshot_stage, source, metadata_json, cost_basis_at, resolved_at, created_at ) SELECT 'order_item', a.order_item_id, oi.order_id, a.order_item_id, a.work_order_id, - 'work_order', si.cost_item_id, COALESCE(rv.id, si.cost_version_id), + 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( @@ -531,14 +533,14 @@ async function syncCommittedWorkOrderRewardSnapshots( ORDER BY wo.order_item_id, wo.id DESC ) INSERT INTO cost_snapshots ( - subject_type, subject_id, order_id, order_item_id, work_order_id, - carrier_type, cost_item_id, cost_version_id, canonical_product_id, + subject_type, subject_id, order_item_id, work_order_id, + cost_item_id, cost_version_id, canonical_product_id, quantity, unit_amount_fen, total_amount_fen, snapshot_stage, source, metadata_json, cost_basis_at, resolved_at, created_at ) SELECT - 'order_item', l.order_item_id, oi.order_id, l.order_item_id, l.work_order_id, - 'work_order', si.cost_item_id, COALESCE(rv.id, si.cost_version_id), + 'order_item', l.order_item_id, oi.order_id, l.work_order_id, + 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( @@ -605,7 +607,7 @@ async function insertAndResolveProductMappingExceptions( oi.canonical_product_id, COALESCE(o.paid_at, o.created_at) AS basis_at, wo.id AS work_order_id, - COALESCE(oi.fulfillment_mode, 'manual') AS carrier_type + COALESCE(oi.fulfillment_mode, 'manual') AS fulfillment_mode FROM order_items oi JOIN orders o ON o.id = oi.order_id LEFT JOIN LATERAL ( @@ -617,16 +619,17 @@ async function insertAndResolveProductMappingExceptions( ) INSERT INTO cost_exceptions ( subject_type, subject_id, order_item_id, work_order_id, - carrier_type, reason, status, detail_json, created_at + reason, status, detail_json, created_at ) SELECT 'order_item', s.order_item_id, s.order_item_id, s.work_order_id, - s.carrier_type, 'product_mapping_missing', 'open', + 'product_mapping_missing', 'open', jsonb_build_object( 'provider', s.provider, 'shopId', s.shop_id, 'skuCode', s.sku_code, 'skuName', s.sku_name, + 'fulfillmentMode', s.fulfillment_mode, 'basisAt', s.basis_at, 'message', '订单项没有归一到标准商品,销售事实会进入未归一桶' ), @@ -668,7 +671,7 @@ async function insertAndResolveCostMissingExceptions( oi.canonical_product_id, COALESCE(o.paid_at, o.created_at) AS basis_at, wo.id AS work_order_id, - COALESCE(oi.fulfillment_mode, 'manual') AS carrier_type + COALESCE(oi.fulfillment_mode, 'manual') AS fulfillment_mode FROM order_items oi JOIN orders o ON o.id = oi.order_id LEFT JOIN LATERAL ( @@ -680,14 +683,14 @@ async function insertAndResolveCostMissingExceptions( ) INSERT INTO cost_exceptions ( subject_type, subject_id, order_item_id, work_order_id, - carrier_type, reason, status, detail_json, created_at + reason, status, detail_json, created_at ) SELECT 'order_item', s.order_item_id, s.order_item_id, s.work_order_id, - s.carrier_type, 'cost_missing', 'open', + 'cost_missing', 'open', jsonb_build_object( 'canonicalProductId', s.canonical_product_id, - 'carrierType', s.carrier_type, + 'fulfillmentMode', s.fulfillment_mode, 'basisAt', s.basis_at, 'message', '订单项没有匹配到任何成本版本' ), diff --git a/apps/backend/src/services/cost-center/product-catalog-service.ts b/apps/backend/src/services/cost-center/product-catalog-service.ts new file mode 100644 index 00000000..dea5959f --- /dev/null +++ b/apps/backend/src/services/cost-center/product-catalog-service.ts @@ -0,0 +1,985 @@ +/** + * 标准商品与渠道映射服务。 + * + * 口径(2026-10-23 重写): + * 1. 商品表不存履约载体,来源只由 canonical_sku 前缀表达; + * 2. 渠道映射唯一键包含匹配方式与名称键,91 同店铺复用 sku_code 才能消歧; + * 3. 归一数量必须由回填脚本按 SKU 名称解析后写入,不预填 quantity。 + */ +import { query } from '../../db/client.js' +import type { AdminSession } from '../admin/admin-auth-service.js' +import type { JsonObject } from '../../types/json.js' +import { createHttpError } from '../../utils/http.js' +import { nowIso } from '../../utils/time.js' +import { normalizeMatchText, normalizeSkuSeriesEquivalence } from '../product-match/normalize.js' +import { resolveSkuNameQuantity } from '../product-match/pricing.js' +import { + AFFILIATE_TASK_AGG_JOIN, + MAPPING_CONFIDENCES, + MAPPING_SOURCES, + PRODUCT_CATEGORY_JOINS, + PRODUCT_SOURCE_PREFIXES, + PRODUCT_STATUSES, + assertKnownProvider, + assertOneOf, + mapMapping, + mapProduct, + mappingKey, + normalizeBoolean, + normalizeCategoryFilter, + normalizeId, + normalizeJsonObject, + normalizeMappingMatchType, + normalizeOptionalId, + normalizeOptionalText, + normalizePage, + normalizePageSize, + normalizePositiveInteger, + normalizeProviderKey, + normalizeRequiredText, + normalizeStatus, + parseJsonObject, + productSourceFilterSql, + requiredRow, + resolveMappingNameKey, + text, + toNumber, + type MappingRow, + type ProductCategoryNode, + type ProductCategoryStatRow, + type ProductRow, + type WorkCategoryRow, +} from './cost-center-shared.js' + +/** 商品来源的筛选取值:与 canonical_sku 前缀一一对应。 */ +const PRODUCT_SOURCE_FILTERS = new Set(['affiliate_dash', 'work_order', 'manual']) + +/** + * 校验新建商品的 SKU 前缀。 + * 前缀是商品来源的唯一表达,不允许出现无法归类的商品。 + */ +function assertKnownSourcePrefix(canonicalSku: string) { + for (const item of PRODUCT_SOURCE_PREFIXES) { + if (canonicalSku.startsWith(item.prefix)) return + } + throw createHttpError( + `标准 SKU 必须以下列前缀开头:${PRODUCT_SOURCE_PREFIXES.map((item) => item.prefix).join(' / ')}`, + { + statusCode: 400, + errorCode: 'cost_product_sku_prefix_invalid', + }, + ) +} + +export async function listAdminProducts(payload: JsonObject = {}) { + const page = normalizePage(payload.page) + const pageSize = normalizePageSize(payload.pageSize) + const offset = (page - 1) * pageSize + const baseParams: unknown[] = [] + const baseClauses: string[] = [] + + const keyword = text(payload.keyword) + if (keyword) { + baseParams.push(`%${keyword}%`) + baseClauses.push( + `(p.canonical_sku ILIKE $${baseParams.length} OR p.canonical_name ILIKE $${baseParams.length} OR p.category ILIKE $${baseParams.length} OR root.name ILIKE $${baseParams.length})`, + ) + } + const status = text(payload.status) + if (status) { + assertOneOf(status, PRODUCT_STATUSES, '商品状态无效') + baseParams.push(status) + baseClauses.push(`p.status = $${baseParams.length}`) + } + // carrierType 参数保留,语义改为「按 canonical_sku 前缀筛选商品来源」。 + const source = text(payload.carrierType ?? payload.carrier_type ?? payload.source) + if (source) { + assertOneOf(source, PRODUCT_SOURCE_FILTERS, '商品来源无效') + baseParams.push(source) + baseClauses.push(productSourceFilterSql(baseParams.length)) + } + + const categoryId = normalizeCategoryFilter(payload.categoryId ?? payload.category_id) + const rootCategoryId = normalizeCategoryFilter(payload.rootCategoryId ?? payload.root_category_id) + const listParams = [...baseParams] + const listClauses = [...baseClauses] + if (categoryId === -1) { + listClauses.push('c.id IS NULL') + } else if (categoryId) { + listParams.push(categoryId) + listClauses.push(`c.id = $${listParams.length}`) + } else if (rootCategoryId === -1) { + listClauses.push('c.id IS NULL') + } else if (rootCategoryId) { + listParams.push(rootCategoryId) + listClauses.push(`COALESCE(c.parent_id, c.id) = $${listParams.length}`) + } + + const baseWhere = baseClauses.length ? `WHERE ${baseClauses.join(' AND ')}` : '' + const where = listClauses.length ? `WHERE ${listClauses.join(' AND ')}` : '' + const pageParams = [...listParams, pageSize, offset] + + const [countResult, result, categoryStatResult, categoryResult] = await Promise.all([ + query<{ total: number }>( + `SELECT COUNT(*)::int AS total FROM product_catalog p ${PRODUCT_CATEGORY_JOINS} ${where}`, + listParams, + ), + query( + ` + SELECT + p.*, + CASE WHEN c.parent_id IS NOT NULL THEN c.id END AS sub_category_id, + CASE WHEN c.parent_id IS NOT NULL THEN c.name ELSE '' END AS sub_category_name, + 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.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 + ${PRODUCT_CATEGORY_JOINS} + ${AFFILIATE_TASK_AGG_JOIN} + ${where} + ORDER BY + p.status ASC, + CASE + WHEN p.canonical_sku LIKE 'aff:%' THEN 1 + WHEN p.canonical_sku LIKE 'tpl:%' OR p.canonical_sku LIKE 'wo:%' THEN 2 + WHEN p.canonical_sku LIKE 'man:%' THEN 3 + ELSE 4 + END, + p.canonical_name ASC, + p.id ASC + LIMIT $${pageParams.length - 1} OFFSET $${pageParams.length} + `, + pageParams, + ), + query( + ` + SELECT c.id AS category_id, c.name, c.parent_id, COUNT(*)::int AS total + FROM product_catalog p + ${PRODUCT_CATEGORY_JOINS} + ${baseWhere} + GROUP BY c.id, c.name, c.parent_id + `, + baseParams, + ), + query( + ` + SELECT id, name, parent_id + FROM work_categories + ORDER BY sort_order ASC, id ASC + `, + ), + ]) + + const categoryCounts = new Map() + let uncategorizedTotal = 0 + for (const row of categoryStatResult.rows) { + const total = toNumber(row.total) + if (!row.category_id) { + uncategorizedTotal += total + continue + } + categoryCounts.set(Number(row.category_id), total) + } + + const categoryTree: ProductCategoryNode[] = [] + const rootById = new Map() + for (const category of categoryResult.rows) { + if (category.parent_id) continue + const categoryId = Number(category.id) + const total = categoryCounts.get(categoryId) || 0 + const node: ProductCategoryNode = { + categoryId, + name: category.name, + total, + directTotal: total, + children: [], + } + rootById.set(categoryId, node) + categoryTree.push(node) + } + for (const category of categoryResult.rows) { + if (!category.parent_id) continue + const parent = rootById.get(Number(category.parent_id)) + if (!parent) continue + const total = categoryCounts.get(Number(category.id)) || 0 + parent.children.push({ + categoryId: Number(category.id), + name: category.name, + total, + }) + parent.total += total + } + + return { + items: result.rows.map(mapProduct), + categoryTree, + uncategorizedTotal, + pagination: { page, pageSize, total: toNumber(countResult.rows[0]?.total) }, + } +} + +export async function createAdminProduct(payload: JsonObject, _session: AdminSession) { + const canonicalSku = normalizeRequiredText(payload.canonicalSku, '标准 SKU', 64) + if (!/^[A-Za-z0-9._:-]+$/.test(canonicalSku)) { + throw createHttpError('标准 SKU 只能包含字母、数字、点、下划线、冒号和短横线', { + statusCode: 400, + errorCode: 'cost_product_sku_invalid', + }) + } + assertKnownSourcePrefix(canonicalSku) + const canonicalName = normalizeRequiredText( + payload.canonicalName ?? payload.canonical_name, + '商品名称', + 120, + ) + const category = normalizeOptionalText(payload.category, 64) + const unitName = normalizeOptionalText(payload.unitName ?? payload.unit_name, 16) || '件' + const status = normalizeStatus(payload.status, PRODUCT_STATUSES, 'active', '商品状态无效') + const attributes = normalizeJsonObject(payload.attributes ?? payload.attributesJson) + const now = nowIso() + + const result = await query( + ` + INSERT INTO product_catalog ( + canonical_sku, canonical_name, category, unit_name, status, + attributes_json, created_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $7) + ON CONFLICT (canonical_sku) DO NOTHING + RETURNING * + `, + [canonicalSku, canonicalName, category, unitName, status, JSON.stringify(attributes), now], + ) + const inserted = result.rows[0] + if (!inserted) { + throw createHttpError('标准 SKU 已存在', { + statusCode: 409, + errorCode: 'cost_product_sku_exists', + }) + } + return { product: mapProduct(inserted) } +} + +export async function updateAdminProduct(productId: number | string, payload: JsonObject) { + const id = normalizeId(productId, '商品 ID') + const current = await getProductRow(id) + const canonicalName = normalizeRequiredText( + payload.canonicalName ?? payload.canonical_name ?? current.canonical_name, + '商品名称', + 120, + ) + const category = + payload.category === undefined ? current.category : normalizeOptionalText(payload.category, 64) + const unitName = + payload.unitName === undefined && payload.unit_name === undefined + ? current.unit_name + : normalizeOptionalText(payload.unitName ?? payload.unit_name, 16) || '件' + const status = + payload.status === undefined + ? current.status + : normalizeStatus(payload.status, PRODUCT_STATUSES, current.status, '商品状态无效') + const attributes = + payload.attributes === undefined && payload.attributesJson === undefined + ? parseJsonObject(current.attributes_json) + : normalizeJsonObject(payload.attributes ?? payload.attributesJson) + + const result = await query( + ` + UPDATE product_catalog + SET canonical_name = $1, category = $2, unit_name = $3, status = $4, + attributes_json = $5::jsonb, updated_at = $6 + WHERE id = $7 + RETURNING * + `, + [canonicalName, category, unitName, status, JSON.stringify(attributes), nowIso(), id], + ) + return { product: mapProduct(requiredRow(result.rows[0], '商品不存在')) } +} + +/** + * 删除标准商品:只允许删除没有任何引用的商品。 + * 有渠道映射/成本版本/成本快照/订单项时返回 409,提示改为归档。 + */ +export async function deleteAdminProduct(productId: number | string) { + const id = normalizeId(productId, '商品 ID') + const current = await getProductRow(id) + const usageResult = await query<{ + mapping_count: number + version_count: number + snapshot_count: number + order_item_count: number + }>( + ` + SELECT + (SELECT COUNT(*)::int FROM channel_product_mappings WHERE canonical_product_id = $1) AS mapping_count, + (SELECT COUNT(*)::int FROM cost_versions WHERE canonical_product_id = $1) AS version_count, + (SELECT COUNT(*)::int FROM cost_snapshots WHERE canonical_product_id = $1) AS snapshot_count, + (SELECT COUNT(*)::int FROM order_items WHERE canonical_product_id = $1) AS order_item_count + `, + [id], + ) + const usage = usageResult.rows[0] + const blockers: string[] = [] + if (toNumber(usage?.mapping_count) > 0) + blockers.push(`${toNumber(usage?.mapping_count)} 条渠道映射`) + if (toNumber(usage?.version_count) > 0) + blockers.push(`${toNumber(usage?.version_count)} 个成本版本`) + if (toNumber(usage?.snapshot_count) > 0) + blockers.push(`${toNumber(usage?.snapshot_count)} 条成本快照`) + if (toNumber(usage?.order_item_count) > 0) + blockers.push(`${toNumber(usage?.order_item_count)} 个订单项`) + if (blockers.length > 0) { + throw createHttpError(`商品仍被引用(${blockers.join('、')}),不能删除;请改为归档`, { + statusCode: 409, + errorCode: 'cost_product_in_use', + }) + } + await query('DELETE FROM product_catalog WHERE id = $1', [id]) + return { deleted: true, product: mapProduct(current) } +} + +type MappingProductGroupRow = { + canonical_product_id: number + canonical_sku: string + canonical_name: string + category: string + unit_name: string + status: string +} + +export async function listAdminChannelMappings(payload: JsonObject = {}) { + const page = normalizePage(payload.page) + const pageSize = normalizePageSize(payload.pageSize) + const offset = (page - 1) * pageSize + const params: unknown[] = [] + const clauses: string[] = [] + + const keyword = text(payload.keyword) + if (keyword) { + params.push(`%${keyword}%`) + clauses.push( + `(m.platform_sku_code ILIKE $${params.length} OR m.platform_sku_name ILIKE $${params.length} + OR p.canonical_sku ILIKE $${params.length} OR p.canonical_name ILIKE $${params.length})`, + ) + } + const provider = text(payload.provider) + if (provider) { + params.push(normalizeProviderKey(provider)) + clauses.push(`m.provider = $${params.length}`) + } + const canonicalProductId = normalizeOptionalId(payload.canonicalProductId) + if (canonicalProductId) { + params.push(canonicalProductId) + clauses.push(`m.canonical_product_id = $${params.length}`) + } + // carrierType 参数保留,语义改为「按 canonical_sku 前缀筛选商品来源」。 + const source = text(payload.carrierType ?? payload.carrier_type ?? payload.source) + if (source) { + assertOneOf(source, PRODUCT_SOURCE_FILTERS, '商品来源无效') + params.push(source) + clauses.push(productSourceFilterSql(params.length)) + } + if (payload.enabled !== undefined && payload.enabled !== '') { + params.push(normalizeBoolean(payload.enabled)) + clauses.push(`m.enabled = $${params.length}`) + } + + const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '' + const groupParams = [...params, pageSize, offset] + const [groupResult, statsResult] = await Promise.all([ + query( + ` + SELECT + p.id AS canonical_product_id, + p.canonical_sku, + p.canonical_name, + p.category, + p.unit_name, + p.status + FROM channel_product_mappings m + JOIN product_catalog p ON p.id = m.canonical_product_id + ${where} + GROUP BY + p.id, + p.canonical_sku, + p.canonical_name, + p.category, + p.unit_name, + p.status + ORDER BY p.canonical_name ASC, p.id ASC + LIMIT $${groupParams.length - 1} OFFSET $${groupParams.length} + `, + groupParams, + ), + query<{ total_count: number; mapping_total: number }>( + ` + SELECT + COUNT(DISTINCT p.id)::int AS total_count, + COUNT(*)::int AS mapping_total + FROM channel_product_mappings m + JOIN product_catalog p ON p.id = m.canonical_product_id + ${where} + `, + params, + ), + ]) + + const groups = groupResult.rows + const totalCount = toNumber(statsResult.rows[0]?.total_count) + const mappingTotal = toNumber(statsResult.rows[0]?.mapping_total) + if (groups.length === 0) { + return { + groups: [], + pagination: { page, pageSize, total: totalCount, mappingTotal }, + } + } + + const productIds = groups.map((row) => Number(row.canonical_product_id)) + const mappingParams = [...params, productIds] + const mappingsResult = await query( + ` + SELECT + m.*, + p.canonical_sku, + p.canonical_name + FROM channel_product_mappings m + JOIN product_catalog p ON p.id = m.canonical_product_id + ${where} + ${where ? 'AND' : 'WHERE'} m.canonical_product_id = ANY($${mappingParams.length}::bigint[]) + ORDER BY + p.canonical_name ASC, + p.id ASC, + m.provider ASC, + m.shop_id ASC, + m.match_type ASC, + m.platform_sku_code ASC, + m.id DESC + `, + mappingParams, + ) + + const mappingsByProduct = new Map[]>() + for (const row of mappingsResult.rows) { + const productId = Number(row.canonical_product_id) + const list = mappingsByProduct.get(productId) || [] + list.push(mapMapping(row)) + mappingsByProduct.set(productId, list) + } + + return { + groups: groups.map((row) => ({ + canonicalProductId: Number(row.canonical_product_id), + canonicalSku: row.canonical_sku, + canonicalName: row.canonical_name, + category: row.category || '', + unitName: row.unit_name || '件', + status: row.status, + mappings: mappingsByProduct.get(Number(row.canonical_product_id)) || [], + })), + pagination: { + page, + pageSize, + total: totalCount, + mappingTotal, + }, + } +} + +export async function createAdminChannelMapping(payload: JsonObject, _session: AdminSession) { + const provider = normalizeProviderKey(normalizeRequiredText(payload.provider, '来源', 64)) + assertKnownProvider(provider) + const shopId = normalizeOptionalText(payload.shopId ?? payload.shop_id, 120) + const matchType = normalizeMappingMatchType(payload.matchType ?? payload.match_type) + const platformSkuCode = normalizeOptionalText( + payload.platformSkuCode ?? payload.platform_sku_code, + 120, + ) + const platformSkuName = normalizeOptionalText( + payload.platformSkuName ?? payload.platform_sku_name, + 200, + ) + if (!platformSkuCode && matchType !== 'sku_name_series') { + throw createHttpError('该匹配方式必须填写平台 SKU', { + statusCode: 400, + errorCode: 'cost_mapping_sku_required', + }) + } + const platformSkuNameKey = resolveMappingNameKey(matchType, platformSkuName) + if (matchType !== 'sku_code' && !platformSkuNameKey) { + throw createHttpError('该匹配方式必须填写平台 SKU 名称', { + statusCode: 400, + errorCode: 'cost_mapping_sku_name_required', + }) + } + const canonicalProductId = normalizeId( + payload.canonicalProductId ?? payload.canonical_product_id, + '标准商品 ID', + ) + await getProductRow(canonicalProductId) + const quantityMultiplier = normalizePositiveInteger( + payload.quantityMultiplier ?? payload.quantity_multiplier, + 1, + ) + const confidence = normalizeStatus( + payload.confidence, + MAPPING_CONFIDENCES, + 'manual', + '映射置信度无效', + ) + const source = normalizeStatus(payload.source, MAPPING_SOURCES, 'manual', '映射来源无效') + const enabled = payload.enabled === undefined ? true : normalizeBoolean(payload.enabled) + const note = normalizeOptionalText(payload.note, 500) + const now = nowIso() + + const result = await query( + ` + INSERT INTO channel_product_mappings ( + provider, shop_id, platform_sku_code, platform_sku_name, + platform_sku_name_key, match_type, + canonical_product_id, quantity_multiplier, confidence, source, + enabled, note, created_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $13) + ON CONFLICT ( + provider, shop_id, platform_sku_code, match_type, platform_sku_name_key + ) DO NOTHING + RETURNING * + `, + [ + provider, + shopId, + platformSkuCode, + platformSkuName, + platformSkuNameKey, + matchType, + canonicalProductId, + quantityMultiplier, + confidence, + source, + enabled, + note, + now, + ], + ) + const inserted = result.rows[0] + if (!inserted) { + throw createHttpError('该来源 + 店铺 + 平台 SKU 与匹配方式已有映射', { + statusCode: 409, + errorCode: 'cost_mapping_exists', + }) + } + const mapping = await getMappingRow(Number(inserted.id)) + return { mapping: mapMapping(mapping) } +} + +export async function updateAdminChannelMapping(mappingId: number | string, payload: JsonObject) { + const id = normalizeId(mappingId, '映射 ID') + const current = await getMappingRow(id) + const provider = normalizeProviderKey( + normalizeRequiredText(payload.provider ?? current.provider, '来源', 64), + ) + assertKnownProvider(provider) + const shopId = + payload.shopId === undefined && payload.shop_id === undefined + ? current.shop_id + : normalizeOptionalText(payload.shopId ?? payload.shop_id, 120) + const matchType = normalizeMappingMatchType( + payload.matchType ?? payload.match_type ?? current.match_type, + ) + const platformSkuCode = normalizeOptionalText( + payload.platformSkuCode ?? payload.platform_sku_code ?? current.platform_sku_code, + 120, + ) + if (!platformSkuCode && matchType !== 'sku_name_series') { + throw createHttpError('该匹配方式必须填写平台 SKU', { + statusCode: 400, + errorCode: 'cost_mapping_sku_required', + }) + } + const platformSkuName = + payload.platformSkuName === undefined && payload.platform_sku_name === undefined + ? current.platform_sku_name + : normalizeOptionalText(payload.platformSkuName ?? payload.platform_sku_name, 200) + const platformSkuNameKey = resolveMappingNameKey(matchType, platformSkuName) + if (matchType !== 'sku_code' && !platformSkuNameKey) { + throw createHttpError('该匹配方式必须填写平台 SKU 名称', { + statusCode: 400, + errorCode: 'cost_mapping_sku_name_required', + }) + } + const canonicalProductId = normalizeId( + payload.canonicalProductId ?? payload.canonical_product_id ?? current.canonical_product_id, + '标准商品 ID', + ) + await getProductRow(canonicalProductId) + await assertMappingKeyAvailable( + provider, + shopId, + platformSkuCode, + matchType, + platformSkuNameKey, + id, + ) + const quantityMultiplier = normalizePositiveInteger( + payload.quantityMultiplier ?? payload.quantity_multiplier ?? current.quantity_multiplier, + 1, + ) + const confidence = normalizeStatus( + payload.confidence, + MAPPING_CONFIDENCES, + current.confidence, + '映射置信度无效', + ) + const source = normalizeStatus(payload.source, MAPPING_SOURCES, current.source, '映射来源无效') + const enabled = + payload.enabled === undefined ? current.enabled : normalizeBoolean(payload.enabled) + const note = payload.note === undefined ? current.note : normalizeOptionalText(payload.note, 500) + + const result = await query( + ` + UPDATE channel_product_mappings + SET provider = $1, shop_id = $2, platform_sku_code = $3, platform_sku_name = $4, + platform_sku_name_key = $5, match_type = $6, + canonical_product_id = $7, quantity_multiplier = $8, confidence = $9, + source = $10, enabled = $11, note = $12, updated_at = $13 + WHERE id = $14 + RETURNING * + `, + [ + provider, + shopId, + platformSkuCode, + platformSkuName, + platformSkuNameKey, + matchType, + canonicalProductId, + quantityMultiplier, + confidence, + source, + enabled, + note, + nowIso(), + id, + ], + ) + const updated = result.rows[0] + if (!updated) { + throw createHttpError('映射不存在', { statusCode: 404, errorCode: 'cost_mapping_not_found' }) + } + const mapping = await getMappingRow(id) + return { mapping: mapMapping(mapping) } +} + +export async function deleteAdminChannelMapping(mappingId: number | string) { + const id = normalizeId(mappingId, '映射 ID') + const result = await query<{ id: number }>( + 'DELETE FROM channel_product_mappings WHERE id = $1 RETURNING id', + [id], + ) + if (!result.rows[0]) { + throw createHttpError('映射不存在', { statusCode: 404, errorCode: 'cost_mapping_not_found' }) + } + return { deleted: true, mappingId: Number(result.rows[0].id) } +} + +export async function backfillAdminProductMappings(payload: JsonObject = {}) { + const provider = normalizeProviderKey(text(payload.provider)) + const limit = Math.min(20000, Math.max(1, Math.trunc(Number(payload.limit) || 5000))) + const all = payload.all === true || payload.all === 'true' + const maxBatches = all ? 40 : 1 + let updatedCount = 0 + let batches = 0 + let lastId = 0 + + const mappingsResult = await query( + ` + SELECT + m.provider, + m.shop_id, + m.platform_sku_code, + m.platform_sku_name_key, + m.match_type, + m.canonical_product_id + FROM channel_product_mappings m + JOIN product_catalog p + ON p.id = m.canonical_product_id + AND p.status = 'active' + WHERE m.enabled = TRUE + `, + ) + const lookup = buildMappingLookup(mappingsResult.rows) + + for (let batch = 0; batch < maxBatches; batch += 1) { + const itemsResult = await query( + ` + SELECT + oi.id, + o.provider, + o.shop_id, + oi.sku_code, + oi.normalized_sku_name, + oi.normalized_sku_series + FROM order_items oi + JOIN orders o ON o.id = oi.order_id + WHERE oi.canonical_product_id IS NULL + AND oi.id > $1 + AND ($2 = '' OR o.provider = $2) + ORDER BY oi.id + LIMIT $3 + `, + [lastId, provider, limit], + ) + if (itemsResult.rows.length === 0) break + + lastId = Number(itemsResult.rows[itemsResult.rows.length - 1]?.id || lastId) + const ids: number[] = [] + const productIds: number[] = [] + for (const row of itemsResult.rows) { + const productId = resolveMappingProductId(lookup, row) + if (!productId) continue + ids.push(Number(row.id)) + productIds.push(productId) + } + + if (ids.length > 0) { + const updated = await query( + ` + UPDATE order_items oi + SET canonical_product_id = input.canonical_product_id, + updated_at = NOW() + FROM unnest($1::bigint[], $2::bigint[]) + AS input(id, canonical_product_id) + WHERE oi.id = input.id + AND oi.canonical_product_id IS NULL + `, + [ids, productIds], + ) + updatedCount += updated.rowCount || 0 + } + batches += 1 + if (!all || itemsResult.rows.length < limit) break + } + + const remainingResult = await query<{ total: number }>( + ` + SELECT COUNT(*)::int AS total + FROM order_items oi + JOIN orders o ON o.id = oi.order_id + WHERE oi.canonical_product_id IS NULL + AND ($1 = '' OR o.provider = $1) + `, + [provider], + ) + return { + updatedCount, + limit, + batches, + remainingCount: Number(remainingResult.rows[0]?.total || 0), + } +} + +type MappingLookupRow = { + provider: string + shop_id: string + platform_sku_code: string + platform_sku_name_key: string + match_type: string + canonical_product_id: number +} + +export type MappingLookupItemRow = { + id: number + provider: string + shop_id: string + sku_code: string + normalized_sku_name: string | null + normalized_sku_series: string | null +} + +export type MappingLookup = { + byKey: Map +} + +function buildMappingLookup(rows: MappingLookupRow[]): MappingLookup { + const byKey = new Map() + for (const row of rows) { + const key = mappingKey( + row.provider, + row.shop_id, + row.platform_sku_code, + row.match_type, + row.platform_sku_name_key, + ) + if (!byKey.has(key)) byKey.set(key, Number(row.canonical_product_id)) + } + return { byKey } +} + +export function resolveMappingProductId( + lookup: MappingLookup, + item: MappingLookupItemRow, +): number | null { + const shopId = item.shop_id || '' + const nameKey = item.normalized_sku_name || normalizeMatchText(item.sku_code) + const seriesKey = item.normalized_sku_series || normalizeSkuSeriesEquivalence(item.sku_code) + const candidates = [ + mappingKey(item.provider, shopId, item.sku_code, 'sku_code', ''), + mappingKey(item.provider, '', item.sku_code, 'sku_code', ''), + mappingKey(item.provider, shopId, item.sku_code, 'sku_code_name', nameKey), + mappingKey(item.provider, '', item.sku_code, 'sku_code_name', nameKey), + mappingKey(item.provider, shopId, item.sku_code, 'sku_code_series', seriesKey), + mappingKey(item.provider, '', item.sku_code, 'sku_code_series', seriesKey), + mappingKey(item.provider, shopId, '', 'sku_name_series', seriesKey), + mappingKey(item.provider, '', '', 'sku_name_series', seriesKey), + ] + for (const key of candidates) { + const productId = lookup.byKey.get(key) + if (productId) return productId + } + return null +} + +export async function backfillAdminNormalizedQuantities(payload: JsonObject = {}) { + const limit = Math.min(50000, Math.max(1, Math.trunc(Number(payload.limit) || 20000))) + const all = payload.all === true || payload.all === 'true' + const maxBatches = all ? 40 : 1 + let updatedCount = 0 + let batches = 0 + + for (let batch = 0; batch < maxBatches; batch += 1) { + const result = await query<{ + id: number + sku_name: string + sku_code: string + normalized_sku_name: string | null + normalized_sku_series: string | null + normalized_quantity: number | null + }>( + ` + SELECT id, sku_name, sku_code, normalized_sku_name, normalized_sku_series, + normalized_quantity + FROM order_items + WHERE normalized_quantity IS NULL + OR normalized_sku_name IS NULL + OR normalized_sku_series IS NULL + ORDER BY id + LIMIT $1 + `, + [limit], + ) + if (result.rows.length === 0) break + + const ids: number[] = [] + const quantities: number[] = [] + const skuNames: string[] = [] + const skuSeries: string[] = [] + for (const row of result.rows) { + const skuText = row.sku_name || row.sku_code + ids.push(Number(row.id)) + quantities.push(Math.max(1, resolveSkuNameQuantity(skuText))) + skuNames.push(normalizeMatchText(skuText)) + skuSeries.push(normalizeSkuSeriesEquivalence(skuText)) + } + + const updated = await query( + ` + UPDATE order_items oi + SET normalized_quantity = input.quantity, + normalized_sku_name = input.sku_name_key, + normalized_sku_series = input.sku_series_key, + updated_at = NOW() + FROM unnest($1::bigint[], $2::int[], $3::text[], $4::text[]) + AS input(id, quantity, sku_name_key, sku_series_key) + WHERE oi.id = input.id + AND ( + oi.normalized_quantity IS NULL + OR oi.normalized_sku_name IS NULL + OR oi.normalized_sku_series IS NULL + ) + `, + [ids, quantities, skuNames, skuSeries], + ) + updatedCount += updated.rowCount || 0 + batches += 1 + if (!all || result.rows.length < limit) break + } + + const remainingResult = await query<{ total: number }>( + `SELECT COUNT(*)::int AS total + FROM order_items + WHERE normalized_quantity IS NULL + OR normalized_sku_name IS NULL + OR normalized_sku_series IS NULL`, + ) + return { + updatedCount, + limit, + batches, + remainingCount: Number(remainingResult.rows[0]?.total || 0), + } +} + +export async function getProductRow(id: number) { + const result = await query('SELECT * FROM product_catalog WHERE id = $1 LIMIT 1', [ + id, + ]) + return requiredRow(result.rows[0], '标准商品不存在') +} + +async function getMappingRow(id: number) { + const result = await query( + ` + SELECT m.*, p.canonical_sku, p.canonical_name + FROM channel_product_mappings m + JOIN product_catalog p ON p.id = m.canonical_product_id + WHERE m.id = $1 + LIMIT 1 + `, + [id], + ) + return requiredRow(result.rows[0], '渠道映射不存在') +} + +async function assertMappingKeyAvailable( + provider: string, + shopId: string, + platformSkuCode: string, + matchType: string, + platformSkuNameKey: string, + excludeId?: number, +) { + const result = await query<{ id: number }>( + ` + SELECT id + FROM channel_product_mappings + WHERE provider = $1 + AND shop_id = $2 + AND platform_sku_code = $3 + AND match_type = $4 + AND platform_sku_name_key = $5 + AND ($6::bigint IS NULL OR id <> $6) + LIMIT 1 + `, + [provider, shopId, platformSkuCode, matchType, platformSkuNameKey, excludeId ?? null], + ) + if (result.rows[0]) { + throw createHttpError('该来源 + 店铺 + 平台 SKU 与匹配方式已有映射', { + statusCode: 409, + errorCode: 'cost_mapping_exists', + }) + } +} diff --git a/apps/backend/src/services/cost-center/template-bootstrap-service.ts b/apps/backend/src/services/cost-center/template-bootstrap-service.ts new file mode 100644 index 00000000..309375c8 --- /dev/null +++ b/apps/backend/src/services/cost-center/template-bootstrap-service.ts @@ -0,0 +1,1370 @@ +/** + * 接单模板引导、自动发货商品同步与工单证据回填。 + * + * 口径(2026-10-23 重写): + * 1. 商品表不存履约载体,来源只由 canonical_sku 前缀表达: + * aff: 自动发货 / tpl: 接单模板 / wo: 工单证据; + * 2. 自动发货 SKU 固定归一为 aff:,不写渠道映射; + * 3. 匹配方式与名称键都参与映射唯一键,91 同店铺复用 sku_code 才能消歧。 + */ +import { query, withTransaction } from '../../db/client.js' +import type { AdminSession } from '../admin/admin-auth-service.js' +import type { JsonObject } from '../../types/json.js' +import { createHttpError } from '../../utils/http.js' +import { shiftDate } from '../../utils/finance-date-range.js' +import { nowIso } from '../../utils/time.js' +import { rebuildSalesDailyFacts } from '../finance/sales-facts-service.js' +import { normalizeMatchText, normalizeSkuSeriesEquivalence } from '../product-match/normalize.js' +import { + MAPPING_MATCH_TYPES, + WORK_ORDER_TEMPLATE_COST_BASELINE, + candidateKey, + mappingKey, + mappingMatchTypeLabel, + normalizeMappingMatchType, + resolveMappingNameKey, + type TemplateMappingMatchType, +} from './cost-center-shared.js' +import { + backfillAdminNormalizedQuantities, + backfillAdminProductMappings, +} from './product-catalog-service.js' + +export async function previewAdminTemplateProductBootstrap() { + const data = await loadTemplateProductBootstrapData() + const plan = buildTemplateMappingPlan(data.evidenceRows) + const templateRuleKeys = new Set(data.templates.map((row) => row.rule_key)) + const candidates = plan.candidates.filter( + (row) => + templateRuleKeys.has(row.ruleKey) && + !data.existingMappingKeys.has( + mappingKey(row.provider, row.shopId, row.skuCode, row.matchType, row.skuNameKey), + ), + ) + const usedRuleIds = new Set(data.evidenceRows.map((row) => Number(row.product_rule_id))) + const candidatesByMatchType = countMappingCandidates(candidates) + return { + templateCount: data.templates.length, + productExistingCount: data.templates.length - data.productsToCreate.length, + productCreateCount: data.productsToCreate.length, + mappingCandidateCount: plan.candidates.length, + mappingExistingCount: plan.candidates.length - candidates.length, + mappingCreateCount: candidates.length, + mappingCreateByMatchType: candidatesByMatchType, + evidenceItemCount: plan.evidenceItemCount, + orderItemCoverCount: Math.max(0, plan.evidenceItemCount - plan.unresolvedItemCount), + mappingConflictCount: plan.conflicts.length, + mappingConflictItemCount: plan.unresolvedItemCount, + multiRuleKeyCount: plan.codeConflictKeyCount, + seriesFallbackConflictCount: plan.seriesFallbackConflictCount, + templatesWithoutWorkOrders: data.templates.filter((row) => !usedRuleIds.has(Number(row.id))) + .length, + samples: candidates.slice(0, 30).map((row) => ({ + ruleKey: row.ruleKey, + productName: data.templates.find((item) => item.rule_key === row.ruleKey)?.product_name || '', + provider: row.provider, + shopId: row.shopId, + skuCode: row.skuCode, + skuName: row.skuName, + matchType: row.matchType, + itemCount: row.itemCount, + })), + conflicts: plan.conflicts.slice(0, 20).map((row) => ({ + provider: row.provider, + shopId: row.shopId, + skuCode: row.skuCode, + skuName: row.skuName, + matchType: row.matchType, + itemCount: row.itemCount, + ruleKeys: row.ruleKeys, + })), + } +} + +export async function applyAdminTemplateProductBootstrap( + payload: JsonObject = {}, + _session?: AdminSession, +) { + const repair = payload.repair !== false + const resetResult = repair + ? await resetImportedTemplateProductBootstrap() + : { deletedMappingCount: 0, resetOrderItemCount: 0 } + const data = await loadTemplateProductBootstrapData() + if (data.templates.length === 0) { + throw createHttpError('没有可导入的接单模板', { + statusCode: 400, + errorCode: 'cost_template_bootstrap_empty', + }) + } + + const plan = buildTemplateMappingPlan(data.evidenceRows) + const now = nowIso() + let createdProductCount = 0 + let reusedProductCount = 0 + let createdMappingCount = 0 + let skippedMappingCount = 0 + let mappedOrderItemCount = 0 + + await withTransaction(async (client) => { + const productIdByRuleKey = new Map() + for (const template of data.templates) { + const existing = data.productByRuleKey.get(template.rule_key) + if (existing) { + productIdByRuleKey.set(template.rule_key, existing) + reusedProductCount += 1 + continue + } + + const canonicalSku = buildTemplateProductSku(template.rule_key) + const inserted = await client.query<{ id: number }>( + ` + INSERT INTO product_catalog ( + canonical_sku, canonical_name, category, unit_name, status, + attributes_json, created_at, updated_at + ) VALUES ($1, $2, $3, $4, 'active', $5::jsonb, $6, $6) + ON CONFLICT (canonical_sku) DO NOTHING + RETURNING id + `, + [ + canonicalSku, + template.product_name, + template.category, + inferTemplateUnitName(template.product_name), + JSON.stringify({ + source: 'work_product_rule', + ruleId: Number(template.id), + ruleKey: template.rule_key, + mappingId: template.mapping_id ? Number(template.mapping_id) : null, + mappingType: template.mapping_type, + skuNick: template.sku_nick, + relSkuId: template.rel_sku_id, + sellerIds: template.seller_ids_json, + unitPriceFen: Number(template.unit_price_fen || 0), + rewardAmountFen: Number(template.reward_amount || 0), + }), + now, + ], + ) + let productId = inserted.rows[0]?.id ? Number(inserted.rows[0].id) : 0 + if (productId) { + createdProductCount += 1 + } else { + const existingResult = await client.query<{ id: number }>( + 'SELECT id FROM product_catalog WHERE canonical_sku = $1 LIMIT 1', + [canonicalSku], + ) + productId = Number(existingResult.rows[0]?.id || 0) + if (productId) reusedProductCount += 1 + } + if (productId) productIdByRuleKey.set(template.rule_key, productId) + } + + for (const row of plan.candidates) { + const productId = productIdByRuleKey.get(row.ruleKey) + if (!productId) continue + const inserted = await client.query<{ id: number }>( + ` + INSERT INTO channel_product_mappings ( + provider, shop_id, platform_sku_code, platform_sku_name, + platform_sku_name_key, match_type, + canonical_product_id, quantity_multiplier, confidence, source, + enabled, note, created_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, 1, 'rule', 'import', TRUE, $8, $9, $9) + ON CONFLICT ( + provider, shop_id, platform_sku_code, match_type, platform_sku_name_key + ) DO NOTHING + RETURNING id + `, + [ + row.provider, + row.shopId, + row.skuCode, + row.skuName, + row.skuNameKey, + row.matchType, + productId, + `接单模板 ${row.ruleKey} 自动初始化(${mappingMatchTypeLabel(row.matchType)})`, + now, + ], + ) + if (inserted.rows[0]) { + createdMappingCount += 1 + } else { + skippedMappingCount += 1 + } + } + }) + + // 名称/系列键必须先于映射回填;所有批次都放在事务外,避免语句超时。 + 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, + createdProductCount, + reusedProductCount, + createdMappingCount, + skippedMappingCount, + mappedOrderItemCount, + deletedImportedMappingCount: resetResult.deletedMappingCount, + resetOrderItemCount: resetResult.resetOrderItemCount, + mappingConflictCount: plan.conflicts.length, + mappingConflictItemCount: plan.unresolvedItemCount, + templateCostVersionCount: templateCosts.activeVersionCount, + templateCostCreatedCount: templateCosts.createdCount, + templateCostRetiredCount: templateCosts.retiredCount, + } +} + +/** + * 按自动发货履约证据同步标准商品。 + * + * 只有真正走 affiliate-dash 执行器的订单项才算自动发货;渠道映射只是 + * 平台 SKU 归一,不能作为自动发货依据。SKU 固定为 `aff:`, + * 重复同步只更新名称/证据,不会重复建商品。 + */ +export async function syncAdminAffiliateDashProducts() { + const now = nowIso() + const result = await query<{ inserted: boolean }>( + ` + WITH auto_task AS ( + SELECT + btrim(context_json->>'skuCode') AS sku_code, + btrim(context_json->>'skuName') AS sku_name, + created_at + FROM fulfillment_tasks + WHERE executor_key = 'affiliate_dash' + AND btrim(COALESCE(context_json->>'skuCode', '')) <> '' + AND btrim(COALESCE(context_json->>'skuName', '')) <> '' + ), + agg AS ( + SELECT sku_code, count(*)::int AS task_count, max(created_at) AS last_fulfilled_at + FROM auto_task + GROUP BY sku_code + ), + ranked AS ( + SELECT + sku_code, + sku_name, + row_number() OVER ( + PARTITION BY sku_code + ORDER BY (sku_name <> sku_code) DESC, count(*) DESC, sku_name + ) AS rn + FROM auto_task + GROUP BY sku_code, sku_name + ) + INSERT INTO product_catalog ( + canonical_sku, canonical_name, category, unit_name, status, + attributes_json, created_at, updated_at + ) + SELECT + 'aff:' || ranked.sku_code, + ranked.sku_name, + '', + CASE WHEN ranked.sku_name LIKE '%个' THEN '个' ELSE '件' END, + 'active', + jsonb_build_object( + 'source', 'affiliate_dash_task', + 'skuCode', ranked.sku_code, + 'taskCount', agg.task_count, + 'lastFulfilledAt', agg.last_fulfilled_at + ), + $1, + $1 + FROM ranked + JOIN agg ON agg.sku_code = ranked.sku_code + WHERE ranked.rn = 1 + ON CONFLICT (canonical_sku) DO UPDATE + SET canonical_name = EXCLUDED.canonical_name, + unit_name = EXCLUDED.unit_name, + attributes_json = product_catalog.attributes_json || EXCLUDED.attributes_json, + updated_at = $1 + RETURNING (xmax = 0) AS inserted + `, + [now], + ) + const createdCount = result.rows.filter((row) => row.inserted).length + return { + createdCount, + updatedCount: result.rows.length - createdCount, + productCount: result.rows.length, + } +} + +type AffiliateSkuBackfillRow = { + affiliate_item_count: number + matched_count: number + updated_count: number + from_date: string | null + to_date: string | null +} + +/** 销售事实单次重算最多 400 天,长历史按窗口分批。 */ +async function rebuildFactsForRange(fromDate: string, toDate: string) { + let cursor = fromDate + let deletedCount = 0 + let insertedCount = 0 + let batchCount = 0 + while (cursor <= toDate) { + const windowEnd = shiftDate(cursor, 399) + const chunkEnd = windowEnd > toDate ? toDate : windowEnd + const rebuilt = await rebuildSalesDailyFacts({ dateFrom: cursor, dateTo: chunkEnd }) + deletedCount += rebuilt.deletedCount + insertedCount += rebuilt.insertedCount + batchCount += 1 + cursor = shiftDate(chunkEnd, 1) + } + return { fromDate, toDate, deletedCount, insertedCount, batchCount } +} + +/** + * 按自动发货履约证据直接回填订单项标准商品。 + * + * 自动发货只有 affiliate-dash 一个上游渠道,SKU 固定归一为 `aff:`, + * 不写 channel_product_mappings。多个任务共用同一订单项时取最新 SKU。 + */ +export async function backfillAdminAffiliateDashSkus() { + const now = nowIso() + const result = await query( + ` + WITH affiliate_item AS ( + SELECT DISTINCT ON (ft.order_item_id) + ft.order_item_id AS id, + btrim(ft.context_json->>'skuCode') AS sku_code + FROM fulfillment_tasks ft + WHERE ft.executor_key = 'affiliate_dash' + AND btrim(COALESCE(ft.context_json->>'skuCode', '')) <> '' + ORDER BY ft.order_item_id, ft.created_at DESC, ft.id DESC + ), + target AS ( + SELECT ai.id, p.id AS canonical_product_id + FROM affiliate_item ai + JOIN product_catalog p + ON p.canonical_sku = 'aff:' || ai.sku_code + ), + updated AS ( + UPDATE order_items oi + SET canonical_product_id = target.canonical_product_id, + updated_at = $1 + FROM target + WHERE oi.id = target.id + AND oi.canonical_product_id IS DISTINCT FROM target.canonical_product_id + RETURNING oi.id + ) + SELECT + ( + SELECT COUNT(DISTINCT ft.order_item_id)::int + FROM fulfillment_tasks ft + WHERE ft.executor_key = 'affiliate_dash' + ) AS affiliate_item_count, + (SELECT COUNT(*)::int FROM target) AS matched_count, + (SELECT COUNT(*)::int FROM updated) AS updated_count, + ( + SELECT to_char( + MIN((COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date), + 'YYYY-MM-DD' + ) + FROM affiliate_item + JOIN order_items oi ON oi.id = affiliate_item.id + JOIN orders o ON o.id = oi.order_id + ) AS from_date, + ( + SELECT to_char( + MAX((COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date), + 'YYYY-MM-DD' + ) + FROM affiliate_item + JOIN order_items oi ON oi.id = affiliate_item.id + JOIN orders o ON o.id = oi.order_id + ) AS to_date + `, + [now], + ) + + const row = result.rows[0] + const affiliateItemCount = Number(row?.affiliate_item_count || 0) + const matchedCount = Number(row?.matched_count || 0) + const updatedCount = Number(row?.updated_count || 0) + const skippedCount = Math.max(0, matchedCount - updatedCount) + const remainingResult = await query<{ total: number }>( + ` + SELECT COUNT(*)::int AS total + FROM order_items oi + WHERE oi.canonical_product_id IS NULL + AND EXISTS ( + SELECT 1 FROM fulfillment_tasks ft + WHERE ft.order_item_id = oi.id + AND ft.executor_key = 'affiliate_dash' + ) + `, + ) + const remainingCount = Number(remainingResult.rows[0]?.total || 0) + + let factsRebuild: Awaited> | null = null + if (matchedCount > 0 && row?.from_date && row?.to_date) { + factsRebuild = await rebuildFactsForRange(row.from_date, row.to_date) + } + + return { + updatedCount, + skippedCount, + remainingCount, + affiliateItemCount, + matchedCount, + factsRebuild, + } +} + +type WorkOrderSkuBackfillRow = { + work_order_item_count: number + matched_count: number + updated_count: number + from_date: string | null + to_date: string | null +} + +/** + * 回填接单平台订单项的标准商品。 + * + * 第一步用工单真实使用的 `product_rule_id` 指向 `tpl:`; + * 仍无法归一的长尾项,再按 `provider + shop_id + sku_code + normalized_sku_name` + * 补建 `wo:` 商品和 `sku_code_name` 渠道映射,保证后续同类订单能自动命中。 + */ +export async function backfillAdminWorkOrderProductSkus() { + const now = nowIso() + const result = await query( + ` + WITH work_order_item AS ( + SELECT DISTINCT ON (wo.order_item_id) + wo.order_item_id AS id, + wo.product_rule_id + FROM work_orders wo + WHERE wo.order_item_id IS NOT NULL + ORDER BY wo.order_item_id, wo.id DESC + ), + target AS ( + SELECT woi.id, p.id AS canonical_product_id + FROM work_order_item woi + JOIN work_product_rules wpr ON wpr.id = woi.product_rule_id + JOIN product_catalog p ON p.canonical_sku = 'tpl:' || wpr.rule_key + ), + updated AS ( + UPDATE order_items oi + SET canonical_product_id = target.canonical_product_id, + updated_at = $1 + FROM target + WHERE oi.id = target.id + AND oi.canonical_product_id IS DISTINCT FROM target.canonical_product_id + RETURNING oi.id + ) + SELECT + ( + SELECT COUNT(DISTINCT wo.order_item_id)::int + FROM work_orders wo + WHERE wo.order_item_id IS NOT NULL + ) AS work_order_item_count, + (SELECT COUNT(*)::int FROM target) AS matched_count, + (SELECT COUNT(*)::int FROM updated) AS updated_count, + ( + SELECT to_char( + MIN((COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date), + 'YYYY-MM-DD' + ) + FROM work_order_item + JOIN order_items oi ON oi.id = work_order_item.id + JOIN orders o ON o.id = oi.order_id + ) AS from_date, + ( + SELECT to_char( + MAX((COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date), + 'YYYY-MM-DD' + ) + FROM work_order_item + JOIN order_items oi ON oi.id = work_order_item.id + JOIN orders o ON o.id = oi.order_id + ) AS to_date + `, + [now], + ) + + const tailProducts = await query<{ inserted: boolean }>( + ` + WITH tail AS ( + SELECT + oi.normalized_sku_name AS name, + MIN(oi.sku_name) AS sample_name + FROM order_items oi + WHERE oi.canonical_product_id IS NULL + AND btrim(COALESCE(oi.normalized_sku_name, '')) <> '' + AND EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id) + GROUP BY 1 + ), + missing AS ( + SELECT t.name, t.sample_name + FROM tail t + WHERE NOT EXISTS ( + SELECT 1 FROM product_catalog p + WHERE p.status = 'active' AND p.canonical_name = t.name + ) + ) + INSERT INTO product_catalog ( + canonical_sku, canonical_name, category, unit_name, status, + attributes_json, created_at, updated_at + ) + SELECT + 'wo:' || substr(md5(m.name), 1, 12), + m.name, + '', + CASE + WHEN m.sample_name LIKE '%颗%' THEN '颗' + WHEN m.sample_name LIKE '%个%' THEN '个' + ELSE '件' + END, + 'active', + jsonb_build_object('source', 'work_order_sku_name', 'normalizedName', m.name), + $1, + $1 + FROM missing m + ON CONFLICT (canonical_sku) DO UPDATE + SET updated_at = $1 + RETURNING (xmax = 0) AS inserted + `, + [now], + ) + + const tailMappings = await query<{ id: number }>( + ` + WITH tail AS ( + SELECT + o.provider, + o.shop_id, + oi.sku_code, + MIN(oi.sku_name) AS sku_name, + oi.normalized_sku_name + FROM order_items oi + JOIN orders o ON o.id = oi.order_id + WHERE oi.canonical_product_id IS NULL + AND btrim(COALESCE(oi.normalized_sku_name, '')) <> '' + AND EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id) + GROUP BY 1, 2, 3, 5 + ), + resolved AS ( + SELECT t.*, p.id AS canonical_product_id + FROM tail t + JOIN LATERAL ( + SELECT id + FROM product_catalog + WHERE status = 'active' AND canonical_name = t.normalized_sku_name + ORDER BY (canonical_sku LIKE 'tpl:%') DESC, id + LIMIT 1 + ) p ON TRUE + ) + INSERT INTO channel_product_mappings ( + provider, shop_id, platform_sku_code, platform_sku_name, + platform_sku_name_key, match_type, canonical_product_id, + quantity_multiplier, confidence, source, enabled, note, created_at, updated_at + ) + SELECT + r.provider, r.shop_id, r.sku_code, r.sku_name, + r.normalized_sku_name, 'sku_code_name', r.canonical_product_id, + 1, 'exact', 'import', TRUE, '按历史工单 SKU 名称自动补齐', $1, $1 + FROM resolved r + ON CONFLICT ( + provider, shop_id, platform_sku_code, match_type, platform_sku_name_key + ) DO NOTHING + RETURNING id + `, + [now], + ) + + const tailUpdated = await query<{ id: number }>( + ` + WITH tail AS ( + SELECT oi.id, oi.normalized_sku_name + FROM order_items oi + WHERE oi.canonical_product_id IS NULL + AND btrim(COALESCE(oi.normalized_sku_name, '')) <> '' + AND EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id) + ), + resolved AS ( + SELECT t.id, p.id AS canonical_product_id + FROM tail t + JOIN LATERAL ( + SELECT id + FROM product_catalog + WHERE status = 'active' AND canonical_name = t.normalized_sku_name + ORDER BY (canonical_sku LIKE 'tpl:%') DESC, id + LIMIT 1 + ) p ON TRUE + ) + UPDATE order_items oi + SET canonical_product_id = r.canonical_product_id, + updated_at = $1 + FROM resolved r + WHERE oi.id = r.id + AND oi.canonical_product_id IS NULL + RETURNING oi.id + `, + [now], + ) + + const row = result.rows[0] + const workOrderItemCount = Number(row?.work_order_item_count || 0) + const ruleMatchedCount = Number(row?.matched_count || 0) + const ruleUpdatedCount = Number(row?.updated_count || 0) + const tailProductCount = tailProducts.rows.filter((item) => item.inserted).length + const tailMappingCount = tailMappings.rows.length + const tailUpdatedCount = tailUpdated.rows.length + const updatedCount = ruleUpdatedCount + tailUpdatedCount + const remainingResult = await query<{ total: number }>( + ` + SELECT COUNT(*)::int AS total + FROM order_items oi + WHERE oi.canonical_product_id IS NULL + AND EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id) + `, + ) + const remainingCount = Number(remainingResult.rows[0]?.total || 0) + const matchedCount = Math.max(0, workOrderItemCount - remainingCount) + const skippedCount = Math.max(0, matchedCount - updatedCount) + + let factsRebuild: Awaited> | null = null + if (matchedCount > 0 && row?.from_date && row?.to_date) { + factsRebuild = await rebuildFactsForRange(row.from_date, row.to_date) + } + + return { + updatedCount, + skippedCount, + remainingCount, + workOrderItemCount, + matchedCount, + ruleMatchedCount, + tailUpdatedCount, + tailProductCount, + tailMappingCount, + factsRebuild, + } +} + +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 + product_name: string + category: string + unit_price_fen: number + reward_amount: number + mapping_id: number | null + mapping_type: string + sku_nick: string + rel_sku_id: string + seller_ids_json: unknown +} + +export type TemplateBootstrapEvidenceRow = { + provider: string + shop_id: string + sku_code: string + sku_name: string + product_rule_id: number + rule_key: string + item_count: number +} + +export type TemplateMappingCandidate = { + provider: string + shopId: string + skuCode: string + skuName: string + skuNameKey: string + matchType: TemplateMappingMatchType + ruleId: number + ruleKey: string + itemCount: number +} + +export type TemplateMappingConflict = { + provider: string + shopId: string + skuCode: string + skuName: string + matchType: 'sku_code_name' + itemCount: number + ruleIds: number[] + ruleKeys: string[] +} + +export type TemplateMappingPlan = { + candidates: TemplateMappingCandidate[] + conflicts: TemplateMappingConflict[] + evidenceItemCount: number + unresolvedItemCount: number + codeConflictKeyCount: number + seriesFallbackConflictCount: number +} + +/** + * 将历史工单的直接 rule_id 证据转换为渠道映射候选。 + * + * 编码唯一时直接按编码映射;编码被复用(91 常见)时,只有 + * 编码 + SKU 名称系列 / 编码 + 精确名称唯一才生成映射。 + * 多层仍无法唯一归属的组合进入 conflicts,绝不按“最新工单”猜测。 + */ +export function buildTemplateMappingPlan( + rows: TemplateBootstrapEvidenceRow[], +): TemplateMappingPlan { + const candidates = new Map() + const conflicts = new Map() + const evidenceItemCount = sumItemCount(rows) + let unresolvedItemCount = 0 + let codeConflictKeyCount = 0 + let seriesFallbackConflictCount = 0 + + function addCandidate( + input: Omit & { + itemCount: number + sampleSkuName: string + }, + ) { + const key = candidateKey( + input.provider, + input.shopId, + input.skuCode, + input.matchType, + input.skuNameKey, + ) + const current = candidates.get(key) + if (current) { + current.itemCount += input.itemCount + if (input.itemCount > current.bestSampleCount) { + current.skuName = input.sampleSkuName + current.bestSampleCount = input.itemCount + } + return + } + candidates.set(key, { + provider: input.provider, + shopId: input.shopId, + skuCode: input.skuCode, + skuName: input.sampleSkuName, + skuNameKey: input.skuNameKey, + matchType: input.matchType, + ruleId: input.ruleId, + ruleKey: input.ruleKey, + itemCount: input.itemCount, + bestSampleCount: input.itemCount, + }) + } + + function addConflict(input: { + provider: string + shopId: string + skuCode: string + skuName: string + nameKey: string + itemCount: number + ruleIds: number[] + ruleKeys: string[] + }) { + const key = candidateKey( + input.provider, + input.shopId, + input.skuCode, + 'sku_code_name', + input.nameKey, + ) + const current = conflicts.get(key) + if (current) { + current.itemCount += input.itemCount + current.ruleIds = Array.from(new Set([...current.ruleIds, ...input.ruleIds])).sort( + (left, right) => left - right, + ) + current.ruleKeys = Array.from(new Set([...current.ruleKeys, ...input.ruleKeys])).sort() + return + } + conflicts.set(key, { + provider: input.provider, + shopId: input.shopId, + skuCode: input.skuCode, + skuName: input.skuName, + matchType: 'sku_code_name', + itemCount: input.itemCount, + ruleIds: [...input.ruleIds].sort((left, right) => left - right), + ruleKeys: [...input.ruleKeys].sort(), + }) + } + + const codeGroups = groupRows(rows, (row) => [row.provider, row.shop_id, row.sku_code]) + for (const codeRows of codeGroups.values()) { + const codeRuleIds = uniqueRuleIds(codeRows) + if (codeRuleIds.length === 1) { + const sample = representativeRow(codeRows) + addCandidate({ + provider: sample.provider, + shopId: sample.shop_id, + skuCode: sample.sku_code, + skuName: sample.sku_name, + skuNameKey: '', + matchType: 'sku_code', + ruleId: Number(sample.product_rule_id), + ruleKey: sample.rule_key, + itemCount: sumItemCount(codeRows), + sampleSkuName: sample.sku_name, + }) + continue + } + + codeConflictKeyCount += 1 + const nameGroups = groupRows(codeRows, (row) => [ + normalizeSkuEvidenceName(row.sku_name, row.sku_code), + ]) + for (const nameRows of nameGroups.values()) { + const nameRuleIds = uniqueRuleIds(nameRows) + const sample = representativeRow(nameRows) + const nameKey = normalizeSkuEvidenceName(sample.sku_name, sample.sku_code) + if (nameRuleIds.length === 1) { + addCandidate({ + provider: sample.provider, + shopId: sample.shop_id, + skuCode: sample.sku_code, + skuName: sample.sku_name, + skuNameKey: nameKey, + matchType: 'sku_code_name', + ruleId: Number(sample.product_rule_id), + ruleKey: sample.rule_key, + itemCount: sumItemCount(nameRows), + sampleSkuName: sample.sku_name, + }) + } else { + const itemCount = sumItemCount(nameRows) + unresolvedItemCount += itemCount + addConflict({ + provider: sample.provider, + shopId: sample.shop_id, + skuCode: sample.sku_code, + skuName: sample.sku_name, + nameKey, + itemCount, + ruleIds: nameRuleIds, + ruleKeys: uniqueRuleKeys(nameRows), + }) + } + } + + const seriesGroups = groupRows(codeRows, (row) => [ + normalizeSkuEvidenceSeries(row.sku_name, row.sku_code), + ]) + for (const seriesRows of seriesGroups.values()) { + const seriesRuleIds = uniqueRuleIds(seriesRows) + if (seriesRuleIds.length !== 1) { + seriesFallbackConflictCount += 1 + continue + } + const sample = representativeRow(seriesRows) + addCandidate({ + provider: sample.provider, + shopId: sample.shop_id, + skuCode: sample.sku_code, + skuName: sample.sku_name, + skuNameKey: normalizeSkuEvidenceSeries(sample.sku_name, sample.sku_code), + matchType: 'sku_code_series', + ruleId: Number(sample.product_rule_id), + ruleKey: sample.rule_key, + itemCount: sumItemCount(seriesRows), + sampleSkuName: sample.sku_name, + }) + } + } + + // 商品名系列兜底:仅在同一来源 + 店铺内唯一归属时生成,供后续新编码使用。 + const globalSeriesGroups = groupRows(rows, (row) => [ + row.provider, + row.shop_id, + normalizeSkuEvidenceSeries(row.sku_name, row.sku_code), + ]) + for (const seriesRows of globalSeriesGroups.values()) { + const seriesRuleIds = uniqueRuleIds(seriesRows) + const sample = representativeRow(seriesRows) + const seriesKey = normalizeSkuEvidenceSeries(sample.sku_name, sample.sku_code) + if (!seriesKey) continue + if (seriesRuleIds.length !== 1) { + seriesFallbackConflictCount += 1 + continue + } + addCandidate({ + provider: sample.provider, + shopId: sample.shop_id, + skuCode: '', + skuName: sample.sku_name, + skuNameKey: seriesKey, + matchType: 'sku_name_series', + ruleId: Number(sample.product_rule_id), + ruleKey: sample.rule_key, + itemCount: sumItemCount(seriesRows), + sampleSkuName: sample.sku_name, + }) + } + + return { + candidates: Array.from(candidates.values()) + .map(({ bestSampleCount: _bestSampleCount, ...row }) => row) + .sort(compareMappingCandidates), + conflicts: Array.from(conflicts.values()).sort( + (left, right) => + right.itemCount - left.itemCount || + left.provider.localeCompare(right.provider) || + left.shopId.localeCompare(right.shopId) || + left.skuCode.localeCompare(right.skuCode), + ), + evidenceItemCount, + unresolvedItemCount, + codeConflictKeyCount, + seriesFallbackConflictCount, + } +} + +function countMappingCandidates(rows: TemplateMappingCandidate[]) { + const counts: Record = { + sku_code: 0, + sku_code_name: 0, + sku_code_series: 0, + sku_name_series: 0, + } + for (const row of rows) counts[row.matchType] += 1 + return counts +} + +function groupRows(rows: T[], keyOf: (row: T) => Array) { + const groups = new Map() + for (const row of rows) { + const key = keyOf(row).map(String).join('\u0000') + const list = groups.get(key) + if (list) list.push(row) + else groups.set(key, [row]) + } + return groups +} + +function uniqueRuleIds(rows: TemplateBootstrapEvidenceRow[]) { + return Array.from(new Set(rows.map((row) => Number(row.product_rule_id)))).sort( + (left, right) => left - right, + ) +} + +function uniqueRuleKeys(rows: TemplateBootstrapEvidenceRow[]) { + return Array.from(new Set(rows.map((row) => row.rule_key).filter(Boolean))).sort() +} + +function sumItemCount(rows: TemplateBootstrapEvidenceRow[]) { + return rows.reduce((sum, row) => sum + Math.max(0, Number(row.item_count) || 0), 0) +} + +function representativeRow(rows: TemplateBootstrapEvidenceRow[]) { + return rows.reduce((best, row) => (Number(row.item_count) > Number(best.item_count) ? row : best)) +} + +function normalizeSkuEvidenceName(skuName: string, skuCode: string) { + return normalizeMatchText(skuName || skuCode) +} + +function normalizeSkuEvidenceSeries(skuName: string, skuCode: string) { + return normalizeSkuSeriesEquivalence(skuName || skuCode) +} + +function compareMappingCandidates(left: TemplateMappingCandidate, right: TemplateMappingCandidate) { + const typeOrder: Record = { + sku_code: 1, + sku_code_name: 2, + sku_code_series: 3, + sku_name_series: 4, + } + return ( + right.itemCount - left.itemCount || + typeOrder[left.matchType] - typeOrder[right.matchType] || + left.provider.localeCompare(right.provider) || + left.shopId.localeCompare(right.shopId) || + left.skuCode.localeCompare(right.skuCode) || + left.skuNameKey.localeCompare(right.skuNameKey) + ) +} + +async function resetImportedTemplateProductBootstrap() { + let resetOrderItemCount = 0 + for (let batch = 0; batch < 20; batch += 1) { + const result = await query<{ id: number }>( + ` + WITH candidates AS ( + SELECT oi.id + FROM order_items oi + JOIN orders o ON o.id = oi.order_id + JOIN channel_product_mappings m + ON m.provider = o.provider + AND (m.shop_id = '' OR m.shop_id = o.shop_id) + AND oi.canonical_product_id = m.canonical_product_id + AND m.source = 'import' + AND m.note LIKE '接单模板 %自动初始化%' + AND ( + (m.match_type = 'sku_code' AND m.platform_sku_code = oi.sku_code) + OR ( + m.match_type = 'sku_code_name' + AND m.platform_sku_code = oi.sku_code + AND m.platform_sku_name_key = oi.normalized_sku_name + ) + OR ( + m.match_type = 'sku_code_series' + AND m.platform_sku_code = oi.sku_code + AND m.platform_sku_name_key = oi.normalized_sku_series + ) + OR ( + m.match_type = 'sku_name_series' + AND m.platform_sku_name_key = oi.normalized_sku_series + ) + ) + WHERE oi.canonical_product_id IS NOT NULL + LIMIT 20000 + ) + UPDATE order_items oi + SET canonical_product_id = NULL, + updated_at = NOW() + FROM candidates c + WHERE oi.id = c.id + RETURNING oi.id + `, + ) + resetOrderItemCount += result.rowCount || 0 + if ((result.rowCount || 0) < 20000) break + } + const deleted = await query( + ` + DELETE FROM channel_product_mappings + WHERE source = 'import' + AND note LIKE '接单模板 %自动初始化%' + `, + ) + return { deletedMappingCount: deleted.rowCount || 0, resetOrderItemCount } +} + +async function loadTemplateProductBootstrapData() { + const [templatesResult, evidenceResult, productsResult, mappingsResult] = await Promise.all([ + query( + ` + SELECT + r.id, + r.rule_key, + r.product_name, + COALESCE(c.name, '') AS category, + r.unit_price_fen, + r.reward_amount, + m.id AS mapping_id, + COALESCE(m.mapping_type, '') AS mapping_type, + COALESCE(m.sku_nick, '') AS sku_nick, + COALESCE(m.rel_sku_id, '') AS rel_sku_id, + COALESCE(m.seller_ids_json, '[]'::jsonb) AS seller_ids_json + FROM work_product_rules r + LEFT JOIN work_categories c ON c.id = r.category_id + LEFT JOIN LATERAL ( + SELECT wm.* + FROM work_product_rule_mappings wm + WHERE wm.rule_id = r.id AND wm.enabled = TRUE + ORDER BY wm.id + LIMIT 1 + ) m ON TRUE + WHERE r.enabled = TRUE + ORDER BY r.id + `, + ), + query( + ` + SELECT + o.provider, + o.shop_id, + oi.sku_code, + oi.sku_name, + wo.product_rule_id, + r.rule_key, + COUNT(DISTINCT oi.id)::int AS item_count + FROM order_items oi + JOIN orders o ON o.id = oi.order_id + JOIN work_orders wo ON wo.order_item_id = oi.id + JOIN work_product_rules r ON r.id = wo.product_rule_id AND r.enabled = TRUE + WHERE wo.product_rule_id IS NOT NULL + GROUP BY + o.provider, + o.shop_id, + oi.sku_code, + oi.sku_name, + wo.product_rule_id, + r.rule_key + ORDER BY o.provider, o.shop_id, oi.sku_code, oi.sku_name, wo.product_rule_id + `, + ), + query<{ id: number; canonical_sku: string; canonical_name: string }>( + 'SELECT id, canonical_sku, canonical_name FROM product_catalog', + ), + query<{ + provider: string + shop_id: string + platform_sku_code: string + platform_sku_name_key: string + match_type: string + }>( + `SELECT provider, shop_id, platform_sku_code, platform_sku_name_key, match_type + FROM channel_product_mappings`, + ), + ]) + + const templates = templatesResult.rows + const productByCanonicalSku = new Map( + productsResult.rows.map((row) => [row.canonical_sku, Number(row.id)]), + ) + const productByCanonicalName = new Map() + for (const row of productsResult.rows) { + const key = normalizeProductNameKey(row.canonical_name) + if (key && !productByCanonicalName.has(key)) { + productByCanonicalName.set(key, Number(row.id)) + } + } + const productByRuleKey = new Map() + const productsToCreate: TemplateBootstrapTemplateRow[] = [] + for (const template of templates) { + const existing = + productByCanonicalSku.get(buildTemplateProductSku(template.rule_key)) || + productByCanonicalName.get(normalizeProductNameKey(template.product_name)) + if (existing) { + productByRuleKey.set(template.rule_key, existing) + } else { + productsToCreate.push(template) + } + } + + return { + templates, + evidenceRows: evidenceResult.rows, + productsToCreate, + productByRuleKey, + existingMappingKeys: new Set( + mappingsResult.rows.map((row) => + mappingKey( + row.provider, + row.shop_id, + row.platform_sku_code, + row.match_type, + row.platform_sku_name_key, + ), + ), + ), + } +} + +export function buildTemplateProductSku(ruleKey: string) { + return `tpl:${String(ruleKey || '').trim()}`.slice(0, 64) +} + +export function inferTemplateUnitName(productName: string) { + const name = String(productName || '').trim() + if (/钻石|颗/.test(name)) return '颗' + if (/枪|把/.test(name)) return '把' + if (/套/.test(name)) return '套' + if (/密钥|秘钥/.test(name)) return '个' + return '件' +} + +function normalizeProductNameKey(value: unknown) { + return String(value || '') + .normalize('NFKC') + .trim() + .toLowerCase() +} diff --git a/apps/backend/src/services/finance/manual-expense-service.ts b/apps/backend/src/services/finance/manual-expense-service.ts index 095d6c7d..61b45155 100644 --- a/apps/backend/src/services/finance/manual-expense-service.ts +++ b/apps/backend/src/services/finance/manual-expense-service.ts @@ -180,18 +180,17 @@ export async function listManualExpenseCostItems() { id: number code: string name: string - cost_type: string - carrier_type: string + cost_model: string pricing_mode: string }>( ` - SELECT id, code, name, cost_type, carrier_type, pricing_mode + SELECT id, code, name, cost_model, pricing_mode FROM cost_items WHERE status = 'active' AND code <> 'sys_work_order_reward' ORDER BY CASE WHEN code = 'sys_manual_service' THEN 0 ELSE 1 END, - carrier_type, + cost_model, id `, ) @@ -200,8 +199,7 @@ export async function listManualExpenseCostItems() { id: Number(row.id), code: row.code, name: row.name, - costType: row.cost_type, - carrierType: row.carrier_type, + costModel: row.cost_model, pricingMode: row.pricing_mode, })), } @@ -369,7 +367,7 @@ export async function createManualExpense(payload: JsonObject, session: AdminSes const expenseCategory = normalizeStatus( payload.expenseCategory ?? payload.expense_category, EXPENSE_CATEGORIES, - inferExpenseCategory(costItem?.costType), + inferExpenseCategory(costItem?.costModel), '支出分类无效', ) const requestedFundAccountId = normalizeOptionalId( @@ -758,7 +756,7 @@ async function attachCostSnapshotForExpense( shop_id: string work_order_id: number | null work_product_rule_id: number | null - carrier_type: string + fulfillment_mode: string }>( ` SELECT @@ -768,10 +766,9 @@ async function attachCostSnapshotForExpense( o.shop_id, wo.id AS work_order_id, wo.product_rule_id AS work_product_rule_id, - ci.carrier_type + COALESCE(oi.fulfillment_mode, 'manual') AS fulfillment_mode FROM order_items oi JOIN orders o ON o.id = oi.order_id - JOIN cost_items ci ON ci.id = $2 LEFT JOIN LATERAL ( SELECT id, product_rule_id FROM work_orders @@ -838,16 +835,15 @@ async function attachCostSnapshotForExpense( ` INSERT INTO cost_exceptions ( subject_type, subject_id, order_item_id, work_order_id, - carrier_type, reason, status, detail_json, created_at + reason, status, detail_json, created_at ) VALUES ( - 'order_item', $1, $1, $2, $3, 'manual_expense_cost_missing', 'open', $4::jsonb, NOW() + 'order_item', $1, $1, $2, 'manual_expense_cost_missing', 'open', $3::jsonb, NOW() ) ON CONFLICT (subject_type, subject_id, reason) DO NOTHING `, [ expense.order_item_id, context.work_order_id, - context.carrier_type, JSON.stringify({ expenseId: expense.id, expenseNo: expense.expense_no, @@ -862,14 +858,14 @@ async function attachCostSnapshotForExpense( ` INSERT INTO cost_snapshots ( subject_type, subject_id, order_id, order_item_id, work_order_id, - carrier_type, cost_item_id, cost_version_id, canonical_product_id, + cost_item_id, cost_version_id, canonical_product_id, quantity, unit_amount_fen, total_amount_fen, snapshot_stage, source, metadata_json, cost_basis_at, resolved_at, created_at ) VALUES ( 'manual_case', $1, $2, $3, $4, - $5, $6, $7, $8, - 1, $9, $9, 'actual', 'manual_input', - $10::jsonb, $11, $12, $12 + $5, $6, $7, + 1, $8, $8, 'actual', 'manual_input', + $9::jsonb, $10, $11, $11 ) ON CONFLICT (subject_type, subject_id, cost_item_id) WHERE superseded_at IS NULL @@ -881,7 +877,6 @@ async function attachCostSnapshotForExpense( context.order_id, expense.order_item_id, context.work_order_id, - context.carrier_type, expense.cost_item_id, versionId, context.canonical_product_id, @@ -893,6 +888,7 @@ async function attachCostSnapshotForExpense( paidByName: expense.paid_by_name, payeeName: expense.payee_name, approvedBy: session.displayName, + fulfillmentMode: context.fulfillment_mode, }), expense.paid_at, nowIso(), @@ -1008,12 +1004,9 @@ async function resolveCostItem(costItemId: number | null) { const result = await query<{ id: number code: string - cost_type: string - carrier_type: string + cost_model: string status: string - }>(`SELECT id, code, cost_type, carrier_type, status FROM cost_items WHERE id = $1 LIMIT 1`, [ - costItemId, - ]) + }>(`SELECT id, code, cost_model, status FROM cost_items WHERE id = $1 LIMIT 1`, [costItemId]) const row = result.rows[0] if (!row) { throw createHttpError('成本项目不存在', { @@ -1033,7 +1026,7 @@ async function resolveCostItem(costItemId: number | null) { errorCode: 'manual_expense_cost_item_archived', }) } - return { id: Number(row.id), costType: row.cost_type, carrierType: row.carrier_type } + return { id: Number(row.id), costModel: row.cost_model } } function mapManualExpense(row: ManualExpenseRow) { @@ -1085,11 +1078,11 @@ function mapManualExpense(row: ManualExpenseRow) { } } -function inferExpenseCategory(costType: string | undefined) { - if (costType === 'auto_purchase') return 'goods_purchase' - if (costType === 'manual_service') return 'service_fee' - if (costType === 'shipping_other') return 'freight' - if (costType === 'payment_fee' || costType === 'platform_fee') return 'service_fee' +function inferExpenseCategory(costModel: string | undefined) { + if (costModel === 'affiliate_purchase') return 'goods_purchase' + if (costModel === 'manual_actual') return 'service_fee' + if (costModel === 'other') return 'freight' + if (costModel === 'payment_fee' || costModel === 'platform_fee') return 'service_fee' return 'other' } diff --git a/apps/backend/src/services/finance/sales-facts-service.ts b/apps/backend/src/services/finance/sales-facts-service.ts index f4f864a6..de768635 100644 --- a/apps/backend/src/services/finance/sales-facts-service.ts +++ b/apps/backend/src/services/finance/sales-facts-service.ts @@ -26,7 +26,7 @@ type SalesFactRow = { canonical_product_id: number | null canonical_sku: string | null canonical_name: string | null - carrier_type: string + fulfillment_mode: string order_count: number item_count: number quantity: string | number @@ -64,7 +64,7 @@ export async function rebuildSalesDailyFacts( o.provider, o.shop_id, oi.canonical_product_id, - COALESCE(oi.fulfillment_mode, 'manual') AS carrier_type, + COALESCE(oi.fulfillment_mode, 'manual') AS fulfillment_mode, oi.order_id, COALESCE(oi.revenue_amount_fen, 0)::bigint AS revenue_fen, CASE @@ -91,7 +91,7 @@ export async function rebuildSalesDailyFacts( BETWEEN $1::date AND $2::date ) INSERT INTO sales_daily_facts ( - stat_date, provider, shop_id, canonical_product_id, carrier_type, + 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, @@ -102,7 +102,7 @@ export async function rebuildSalesDailyFacts( provider, shop_id, canonical_product_id, - carrier_type, + fulfillment_mode, COUNT(DISTINCT order_id)::int, COUNT(*)::int, SUM(quantity), @@ -123,7 +123,7 @@ export async function rebuildSalesDailyFacts( $3::timestamptz, $3::timestamptz FROM item_base - GROUP BY stat_date, provider, shop_id, canonical_product_id, carrier_type + GROUP BY stat_date, provider, shop_id, canonical_product_id, fulfillment_mode `, [range.fromDate, range.toDate, computedAt], ) @@ -196,7 +196,7 @@ export async function getSalesProfitOverview(input: SalesFactsInput = {}) { [range.fromDate, range.toDate], ), query<{ - carrier_type: string + fulfillment_mode: string item_count: number gross_revenue_fen: number cost_amount_fen: number @@ -205,7 +205,7 @@ export async function getSalesProfitOverview(input: SalesFactsInput = {}) { }>( ` SELECT - carrier_type, + fulfillment_mode, SUM(item_count)::int AS item_count, SUM(gross_revenue_fen)::bigint AS gross_revenue_fen, SUM(cost_amount_fen)::bigint AS cost_amount_fen, @@ -213,7 +213,7 @@ export async function getSalesProfitOverview(input: SalesFactsInput = {}) { SUM(gross_profit_fen)::bigint AS gross_profit_fen FROM sales_daily_facts WHERE stat_date BETWEEN $1::date AND $2::date - GROUP BY carrier_type + GROUP BY fulfillment_mode ORDER BY gross_revenue_fen DESC `, [range.fromDate, range.toDate], @@ -302,8 +302,8 @@ export async function getSalesProfitOverview(input: SalesFactsInput = {}) { costMissingCount: Number(row.cost_missing_count || 0), grossProfitFen: Number(row.gross_profit_fen || 0), })), - byCarrier: carrierResult.rows.map((row) => ({ - carrierType: row.carrier_type, + byFulfillmentMode: carrierResult.rows.map((row) => ({ + fulfillmentMode: row.fulfillment_mode, itemCount: Number(row.item_count || 0), grossRevenueFen: Number(row.gross_revenue_fen || 0), costAmountFen: Number(row.cost_amount_fen || 0), @@ -331,7 +331,9 @@ export async function listSalesDailyFacts(input: Record = {}) { normalizePositiveInteger(input.pageSize ?? input.page_size, 20, '每页数量'), ) const provider = String(input.provider || '').trim() - const carrierType = String(input.carrierType || input.carrier_type || '').trim() + const fulfillmentMode = String( + input.fulfillmentMode || input.fulfillment_mode || input.carrierType || '', + ).trim() const canonicalProductId = Number(input.canonicalProductId || input.canonical_product_id || 0) const conditions = ['f.stat_date BETWEEN $1::date AND $2::date'] @@ -340,9 +342,9 @@ export async function listSalesDailyFacts(input: Record = {}) { params.push(provider) conditions.push(`f.provider = $${params.length}`) } - if (carrierType) { - params.push(carrierType) - conditions.push(`f.carrier_type = $${params.length}`) + if (fulfillmentMode) { + params.push(fulfillmentMode) + conditions.push(`f.fulfillment_mode = $${params.length}`) } if (Number.isInteger(canonicalProductId) && canonicalProductId > 0) { params.push(canonicalProductId) @@ -391,7 +393,7 @@ function mapSalesFact(row: SalesFactRow) { canonicalProductId: row.canonical_product_id ? Number(row.canonical_product_id) : null, canonicalSku: row.canonical_sku || '', canonicalName: row.canonical_name || '未归一', - carrierType: row.carrier_type, + fulfillmentMode: row.fulfillment_mode, orderCount: Number(row.order_count || 0), itemCount: Number(row.item_count || 0), quantity: Number(row.quantity || 0), diff --git a/apps/backend/src/services/fulfillment/fulfillment-mode-service.ts b/apps/backend/src/services/fulfillment/fulfillment-mode-service.ts index 57dc4c93..f1fd2c02 100644 --- a/apps/backend/src/services/fulfillment/fulfillment-mode-service.ts +++ b/apps/backend/src/services/fulfillment/fulfillment-mode-service.ts @@ -27,7 +27,7 @@ export type ReconcileFulfillmentModesResult = { * 统一履约判定(纯函数,供单测与文档对照)。 * * 优先级固定:工单 → 自动发货任务 → 历史平台任务 → 人工。 - * 核销码、provider、商品 carrier_type 都不参与判定。 + * 核销码、provider、商品来源都不参与判定。 */ export function classifyFulfillmentMode({ hasWorkOrder, @@ -102,43 +102,6 @@ export async function decideOrderItemsFulfillmentModes( [ids, String(reason || 'order_sync')], ) - // 已有的 open 成本异常必须跟着履约方式一起改,否则历史平台会继续显示成人工。 - if ((result.rowCount || 0) > 0) { - await syncOpenCostExceptionCarrierTypes(ids) - } - - return { updatedCount: result.rowCount || 0 } -} - -export async function syncOpenCostExceptionCarrierTypes( - orderItemIds?: Array, -): Promise<{ updatedCount: number }> { - const ids = Array.isArray(orderItemIds) - ? Array.from( - new Set( - orderItemIds - .map((value) => Number(value)) - .filter((value) => Number.isInteger(value) && value > 0), - ), - ) - : null - if (ids && ids.length === 0) return { updatedCount: 0 } - - const result = await query( - ` - UPDATE cost_exceptions ce - SET carrier_type = oi.fulfillment_mode - FROM order_items oi - WHERE ce.subject_type = 'order_item' - AND ce.order_item_id = oi.id - AND ce.status = 'open' - AND oi.fulfillment_mode IS NOT NULL - AND ce.carrier_type IS DISTINCT FROM oi.fulfillment_mode - ${ids ? 'AND oi.id = ANY($1::bigint[])' : ''} - `, - ids ? [ids] : [], - ) - return { updatedCount: result.rowCount || 0 } } diff --git a/apps/frontend/src/pages/admin/AdminCostCenterPage.tsx b/apps/frontend/src/pages/admin/AdminCostCenterPage.tsx index c08245ce..c4e5b576 100644 --- a/apps/frontend/src/pages/admin/AdminCostCenterPage.tsx +++ b/apps/frontend/src/pages/admin/AdminCostCenterPage.tsx @@ -1,2887 +1,18 @@ -import { - DeleteOutlined, - EditOutlined, - FolderOpenOutlined, - PlusOutlined, - ReloadOutlined, - SearchOutlined, - SyncOutlined, -} from '@ant-design/icons' -import { - Alert, - App, - Button, - Card, - Col, - DatePicker, - Descriptions, - Empty, - Form, - Input, - InputNumber, - Modal, - Row, - Select, - Space, - Statistic, - Switch, - Table, - Tabs, - Tag, - Typography, -} from 'antd' -import type { TableColumnsType } from 'antd' -import { useQuery, useQueryClient } from '@tanstack/react-query' -import { useEffect, useState } from 'react' -import type { Dayjs } from 'dayjs' - -import PageHeader from '@/components/admin/PageHeader' -import { usePersistedState } from '@/hooks/usePersistedState' -import dayjs from '@/lib/dayjs' -import { - applyAdminTemplateProductBootstrap, - backfillAdminAffiliateDashSkus, - backfillAdminNormalizedQuantities, - backfillAdminProductMappings, - backfillAdminWorkOrderProductSkus, - createAdminChannelProductMapping, - createAdminCostItem, - createAdminCostSnapshot, - createAdminCostVersion, - createAdminProductCatalog, - deleteAdminChannelProductMapping, - deleteAdminCostItem, - deleteAdminCostVersion, - deleteAdminProductCatalog, - fetchAdminChannelProductMappings, - fetchAdminCostCenterOptions, - fetchAdminCostCenterOverview, - fetchAdminCostExceptions, - fetchAdminCostItems, - fetchAdminCostVersions, - fetchAdminProductCatalog, - previewAdminCost, - previewAdminTemplateProductBootstrap, - resolveAdminCostException, - retireAdminCostVersion, - syncAdminAffiliateDashProducts, - syncAdminWorkOrderTemplateCostVersions, - updateAdminChannelProductMapping, - updateAdminCostItem, - updateAdminProductCatalog, -} from '@/services/admin' -import type { - ChannelProductMapping, - ChannelProductMappingGroup, - CostCarrierType, - CostException, - CostItem, - CostPreview, - CostVersion, - ProductCatalogItem, -} from '@/types/admin' -import { hasAdminPermission } from '@/utils/admin-auth' -import { - ADMIN_DEFAULT_PAGE_SIZE, - buildAdminLocalTablePagination, - buildAdminTablePagination, -} from '@/utils/admin-pagination' -import { formatAdminDateTime } from '@/utils/admin-time' -import { formatMoney } from './panels/shared' - -type ProductFormValues = { - canonicalSku: string - canonicalName: string - category?: string - carrierType: CostCarrierType - unitName?: string - status?: 'active' | 'archived' -} - -type MappingFormValues = { - provider: string - shopId?: string - platformSkuCode?: string - platformSkuName?: string - matchType?: ChannelProductMapping['matchType'] - canonicalProductId: number - quantityMultiplier?: number - confidence?: 'exact' | 'rule' | 'manual' - source?: 'manual' | 'import' | 'rule' - enabled?: boolean - note?: string -} - -type CostItemFormValues = { - name: string - costType: CostItem['costType'] - carrierType: CostItem['carrierType'] - pricingMode: CostItem['pricingMode'] - unitName?: string - status?: 'active' | 'archived' - note?: string -} - -type CostVersionFormValues = { - costItemId: number - scopeType: CostVersion['scopeType'] - provider?: string - shopId?: string - canonicalProductId?: number - workProductRuleId?: number - amountYuan?: number - rateBps?: number - effectiveFrom?: Dayjs - effectiveTo?: Dayjs - status?: 'draft' | 'active' - changeReason?: string -} - -const COST_TYPE_OPTIONS = [ - { value: 'auto_purchase', label: '自动发货采购成本' }, - { value: 'work_order_reward', label: '接单平台打手报酬' }, - { value: 'manual_service', label: '人工客服成本' }, - { value: 'platform_fee', label: '平台服务费' }, - { value: 'payment_fee', label: '支付通道费' }, - { value: 'after_sales_loss', label: '售后损失' }, - { value: 'shipping_other', label: '其他履约费用' }, - { value: 'other', label: '其他' }, -] - -const CARRIER_OPTIONS = [ - { value: 'affiliate_dash', label: '自动发货' }, - { value: 'work_order', label: '接单平台' }, - { value: 'manual', label: '人工客服' }, - { value: 'shared', label: '通用' }, -] - -const CARRIER_TAG_COLORS: Record = { - affiliate_dash: 'green', - work_order: 'blue', - manual: 'orange', - shared: 'default', -} - -type CarrierScope = 'all' | CostCarrierType - -const CARRIER_WORKSPACES: Array<{ - key: CarrierScope - label: string - hint: string -}> = [ - { - key: 'all', - label: '全部', - hint: '不区分载体,查看全部商品、映射、成本项与版本。', - }, - { - key: 'affiliate_dash', - label: '自动发货', - hint: '只按上游货源 SKU 配采购成本,不维护商品目录、游戏分类和渠道映射;那些只在接单平台使用。', - }, - { - key: 'work_order', - label: '接单平台', - hint: '需要打手接单履约的商品;成本以打手报酬为主,可按接单规则或商品配价。', - }, - { - key: 'manual', - label: '人工客服', - hint: '客服手工处理的商品;成本按人工服务计,当前没有商品时可直接新建并设为人工。', - }, - { - key: 'shared', - label: '共用成本', - hint: '平台服务费、支付通道费、售后损失等与履约载体无关的费用;只有成本项和版本。', - }, -] - -function tabKeysForCarrier(carrier: CarrierScope) { - return [ - ...(carrier === 'all' || carrier === 'work_order' ? ['products', 'mappings'] : []), - ...(carrier === 'affiliate_dash' ? ['affiliate-skus'] : []), - 'cost-items', - 'cost-versions', - 'exceptions', - 'preview', - ] -} - -type CostItemPreset = { - key: string - label: string - name: string - costType: CostItem['costType'] - carrierType: CostCarrierType - pricingMode: CostItem['pricingMode'] - unitName: string -} - -/** 常用成本项模板:避免每次手填编码/类型/计价方式。 */ -const COST_ITEM_PRESETS: CostItemPreset[] = [ - { - key: 'auto_purchase', - label: '自动发货采购成本', - name: '自动发货采购成本', - costType: 'auto_purchase', - carrierType: 'affiliate_dash', - pricingMode: 'per_item', - unitName: '件', - }, - { - key: 'work_order_reward', - label: '接单平台打手报酬', - name: '接单平台打手报酬', - costType: 'work_order_reward', - carrierType: 'work_order', - pricingMode: 'per_item', - unitName: '件', - }, - { - key: 'manual_service', - label: '人工客服成本', - name: '人工客服成本', - costType: 'manual_service', - carrierType: 'manual', - pricingMode: 'per_item', - unitName: '单', - }, - { - key: 'platform_fee', - label: '平台服务费', - name: '平台服务费', - costType: 'platform_fee', - carrierType: 'shared', - pricingMode: 'percent_of_revenue', - unitName: '单', - }, - { - key: 'payment_fee', - label: '支付通道费', - name: '支付通道费', - costType: 'payment_fee', - carrierType: 'shared', - pricingMode: 'percent_of_revenue', - unitName: '单', - }, - { - key: 'after_sales_loss', - label: '售后损失', - name: '售后损失', - costType: 'after_sales_loss', - carrierType: 'shared', - pricingMode: 'per_order', - unitName: '单', - }, -] - -const PRICING_OPTIONS = [ - { value: 'per_item', label: '按件' }, - { value: 'per_order', label: '按单' }, - { value: 'per_hour', label: '按小时' }, - { value: 'percent_of_revenue', label: '按收入比例' }, - { value: 'fixed_daily', label: '按天固定' }, -] - -const SCOPE_OPTIONS = [ - { value: 'global', label: '全局' }, - { value: 'provider', label: '按来源' }, - { value: 'shop', label: '按店铺' }, - { value: 'product', label: '按标准商品' }, - { value: 'rule', label: '按接单规则' }, -] - -const PROVIDER_LABELS: Record = { - '91kaquan': '91 卡券(快手)', - pdd: '拼多多', - douyin_mashida: '抖音码速达', -} - -const MAPPING_MATCH_TYPE_OPTIONS: Array<{ - value: ChannelProductMapping['matchType'] - label: string -}> = [ - { value: 'sku_code', label: 'SKU 编码(唯一)' }, - { value: 'sku_code_name', label: 'SKU 编码 + 精确名称' }, - { value: 'sku_code_series', label: 'SKU 编码 + 名称系列' }, - { value: 'sku_name_series', label: 'SKU 名称系列兜底' }, -] - -function mappingMatchTypeLabel(value: ChannelProductMapping['matchType']) { - return MAPPING_MATCH_TYPE_OPTIONS.find((item) => item.value === value)?.label || value || '-' -} - -function labelOf(options: Array<{ value: string; label: string }>, value: string) { - return options.find((item) => item.value === value)?.label || value || '-' -} - -function providerLabel(value: string) { - return PROVIDER_LABELS[value] || value || '-' -} - -function formatRate(rateBps: number) { - return `${((Number(rateBps || 0) || 0) / 100).toFixed(2)}%` -} +/** + * 成本中心页面入口。 + * + * 实现已拆分到 `pages/admin/cost-center/` 下的各 Panel: + * - CostCenterShell.tsx 页面骨架、顶部来源切换、Tab 定义 + * - ProductCatalogPanel.tsx 标准商品 + * - ChannelMappingPanel.tsx 渠道映射 + * - AffiliateSkuPanel.tsx 自动发货 SKU + * - CostItemPanel.tsx 成本项 + * - CostVersionPanel.tsx 成本版本 + * - CostExceptionPanel.tsx 成本异常 + * - CostPreviewPanel.tsx 成本预览 + */ +import CostCenterShell from './cost-center/CostCenterShell' export default function AdminCostCenterPage() { - const { message, modal } = App.useApp() - const queryClient = useQueryClient() - const canConfig = hasAdminPermission('finance.config') - - const [productForm] = Form.useForm() - const [mappingForm] = Form.useForm() - const [costItemForm] = Form.useForm() - const [versionForm] = Form.useForm() - - const [activeCarrier, setActiveCarrier] = usePersistedState( - 'admin-cost-center:carrier', - 'all', - (value): value is CarrierScope => - value === 'all' || CARRIER_WORKSPACES.some((workspace) => workspace.key === value), - ) - const [activeTab, setActiveTab] = usePersistedState('admin-cost-center:tab', 'products') - const [productKeyword, setProductKeyword] = useState('') - const [productRootCategoryId, setProductRootCategoryId] = useState(null) - const [productSubCategoryId, setProductSubCategoryId] = useState(null) - const [productStatusUpdatingId, setProductStatusUpdatingId] = useState(null) - const [mappingKeyword, setMappingKeyword] = useState('') - const [mappingProvider, setMappingProvider] = useState('') - const [costItemKeyword, setCostItemKeyword] = useState('') - const [versionKeyword, setVersionKeyword] = useState('') - const [versionStatus, setVersionStatus] = useState('') - const [exceptionStatus, setExceptionStatus] = useState('open') - const [productPage, setProductPage] = useState(1) - const [productPageSize, setProductPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE) - const [mappingPage, setMappingPage] = useState(1) - const [mappingPageSize, setMappingPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE) - const [expandedMappingProductIds, setExpandedMappingProductIds] = useState([]) - const [mappingDetailPageSize, setMappingDetailPageSize] = useState(10) - const [costItemPage, setCostItemPage] = useState(1) - const [costItemPageSize, setCostItemPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE) - const [versionPage, setVersionPage] = useState(1) - const [versionPageSize, setVersionPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE) - const [exceptionPage, setExceptionPage] = useState(1) - const [exceptionPageSize, setExceptionPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE) - const [previewOrderItemId, setPreviewOrderItemId] = useState(null) - const [preview, setPreview] = useState(null) - const [previewing, setPreviewing] = useState(false) - const [snapshotting, setSnapshotting] = useState(false) - const [bootstrapPreviewing, setBootstrapPreviewing] = useState(false) - const [bootstrapApplying, setBootstrapApplying] = useState(false) - 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) - - const [productModalOpen, setProductModalOpen] = useState(false) - const [mappingModalOpen, setMappingModalOpen] = useState(false) - const [costItemModalOpen, setCostItemModalOpen] = useState(false) - const [versionModalOpen, setVersionModalOpen] = useState(false) - const [editingProduct, setEditingProduct] = useState(null) - const [editingMapping, setEditingMapping] = useState(null) - const [editingCostItem, setEditingCostItem] = useState(null) - - const overviewQuery = useQuery({ - queryKey: ['admin-cost-center-overview'], - queryFn: fetchAdminCostCenterOverview, - }) - const optionsQuery = useQuery({ - queryKey: ['admin-cost-center-options'], - queryFn: fetchAdminCostCenterOptions, - }) - const carrierFilter = activeCarrier === 'all' ? undefined : activeCarrier - const activeCarrierLabel = - activeCarrier === 'all' ? '全部载体' : labelOf(CARRIER_OPTIONS, activeCarrier) - // 商品目录 / 渠道映射只服务接单平台;自动发货只需上游 SKU + 采购成本。 - const visibleTabKeys = tabKeysForCarrier(activeCarrier) - useEffect(() => { - const keys = tabKeysForCarrier(activeCarrier) - if (!keys.includes(activeTab)) { - setActiveTab(keys[0] ?? 'cost-items') - } - }, [activeCarrier, activeTab, setActiveTab]) - const visibleCostItemPresets = - activeCarrier === 'all' - ? COST_ITEM_PRESETS - : COST_ITEM_PRESETS.filter((preset) => preset.carrierType === activeCarrier) - - const productsQuery = useQuery({ - queryKey: [ - 'admin-cost-center-products', - activeCarrier, - productKeyword, - productRootCategoryId, - productSubCategoryId, - productPage, - productPageSize, - ], - queryFn: () => - fetchAdminProductCatalog({ - keyword: productKeyword || undefined, - carrierType: carrierFilter, - rootCategoryId: productRootCategoryId ?? undefined, - categoryId: productSubCategoryId ?? undefined, - page: productPage, - pageSize: productPageSize, - }), - }) - const mappingsQuery = useQuery({ - queryKey: [ - 'admin-cost-center-mappings', - activeCarrier, - mappingKeyword, - mappingProvider, - mappingPage, - mappingPageSize, - ], - queryFn: () => - fetchAdminChannelProductMappings({ - keyword: mappingKeyword || undefined, - provider: mappingProvider || undefined, - carrierType: carrierFilter, - page: mappingPage, - pageSize: mappingPageSize, - }), - }) - const costItemsQuery = useQuery({ - queryKey: [ - 'admin-cost-center-cost-items', - activeCarrier, - costItemKeyword, - costItemPage, - costItemPageSize, - ], - queryFn: () => - fetchAdminCostItems({ - keyword: costItemKeyword || undefined, - carrierType: carrierFilter, - page: costItemPage, - pageSize: costItemPageSize, - }), - }) - const versionsQuery = useQuery({ - queryKey: [ - 'admin-cost-center-cost-versions', - activeCarrier, - versionKeyword, - versionStatus, - versionPage, - versionPageSize, - ], - queryFn: () => - fetchAdminCostVersions({ - keyword: versionKeyword || undefined, - status: versionStatus || undefined, - carrierType: carrierFilter, - page: versionPage, - pageSize: versionPageSize, - }), - }) - const exceptionsQuery = useQuery({ - queryKey: [ - 'admin-cost-center-exceptions', - activeCarrier, - exceptionStatus, - exceptionPage, - exceptionPageSize, - ], - queryFn: () => - fetchAdminCostExceptions({ - status: exceptionStatus || undefined, - carrierType: carrierFilter, - page: exceptionPage, - pageSize: exceptionPageSize, - }), - }) - - const overview = overviewQuery.data?.data - const options = optionsQuery.data?.data - const products = productsQuery.data?.data.items || [] - const mappingGroups = mappingsQuery.data?.data.groups || [] - const costItems = costItemsQuery.data?.data.items || [] - const versions = versionsQuery.data?.data.items || [] - const exceptions = exceptionsQuery.data?.data.items || [] - const productTotal = productsQuery.data?.data.pagination.total || 0 - const productCategoryTree = productsQuery.data?.data.categoryTree || [] - const productUncategorizedTotal = productsQuery.data?.data.uncategorizedTotal || 0 - const productCatalogTotal = productsQuery.data - ? productCategoryTree.reduce((sum, root) => sum + root.total, 0) + productUncategorizedTotal - : productTotal - const selectedProductRoot = - productRootCategoryId && productRootCategoryId > 0 - ? productCategoryTree.find((root) => root.categoryId === productRootCategoryId) || null - : null - const productSubCategories = selectedProductRoot?.children || [] - const mappingTotal = mappingsQuery.data?.data.pagination.mappingTotal || 0 - const mappingGroupTotal = mappingsQuery.data?.data.pagination.total || 0 - const costItemTotal = costItemsQuery.data?.data.pagination.total || 0 - const versionTotal = versionsQuery.data?.data.pagination.total || 0 - const exceptionTotal = exceptionsQuery.data?.data.pagination.total || 0 - const versionPricingMode = options?.costItems.find( - (item) => item.id === versionCostItemId, - )?.pricingMode - const providers = options?.providers || [] - const productOptions = - options?.products - .filter((item) => activeCarrier === 'all' || item.carrierType === activeCarrier) - .map((item) => ({ - value: item.id, - label: `${item.canonicalName}(${item.canonicalSku})`, - })) || [] - const costItemOptions = - options?.costItems - .filter((item) => activeCarrier === 'all' || item.carrierType === activeCarrier) - .map((item) => ({ - value: item.id, - label: `${item.name}(${item.code})`, - })) || [] - const ruleOptions = - options?.rules.map((item) => ({ - value: item.id, - label: `${item.productName || item.ruleKey} · #${item.id}`, - })) || [] - - function refreshAll() { - void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-overview'] }) - void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-options'] }) - void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-products'] }) - void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-mappings'] }) - void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-cost-items'] }) - void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-cost-versions'] }) - void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-exceptions'] }) - } - - function openCreateProduct() { - setEditingProduct(null) - productForm.resetFields() - productForm.setFieldsValue({ - unitName: '件', - status: 'active', - carrierType: activeCarrier === 'all' ? 'work_order' : activeCarrier, - }) - setProductModalOpen(true) - } - - async function runTemplateBootstrap() { - setBootstrapPreviewing(true) - try { - const response = await previewAdminTemplateProductBootstrap() - const data = response.data - modal.confirm({ - title: '从接单模板初始化商品与映射', - width: 720, - content: ( - - - 启用接单模板 {data.templateCount} 个:将新建{' '} - {data.productCreateCount} 个标准商品,复用{' '} - {data.productExistingCount} 个已有商品。 - - - 根据历史工单中已命中的模板,将建立 {data.mappingCreateCount}{' '} - 条渠道映射:编码精确 {data.mappingCreateByMatchType.sku_code}{' '} - 条、编码 + 名称系列 {data.mappingCreateByMatchType.sku_code_series}{' '} - 条、 编码 + 精确名称 {data.mappingCreateByMatchType.sku_code_name}{' '} - 条、名称系列兜底 {data.mappingCreateByMatchType.sku_name_series} 条。 - - - 预计可归一 {data.orderItemCoverCount} / {data.evidenceItemCount}{' '} - 个有工单证据的订单项。 - - - {data.templatesWithoutWorkOrders} 个模板暂无历史工单,仍会先建商品。 - - {data.mappingConflictCount > 0 ? ( - - - 这些组合不会自动选择,也不会按最新工单覆盖;未列出的冲突可在再次预览时查看。 - - {data.conflicts.slice(0, 4).map((item) => ( - - {providerLabel(item.provider)} / {item.shopId || '全局'} ·{' '} - {item.skuName || item.skuCode} → {item.ruleKeys.join(' / ')}( - {item.itemCount} 单) - - ))} - - } - /> - ) : ( - - )} - - 数量系列(1个/2个/5个/10个)归为同一标准商品,数量写入 - normalized_quantity,不会拆成多个商品,也不会重复乘倍率。 - - - ), - okText: '开始初始化并修复旧导入', - cancelText: '取消', - onOk: async () => { - setBootstrapApplying(true) - try { - const result = await applyAdminTemplateProductBootstrap() - const applied = result.data - const rebuildText = - applied.deletedImportedMappingCount > 0 - ? `,重建 ${applied.deletedImportedMappingCount} 条旧映射、重置 ${applied.resetOrderItemCount} 个订单项` - : '' - const conflictText = - applied.mappingConflictCount > 0 - ? `;保留 ${applied.mappingConflictCount} 组冲突待人工处理` - : '' - message.success( - `已新建 ${applied.createdProductCount} 个商品、${applied.createdMappingCount} 条映射,同步 ${applied.templateCostVersionCount} 条模板发单金额,回填 ${applied.mappedOrderItemCount} 个订单项${rebuildText}${conflictText}`, - ) - refreshAll() - } catch (error) { - message.error(error instanceof Error ? error.message : '接单模板初始化失败') - throw error - } finally { - setBootstrapApplying(false) - } - }, - }) - } catch (error) { - message.error(error instanceof Error ? error.message : '读取接单模板初始化预览失败') - } finally { - setBootstrapPreviewing(false) - } - } - - async function syncAffiliateProducts() { - setSyncingAffiliate(true) - try { - const result = await syncAdminAffiliateDashProducts() - message.success( - `已同步 ${result.data.productCount} 个自动发货商品(新增 ${result.data.createdCount},更新 ${result.data.updatedCount})`, - ) - refreshAll() - } catch (error) { - message.error(error instanceof Error ? error.message : '同步自动发货商品失败') - } finally { - setSyncingAffiliate(false) - } - } - - async function backfillAffiliateSkus() { - setBackfillingAffiliateSkus(true) - try { - const response = await backfillAdminAffiliateDashSkus() - const result = response.data - const factsText = result.factsRebuild - ? `;已重算 ${result.factsRebuild.fromDate} ~ ${result.factsRebuild.toDate} 的销售事实` - : '' - const remainingText = - result.remainingCount > 0 - ? `;仍有 ${result.remainingCount} 个订单项缺少可匹配的自动发货 SKU` - : '' - message.success( - `已回填 ${result.updatedCount} 个订单项,${result.skippedCount} 个已是归一状态${factsText}${remainingText}`, - ) - refreshAll() - } catch (error) { - message.error(error instanceof Error ? error.message : '回填自动发货 SKU 失败') - } finally { - setBackfillingAffiliateSkus(false) - } - } - - async function backfillWorkOrderProducts() { - setBackfillingWorkOrderSkus(true) - try { - const response = await backfillAdminWorkOrderProductSkus() - const result = response.data - const factsText = result.factsRebuild - ? `;已重算 ${result.factsRebuild.fromDate} ~ ${result.factsRebuild.toDate} 的销售事实` - : '' - const remainingText = - result.remainingCount > 0 - ? `;仍有 ${result.remainingCount} 个订单项没有可用的工单模板` - : '' - const tailText = - result.tailUpdatedCount > 0 - ? `,其中长尾商品 ${result.tailUpdatedCount} 个(新建商品 ${result.tailProductCount}、映射 ${result.tailMappingCount})` - : '' - message.success( - `已按工单模板回填 ${result.updatedCount} 个订单项${tailText},${result.skippedCount} 个已一致${factsText}${remainingText}`, - ) - refreshAll() - } catch (error) { - message.error(error instanceof Error ? error.message : '回填接单平台商品失败') - } finally { - setBackfillingWorkOrderSkus(false) - } - } - - 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({ - canonicalSku: item.canonicalSku, - canonicalName: item.canonicalName, - category: item.category, - carrierType: item.carrierType, - unitName: item.unitName, - status: item.status, - }) - setProductModalOpen(true) - } - - async function submitProduct(values: ProductFormValues) { - try { - if (editingProduct) { - await updateAdminProductCatalog(editingProduct.id, { - canonicalName: values.canonicalName, - category: values.category || '', - carrierType: values.carrierType, - unitName: values.unitName || '件', - status: values.status || 'active', - }) - message.success('标准商品已更新') - } else { - await createAdminProductCatalog(values) - message.success('标准商品已创建') - } - setProductModalOpen(false) - refreshAll() - } catch (error) { - message.error(error instanceof Error ? error.message : '保存标准商品失败') - } - } - - async function toggleProductStatus(row: ProductCatalogItem, checked: boolean) { - if (!canConfig) return - setProductStatusUpdatingId(row.id) - try { - await updateAdminProductCatalog(row.id, { status: checked ? 'active' : 'archived' }) - message.success(checked ? '标准商品已启用' : '标准商品已归档') - await queryClient.invalidateQueries({ queryKey: ['admin-cost-center-products'] }) - void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-overview'] }) - void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-options'] }) - } catch (error) { - message.error(error instanceof Error ? error.message : '更新商品状态失败') - } finally { - setProductStatusUpdatingId(null) - } - } - - function openCreateMapping() { - setEditingMapping(null) - mappingForm.resetFields() - mappingForm.setFieldsValue({ - provider: providers[0] || '91kaquan', - matchType: 'sku_code', - quantityMultiplier: 1, - confidence: 'manual', - source: 'manual', - enabled: true, - }) - setMappingModalOpen(true) - } - - function openEditMapping(item: ChannelProductMapping) { - setEditingMapping(item) - mappingForm.setFieldsValue({ - provider: item.provider, - shopId: item.shopId, - platformSkuCode: item.platformSkuCode, - platformSkuName: item.platformSkuName, - matchType: item.matchType, - canonicalProductId: item.canonicalProductId, - quantityMultiplier: item.quantityMultiplier, - confidence: item.confidence, - source: item.source, - enabled: item.enabled, - note: item.note, - }) - setMappingModalOpen(true) - } - - async function submitMapping(values: MappingFormValues) { - try { - if (editingMapping) { - await updateAdminChannelProductMapping(editingMapping.id, values) - message.success('渠道映射已更新') - } else { - await createAdminChannelProductMapping(values) - message.success('渠道映射已创建') - } - setMappingModalOpen(false) - refreshAll() - } catch (error) { - message.error(error instanceof Error ? error.message : '保存渠道映射失败') - } - } - - function openCreateCostItem(preset?: CostItemPreset) { - setEditingCostItem(null) - costItemForm.resetFields() - if (preset) { - costItemForm.setFieldsValue({ - name: preset.name, - costType: preset.costType, - carrierType: preset.carrierType, - pricingMode: preset.pricingMode, - unitName: preset.unitName, - status: 'active', - }) - setCostItemModalOpen(true) - return - } - const defaultCostType = - activeCarrier === 'affiliate_dash' - ? 'auto_purchase' - : activeCarrier === 'work_order' - ? 'work_order_reward' - : activeCarrier === 'manual' - ? 'manual_service' - : activeCarrier === 'shared' - ? 'platform_fee' - : undefined - costItemForm.setFieldsValue({ - carrierType: activeCarrier === 'all' ? 'work_order' : activeCarrier, - ...(defaultCostType ? { costType: defaultCostType } : {}), - pricingMode: 'per_item', - unitName: '件', - status: 'active', - }) - setCostItemModalOpen(true) - } - - function openEditCostItem(item: CostItem) { - setEditingCostItem(item) - costItemForm.setFieldsValue({ - name: item.name, - costType: item.costType, - carrierType: item.carrierType, - pricingMode: item.pricingMode, - unitName: item.unitName, - status: item.status, - note: item.note, - }) - setCostItemModalOpen(true) - } - - async function submitCostItem(values: CostItemFormValues) { - try { - if (editingCostItem) { - await updateAdminCostItem(editingCostItem.id, { - name: values.name, - costType: values.costType, - carrierType: values.carrierType, - pricingMode: values.pricingMode, - unitName: values.unitName || '件', - status: values.status || 'active', - note: values.note || '', - }) - message.success('成本项目已更新') - } else { - const created = await createAdminCostItem(values) - message.success(`成本项目已创建(编码 ${created.data.costItem.code})`) - } - setCostItemModalOpen(false) - refreshAll() - } catch (error) { - message.error(error instanceof Error ? error.message : '保存成本项目失败') - } - } - - function openCreateVersion(costItem?: CostItem) { - versionForm.resetFields() - const defaultScope: CostVersionFormValues['scopeType'] = costItem - ? costItem.costType === 'auto_purchase' - ? 'product' - : costItem.costType === 'work_order_reward' - ? 'rule' - : 'global' - : activeCarrier === 'affiliate_dash' - ? 'product' - : 'global' - versionForm.setFieldsValue({ - costItemId: costItem?.id, - scopeType: defaultScope, - effectiveFrom: dayjs(), - status: 'active', - }) - setVersionModalOpen(true) - } - - async function submitVersion(values: CostVersionFormValues) { - try { - const isPercent = versionPricingMode === 'percent_of_revenue' - const usesProvider = values.scopeType === 'provider' || values.scopeType === 'shop' - await createAdminCostVersion({ - costItemId: values.costItemId, - scopeType: values.scopeType, - provider: usesProvider ? values.provider || '' : '', - shopId: values.scopeType === 'shop' ? values.shopId || '' : '', - canonicalProductId: values.scopeType === 'product' ? values.canonicalProductId : undefined, - workProductRuleId: values.scopeType === 'rule' ? values.workProductRuleId : undefined, - amountFen: isPercent ? 0 : Math.max(0, Math.round(Number(values.amountYuan || 0) * 100)), - rateBps: isPercent ? values.rateBps || 0 : 0, - effectiveFrom: values.effectiveFrom?.format('YYYY-MM-DD HH:mm:ss'), - effectiveTo: values.effectiveTo?.format('YYYY-MM-DD HH:mm:ss'), - status: values.status || 'active', - changeReason: values.changeReason || '', - }) - message.success('成本版本已创建') - setVersionModalOpen(false) - refreshAll() - } catch (error) { - message.error(error instanceof Error ? error.message : '保存成本版本失败') - } - } - - function retireVersion(item: CostVersion) { - modal.confirm({ - title: '停用成本版本', - content: `确认停用「${item.costItemName} · ${item.scopeType}」这个版本?历史快照不会改变。`, - okText: '停用', - okButtonProps: { danger: true }, - cancelText: '取消', - onOk: async () => { - try { - await retireAdminCostVersion(item.id, { changeReason: '后台手动停用' }) - message.success('成本版本已停用') - refreshAll() - } catch (error) { - message.error(error instanceof Error ? error.message : '停用成本版本失败') - } - }, - }) - } - - function deleteProduct(item: ProductCatalogItem) { - const affiliateHint = - item.carrierType === 'affiliate_dash' - ? '这是自动发货 SKU,删除后再次同步会自动生成;只想隐藏它请改为归档。' - : '' - modal.confirm({ - title: '删除标准商品', - content: `确认删除「${item.canonicalName}(${item.canonicalSku})」?不可恢复;存在渠道映射、成本版本、成本快照或订单引用时会被拒绝,请改用归档。${affiliateHint}`, - okText: '删除', - okButtonProps: { danger: true }, - cancelText: '取消', - onOk: async () => { - try { - await deleteAdminProductCatalog(item.id) - message.success('标准商品已删除') - refreshAll() - } catch (error) { - message.error(error instanceof Error ? error.message : '删除标准商品失败') - } - }, - }) - } - - function deleteMapping(item: ChannelProductMapping) { - modal.confirm({ - title: '删除渠道映射', - content: `确认删除「${providerLabel(item.provider)} / ${item.shopId || '全局'} / ${item.platformSkuCode || item.platformSkuName || '-'}」这条映射?删除后该平台 SKU 将不再自动归一。`, - okText: '删除', - okButtonProps: { danger: true }, - cancelText: '取消', - onOk: async () => { - try { - await deleteAdminChannelProductMapping(item.id) - message.success('渠道映射已删除') - refreshAll() - } catch (error) { - message.error(error instanceof Error ? error.message : '删除渠道映射失败') - } - }, - }) - } - - function deleteCostItem(item: CostItem) { - modal.confirm({ - title: '删除成本项目', - content: `确认删除「${item.name}(${item.code})」?存在成本版本、成本快照或接单规则引用时会被拒绝,请改用归档。`, - okText: '删除', - okButtonProps: { danger: true }, - cancelText: '取消', - onOk: async () => { - try { - await deleteAdminCostItem(item.id) - message.success('成本项目已删除') - refreshAll() - } catch (error) { - message.error(error instanceof Error ? error.message : '删除成本项目失败') - } - }, - }) - } - - function deleteVersion(item: CostVersion) { - modal.confirm({ - title: '删除成本版本', - content: `确认删除「${item.costItemName} · ${item.scopeType}」这个版本?已被成本快照引用的版本不可删除。`, - okText: '删除', - okButtonProps: { danger: true }, - cancelText: '取消', - onOk: async () => { - try { - await deleteAdminCostVersion(item.id) - message.success('成本版本已删除') - refreshAll() - } catch (error) { - message.error(error instanceof Error ? error.message : '删除成本版本失败') - } - }, - }) - } - - function resolveException(item: CostException, status: 'resolved' | 'ignored') { - modal.confirm({ - title: status === 'resolved' ? '标记异常已处理' : '忽略异常', - content: `确认处理「${item.reason}」?`, - okText: '确认', - cancelText: '取消', - onOk: async () => { - try { - await resolveAdminCostException(item.id, { status }) - message.success('成本异常已处理') - refreshAll() - } catch (error) { - message.error(error instanceof Error ? error.message : '处理成本异常失败') - } - }, - }) - } - - function backfillMappings() { - modal.confirm({ - title: '回填订单项商品映射', - content: - '按编码、编码 + 名称系列、编码 + 精确名称、名称系列兜底的优先级,分批匹配全部未映射订单项。', - okText: '开始回填', - cancelText: '取消', - onOk: async () => { - try { - const response = await backfillAdminProductMappings({ limit: 20000, all: true }) - const { updatedCount, batches, remainingCount } = response.data - message.success( - `已回填 ${updatedCount} 条订单项映射(${batches} 批)${remainingCount > 0 ? `,剩余 ${remainingCount} 条无可用映射` : ''}`, - ) - refreshAll() - } catch (error) { - message.error(error instanceof Error ? error.message : '回填商品映射失败') - } - }, - }) - } - - function backfillQuantities() { - modal.confirm({ - title: '回填归一数量', - content: '按 SKU 名称/编码解析数量,自动分批处理全部订单项;解析不到时按 1 件记录。', - okText: '开始回填', - cancelText: '取消', - onOk: async () => { - try { - const response = await backfillAdminNormalizedQuantities({ limit: 20000, all: true }) - const { updatedCount, remainingCount, batches } = response.data - message.success( - `已回填 ${updatedCount} 条归一数量(${batches} 批)${remainingCount > 0 ? `,剩余 ${remainingCount} 条` : ''}`, - ) - refreshAll() - } catch (error) { - message.error(error instanceof Error ? error.message : '回填归一数量失败') - } - }, - }) - } - - async function runPreview() { - if (!previewOrderItemId) { - message.warning('请输入订单项 ID') - return - } - setPreviewing(true) - setPreview(null) - try { - const response = await previewAdminCost({ orderItemId: previewOrderItemId }) - setPreview(response.data) - } catch (error) { - message.error(error instanceof Error ? error.message : '成本预览失败') - } finally { - setPreviewing(false) - } - } - - async function createSnapshot() { - const orderItemId = preview?.context.orderItemId - if (!orderItemId) return - setSnapshotting(true) - try { - const response = await createAdminCostSnapshot({ orderItemId }) - const created = response.data.createdCount - const exceptionCreated = response.data.exceptionCreatedCount - message.success( - created > 0 - ? `已生成 ${created} 条成本快照${exceptionCreated > 0 ? `,新增 ${exceptionCreated} 条异常` : ''}` - : `没有新增快照${exceptionCreated > 0 ? `,已记录 ${exceptionCreated} 条异常` : ',已有快照保持不变'}`, - ) - setPreview(response.data.preview) - refreshAll() - } catch (error) { - message.error(error instanceof Error ? error.message : '生成成本快照失败') - } finally { - setSnapshotting(false) - } - } - - const productColumns: TableColumnsType = [ - { - title: '商品 / 分类', - dataIndex: 'canonicalName', - width: 460, - ellipsis: true, - render: (value: string, row) => ( -
- - {value} - - - - {row.canonicalSku} - - - {row.rootCategoryId - ? `${row.rootCategoryName}${row.subCategoryName ? ` / ${row.subCategoryName}` : ''}` - : '未分类'} - - - 单位:{row.unitName || '件'} - - -
- ), - }, - { - title: '载体', - dataIndex: 'carrierType', - width: 110, - render: (value: CostCarrierType) => ( - {labelOf(CARRIER_OPTIONS, value)} - ), - }, - { - title: '已绑定渠道', - dataIndex: 'mappingCount', - width: 130, - render: (value: number) => - value > 0 ? ( - 已匹配 {value} 条 - ) : ( - 未绑定 - ), - }, - { - title: '成本版本', - dataIndex: 'costVersionCount', - width: 110, - render: (value: number, row) => - value > 0 ? ( - {value} 个版本 - ) : row.carrierType === 'work_order' ? ( - 按工单实际结算 - ) : ( - 未配置 - ), - }, - { - title: '状态', - dataIndex: 'status', - width: 120, - render: (value: ProductCatalogItem['status'], row) => ( - - void toggleProductStatus(row, checked)} - /> - - {value === 'active' ? '启用' : '归档'} - - - ), - }, - { - title: '操作', - width: 100, - fixed: 'right', - render: (_, row) => - canConfig ? ( - - - - - ) : null, - }, - ] - - const mappingGroupColumns: TableColumnsType = [ - { - title: '标准商品', - dataIndex: 'canonicalName', - width: 260, - render: (value: string, row) => ( - - - - - {value} - - - {row.canonicalSku} · {row.unitName} - - - - ), - }, - { - title: '载体', - dataIndex: 'carrierType', - width: 110, - render: (value: CostCarrierType) => ( - {labelOf(CARRIER_OPTIONS, value)} - ), - }, - { - title: '分类', - dataIndex: 'category', - width: 120, - render: (value: string) => - value || 未分类, - }, - { - title: '覆盖范围', - width: 220, - render: (_, row) => { - const providers = Array.from(new Set(row.mappings.map((item) => item.provider))) - const shops = new Set(row.mappings.map((item) => `${item.provider}:${item.shopId || ''}`)) - .size - return ( - - {providers.map((provider) => ( - {providerLabel(provider)} - ))} - {shops} 个店铺键 - - ) - }, - }, - { - title: '映射', - width: 150, - render: (_, row) => { - const enabledCount = row.mappings.filter((item) => item.enabled).length - return ( - - {row.mappings.length} 条 - 启用 {enabledCount} - - ) - }, - }, - { - title: '匹配方式', - width: 280, - render: (_, row) => { - const matchTypes = Array.from(new Set(row.mappings.map((item) => item.matchType))) - return ( - - {matchTypes.map((matchType) => ( - - {mappingMatchTypeLabel(matchType)} - - ))} - - ) - }, - }, - { - title: '状态', - dataIndex: 'status', - width: 80, - render: (value: string) => ( - - {value === 'active' ? '启用' : '归档'} - - ), - }, - { - title: '目录', - width: 90, - fixed: 'right', - render: (_, row) => { - const key = String(row.canonicalProductId) - const expanded = expandedMappingProductIds.includes(key) - return ( - - ) - }, - }, - ] - - const costItemColumns: TableColumnsType = [ - { title: '编码', dataIndex: 'code', width: 160 }, - { title: '名称', dataIndex: 'name', width: 320, ellipsis: true }, - { - title: '类型', - dataIndex: 'costType', - width: 150, - render: (value: string) => labelOf(COST_TYPE_OPTIONS, value), - }, - { - title: '载体', - dataIndex: 'carrierType', - width: 110, - render: (value: string) => labelOf(CARRIER_OPTIONS, value), - }, - { - title: '计价', - dataIndex: 'pricingMode', - width: 120, - render: (value: string) => labelOf(PRICING_OPTIONS, value), - }, - { title: '版本数', dataIndex: 'versionCount', width: 90 }, - { - title: '状态', - dataIndex: 'status', - width: 90, - render: (value: string) => ( - - {value === 'active' ? '启用' : '归档'} - - ), - }, - { - title: '操作', - width: 210, - fixed: 'right', - render: (_, row) => - canConfig ? ( - - - - - - ) : null, - }, - ] - - const versionColumns: TableColumnsType = [ - { - title: '成本项', - dataIndex: 'costItemName', - width: 240, - ellipsis: true, - render: (value: string, row) => ( - - - {value} - - - {row.costItemCode} - - - ), - }, - { - title: '载体', - dataIndex: 'carrierType', - width: 110, - render: (value: CostCarrierType) => ( - {labelOf(CARRIER_OPTIONS, value)} - ), - }, - { - title: '范围', - dataIndex: 'scopeType', - width: 200, - render: (value: string, row) => { - 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}` - const ruleLabel = - row.workProductRuleName || - (row.workProductRuleId ? `规则 #${row.workProductRuleId}` : '历史模板版本') - const ruleSub = - row.workProductRuleKey || - (row.workProductRuleId ? '' : row.changeReason || '模板已删除,历史版本保留') - return ( - - {ruleLabel} - - {ruleSub} - - - ) - }, - }, - { - title: '用户实付均价', - width: 160, - render: (_, row) => - 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: '生效时间', - dataIndex: 'effectiveFrom', - width: 190, - render: (value: string, row) => ( - - {row.isSystemPlaceholder ? '系统占位' : formatAdminDateTime(value)} - - {row.isSystemPlaceholder - ? '按工单实际结算' - : row.effectiveTo - ? `至 ${formatAdminDateTime(row.effectiveTo)}` - : '长期'} - - - ), - }, - { - title: '状态', - dataIndex: 'status', - width: 90, - render: (value: string) => ( - - {value === 'active' ? '生效' : value === 'draft' ? '草稿' : '停用'} - - ), - }, - { - title: '操作', - width: 90, - fixed: 'right', - render: (_, row) => { - if (!canConfig) return null - if (row.status === 'active') { - return ( - - ) - } - return ( - - ) - }, - }, - ] - - const exceptionColumns: TableColumnsType = [ - { title: '主体', dataIndex: 'subjectType', width: 110 }, - { title: '订单项', dataIndex: 'orderItemId', width: 100, render: (v) => (v ? `#${v}` : '-') }, - { - title: '载体', - dataIndex: 'carrierType', - width: 110, - render: (value: CostCarrierType) => ( - {labelOf(CARRIER_OPTIONS, value)} - ), - }, - { - title: '原因', - dataIndex: 'reason', - width: 360, - render: (value: string, row) => ( - - {value} - {String(row.detail?.message || '')} - - ), - }, - { - title: '状态', - dataIndex: 'status', - width: 90, - render: (value: string) => ( - - {value === 'open' ? '待处理' : value === 'resolved' ? '已处理' : '已忽略'} - - ), - }, - { - title: '创建时间', - dataIndex: 'createdAt', - width: 180, - render: (value: string) => formatAdminDateTime(value), - }, - { - title: '操作', - width: 170, - render: (_, row) => - canConfig && row.status === 'open' ? ( - - - - - ) : null, - }, - ] - - return ( -
- } onClick={refreshAll} loading={overviewQuery.isFetching}> - 刷新 - - } - /> - - - {[ - ['标准商品', overview?.productCount || 0], - ['渠道映射', overview?.mappingCount || 0], - ['成本项目', overview?.costItemCount || 0], - ['生效版本', overview?.activeVersionCount || 0], - ['待处理异常', overview?.openExceptionCount || 0], - ['未映射订单项', overview?.unmappedOrderItemCount || 0], - ].map(([label, value]) => ( - - - - - - ))} - - - - { - const next = key as CarrierScope - setActiveCarrier(next) - setActiveTab( - next === 'all' || next === 'work_order' - ? 'products' - : next === 'affiliate_dash' - ? 'affiliate-skus' - : 'cost-items', - ) - setProductPage(1) - setMappingPage(1) - setCostItemPage(1) - setVersionPage(1) - setExceptionPage(1) - setProductRootCategoryId(null) - setProductSubCategoryId(null) - setExpandedMappingProductIds([]) - }} - items={CARRIER_WORKSPACES.map((workspace) => { - const productCount = - workspace.key === 'all' - ? overview?.productCount - : overview?.productCarrierCounts?.[workspace.key] - const costItemCount = - workspace.key === 'all' - ? overview?.costItemCount - : overview?.costItemCarrierCounts?.[workspace.key] - return { - key: workspace.key, - label: ( - - {workspace.label} - - {Number(productCount || 0)} 商品 / {Number(costItemCount || 0)} 成本项 - - - ), - children: {workspace.hint}, - } - })} - /> - - - {!canConfig ? ( - - ) : null} - - - 标准商品目录 - {productCatalogTotal} - {activeCarrier === 'all' ? null : ( - {activeCarrierLabel} - )} - {productRootCategoryId ? 当前 {productTotal} : null} - - } - extra={ - - { - setProductKeyword(event.target.value) - setProductPage(1) - }} - onSearch={() => setProductPage(1)} - /> - - - - ) : null} - {activeCarrier === 'all' || activeCarrier === 'affiliate_dash' ? ( - - ) : null} - - - ) : null} - - } - > -
- { - if (key === 'all') { - setProductRootCategoryId(null) - setProductSubCategoryId(null) - } else if (key === 'uncategorized') { - setProductRootCategoryId(-1) - setProductSubCategoryId(null) - } else { - setProductRootCategoryId(Number(key)) - setProductSubCategoryId(null) - } - setProductPage(1) - }} - items={[ - { key: 'all', label: `全部 (${productCatalogTotal})` }, - ...productCategoryTree.map((root) => ({ - key: String(root.categoryId), - label: `${root.name} (${root.total})`, - })), - ...(productUncategorizedTotal > 0 - ? [ - { - key: 'uncategorized', - label: `未分类 (${productUncategorizedTotal})`, - }, - ] - : []), - ]} - /> -
- - {selectedProductRoot && productSubCategories.length > 0 ? ( -
-
- - 子分类: -
-
- - {productSubCategories.map((child) => ( - - ))} -
-
- ) : null} - - (row.mappingCount > 0 ? 'product-rule-row-mapped' : '')} - scroll={{ x: 1180 }} - pagination={buildAdminTablePagination({ - current: productPage, - pageSize: productPageSize, - total: productTotal, - showTotal: (total) => `共 ${total} 个商品`, - onChange: (page, pageSize) => { - setProductPage(page) - setProductPageSize(pageSize) - }, - })} - /> - - ), - }, - { - key: 'mappings', - label: '渠道映射', - children: ( - - 渠道映射目录 - {mappingTotal} 条映射 - {mappingGroupTotal} 个标准商品 - {activeCarrier === 'all' ? null : ( - {activeCarrierLabel} - )} - - } - extra={ - - {canConfig ? ( - <> - - - - - ) : null} - - } - > - - } - placeholder="标准商品 / 平台 SKU" - value={mappingKeyword} - onChange={(event) => { - setMappingKeyword(event.target.value) - setMappingPage(1) - setExpandedMappingProductIds([]) - }} - style={{ width: 260 }} - /> -
`共 ${total} 个 SKU`, - onChange: (page, pageSize) => { - setProductPage(page) - setProductPageSize(pageSize) - }, - })} - columns={[ - { - title: 'SKU 名称', - dataIndex: 'canonicalName', - width: 320, - ellipsis: true, - render: (value: string, row) => ( - - - {value} - - - {String(row.attributes?.skuCode || row.canonicalSku)} - - - ), - }, - { - title: '履约任务', - width: 110, - render: (_, row) => `${row.affiliateTaskCount} 单`, - }, - { - title: '最近履约', - width: 180, - render: (_, row) => - row.affiliateLastFulfilledAt - ? formatAdminDateTime(row.affiliateLastFulfilledAt) - : '-', - }, - { - title: '成本版本', - dataIndex: 'costVersionCount', - width: 110, - render: (value: number) => (value > 0 ? `${value} 个` : 未配置), - }, - { - title: '状态', - dataIndex: 'status', - width: 120, - render: (value: string, row) => ( - - void toggleProductStatus(row, checked)} - /> - - {value === 'active' ? '启用' : '归档'} - - - ), - }, - { - title: '操作', - width: 140, - render: (_, row) => - canConfig ? ( - - - - - ) : null, - }, - ]} - /> - - ), - }, - { - key: 'cost-items', - label: '成本项目', - children: ( - - 成本项目 - {activeCarrier === 'all' ? null : ( - {activeCarrierLabel} - )} - - } - extra={ - canConfig ? ( - - ) : null - } - > - - - 1. 先建成本项目:如「自动发货采购成本」「接单平台打手报酬」「平台服务费」; - - - 2. 再建成本版本:同一成本项可有多条,按范围优先级 接单规则 > 标准商品 - > 店铺 > 来源 > 全局 命中; - - 3. 改价只新增版本,历史订单走成本快照。 - - } - /> - {canConfig && visibleCostItemPresets.length > 0 ? ( - - 常用成本项: - {visibleCostItemPresets.map((preset) => ( - - ))} - - ) : null} - - } - placeholder="编码 / 名称" - value={costItemKeyword} - onChange={(event) => { - setCostItemKeyword(event.target.value) - setCostItemPage(1) - }} - style={{ width: 260 }} - /> - -
`共 ${total} 个成本项`, - onChange: (page, pageSize) => { - setCostItemPage(page) - setCostItemPageSize(pageSize) - }, - })} - /> - - ), - }, - { - key: 'cost-versions', - label: '成本版本', - children: ( - - 成本版本(改价只新增版本) - {activeCarrier === 'all' ? null : ( - {activeCarrierLabel} - )} - - } - extra={ - canConfig ? ( - - {activeCarrier === 'all' || activeCarrier === 'work_order' ? ( - - ) : null} - - - ) : null - } - > - - - } - placeholder="成本项 / 商品" - value={versionKeyword} - onChange={(event) => { - setVersionKeyword(event.target.value) - setVersionPage(1) - }} - style={{ width: 260 }} - /> -
`共 ${total} 个版本`, - onChange: (page, pageSize) => { - setVersionPage(page) - setVersionPageSize(pageSize) - }, - })} - /> - - ), - }, - { - key: 'exceptions', - label: '成本异常', - children: ( - { - setExceptionStatus(value) - setExceptionPage(1) - }} - options={[ - { value: 'open', label: '待处理' }, - { value: 'resolved', label: '已处理' }, - { value: 'ignored', label: '已忽略' }, - { value: '', label: '全部' }, - ]} - style={{ width: 140 }} - /> - } - > -
`共 ${total} 条异常`, - onChange: (page, pageSize) => { - setExceptionPage(page) - setExceptionPageSize(pageSize) - }, - })} - /> - - ), - }, - { - key: 'preview', - label: '成本预览', - children: ( - - - { - const nextId = value ? Number(value) : null - setPreviewOrderItemId(nextId) - if (nextId !== preview?.context.orderItemId) { - setPreview(null) - } - }} - style={{ width: 180 }} - /> - - {canConfig ? ( - - ) : null} - - - {!preview ? ( - - ) : ( - - - - #{preview.context.orderItemId} - - - {providerLabel(preview.context.provider)} /{' '} - {preview.context.shopId || '全局'} - - - {preview.context.skuCode} · {preview.context.skuName || '-'} - - - {Number(preview.context.normalizedQuantity || 0).toFixed(3)} - - - {formatMoney(preview.context.revenueFen)} - - - {preview.context.carrierType} - - - {preview.product - ? `${preview.product.canonicalName}(${preview.product.canonicalSku})` - : '未映射'} - - - - {formatMoney(preview.totalCostFen)} - - - - - {preview.exceptions.length > 0 ? ( - item.message).join(';')} - /> - ) : null} - -
`${row.costItemId}-${row.costVersionId}`} - size="small" - pagination={false} - dataSource={preview.lines} - columns={[ - { title: '成本项', dataIndex: 'costItemName', width: 220 }, - { title: '类型', dataIndex: 'costType', width: 120 }, - { title: '范围', dataIndex: 'scopeType', width: 160 }, - { - title: '数量', - dataIndex: 'quantity', - width: 90, - render: (value: number) => Number(value || 0).toFixed(3), - }, - { - title: '单位成本', - dataIndex: 'unitAmountFen', - width: 110, - render: (value: number) => formatMoney(value), - }, - { - title: '合计', - dataIndex: 'totalAmountFen', - width: 110, - render: (value: number) => formatMoney(value), - }, - ]} - /> - - )} - - ), - }, - ].filter((item) => visibleTabKeys.includes(item.key))} - /> - - setProductModalOpen(false)} - onOk={() => productForm.submit()} - okText="保存" - cancelText="取消" - destroyOnClose - > -
- - - - - - - - - - - - - - ({ - value: provider, - label: providerLabel(provider), - }))} - /> - - - - - - - - - - - - ) - }} - - - - - - - - - - - - - - - - - - ({ - value: provider, - label: providerLabel(provider), - }))} - /> - - ) : null} - {versionScopeType === 'shop' ? ( - - - - ) : null} - {versionScopeType === 'product' ? ( - - - - ) : null} - {versionPricingMode === 'percent_of_revenue' ? ( - - - - ) : ( - - - - )} - - - - - - - - - - -
- - ) + return } diff --git a/apps/frontend/src/pages/admin/AdminSalesProfitPage.tsx b/apps/frontend/src/pages/admin/AdminSalesProfitPage.tsx index f39a8a9c..55b4ca3e 100644 --- a/apps/frontend/src/pages/admin/AdminSalesProfitPage.tsx +++ b/apps/frontend/src/pages/admin/AdminSalesProfitPage.tsx @@ -32,22 +32,21 @@ import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admi import { hasAdminPermission } from '@/utils/admin-auth' import { formatMoney } from './panels/shared' -type FilterForm = { dateRange?: unknown; provider?: string; carrierType?: string } +type FilterForm = { dateRange?: unknown; provider?: string; fulfillmentMode?: string } -const CARRIER_OPTIONS = [ - { value: '', label: '全部载体' }, - { value: 'affiliate_dash', label: '自动发货' }, +const FULFILLMENT_MODE_OPTIONS = [ + { value: '', label: '全部履约方式' }, { value: 'work_order', label: '接单平台' }, + { value: 'affiliate_dash', label: '自动发货' }, + { value: 'legacy_platform', label: '历史平台' }, { value: 'manual', label: '人工客服' }, - { value: 'shared', label: '共用' }, ] -const CARRIER_LABEL: Record = { - affiliate_dash: '自动发货', +const FULFILLMENT_MODE_LABEL: Record = { work_order: '接单平台', + affiliate_dash: '自动发货', + legacy_platform: '历史平台', manual: '人工客服', - shared: '共用', - unknown: '未知', } const COST_STAGE_LABEL: Record = { @@ -65,8 +64,8 @@ export default function AdminSalesProfitPage() { dateFrom: string dateTo: string provider: string - carrierType: string - }>({ dateFrom: '', dateTo: '', provider: '', carrierType: '' }) + fulfillmentMode: string + }>({ dateFrom: '', dateTo: '', provider: '', fulfillmentMode: '' }) const [page, setPage] = useState(1) const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE) const [rebuilding, setRebuilding] = useState(false) @@ -90,14 +89,14 @@ export default function AdminSalesProfitPage() { dateFrom: range[0] ? dayjs(range[0]).format('YYYY-MM-DD') : '', dateTo: range[1] ? dayjs(range[1]).format('YYYY-MM-DD') : '', provider: values.provider || '', - carrierType: values.carrierType || '', + fulfillmentMode: values.fulfillmentMode || '', }) } function resetFilters() { form.resetFields() setPage(1) - setFilters({ dateFrom: '', dateTo: '', provider: '', carrierType: '' }) + setFilters({ dateFrom: '', dateTo: '', provider: '', fulfillmentMode: '' }) } async function rebuildFacts() { @@ -169,11 +168,13 @@ export default function AdminSalesProfitPage() { }, ] - const carrierColumns: TableColumnsType['byCarrier'][number]> = [ + const fulfillmentModeColumns: TableColumnsType< + NonNullable['byFulfillmentMode'][number] + > = [ { - title: '履约载体', - dataIndex: 'carrierType', - render: (value: string) => CARRIER_LABEL[value] || value, + title: '履约方式', + dataIndex: 'fulfillmentMode', + render: (value: string) => FULFILLMENT_MODE_LABEL[value] || value, }, { title: '订单项', dataIndex: 'itemCount', width: 90 }, { title: '销售收入', dataIndex: 'grossRevenueFen', render: formatMoney }, @@ -209,10 +210,10 @@ export default function AdminSalesProfitPage() { row.canonicalProductId ? value : 未归一, }, { - title: '载体', - dataIndex: 'carrierType', + title: '履约方式', + dataIndex: 'fulfillmentMode', width: 100, - render: (value: string) => CARRIER_LABEL[value] || value, + render: (value: string) => FULFILLMENT_MODE_LABEL[value] || value, }, { title: '订单项', dataIndex: 'itemCount', width: 80 }, { title: '销售收入', dataIndex: 'grossRevenueFen', width: 120, render: formatMoney }, @@ -254,7 +255,7 @@ export default function AdminSalesProfitPage() { initialValues={{ dateRange: [dayjs().subtract(29, 'day'), dayjs()], provider: '', - carrierType: '', + fulfillmentMode: '', }} > @@ -271,8 +272,8 @@ export default function AdminSalesProfitPage() { ]} /> - - @@ -412,14 +413,14 @@ export default function AdminSalesProfitPage() { ), }, { - key: 'carrier', - label: '载体', + key: 'fulfillment-mode', + label: '履约方式', children: (
`, + * 不写渠道映射,也不维护游戏分类。 + */ +import { ReloadOutlined, SyncOutlined } from '@ant-design/icons' +import { Alert, App, Button, Card, Space, Switch, Table, Tag, Typography } from 'antd' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { useState } from 'react' + +import { + backfillAdminAffiliateDashSkus, + fetchAdminProductCatalog, + syncAdminAffiliateDashProducts, + updateAdminProductCatalog, +} from '@/services/admin' +import type { ProductCatalogItem } from '@/types/admin' +import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination' +import { formatAdminDateTime } from '@/utils/admin-time' + +import { useCostCenter } from './CostCenterContext' + +export function AffiliateSkuPanel() { + const { message } = App.useApp() + const queryClient = useQueryClient() + const { canConfig, refreshAll } = useCostCenter() + + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE) + const [statusUpdatingId, setStatusUpdatingId] = useState(null) + const [syncing, setSyncing] = useState(false) + const [backfilling, setBackfilling] = useState(false) + + const productsQuery = useQuery({ + queryKey: ['admin-cost-center-products', 'affiliate_dash', page, pageSize], + queryFn: () => + fetchAdminProductCatalog({ + carrierType: 'affiliate_dash', + page, + pageSize, + }), + }) + + const products = productsQuery.data?.data.items || [] + const total = productsQuery.data?.data.pagination.total || 0 + + async function syncProducts() { + setSyncing(true) + try { + const result = await syncAdminAffiliateDashProducts() + message.success( + `已同步 ${result.data.productCount} 个自动发货商品(新增 ${result.data.createdCount},更新 ${result.data.updatedCount})`, + ) + refreshAll() + } catch (error) { + message.error(error instanceof Error ? error.message : '同步自动发货商品失败') + } finally { + setSyncing(false) + } + } + + async function backfillSkus() { + setBackfilling(true) + try { + const response = await backfillAdminAffiliateDashSkus() + const result = response.data + const factsText = result.factsRebuild + ? `;已重算 ${result.factsRebuild.fromDate} ~ ${result.factsRebuild.toDate} 的销售事实` + : '' + const remainingText = + result.remainingCount > 0 + ? `;仍有 ${result.remainingCount} 个订单项缺少可匹配的自动发货 SKU` + : '' + message.success( + `已回填 ${result.updatedCount} 个订单项,${result.skippedCount} 个已是归一状态${factsText}${remainingText}`, + ) + refreshAll() + } catch (error) { + message.error(error instanceof Error ? error.message : '回填自动发货 SKU 失败') + } finally { + setBackfilling(false) + } + } + + async function toggleStatus(row: ProductCatalogItem, checked: boolean) { + if (!canConfig) return + setStatusUpdatingId(row.id) + try { + await updateAdminProductCatalog(row.id, { status: checked ? 'active' : 'archived' }) + message.success(checked ? 'SKU 已启用' : 'SKU 已归档') + await queryClient.invalidateQueries({ queryKey: ['admin-cost-center-products'] }) + void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-overview'] }) + } catch (error) { + message.error(error instanceof Error ? error.message : '更新 SKU 状态失败') + } finally { + setStatusUpdatingId(null) + } + } + + return ( + + 自动发货 SKU + {total} + + } + extra={ + canConfig ? ( + + + +
`共 ${count} 个 SKU`, + onChange: (nextPage, nextPageSize) => { + setPage(nextPage) + setPageSize(nextPageSize) + }, + })} + columns={[ + { + title: 'SKU 名称', + dataIndex: 'canonicalName', + width: 320, + ellipsis: true, + render: (value: string, row) => ( + + + {value} + + + {String(row.attributes?.skuCode || row.canonicalSku)} + + + ), + }, + { + title: '履约任务', + width: 110, + render: (_, row) => `${row.affiliateTaskCount} 单`, + }, + { + title: '最近履约', + width: 180, + render: (_, row) => + row.affiliateLastFulfilledAt + ? formatAdminDateTime(row.affiliateLastFulfilledAt) + : '-', + }, + { + title: '成本版本', + dataIndex: 'costVersionCount', + width: 110, + render: (value: number) => (value > 0 ? `${value} 个` : 未配置), + }, + { + title: '状态', + dataIndex: 'status', + width: 120, + render: (value: string, row) => ( + + void toggleStatus(row, checked)} + /> + + {value === 'active' ? '启用' : '归档'} + + + ), + }, + ]} + /> + + ) +} diff --git a/apps/frontend/src/pages/admin/cost-center/ChannelMappingPanel.tsx b/apps/frontend/src/pages/admin/cost-center/ChannelMappingPanel.tsx new file mode 100644 index 00000000..524eebba --- /dev/null +++ b/apps/frontend/src/pages/admin/cost-center/ChannelMappingPanel.tsx @@ -0,0 +1,575 @@ +/** + * 渠道映射 Tab:来源 + 店铺 + 平台 SKU -> 标准商品。 + * + * 映射唯一键包含匹配方式与名称键,91 同店铺复用 sku_code 才能消歧。 + */ +import { SearchOutlined, SyncOutlined } from '@ant-design/icons' +import { + App, + Button, + Card, + Form, + Input, + InputNumber, + Modal, + Select, + Space, + Switch, + Table, + Tag, + Typography, +} from 'antd' +import type { TableColumnsType } from 'antd' +import { useQuery } from '@tanstack/react-query' +import { useState } from 'react' + +import { + backfillAdminNormalizedQuantities, + backfillAdminProductMappings, + createAdminChannelProductMapping, + deleteAdminChannelProductMapping, + fetchAdminChannelProductMappings, + updateAdminChannelProductMapping, +} from '@/services/admin' +import type { ChannelProductMapping, ChannelProductMappingGroup } from '@/types/admin' +import { + ADMIN_DEFAULT_PAGE_SIZE, + buildAdminLocalTablePagination, + buildAdminTablePagination, +} from '@/utils/admin-pagination' + +import { useCostCenter } from './CostCenterContext' +import { + MAPPING_MATCH_TYPE_OPTIONS, + mappingMatchTypeLabel, + providerLabel, + type MappingFormValues, +} from './shared' + +export function ChannelMappingPanel() { + const { message, modal } = App.useApp() + const { canConfig, productSourceFilter, options, refreshAll } = useCostCenter() + const [mappingForm] = Form.useForm() + + const [mappingKeyword, setMappingKeyword] = useState('') + const [mappingProvider, setMappingProvider] = useState('') + const [mappingPage, setMappingPage] = useState(1) + const [mappingPageSize, setMappingPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE) + const [expandedMappingProductIds, setExpandedMappingProductIds] = useState([]) + const [mappingDetailPageSize, setMappingDetailPageSize] = useState(10) + const [mappingModalOpen, setMappingModalOpen] = useState(false) + const [editingMapping, setEditingMapping] = useState(null) + + const mappingsQuery = useQuery({ + queryKey: [ + 'admin-cost-center-mappings', + productSourceFilter, + mappingKeyword, + mappingProvider, + mappingPage, + mappingPageSize, + ], + queryFn: () => + fetchAdminChannelProductMappings({ + keyword: mappingKeyword || undefined, + provider: mappingProvider || undefined, + carrierType: productSourceFilter, + page: mappingPage, + pageSize: mappingPageSize, + }), + }) + + const mappingGroups = mappingsQuery.data?.data.groups || [] + const mappingTotal = mappingsQuery.data?.data.pagination.mappingTotal || 0 + const mappingGroupTotal = mappingsQuery.data?.data.pagination.total || 0 + const providers = options?.providers || [] + const productOptions = + options?.products + .filter((item) => !productSourceFilter || item.source === productSourceFilter) + .map((item) => ({ + value: item.id, + label: `${item.canonicalName}(${item.canonicalSku})`, + })) || [] + + function openCreateMapping() { + setEditingMapping(null) + mappingForm.resetFields() + mappingForm.setFieldsValue({ + provider: providers[0] || '91kaquan', + matchType: 'sku_code', + quantityMultiplier: 1, + confidence: 'manual', + source: 'manual', + enabled: true, + }) + setMappingModalOpen(true) + } + + function openEditMapping(item: ChannelProductMapping) { + setEditingMapping(item) + mappingForm.setFieldsValue({ + provider: item.provider, + shopId: item.shopId, + platformSkuCode: item.platformSkuCode, + platformSkuName: item.platformSkuName, + matchType: item.matchType, + canonicalProductId: item.canonicalProductId, + quantityMultiplier: item.quantityMultiplier, + confidence: item.confidence, + source: item.source, + enabled: item.enabled, + note: item.note, + }) + setMappingModalOpen(true) + } + + async function submitMapping(values: MappingFormValues) { + try { + if (editingMapping) { + await updateAdminChannelProductMapping(editingMapping.id, values) + message.success('渠道映射已更新') + } else { + await createAdminChannelProductMapping(values) + message.success('渠道映射已创建') + } + setMappingModalOpen(false) + refreshAll() + } catch (error) { + message.error(error instanceof Error ? error.message : '保存渠道映射失败') + } + } + + function deleteMapping(item: ChannelProductMapping) { + modal.confirm({ + title: '删除渠道映射', + content: `确认删除「${providerLabel(item.provider)} / ${item.shopId || '全局'} / ${item.platformSkuCode || item.platformSkuName || '-'}」这条映射?删除后该平台 SKU 将不再自动归一。`, + okText: '删除', + okButtonProps: { danger: true }, + cancelText: '取消', + onOk: async () => { + try { + await deleteAdminChannelProductMapping(item.id) + message.success('渠道映射已删除') + refreshAll() + } catch (error) { + message.error(error instanceof Error ? error.message : '删除渠道映射失败') + } + }, + }) + } + + function backfillMappings() { + modal.confirm({ + title: '回填订单项商品映射', + content: + '按编码、编码 + 名称系列、编码 + 精确名称、名称系列兜底的优先级,分批匹配全部未映射订单项。', + okText: '开始回填', + cancelText: '取消', + onOk: async () => { + try { + const response = await backfillAdminProductMappings({ limit: 20000, all: true }) + const { updatedCount, batches, remainingCount } = response.data + message.success( + `已回填 ${updatedCount} 条订单项映射(${batches} 批)${remainingCount > 0 ? `,剩余 ${remainingCount} 条无可用映射` : ''}`, + ) + refreshAll() + } catch (error) { + message.error(error instanceof Error ? error.message : '回填商品映射失败') + } + }, + }) + } + + function backfillQuantities() { + modal.confirm({ + title: '回填归一数量', + content: '按 SKU 名称/编码解析数量,自动分批处理全部订单项;解析不到时按 1 件记录。', + okText: '开始回填', + cancelText: '取消', + onOk: async () => { + try { + const response = await backfillAdminNormalizedQuantities({ limit: 20000, all: true }) + const { updatedCount, remainingCount, batches } = response.data + message.success( + `已回填 ${updatedCount} 条归一数量(${batches} 批)${remainingCount > 0 ? `,剩余 ${remainingCount} 条` : ''}`, + ) + refreshAll() + } catch (error) { + message.error(error instanceof Error ? error.message : '回填归一数量失败') + } + }, + }) + } + + const mappingDetailColumns: TableColumnsType = [ + { title: '来源', dataIndex: 'provider', width: 130, render: providerLabel }, + { + title: '店铺', + dataIndex: 'shopId', + width: 150, + render: (value: string) => value || 全局, + }, + { + title: '平台 SKU', + dataIndex: 'platformSkuCode', + width: 220, + render: (value: string, row) => ( + + {value || row.platformSkuName || '-'} + + {value ? row.platformSkuName || '-' : row.platformSkuNameKey || '-'} + + + ), + }, + { + title: '匹配方式', + dataIndex: 'matchType', + width: 160, + render: (value: ChannelProductMapping['matchType']) => ( + {mappingMatchTypeLabel(value)} + ), + }, + { title: '倍率', dataIndex: 'quantityMultiplier', width: 70 }, + { title: '置信度', dataIndex: 'confidence', width: 85 }, + { + title: '启用', + dataIndex: 'enabled', + width: 75, + render: (value: boolean) => ( + {value ? '启用' : '停用'} + ), + }, + { + title: '操作', + width: 140, + fixed: 'right', + render: (_, row) => + canConfig ? ( + + + + + ) : null, + }, + ] + + const mappingGroupColumns: TableColumnsType = [ + { + title: '标准商品', + dataIndex: 'canonicalName', + width: 260, + render: (value: string, row) => ( + + + + {value} + + + {row.canonicalSku} · {row.unitName} + + + + ), + }, + { + title: '分类', + dataIndex: 'category', + width: 120, + render: (value: string) => + value || 未分类, + }, + { + title: '覆盖范围', + width: 220, + render: (_, row) => { + const rowProviders = Array.from(new Set(row.mappings.map((item) => item.provider))) + const shops = new Set(row.mappings.map((item) => `${item.provider}:${item.shopId || ''}`)) + .size + return ( + + {rowProviders.map((provider) => ( + {providerLabel(provider)} + ))} + {shops} 个店铺键 + + ) + }, + }, + { + title: '映射', + width: 150, + render: (_, row) => { + const enabledCount = row.mappings.filter((item) => item.enabled).length + return ( + + {row.mappings.length} 条 + 启用 {enabledCount} + + ) + }, + }, + { + title: '匹配方式', + width: 280, + render: (_, row) => { + const matchTypes = Array.from(new Set(row.mappings.map((item) => item.matchType))) + return ( + + {matchTypes.map((matchType) => ( + + {mappingMatchTypeLabel(matchType)} + + ))} + + ) + }, + }, + { + title: '状态', + dataIndex: 'status', + width: 80, + render: (value: string) => ( + + {value === 'active' ? '启用' : '归档'} + + ), + }, + { + title: '目录', + width: 90, + fixed: 'right', + render: (_, row) => { + const key = String(row.canonicalProductId) + const expanded = expandedMappingProductIds.includes(key) + return ( + + ) + }, + }, + ] + + return ( + <> + + 渠道映射目录 + {mappingTotal} 条映射 + {mappingGroupTotal} 个标准商品 + + } + extra={ + canConfig ? ( + + + + + + ) : null + } + > + + } + placeholder="标准商品 / 平台 SKU" + value={mappingKeyword} + onChange={(event) => { + setMappingKeyword(event.target.value) + setMappingPage(1) + setExpandedMappingProductIds([]) + }} + style={{ width: 260 }} + /> + ({ + value: provider, + label: providerLabel(provider), + }))} + /> + + + + + + + + + + + + ) + }} + + + + + + { + setStatus(value) + setPage(1) + }} + options={[ + { value: 'open', label: '待处理' }, + { value: 'resolved', label: '已处理' }, + { value: 'ignored', label: '已忽略' }, + { value: '', label: '全部' }, + ]} + style={{ width: 140 }} + /> + } + > +
`共 ${count} 条异常`, + onChange: (nextPage, nextPageSize) => { + setPage(nextPage) + setPageSize(nextPageSize) + }, + })} + /> + + ) +} diff --git a/apps/frontend/src/pages/admin/cost-center/CostItemPanel.tsx b/apps/frontend/src/pages/admin/cost-center/CostItemPanel.tsx new file mode 100644 index 00000000..48b2c817 --- /dev/null +++ b/apps/frontend/src/pages/admin/cost-center/CostItemPanel.tsx @@ -0,0 +1,332 @@ +/** + * 成本项 Tab:有哪些成本。 + * + * 成本项 = 成本模型 + 计价方式;不再有「适用载体」列。 + * 专属成本模型(上游采购 / 打手报酬 / 人工实际支出)只对对应履约方式生效, + * 通用成本模型(平台费 / 支付费 / 售后损失 / 其他)对所有履约方式生效。 + */ +import { PlusOutlined, SearchOutlined } from '@ant-design/icons' +import { + Alert, + App, + Button, + Card, + Form, + Input, + Modal, + Select, + Space, + Table, + Tag, + Typography, +} from 'antd' +import type { TableColumnsType } from 'antd' +import { useQuery } from '@tanstack/react-query' +import { useState } from 'react' + +import { + createAdminCostItem, + deleteAdminCostItem, + fetchAdminCostItems, + updateAdminCostItem, +} from '@/services/admin' +import type { CostItem } from '@/types/admin' +import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination' + +import { useCostCenter } from './CostCenterContext' +import { + COST_ITEM_PRESETS, + COST_MODEL_OPTIONS, + PRICING_OPTIONS, + costModelLabel, + labelOf, + type CostItemFormValues, + type CostItemPreset, +} from './shared' + +export function CostItemPanel() { + const { message, modal } = App.useApp() + const { canConfig, fulfillmentModeFilter, refreshAll } = useCostCenter() + const [costItemForm] = Form.useForm() + + const [keyword, setKeyword] = useState('') + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE) + const [modalOpen, setModalOpen] = useState(false) + const [editing, setEditing] = useState(null) + + const costItemsQuery = useQuery({ + queryKey: ['admin-cost-center-cost-items', fulfillmentModeFilter, keyword, page, pageSize], + queryFn: () => + fetchAdminCostItems({ + keyword: keyword || undefined, + fulfillmentMode: fulfillmentModeFilter, + page, + pageSize, + }), + }) + + const costItems = costItemsQuery.data?.data.items || [] + const total = costItemsQuery.data?.data.pagination.total || 0 + + const visiblePresets = + fulfillmentModeFilter === undefined + ? COST_ITEM_PRESETS + : COST_ITEM_PRESETS.filter((preset) => presetAppliesToScope(preset, fulfillmentModeFilter)) + + function openCreate(preset?: CostItemPreset) { + setEditing(null) + costItemForm.resetFields() + costItemForm.setFieldsValue({ + name: preset?.name, + costModel: preset?.costModel, + pricingMode: preset?.pricingMode || 'per_item', + unitName: preset?.unitName || '件', + status: 'active', + }) + setModalOpen(true) + } + + function openEdit(item: CostItem) { + setEditing(item) + costItemForm.setFieldsValue({ + name: item.name, + costModel: item.costModel, + pricingMode: item.pricingMode, + unitName: item.unitName, + status: item.status, + note: item.note, + }) + setModalOpen(true) + } + + async function submit(values: CostItemFormValues) { + try { + if (editing) { + await updateAdminCostItem(editing.id, { + name: values.name, + costModel: values.costModel, + pricingMode: values.pricingMode, + unitName: values.unitName || '件', + status: values.status || 'active', + note: values.note || '', + }) + message.success('成本项目已更新') + } else { + const created = await createAdminCostItem(values) + message.success(`成本项目已创建(编码 ${created.data.costItem.code})`) + } + setModalOpen(false) + refreshAll() + } catch (error) { + message.error(error instanceof Error ? error.message : '保存成本项目失败') + } + } + + function remove(item: CostItem) { + modal.confirm({ + title: '删除成本项目', + content: `确认删除「${item.name}(${item.code})」?存在成本版本、成本快照或接单规则引用时会被拒绝,请改用归档。`, + okText: '删除', + okButtonProps: { danger: true }, + cancelText: '取消', + onOk: async () => { + try { + await deleteAdminCostItem(item.id) + message.success('成本项目已删除') + refreshAll() + } catch (error) { + message.error(error instanceof Error ? error.message : '删除成本项目失败') + } + }, + }) + } + + const columns: TableColumnsType = [ + { title: '编码', dataIndex: 'code', width: 180 }, + { title: '名称', dataIndex: 'name', width: 320, ellipsis: true }, + { + title: '成本模型', + dataIndex: 'costModel', + width: 170, + render: (value: string) => costModelLabel(value), + }, + { + title: '计价', + dataIndex: 'pricingMode', + width: 120, + render: (value: string) => labelOf(PRICING_OPTIONS, value), + }, + { title: '版本数', dataIndex: 'versionCount', width: 90 }, + { + title: '状态', + dataIndex: 'status', + width: 90, + render: (value: string) => ( + + {value === 'active' ? '启用' : '归档'} + + ), + }, + { + title: '操作', + width: 150, + fixed: 'right', + render: (_, row) => + canConfig ? ( + + + + + ) : null, + }, + ] + + return ( + <> + + 成本项目 + {total} 个 + + } + extra={ + canConfig ? ( + + ) : null + } + > + + + 1. 先建成本项目:上游采购 / 接单平台打手报酬 / 人工客服实际支出 / 平台服务费等; + + + 2. 再建成本版本:同一成本项可有多条,按范围优先级 接单规则 > 标准商品 > 店铺 + > 来源 > 全局 命中; + + 3. 改价只新增版本,历史订单走成本快照。 + + } + /> + {canConfig && visiblePresets.length > 0 ? ( + + 常用成本项: + {visiblePresets.map((preset) => ( + + ))} + + ) : null} + + } + placeholder="编码 / 名称" + value={keyword} + onChange={(event) => { + setKeyword(event.target.value) + setPage(1) + }} + style={{ width: 260 }} + /> + +
`共 ${count} 个成本项`, + onChange: (nextPage, nextPageSize) => { + setPage(nextPage) + setPageSize(nextPageSize) + }, + })} + /> + + + setModalOpen(false)} + onOk={() => costItemForm.submit()} + okText="保存" + cancelText="取消" + destroyOnClose + > +
+ + + + + + + + + + + +
`${row.costItemId}-${row.costVersionId}`} + size="small" + pagination={false} + dataSource={preview.lines} + columns={[ + { title: '成本项', dataIndex: 'costItemName', width: 220 }, + { + title: '成本模型', + dataIndex: 'costModel', + width: 140, + render: (value: string) => costModelLabel(value), + }, + { title: '范围', dataIndex: 'scopeType', width: 160 }, + { + title: '数量', + dataIndex: 'quantity', + width: 90, + render: (value: number) => Number(value || 0).toFixed(3), + }, + { + title: '单位成本', + dataIndex: 'unitAmountFen', + width: 110, + render: (value: number) => formatMoney(value), + }, + { + title: '合计', + dataIndex: 'totalAmountFen', + width: 110, + render: (value: number) => formatMoney(value), + }, + ]} + /> + + )} + + ) +} diff --git a/apps/frontend/src/pages/admin/cost-center/CostVersionPanel.tsx b/apps/frontend/src/pages/admin/cost-center/CostVersionPanel.tsx new file mode 100644 index 00000000..4d6da09e --- /dev/null +++ b/apps/frontend/src/pages/admin/cost-center/CostVersionPanel.tsx @@ -0,0 +1,530 @@ +/** + * 成本版本 Tab:给成本项定价。 + * + * 一条版本 = 一个价格 + 一个生效区间 + 一个适用范围。 + * 改价只新增版本,旧版本停用,历史订单按成本快照不变。 + */ +import { PlusOutlined, SearchOutlined, SyncOutlined } from '@ant-design/icons' +import { + Alert, + App, + Button, + Card, + DatePicker, + Form, + Input, + InputNumber, + Modal, + Select, + Space, + Table, + Tag, + Typography, +} from 'antd' +import type { TableColumnsType } from 'antd' +import { useQuery } from '@tanstack/react-query' +import { useState } from 'react' + +import dayjs from '@/lib/dayjs' + +import { + createAdminCostVersion, + deleteAdminCostVersion, + fetchAdminCostVersions, + retireAdminCostVersion, + syncAdminWorkOrderTemplateCostVersions, +} from '@/services/admin' +import type { CostVersion } from '@/types/admin' +import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination' +import { formatAdminDateTime } from '@/utils/admin-time' +import { formatMoney } from '../panels/shared' + +import { useCostCenter } from './CostCenterContext' +import { + SCOPE_OPTIONS, + costModelLabel, + formatRate, + providerLabel, + type CostVersionFormValues, +} from './shared' + +export function CostVersionPanel() { + const { message, modal } = App.useApp() + const { canConfig, fulfillmentModeFilter, options, refreshAll } = useCostCenter() + const [versionForm] = Form.useForm() + + const [keyword, setKeyword] = useState('') + const [status, setStatus] = useState('') + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE) + const [modalOpen, setModalOpen] = useState(false) + const [syncing, setSyncing] = useState(false) + + const versionScopeType = Form.useWatch('scopeType', versionForm) + const versionCostItemId = Form.useWatch('costItemId', versionForm) + + const versionsQuery = useQuery({ + queryKey: [ + 'admin-cost-center-cost-versions', + fulfillmentModeFilter, + keyword, + status, + page, + pageSize, + ], + queryFn: () => + fetchAdminCostVersions({ + keyword: keyword || undefined, + status: status || undefined, + fulfillmentMode: fulfillmentModeFilter, + page, + pageSize, + }), + }) + + const versions = versionsQuery.data?.data.items || [] + const total = versionsQuery.data?.data.pagination.total || 0 + + const versionPricingMode = options?.costItems.find( + (item) => item.id === versionCostItemId, + )?.pricingMode + const providers = options?.providers || [] + const productOptions = + options?.products.map((item) => ({ + value: item.id, + label: `${item.canonicalName}(${item.canonicalSku})`, + })) || [] + const costItemOptions = + options?.costItems.map((item) => ({ + value: item.id, + label: `${item.name}(${item.code})`, + })) || [] + const ruleOptions = + options?.rules.map((item) => ({ + value: item.id, + label: `${item.productName || item.ruleKey} · #${item.id}`, + })) || [] + + function openCreate() { + versionForm.resetFields() + versionForm.setFieldsValue({ + scopeType: fulfillmentModeFilter === 'affiliate_dash' ? 'product' : 'global', + effectiveFrom: dayjs(), + status: 'active', + }) + setModalOpen(true) + } + + async function submit(values: CostVersionFormValues) { + try { + const isPercent = versionPricingMode === 'percent_of_revenue' + const usesProvider = values.scopeType === 'provider' || values.scopeType === 'shop' + await createAdminCostVersion({ + costItemId: values.costItemId, + scopeType: values.scopeType, + provider: usesProvider ? values.provider || '' : '', + shopId: values.scopeType === 'shop' ? values.shopId || '' : '', + canonicalProductId: values.scopeType === 'product' ? values.canonicalProductId : undefined, + workProductRuleId: values.scopeType === 'rule' ? values.workProductRuleId : undefined, + amountFen: isPercent ? 0 : Math.max(0, Math.round(Number(values.amountYuan || 0) * 100)), + rateBps: isPercent ? values.rateBps || 0 : 0, + effectiveFrom: values.effectiveFrom?.format('YYYY-MM-DD HH:mm:ss'), + effectiveTo: values.effectiveTo?.format('YYYY-MM-DD HH:mm:ss'), + status: values.status || 'active', + changeReason: values.changeReason || '', + }) + message.success('成本版本已创建') + setModalOpen(false) + refreshAll() + } catch (error) { + message.error(error instanceof Error ? error.message : '保存成本版本失败') + } + } + + function retire(item: CostVersion) { + modal.confirm({ + title: '停用成本版本', + content: `确认停用「${item.costItemName} · ${item.scopeType}」这个版本?历史快照不会改变。`, + okText: '停用', + okButtonProps: { danger: true }, + cancelText: '取消', + onOk: async () => { + try { + await retireAdminCostVersion(item.id, { changeReason: '后台手动停用' }) + message.success('成本版本已停用') + refreshAll() + } catch (error) { + message.error(error instanceof Error ? error.message : '停用成本版本失败') + } + }, + }) + } + + function remove(item: CostVersion) { + modal.confirm({ + title: '删除成本版本', + content: `确认删除「${item.costItemName} · ${item.scopeType}」这个版本?已被成本快照引用的版本不可删除。`, + okText: '删除', + okButtonProps: { danger: true }, + cancelText: '取消', + onOk: async () => { + try { + await deleteAdminCostVersion(item.id) + message.success('成本版本已删除') + refreshAll() + } catch (error) { + message.error(error instanceof Error ? error.message : '删除成本版本失败') + } + }, + }) + } + + async function syncTemplateCosts() { + setSyncing(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 { + setSyncing(false) + } + } + + const columns: TableColumnsType = [ + { + title: '成本项', + dataIndex: 'costItemName', + width: 240, + ellipsis: true, + render: (value: string, row) => ( + + + {value} + + + {row.costItemCode} + + + ), + }, + { + title: '成本模型', + dataIndex: 'costItemCostModel', + width: 150, + render: (value: string) => costModelLabel(value), + }, + { + title: '范围', + dataIndex: 'scopeType', + width: 200, + render: (value: string, row) => { + 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}` + const ruleLabel = + row.workProductRuleName || + (row.workProductRuleId ? `规则 #${row.workProductRuleId}` : '历史模板版本') + const ruleSub = + row.workProductRuleKey || + (row.workProductRuleId ? '' : row.changeReason || '模板已删除,历史版本保留') + return ( + + {ruleLabel} + + {ruleSub} + + + ) + }, + }, + { + title: '用户实付均价', + width: 160, + render: (_, row) => + row.revenueItemCount > 0 ? ( + + {formatMoney(row.revenueAvgFen)} + + {row.revenueItemCount} 项 / 合计 {formatMoney(row.revenueFen)} + + + ) : ( + '-' + ), + }, + { + title: fulfillmentModeFilter === '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: '生效时间', + dataIndex: 'effectiveFrom', + width: 190, + render: (value: string, row) => ( + + {row.isSystemPlaceholder ? '系统占位' : formatAdminDateTime(value)} + + {row.isSystemPlaceholder + ? '按工单实际结算' + : row.effectiveTo + ? `至 ${formatAdminDateTime(row.effectiveTo)}` + : '长期'} + + + ), + }, + { + title: '状态', + dataIndex: 'status', + width: 90, + render: (value: string) => ( + + {value === 'active' ? '生效' : value === 'draft' ? '草稿' : '停用'} + + ), + }, + { + title: '操作', + width: 90, + fixed: 'right', + render: (_, row) => { + if (!canConfig) return null + if (row.status === 'active') { + return ( + + ) + } + return ( + + ) + }, + }, + ] + + return ( + <> + + 成本版本(改价只新增版本) + {total} 个 + + } + extra={ + canConfig ? ( + + {fulfillmentFilterAllowsTemplateSync(fulfillmentModeFilter) ? ( + + ) : null} + + + ) : null + } + > + + + } + placeholder="成本项 / 商品" + value={keyword} + onChange={(event) => { + setKeyword(event.target.value) + setPage(1) + }} + style={{ width: 260 }} + /> +
`共 ${count} 个版本`, + onChange: (nextPage, nextPageSize) => { + setPage(nextPage) + setPageSize(nextPageSize) + }, + })} + /> + + + setModalOpen(false)} + onOk={() => versionForm.submit()} + okText="保存" + cancelText="取消" + width={620} + destroyOnClose + > + + + + + + {versionScopeType === 'provider' || versionScopeType === 'shop' ? ( + + + + ) : null} + {versionScopeType === 'product' ? ( + + + + ) : null} + {versionPricingMode === 'percent_of_revenue' ? ( + + + + ) : ( + + + + )} + + + + + + + + + + + + + ) +} + +/** 只有接单平台(或全部)才需要同步模板发单金额。 */ +function fulfillmentFilterAllowsTemplateSync( + fulfillmentMode: 'affiliate_dash' | 'work_order' | 'manual' | undefined, +) { + return fulfillmentMode === undefined || fulfillmentMode === 'work_order' +} diff --git a/apps/frontend/src/pages/admin/cost-center/ProductCatalogPanel.tsx b/apps/frontend/src/pages/admin/cost-center/ProductCatalogPanel.tsx new file mode 100644 index 00000000..462d8fc8 --- /dev/null +++ b/apps/frontend/src/pages/admin/cost-center/ProductCatalogPanel.tsx @@ -0,0 +1,627 @@ +/** + * 标准商品 Tab:商品目录、分类树、新建/编辑/归档/删除。 + * + * 商品不再存「履约载体」;来源只由 canonical_sku 前缀表达: + * aff: 自动发货 / tpl: 接单模板 / wo: 工单证据 / man: 人工。 + */ +import { + DeleteOutlined, + EditOutlined, + FolderOpenOutlined, + PlusOutlined, + ReloadOutlined, + SyncOutlined, +} from '@ant-design/icons' +import { + Alert, + App, + Button, + Card, + Form, + Input, + Modal, + Select, + Space, + Switch, + Table, + Tabs, + Tag, + Typography, +} from 'antd' +import type { TableColumnsType } from 'antd' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { useState } from 'react' + +import { + applyAdminTemplateProductBootstrap, + backfillAdminWorkOrderProductSkus, + createAdminProductCatalog, + deleteAdminProductCatalog, + fetchAdminProductCatalog, + previewAdminTemplateProductBootstrap, + updateAdminProductCatalog, +} from '@/services/admin' +import type { ProductCatalogItem } from '@/types/admin' +import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination' + +import { useCostCenter } from './CostCenterContext' +import { + PRODUCT_SOURCE_OPTIONS, + PRODUCT_SOURCE_TAG_COLORS, + labelOf, + providerLabel, + type ProductFormValues, +} from './shared' + +export function ProductCatalogPanel() { + const { message, modal } = App.useApp() + const queryClient = useQueryClient() + const { canConfig, productSourceFilter, refreshAll } = useCostCenter() + const [productForm] = Form.useForm() + + const [productKeyword, setProductKeyword] = useState('') + const [productRootCategoryId, setProductRootCategoryId] = useState(null) + const [productSubCategoryId, setProductSubCategoryId] = useState(null) + const [productStatusUpdatingId, setProductStatusUpdatingId] = useState(null) + const [bootstrapPreviewing, setBootstrapPreviewing] = useState(false) + const [bootstrapApplying, setBootstrapApplying] = useState(false) + const [backfillingWorkOrderSkus, setBackfillingWorkOrderSkus] = useState(false) + const [productPage, setProductPage] = useState(1) + const [productPageSize, setProductPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE) + const [productModalOpen, setProductModalOpen] = useState(false) + const [editingProduct, setEditingProduct] = useState(null) + + const productsQuery = useQuery({ + queryKey: [ + 'admin-cost-center-products', + productSourceFilter, + productKeyword, + productRootCategoryId, + productSubCategoryId, + productPage, + productPageSize, + ], + queryFn: () => + fetchAdminProductCatalog({ + keyword: productKeyword || undefined, + carrierType: productSourceFilter, + rootCategoryId: productRootCategoryId ?? undefined, + categoryId: productSubCategoryId ?? undefined, + page: productPage, + pageSize: productPageSize, + }), + }) + + const products = productsQuery.data?.data.items || [] + const productTotal = productsQuery.data?.data.pagination.total || 0 + const productCategoryTree = productsQuery.data?.data.categoryTree || [] + const productUncategorizedTotal = productsQuery.data?.data.uncategorizedTotal || 0 + const productCatalogTotal = + productCategoryTree.reduce((sum, root) => sum + root.total, 0) + productUncategorizedTotal + const selectedProductRoot = + productRootCategoryId && productRootCategoryId > 0 + ? productCategoryTree.find((root) => root.categoryId === productRootCategoryId) || null + : null + const productSubCategories = selectedProductRoot?.children || [] + + async function runTemplateBootstrap() { + setBootstrapPreviewing(true) + try { + const response = await previewAdminTemplateProductBootstrap() + const data = response.data + modal.confirm({ + title: '从接单模板初始化商品与映射', + width: 720, + content: ( + + + 启用接单模板 {data.templateCount} 个:将新建{' '} + {data.productCreateCount} 个标准商品,复用{' '} + {data.productExistingCount} 个已有商品。 + + + 根据历史工单中已命中的模板,将建立 {data.mappingCreateCount}{' '} + 条渠道映射:编码精确 {data.mappingCreateByMatchType.sku_code}{' '} + 条、编码 + 名称系列 {data.mappingCreateByMatchType.sku_code_series}{' '} + 条、 编码 + 精确名称 {data.mappingCreateByMatchType.sku_code_name}{' '} + 条、名称系列兜底 {data.mappingCreateByMatchType.sku_name_series} 条。 + + + 预计可归一 {data.orderItemCoverCount} / {data.evidenceItemCount}{' '} + 个有工单证据的订单项。 + + + {data.templatesWithoutWorkOrders} 个模板暂无历史工单,仍会先建商品。 + + {data.mappingConflictCount > 0 ? ( + + + 这些组合不会自动选择,也不会按最新工单覆盖;未列出的冲突可在再次预览时查看。 + + {data.conflicts.slice(0, 4).map((item) => ( + + {providerLabel(item.provider)} / {item.shopId || '全局'} ·{' '} + {item.skuName || item.skuCode} → {item.ruleKeys.join(' / ')}( + {item.itemCount} 单) + + ))} + + } + /> + ) : ( + + )} + + 数量系列(1个/2个/5个/10个)归为同一标准商品,数量写入 + normalized_quantity,不会拆成多个商品,也不会重复乘倍率。 + + + ), + okText: '开始初始化并修复旧导入', + cancelText: '取消', + onOk: async () => { + setBootstrapApplying(true) + try { + const result = await applyAdminTemplateProductBootstrap() + const applied = result.data + const rebuildText = + applied.deletedImportedMappingCount > 0 + ? `,重建 ${applied.deletedImportedMappingCount} 条旧映射、重置 ${applied.resetOrderItemCount} 个订单项` + : '' + const conflictText = + applied.mappingConflictCount > 0 + ? `;保留 ${applied.mappingConflictCount} 组冲突待人工处理` + : '' + message.success( + `已新建 ${applied.createdProductCount} 个商品、${applied.createdMappingCount} 条映射,同步 ${applied.templateCostVersionCount} 条模板发单金额,回填 ${applied.mappedOrderItemCount} 个订单项${rebuildText}${conflictText}`, + ) + refreshAll() + } catch (error) { + message.error(error instanceof Error ? error.message : '接单模板初始化失败') + throw error + } finally { + setBootstrapApplying(false) + } + }, + }) + } catch (error) { + message.error(error instanceof Error ? error.message : '读取接单模板初始化预览失败') + } finally { + setBootstrapPreviewing(false) + } + } + + async function backfillWorkOrderProducts() { + setBackfillingWorkOrderSkus(true) + try { + const response = await backfillAdminWorkOrderProductSkus() + const result = response.data + const factsText = result.factsRebuild + ? `;已重算 ${result.factsRebuild.fromDate} ~ ${result.factsRebuild.toDate} 的销售事实` + : '' + const remainingText = + result.remainingCount > 0 + ? `;仍有 ${result.remainingCount} 个订单项没有可用的工单模板` + : '' + const tailText = + result.tailUpdatedCount > 0 + ? `,其中长尾商品 ${result.tailUpdatedCount} 个(新建商品 ${result.tailProductCount}、映射 ${result.tailMappingCount})` + : '' + message.success( + `已按工单模板回填 ${result.updatedCount} 个订单项${tailText},${result.skippedCount} 个已一致${factsText}${remainingText}`, + ) + refreshAll() + } catch (error) { + message.error(error instanceof Error ? error.message : '回填接单平台商品失败') + } finally { + setBackfillingWorkOrderSkus(false) + } + } + + function openCreateProduct() { + setEditingProduct(null) + productForm.resetFields() + productForm.setFieldsValue({ + unitName: '件', + status: 'active', + }) + setProductModalOpen(true) + } + + function openEditProduct(item: ProductCatalogItem) { + setEditingProduct(item) + productForm.setFieldsValue({ + canonicalSku: item.canonicalSku, + canonicalName: item.canonicalName, + category: item.category, + unitName: item.unitName, + status: item.status, + }) + setProductModalOpen(true) + } + + async function submitProduct(values: ProductFormValues) { + try { + if (editingProduct) { + await updateAdminProductCatalog(editingProduct.id, { + canonicalName: values.canonicalName, + category: values.category || '', + unitName: values.unitName || '件', + status: values.status || 'active', + }) + message.success('标准商品已更新') + } else { + await createAdminProductCatalog(values) + message.success('标准商品已创建') + } + setProductModalOpen(false) + refreshAll() + } catch (error) { + message.error(error instanceof Error ? error.message : '保存标准商品失败') + } + } + + async function toggleProductStatus(row: ProductCatalogItem, checked: boolean) { + if (!canConfig) return + setProductStatusUpdatingId(row.id) + try { + await updateAdminProductCatalog(row.id, { status: checked ? 'active' : 'archived' }) + message.success(checked ? '标准商品已启用' : '标准商品已归档') + await queryClient.invalidateQueries({ queryKey: ['admin-cost-center-products'] }) + void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-overview'] }) + void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-options'] }) + } catch (error) { + message.error(error instanceof Error ? error.message : '更新商品状态失败') + } finally { + setProductStatusUpdatingId(null) + } + } + + function deleteProduct(item: ProductCatalogItem) { + const affiliateHint = + item.source === 'affiliate_dash' + ? '这是自动发货 SKU,删除后再次同步会自动生成;只想隐藏它请改为归档。' + : '' + modal.confirm({ + title: '删除标准商品', + content: `确认删除「${item.canonicalName}(${item.canonicalSku})」?不可恢复;存在渠道映射、成本版本、成本快照或订单引用时会被拒绝,请改用归档。${affiliateHint}`, + okText: '删除', + okButtonProps: { danger: true }, + cancelText: '取消', + onOk: async () => { + try { + await deleteAdminProductCatalog(item.id) + message.success('标准商品已删除') + refreshAll() + } catch (error) { + message.error(error instanceof Error ? error.message : '删除标准商品失败') + } + }, + }) + } + + const productColumns: TableColumnsType = [ + { + title: '商品 / 分类', + dataIndex: 'canonicalName', + width: 460, + ellipsis: true, + render: (value: string, row) => ( +
+ + {value} + + + + {row.canonicalSku} + + + {row.rootCategoryId + ? `${row.rootCategoryName}${row.subCategoryName ? ` / ${row.subCategoryName}` : ''}` + : '未分类'} + + + 单位:{row.unitName || '件'} + + +
+ ), + }, + { + title: '来源', + dataIndex: 'source', + width: 110, + render: (value: string) => + value ? ( + + {labelOf(PRODUCT_SOURCE_OPTIONS, value)} + + ) : ( + 未分类 + ), + }, + { + title: '已绑定渠道', + dataIndex: 'mappingCount', + width: 130, + render: (value: number) => + value > 0 ? ( + 已匹配 {value} 条 + ) : ( + 未绑定 + ), + }, + { + title: '成本版本', + dataIndex: 'costVersionCount', + width: 110, + render: (value: number, row) => + value > 0 ? ( + {value} 个版本 + ) : row.source === 'work_order' ? ( + 按工单实际结算 + ) : ( + 未配置 + ), + }, + { + title: '状态', + dataIndex: 'status', + width: 120, + render: (value: ProductCatalogItem['status'], row) => ( + + void toggleProductStatus(row, checked)} + /> + + {value === 'active' ? '启用' : '归档'} + + + ), + }, + { + title: '操作', + width: 100, + fixed: 'right', + render: (_, row) => + canConfig ? ( + + + + + + ) : null + } + > +
+ { + if (key === 'all') { + setProductRootCategoryId(null) + setProductSubCategoryId(null) + } else if (key === 'uncategorized') { + setProductRootCategoryId(-1) + setProductSubCategoryId(null) + } else { + setProductRootCategoryId(Number(key)) + setProductSubCategoryId(null) + } + setProductPage(1) + }} + items={[ + { key: 'all', label: `全部 (${productCatalogTotal})` }, + ...productCategoryTree.map((root) => ({ + key: String(root.categoryId), + label: `${root.name} (${root.total})`, + })), + ...(productUncategorizedTotal > 0 + ? [{ key: 'uncategorized', label: `未分类 (${productUncategorizedTotal})` }] + : []), + ]} + /> +
+ + {selectedProductRoot && productSubCategories.length > 0 ? ( +
+
+ + 子分类: +
+
+ + {productSubCategories.map((child) => ( + + ))} +
+
+ ) : null} + + + } + placeholder="商品名称 / SKU / 分类" + value={productKeyword} + onChange={(event) => { + setProductKeyword(event.target.value) + setProductPage(1) + }} + style={{ width: 280 }} + /> + +
(row.mappingCount > 0 ? 'product-rule-row-mapped' : '')} + scroll={{ x: 1180 }} + pagination={buildAdminTablePagination({ + current: productPage, + pageSize: productPageSize, + total: productTotal, + showTotal: (total) => `共 ${total} 个商品`, + onChange: (page, pageSize) => { + setProductPage(page) + setProductPageSize(pageSize) + }, + })} + /> + + + setProductModalOpen(false)} + onOk={() => productForm.submit()} + okText="保存" + cancelText="取消" + destroyOnClose + > +
+ + + + + + + + + + + + + +