From 8b9b4fc4adfcd5051e446b6b64adc464ccbee5b6 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 5 Nov 2026 17:58:57 +0800 Subject: [PATCH] =?UTF-8?q?refactor(cost-center):=20=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E4=BA=BA=E5=B7=A5=E6=A0=87=E5=87=86=E5=95=86=E5=93=81=E4=B8=8E?= =?UTF-8?q?=E6=88=90=E6=9C=AC=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...135_reconcile_manual_standard_products.sql | 190 ++++++++++++++++++ .../manual-fulfillment-config-service.ts | 180 ++++++++++++++--- .../manual-fulfillment-integrity.test.ts | 35 +++- .../cost-center/ManualSkuConfigPanel.tsx | 98 ++------- .../src/services/admin/cost-center.ts | 1 + .../tests/manual-sku-config-ui.test.ts | 17 ++ 6 files changed, 406 insertions(+), 115 deletions(-) create mode 100644 apps/backend/src/db/migrations/135_reconcile_manual_standard_products.sql create mode 100644 apps/frontend/tests/manual-sku-config-ui.test.ts diff --git a/apps/backend/src/db/migrations/135_reconcile_manual_standard_products.sql b/apps/backend/src/db/migrations/135_reconcile_manual_standard_products.sql new file mode 100644 index 00000000..20214dab --- /dev/null +++ b/apps/backend/src/db/migrations/135_reconcile_manual_standard_products.sql @@ -0,0 +1,190 @@ +-- 135_reconcile_manual_standard_products.sql +-- +-- 人工标准商品归一: +-- 1. 同一内部 SKU 因大小写/全半角残留出多条 active man: 商品时,只保留最早一条; +-- 2. 订单项、人工配置、成本版本、成本快照、渠道映射和渠道能力统一指向保留商品; +-- 3. 重复商品只归档,不删除历史成本与订单数据。 + +-- 已有配置/登记但缺少 man: 标准商品的 SKU,先补标准商品。 +-- 若该 SKU 已有其他启用主渠道,则不再补人工商品,避免重新制造跨渠道重复。 +WITH raw_keys AS ( + SELECT canonicalize_internal_sku(sku_key) AS sku_key, sku_key AS display_name + FROM manual_fulfillment_configs + UNION ALL + SELECT canonicalize_internal_sku(sku_key) AS sku_key, sku_key AS display_name + FROM manual_sku_registry +), +keys AS ( + SELECT sku_key, MAX(display_name) AS display_name + FROM raw_keys + GROUP BY 1 +), +missing AS ( + SELECT keys.* + FROM keys + WHERE keys.sku_key <> '' + AND NOT EXISTS ( + SELECT 1 + FROM product_catalog product + WHERE product.status = 'active' + AND product.canonical_sku LIKE 'man:%' + AND canonicalize_internal_sku(substring(product.canonical_sku from 5)) = keys.sku_key + ) + AND NOT EXISTS ( + SELECT 1 + FROM internal_sku_channels channel + WHERE channel.enabled = TRUE + AND channel.is_fallback = FALSE + AND channel.channel <> 'manual' + AND canonicalize_internal_sku(channel.internal_sku) = keys.sku_key + ) +) +INSERT INTO product_catalog ( + canonical_sku, canonical_name, category, unit_name, status, + attributes_json, created_at, updated_at +) +SELECT + 'man:' || sku_key, + COALESCE(NULLIF(display_name, ''), sku_key), + '人工', + '件', + 'active', + '{"source":"manual_config","auto_created":true,"reconciled":true}'::jsonb, + NOW(), + NOW() +FROM missing +ON CONFLICT (canonical_sku) DO UPDATE SET + status = 'active', + canonical_name = CASE + WHEN BTRIM(product_catalog.canonical_name) = '' THEN EXCLUDED.canonical_name + ELSE product_catalog.canonical_name + END, + updated_at = EXCLUDED.updated_at; + +DROP TABLE IF EXISTS pg_temp.tmp_manual_product_merge; + +CREATE TEMP TABLE tmp_manual_product_merge AS +WITH ranked AS ( + SELECT + id, + FIRST_VALUE(id) OVER ( + PARTITION BY canonicalize_internal_sku(substring(canonical_sku from 5)) + ORDER BY id + ) AS keeper_id, + ROW_NUMBER() OVER ( + PARTITION BY canonicalize_internal_sku(substring(canonical_sku from 5)) + ORDER BY id + ) AS candidate_rank + FROM product_catalog + WHERE status = 'active' AND canonical_sku LIKE 'man:%' +) +SELECT id AS duplicate_id, keeper_id +FROM ranked +WHERE candidate_rank > 1; + +CREATE UNIQUE INDEX ON tmp_manual_product_merge (duplicate_id); + +UPDATE order_items oi +SET canonical_product_id = merge.keeper_id, + updated_at = NOW() +FROM tmp_manual_product_merge merge +WHERE oi.canonical_product_id = merge.duplicate_id; + +UPDATE manual_fulfillment_configs cfg +SET canonical_product_id = merge.keeper_id, + updated_at = NOW() +FROM tmp_manual_product_merge merge +WHERE cfg.canonical_product_id = merge.duplicate_id; + +UPDATE cost_snapshots snapshot +SET canonical_product_id = merge.keeper_id +FROM tmp_manual_product_merge merge +WHERE snapshot.canonical_product_id = merge.duplicate_id; + +-- 无冲突版本迁到保留商品;若生效时间等唯一范围冲突,则留在归档商品上供历史快照追溯。 +UPDATE cost_versions version +SET canonical_product_id = merge.keeper_id, + updated_at = NOW() +FROM tmp_manual_product_merge merge +WHERE version.canonical_product_id = merge.duplicate_id + AND NOT EXISTS ( + SELECT 1 + FROM cost_versions keeper + WHERE keeper.canonical_product_id = merge.keeper_id + AND keeper.cost_item_id = version.cost_item_id + AND keeper.scope_type = version.scope_type + AND keeper.provider = version.provider + AND keeper.shop_id = version.shop_id + AND keeper.work_product_rule_id IS NOT DISTINCT FROM version.work_product_rule_id + AND keeper.effective_from = version.effective_from + ); + +DELETE FROM channel_product_mappings duplicate +USING tmp_manual_product_merge merge, channel_product_mappings keeper +WHERE duplicate.canonical_product_id = merge.duplicate_id + AND keeper.canonical_product_id = merge.keeper_id + AND keeper.provider = duplicate.provider + AND keeper.shop_id = duplicate.shop_id + AND keeper.platform_sku_code = duplicate.platform_sku_code + AND keeper.match_type = duplicate.match_type + AND keeper.platform_sku_name_key = duplicate.platform_sku_name_key; + +UPDATE channel_product_mappings mapping +SET canonical_product_id = merge.keeper_id, + updated_at = NOW() +FROM tmp_manual_product_merge merge +WHERE mapping.canonical_product_id = merge.duplicate_id; + +DELETE FROM product_channel_capabilities duplicate +USING tmp_manual_product_merge merge, product_channel_capabilities keeper +WHERE duplicate.product_id = merge.duplicate_id + AND keeper.product_id = merge.keeper_id + AND keeper.channel = duplicate.channel; + +UPDATE product_channel_capabilities capability +SET product_id = merge.keeper_id, + updated_at = NOW() +FROM tmp_manual_product_merge merge +WHERE capability.product_id = merge.duplicate_id; + +UPDATE internal_sku_channels channel +SET canonical_product_id = merge.keeper_id, + updated_at = NOW() +FROM tmp_manual_product_merge merge +WHERE channel.canonical_product_id = merge.duplicate_id; + +UPDATE product_catalog product +SET status = 'archived', + attributes_json = product.attributes_json || jsonb_build_object( + 'reconciledIntoProductId', merge.keeper_id, + 'reconciledAt', NOW() + ), + updated_at = NOW() +FROM tmp_manual_product_merge merge +WHERE product.id = merge.duplicate_id; + +-- 人工配置统一挂到唯一 active man: 标准商品。 +UPDATE manual_fulfillment_configs cfg +SET canonical_product_id = product.id, + updated_at = NOW() +FROM product_catalog product +WHERE product.status = 'active' + AND product.canonical_sku LIKE 'man:%' + AND canonicalize_internal_sku(substring(product.canonical_sku from 5)) + = canonicalize_internal_sku(cfg.sku_key) + AND cfg.canonical_product_id IS DISTINCT FROM product.id; + +UPDATE manual_sku_registry registry +SET config_id = cfg.id, + updated_at = NOW() +FROM manual_fulfillment_configs cfg +JOIN product_catalog product ON product.id = cfg.canonical_product_id +WHERE product.status = 'active' + AND product.canonical_sku LIKE 'man:%' + AND canonicalize_internal_sku(registry.sku_key) + = canonicalize_internal_sku(substring(product.canonical_sku from 5)); + +COMMENT ON COLUMN product_catalog.attributes_json IS + '标准商品扩展信息;人工商品归档时记录 reconciledIntoProductId 便于追溯'; + +DROP TABLE IF EXISTS pg_temp.tmp_manual_product_merge; diff --git a/apps/backend/src/services/cost-center/manual-fulfillment-config-service.ts b/apps/backend/src/services/cost-center/manual-fulfillment-config-service.ts index 48acc6c8..51659e89 100644 --- a/apps/backend/src/services/cost-center/manual-fulfillment-config-service.ts +++ b/apps/backend/src/services/cost-center/manual-fulfillment-config-service.ts @@ -13,6 +13,7 @@ import { normalizeManualCreditQuantity, parseManualChannelQuantity, } from './manual-credit-quantity.js' +import { productSourceFilterSql } from './cost-center-mappers.js' /** * 人工履约 SKU 配置服务。 @@ -223,9 +224,7 @@ export async function scanManualSkus(options: ScanOptions) { return { scanned: rows.length, registered: rows.length, fromDate, toDate } } -/** - * 列出人工 SKU 清单(登记表 + 配置状态)。 - */ +/** 列出人工标准商品清单(标准商品为唯一主键,配置与订单统计只做关联)。 */ export async function listManualSkus(input: Record = {}) { const unitPrices = await loadPlatformUnitPriceFen() const page = normalizePositiveInteger(input.page, 1, '页码') @@ -238,13 +237,16 @@ export async function listManualSkus(input: Record = {}) { const platformKey = String(input.platformKey || input.platform_key || '').trim() const provider = String(input.provider || '').trim() - const conditions: string[] = ['1 = 1'] - const params: unknown[] = [] + const conditions: string[] = [`p.status = 'active'`, `p.canonical_sku LIKE 'man:%'`] + const params: unknown[] = ['manual'] + conditions.push(productSourceFilterSql(params.length)) if (keyword) { params.push(`%${escapeLike(keyword)}%`) const placeholder = `$${params.length}` - conditions.push(`(r.sku_key ILIKE ${placeholder} OR r.display_name ILIKE ${placeholder})`) + conditions.push( + `(p.canonical_sku ILIKE ${placeholder} OR p.canonical_name ILIKE ${placeholder})`, + ) } if (status === 'configured') { conditions.push('cfg.id IS NOT NULL') @@ -266,7 +268,7 @@ export async function listManualSkus(input: Record = {}) { WHERE oi.fulfillment_status = 'assigned' AND oi.fulfillment_channel = 'manual' AND o.provider = $${params.length} AND resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) - = canonicalize_internal_sku(r.sku_key) + = canonicalize_internal_sku(substring(p.canonical_sku from 5)) )`) } @@ -274,9 +276,15 @@ export async function listManualSkus(input: Record = {}) { const countResult = await query<{ total: number }>( ` SELECT COUNT(*)::int AS total - FROM manual_sku_registry r - LEFT JOIN manual_fulfillment_configs cfg - ON canonicalize_internal_sku(cfg.sku_key) = canonicalize_internal_sku(r.sku_key) + FROM product_catalog p + LEFT JOIN LATERAL ( + SELECT * + FROM manual_fulfillment_configs cfg + WHERE canonicalize_internal_sku(cfg.sku_key) + = canonicalize_internal_sku(substring(p.canonical_sku from 5)) + ORDER BY cfg.id + LIMIT 1 + ) cfg ON TRUE WHERE ${where} `, params, @@ -284,22 +292,56 @@ export async function listManualSkus(input: Record = {}) { const listParams = [...params, pageSize, (page - 1) * pageSize] const result = await query( ` + WITH manual_stats AS ( + SELECT + resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) AS sku_key, + COUNT(DISTINCT o.shop_id)::int AS shop_count, + COUNT(DISTINCT o.id)::int AS order_count, + COUNT(*)::int AS item_count, + COALESCE(SUM(GREATEST(COALESCE(oi.revenue_amount_fen, 0), 0)), 0)::bigint + AS revenue_fen, + MIN((COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date) + AS first_seen_date, + MAX((COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date) + AS last_seen_date + FROM order_items oi + JOIN orders o ON o.id = oi.order_id + WHERE oi.fulfillment_status = 'assigned' + AND oi.fulfillment_channel = 'manual' + GROUP BY 1 + ) SELECT - r.id, r.sku_key, r.display_name, r.shop_count, r.order_count, r.item_count, - r.revenue_fen, r.first_seen_date, r.last_seen_date, - r.scan_from_date, r.scan_to_date, + p.id, + substring(p.canonical_sku from 5) AS sku_key, + p.canonical_name AS display_name, + COALESCE(manual_stats.shop_count, 0)::int AS shop_count, + COALESCE(manual_stats.order_count, 0)::int AS order_count, + COALESCE(manual_stats.item_count, 0)::int AS item_count, + COALESCE(manual_stats.revenue_fen, 0)::bigint AS revenue_fen, + manual_stats.first_seen_date, + manual_stats.last_seen_date, + manual_stats.first_seen_date AS scan_from_date, + manual_stats.last_seen_date AS scan_to_date, cfg.id AS config_id, cfg.platform_key AS config_platform_key, cfg.credit_quantity AS config_credit_quantity, cfg.cost_mode AS config_cost_mode, - cfg.canonical_product_id AS config_canonical_product_id, + COALESCE(cfg.canonical_product_id, p.id) AS config_canonical_product_id, cfg.enabled AS config_enabled, cfg.channels_json AS config_channels_json - FROM manual_sku_registry r - LEFT JOIN manual_fulfillment_configs cfg - ON canonicalize_internal_sku(cfg.sku_key) = canonicalize_internal_sku(r.sku_key) + FROM product_catalog p + LEFT JOIN LATERAL ( + SELECT * + FROM manual_fulfillment_configs cfg + WHERE canonicalize_internal_sku(cfg.sku_key) + = canonicalize_internal_sku(substring(p.canonical_sku from 5)) + ORDER BY cfg.id + LIMIT 1 + ) cfg ON TRUE + LEFT JOIN manual_stats + ON manual_stats.sku_key = canonicalize_internal_sku(substring(p.canonical_sku from 5)) WHERE ${where} - ORDER BY r.item_count DESC, r.sku_key ASC + ORDER BY COALESCE(manual_stats.item_count, 0) DESC, p.canonical_name ASC, p.id ASC LIMIT $${listParams.length - 1} OFFSET $${listParams.length} `, listParams, @@ -323,20 +365,50 @@ export async function getManualSkuOverview() { last_scan_at: string | null }>( ` + WITH manual_stats AS ( + SELECT + resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) AS sku_key, + COUNT(*)::int AS item_count, + MAX((COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date) + AS last_seen_date + FROM order_items oi + JOIN orders o ON o.id = oi.order_id + WHERE oi.fulfillment_status = 'assigned' + AND oi.fulfillment_channel = 'manual' + GROUP BY 1 + ), + manual_scope AS ( + SELECT p.id, p.canonical_sku, p.canonical_name, cfg.id AS config_id + FROM product_catalog p + LEFT JOIN LATERAL ( + SELECT cfg.id + FROM manual_fulfillment_configs cfg + WHERE canonicalize_internal_sku(cfg.sku_key) + = canonicalize_internal_sku(substring(p.canonical_sku from 5)) + ORDER BY cfg.id + LIMIT 1 + ) cfg ON TRUE + WHERE p.status = 'active' + AND p.canonical_sku LIKE 'man:%' + AND ${productSourceFilterSql(1)} + ) SELECT COUNT(*)::int AS total_sku, - COUNT(*) FILTER (WHERE cfg.id IS NOT NULL)::int AS configured_sku, - COUNT(*) FILTER (WHERE cfg.id IS NULL)::int AS unconfigured_sku, - COALESCE(SUM(r.item_count), 0)::bigint AS total_items, - COALESCE(SUM(r.item_count) FILTER (WHERE cfg.id IS NOT NULL), 0)::bigint + COUNT(*) FILTER (WHERE config_id IS NOT NULL)::int AS configured_sku, + COUNT(*) FILTER (WHERE config_id IS NULL)::int AS unconfigured_sku, + COALESCE(SUM(manual_stats.item_count), 0)::bigint AS total_items, + COALESCE(SUM(manual_stats.item_count) FILTER (WHERE config_id IS NOT NULL), 0)::bigint AS configured_items, - COALESCE(SUM(r.item_count) FILTER (WHERE cfg.id IS NULL), 0)::bigint + COALESCE(SUM(manual_stats.item_count) FILTER (WHERE config_id IS NULL), 0)::bigint AS unconfigured_items, - MAX(r.updated_at) AS last_scan_at - FROM manual_sku_registry r - LEFT JOIN manual_fulfillment_configs cfg - ON canonicalize_internal_sku(cfg.sku_key) = canonicalize_internal_sku(r.sku_key) + MAX(manual_stats.last_seen_date)::text AS last_scan_at + FROM manual_scope + LEFT JOIN manual_stats + ON manual_stats.sku_key = canonicalize_internal_sku( + substring(manual_scope.canonical_sku from 5) + ) `, + ['manual'], ) const row = result.rows[0] return { @@ -470,6 +542,7 @@ export async function saveManualFulfillmentConfig(payload: JsonObject) { }) await syncRegistryConfigId(config.sku_key, config.id) + await upsertManualRegistryConfig(config) // 同步渠道规则:人工配置就是「这个内部 SKU 走人工渠道」的唯一声明, // 少了这一步,新配的 SKU 会一直停在待分类。 await syncManualChannelRule(config) @@ -556,6 +629,7 @@ export type DeleteManualSkuResult = { deletedConfigs: number deletedRegistry: number deletedRules: number + archivedProducts: number refreshedDays: number skippedClosedDays: number } @@ -671,6 +745,22 @@ export async function deleteManualSkuClassification( RETURNING id`, [key], ) + const products = await client.query( + ` + UPDATE product_catalog + SET status = 'archived', + attributes_json = attributes_json || jsonb_build_object( + 'deletedAt', $2::text, + 'deletedBy', 'manual_sku_config' + ), + updated_at = $2::timestamptz + WHERE status = 'active' + AND canonical_sku LIKE 'man:%' + AND canonicalize_internal_sku(substring(canonical_sku from 5)) = $1 + RETURNING id + `, + [key, nowIso()], + ) if (itemIds.length > 0) { await client.query( @@ -703,6 +793,7 @@ export async function deleteManualSkuClassification( deletedConfigs: configs.rowCount || 0, deletedRegistry: registry.rowCount || 0, deletedRules: rules.rowCount || 0, + archivedProducts: products.rowCount || 0, dates: [...new Set(items.rows.map((row) => row.basis_date).filter(Boolean))], } }) @@ -714,6 +805,7 @@ export async function deleteManualSkuClassification( deletedConfigs: result.deletedConfigs, deletedRegistry: result.deletedRegistry, deletedRules: result.deletedRules, + archivedProducts: result.archivedProducts, refreshedDays: refreshed.days, skippedClosedDays: refreshed.skippedClosed, } @@ -840,6 +932,40 @@ async function syncRegistryConfigId(skuKey: string, configId: number | null) { ) } +/** 配置保存后确保登记表仍有同一 SKU 的记录,兼容旧扫描清单和运维查询。 */ +async function upsertManualRegistryConfig(config: { + id: number + sku_key: string + canonical_product_id: number | null +}) { + const key = canonicalizeInternalSku(config.sku_key) + if (!key) return + const now = nowIso() + await query( + ` + INSERT INTO manual_sku_registry ( + sku_key, display_name, config_id, created_at, updated_at + ) + SELECT + $1, + COALESCE(NULLIF(product.canonical_name, ''), $1), + $2, + $3, + $3 + FROM (SELECT 1) seed + LEFT JOIN product_catalog product ON product.id = $4 + ON CONFLICT (sku_key) DO UPDATE SET + display_name = CASE + WHEN BTRIM(manual_sku_registry.display_name) = '' THEN EXCLUDED.display_name + ELSE manual_sku_registry.display_name + END, + config_id = EXCLUDED.config_id, + updated_at = EXCLUDED.updated_at + `, + [key, config.id, now, config.canonical_product_id], + ) +} + /** 读取单个 SKU 的配置详情(含平台标签与成本折算)。 */ export async function getManualFulfillmentConfig(skuKey: string) { const unitPrices = await loadPlatformUnitPriceFen() diff --git a/apps/backend/src/services/finance/manual-fulfillment-integrity.test.ts b/apps/backend/src/services/finance/manual-fulfillment-integrity.test.ts index 85952ad6..aa428900 100644 --- a/apps/backend/src/services/finance/manual-fulfillment-integrity.test.ts +++ b/apps/backend/src/services/finance/manual-fulfillment-integrity.test.ts @@ -90,15 +90,20 @@ test('人工确认必须单事务:扣款与快照同事务,且使用 with-cl }) test('人工确认前必须自动匹配履约配置,配置保存/定时任务也要回填', async () => { - const [manualSource, configSource, jobSource, migrationSource] = await Promise.all([ - readFile(new URL('./manual-fulfillment-service.ts', import.meta.url), 'utf8'), - readFile( - new URL('../cost-center/manual-fulfillment-config-service.ts', import.meta.url), - 'utf8', - ), - readFile(new URL('../scheduler/finance-pipeline-jobs.ts', import.meta.url), 'utf8'), - readFile(new URL('../../db/migrations/106_manual_sku_key.sql', import.meta.url), 'utf8'), - ]) + const [manualSource, configSource, jobSource, migrationSource, reconcileMigration] = + await Promise.all([ + readFile(new URL('./manual-fulfillment-service.ts', import.meta.url), 'utf8'), + readFile( + new URL('../cost-center/manual-fulfillment-config-service.ts', import.meta.url), + 'utf8', + ), + readFile(new URL('../scheduler/finance-pipeline-jobs.ts', import.meta.url), 'utf8'), + readFile(new URL('../../db/migrations/106_manual_sku_key.sql', import.meta.url), 'utf8'), + readFile( + new URL('../../db/migrations/135_reconcile_manual_standard_products.sql', import.meta.url), + 'utf8', + ), + ]) assert.ok( manualSource.includes('matchManualFulfillmentConfigWithClient(client, orderItemId, now)'), '确认时必须按 SKU 自动挂渠道', @@ -137,6 +142,18 @@ test('人工确认前必须自动匹配履约配置,配置保存/定时任务 migrationSource.includes('sku_name'), '生成列必须保留 sku_code / sku_name 兜底', ) + const listStart = configSource.indexOf('export async function listManualSkus') + const listEnd = configSource.indexOf('export async function getManualSkuOverview') + const listBody = configSource.slice(listStart, listEnd) + assert.ok( + listBody.includes('FROM product_catalog p') && listBody.includes('productSourceFilterSql'), + '人工成本配置必须以 man: 标准商品为唯一清单来源', + ) + assert.ok( + reconcileMigration.includes('tmp_manual_product_merge') && + reconcileMigration.includes("status = 'archived'"), + '存量重复人工 SKU 必须合并到同一标准商品并归档重复项', + ) }) test('人工成本缺失由管道按配置与快照生成并收口', async () => { diff --git a/apps/frontend/src/pages/admin/cost-center/ManualSkuConfigPanel.tsx b/apps/frontend/src/pages/admin/cost-center/ManualSkuConfigPanel.tsx index 5834a7c2..97eff9c0 100644 --- a/apps/frontend/src/pages/admin/cost-center/ManualSkuConfigPanel.tsx +++ b/apps/frontend/src/pages/admin/cost-center/ManualSkuConfigPanel.tsx @@ -1,5 +1,5 @@ /** - * 人工 SKU 配置 Tab:扫描 → 配置 → 重新匹配。 + * 人工 SKU 配置 Tab:按人工标准商品配置渠道与成本。 * * 口径(2026-10-24 确认): * * 配置主键是**内部 SKU**(快手 skuNick / 拼多多 outSkuSn),商品名称仅供识别; @@ -16,8 +16,6 @@ import { MinusCircleOutlined, PlusOutlined, ReloadOutlined, - ScanOutlined, - SyncOutlined, } from '@ant-design/icons' import { Alert, @@ -25,7 +23,6 @@ import { Button, Card, Col, - DatePicker, Form, Input, InputNumber, @@ -48,9 +45,7 @@ import { fetchAdminManualPlatforms, fetchAdminManualSkuOverview, fetchAdminManualSkus, - rematchAdminManualFulfillments, saveAdminManualConfig, - scanAdminManualSkus, } from '@/services/admin' import type { ManualPlatformKey, ManualSkuItem } from '@/types/admin' import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination' @@ -59,8 +54,6 @@ import { formatCreditUnitPriceLabel, formatCreditUnitPriceYuan, } from '@/utils/credit-unit-price' -import dayjs from '@/lib/dayjs' -import type { Dayjs } from 'dayjs' type ConfigFormValues = { channels: Array<{ @@ -102,10 +95,6 @@ export function ManualSkuConfigPanel() { const [platformKey, setPlatformKey] = useState('') const [page, setPage] = useState(1) const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE) - const [scanRange, setScanRange] = useState<[Dayjs, Dayjs]>([ - dayjs().startOf('month'), - dayjs().endOf('month'), - ]) const [modalOpen, setModalOpen] = useState(false) const [editing, setEditing] = useState(null) @@ -133,25 +122,6 @@ export function ManualSkuConfigPanel() { const platforms = platformQuery.data?.data.items || [] const platformMap = new Map(platforms.map((item) => [item.key, item])) - const scanMutation = useMutation({ - mutationFn: () => - scanAdminManualSkus({ - fromDate: scanRange[0].format('YYYY-MM-DD'), - toDate: scanRange[1].format('YYYY-MM-DD'), - }), - onSuccess: (response) => { - const result = response.data - message.success(`扫描完成,登记 ${result.registered} 个内部 SKU`) - void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-manual-skus'] }) - void queryClient.invalidateQueries({ - queryKey: ['admin-cost-center-manual-skus-overview'], - }) - }, - onError: (error) => { - message.error(error instanceof Error ? error.message : '扫描失败') - }, - }) - const saveMutation = useMutation({ mutationFn: (values: ConfigFormValues) => saveAdminManualConfig({ @@ -183,7 +153,7 @@ export function ManualSkuConfigPanel() { mutationFn: (skuKey: string) => deleteAdminManualSku(skuKey), onSuccess: (response) => { message.success( - `人工分类已移除;${response.data.affectedItems} 个订单项已退回待分类,原始订单保留`, + `人工标准商品已归档;${response.data.affectedItems} 个订单项已退回待分类,原始订单保留`, ) void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-manual-skus'] }) void queryClient.invalidateQueries({ @@ -197,17 +167,6 @@ export function ManualSkuConfigPanel() { }, }) - const rematchMutation = useMutation({ - mutationFn: () => rematchAdminManualFulfillments(), - onSuccess: (response) => { - message.success(`重新匹配完成,回填 ${response.data.updatedCount} 个订单项`) - void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-manual-skus'] }) - }, - onError: (error) => { - message.error(error instanceof Error ? error.message : '重新匹配失败') - }, - }) - function openConfig(item: ManualSkuItem) { setEditing(item) const configuredChannels = item.config?.channels?.length @@ -232,16 +191,18 @@ export function ManualSkuConfigPanel() { const columns: TableColumnsType = [ { - title: '内部 SKU(配置主键)', + title: '人工标准商品', dataIndex: 'skuKey', - width: 280, + width: 300, render: (_, row) => (
- - {row.skuKey} - - - {row.displayName || '(无名称)'} + {row.displayName || row.skuKey} + + SKU:{row.skuKey}
), @@ -321,14 +282,14 @@ export function ManualSkuConfigPanel() { {row.configured ? '编辑' : '配置'} deleteMutation.mutate(row.skuKey)} > @@ -347,13 +308,13 @@ export function ManualSkuConfigPanel() { type="info" showIcon style={{ marginBottom: 14 }} - message="人工履约只需在这里配置一次" - description="保存时会自动创建或关联 man: 标准商品、同步统一渠道规则、回填最近 30 天订单,并重试待处理的人工渠道订单。消耗积分 × 平台换算率就是这单的人工成本,不需要再去标准商品或渠道规则页重复配置;消耗积分填 0 表示成本不固定,由客服在确认履约时手填。" + message="人工标准商品与成本配置一一对应" + description="这里直接列出人工客服范围内的 man: 标准商品。点“配置”选择实际履约渠道并填写每单成本即可;保存后会同步渠道规则、回填最近 30 天订单并重试待处理订单。消耗积分填 0 表示成本不固定,由客服确认时手填。" /> - + - { - if (value?.[0] && value?.[1]) setScanRange([value[0], value[1]]) - }} - /> - - { diff --git a/apps/frontend/src/services/admin/cost-center.ts b/apps/frontend/src/services/admin/cost-center.ts index fbd06c08..f0dd3780 100644 --- a/apps/frontend/src/services/admin/cost-center.ts +++ b/apps/frontend/src/services/admin/cost-center.ts @@ -403,6 +403,7 @@ export function deleteAdminManualSku(skuKey: string) { deletedConfigs: number deletedRegistry: number deletedRules: number + archivedProducts: number refreshedDays: number skippedClosedDays: number }>(`/api/v1/admin/cost-center/manual-skus/${encodeURIComponent(skuKey)}`) diff --git a/apps/frontend/tests/manual-sku-config-ui.test.ts b/apps/frontend/tests/manual-sku-config-ui.test.ts new file mode 100644 index 00000000..11157747 --- /dev/null +++ b/apps/frontend/tests/manual-sku-config-ui.test.ts @@ -0,0 +1,17 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import test from 'node:test' + +test('人工成本配置直接展示标准商品,不再要求按日期扫描内部 SKU', async () => { + const source = await readFile( + new URL('../src/pages/admin/cost-center/ManualSkuConfigPanel.tsx', import.meta.url), + 'utf8', + ) + + assert.ok(source.includes('人工标准商品与成本配置一一对应')) + assert.ok(source.includes("title: '人工标准商品'")) + assert.ok(source.includes('SKU:{row.skuKey}')) + assert.ok(!source.includes('扫描内部 SKU')) + assert.ok(!source.includes('DatePicker.RangePicker')) + assert.ok(!source.includes('rematchAdminManualFulfillments')) +})