refactor(cost-center): 统一人工标准商品与成本配置

This commit is contained in:
yml2213
2026-11-05 17:58:57 +08:00
parent a6fbb43955
commit 8b9b4fc4ad
6 changed files with 406 additions and 115 deletions
@@ -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;
@@ -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<string, unknown> = {}) {
const unitPrices = await loadPlatformUnitPriceFen()
const page = normalizePositiveInteger(input.page, 1, '页码')
@@ -238,13 +237,16 @@ export async function listManualSkus(input: Record<string, unknown> = {}) {
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<string, unknown> = {}) {
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<string, unknown> = {}) {
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<string, unknown> = {}) {
const listParams = [...params, pageSize, (page - 1) * pageSize]
const result = await query<ManualSkuRow>(
`
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()
@@ -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 () => {
@@ -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<ManualSkuItem | null>(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<ManualSkuItem> = [
{
title: '内部 SKU(配置主键)',
title: '人工标准商品',
dataIndex: 'skuKey',
width: 280,
width: 300,
render: (_, row) => (
<div className="cell-stack">
<Typography.Text strong copyable={{ text: row.skuKey }}>
{row.skuKey}
</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{row.displayName || '(无名称)'}
<Typography.Text strong>{row.displayName || row.skuKey}</Typography.Text>
<Typography.Text
type="secondary"
copyable={{ text: row.skuKey }}
style={{ fontSize: 12 }}
>
SKU:{row.skuKey}
</Typography.Text>
</div>
),
@@ -321,14 +282,14 @@ export function ManualSkuConfigPanel() {
{row.configured ? '编辑' : '配置'}
</Button>
<Popconfirm
title="移除该人工分类?"
description="会删除人工登记与配置、撤销人工渠道规则、清除已生成的人工成本快照,并把关联订单项退回待分类;原始订单不会删除。"
okText="确认移除"
title="归档该人工标准商品?"
description="会归档标准商品、删除人工配置与渠道规则、清除已生成的人工成本快照,并把关联订单项退回待分类;原始订单不会删除。"
okText="确认归档"
cancelText="取消"
onConfirm={() => deleteMutation.mutate(row.skuKey)}
>
<Button size="small" danger icon={<DeleteOutlined />}>
移除
归档
</Button>
</Popconfirm>
</Space>
@@ -347,13 +308,13 @@ export function ManualSkuConfigPanel() {
type="info"
showIcon
style={{ marginBottom: 14 }}
message="人工履约只需在这里配置一次"
description="保存时会自动创建或关联 man: 标准商品、同步统一渠道规则、回填最近 30 天订单,并重试待处理的人工渠道订单。消耗积分 × 平台换算率就是这单的人工成本,不需要再去标准商品或渠道规则页重复配置;消耗积分填 0 表示成本不固定,由客服在确认履约时手填。"
message="人工标准商品与成本配置一一对应"
description="这里直接列出人工客服范围内的 man: 标准商品。点“配置”选择实际履约渠道并填写每单成本即可;保存后会同步渠道规则、回填最近 30 天订单并重试待处理订单。消耗积分填 0 表示成本不固定,由客服确认时手填。"
/>
<Row gutter={12} style={{ marginBottom: 12 }}>
<Col span={6}>
<Statistic title="内部 SKU 总数" value={overview?.totalSku ?? 0} />
<Statistic title="人工标准商品" value={overview?.totalSku ?? 0} />
</Col>
<Col span={6}>
<Statistic
@@ -380,29 +341,8 @@ export function ManualSkuConfigPanel() {
</Row>
<Space wrap style={{ marginBottom: 12 }}>
<DatePicker.RangePicker
value={scanRange}
onChange={(value) => {
if (value?.[0] && value?.[1]) setScanRange([value[0], value[1]])
}}
/>
<Button
type="primary"
icon={<ScanOutlined />}
loading={scanMutation.isPending}
onClick={() => scanMutation.mutate()}
>
扫描内部 SKU
</Button>
<Button
icon={<SyncOutlined />}
loading={rematchMutation.isPending}
onClick={() => rematchMutation.mutate()}
>
重新匹配已配置
</Button>
<Input.Search
placeholder="搜索内部 SKU 或名称"
placeholder="搜索标准商品或 SKU"
allowClear
style={{ width: 220 }}
onSearch={(value) => {
@@ -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)}`)
@@ -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'))
})