feat(finance): P1 人工商品归一,按内部 SKU 生成 man 商品并回填

- 新服务 manual-product-normalization-service:skuNick → outSkuSn → sku_code 兜底,按内部 SKU 生成 man:<sku> 商品
- 订单项按 id 分批(2 万/批)回填,避免 15s 语句超时;同步补齐人工成本快照的商品维度
- 新增 POST /cost-center/manual-products/normalize(支持 dryRun/日期窗口),成本中心「标准商品」加「归一人工商品」按钮
- 每日 cost_finance_sync 接入最近 N 天的增量归一
- 渠道映射未归一队列增加人工单提示:人工单不能用平台 sku_code 建映射
- 文档同步:P1 标记完成,补充归一接口契约与销售事实重算结果
This commit is contained in:
yml2213
2026-10-24 16:54:55 +08:00
parent e5122d116a
commit a78cc1f984
8 changed files with 425 additions and 7 deletions
@@ -733,6 +733,15 @@ export function ChannelMappingPanel() {
/>
)}
{unmappedGroups.some((row) => row.fulfillmentMode === 'manual') ? (
<Alert
type="warning"
showIcon
message="人工客服的未归一建议按内部 SKU 批量归一"
description="人工单的内部 SKU(快手 skuNick / 拼多多 outSkuSn)和平台 sku_code 不是同一个值,手建渠道映射通常抓不到历史单。请到「标准商品 → 归一人工商品」,按内部 SKU 生成 man: 商品并回填。"
/>
) : null}
<Space wrap>
<Input
allowClear
@@ -38,6 +38,7 @@ import {
createAdminProductCatalog,
deleteAdminProductCatalog,
fetchAdminProductCatalog,
normalizeAdminManualProducts,
previewAdminTemplateProductBootstrap,
updateAdminProductCatalog,
} from '@/services/admin'
@@ -66,6 +67,7 @@ export function ProductCatalogPanel() {
const [bootstrapPreviewing, setBootstrapPreviewing] = useState(false)
const [bootstrapApplying, setBootstrapApplying] = useState(false)
const [backfillingWorkOrderSkus, setBackfillingWorkOrderSkus] = useState(false)
const [normalizingManualProducts, setNormalizingManualProducts] = useState(false)
const [productPage, setProductPage] = useState(1)
const [productPageSize, setProductPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE)
const [productModalOpen, setProductModalOpen] = useState(false)
@@ -227,6 +229,52 @@ export function ProductCatalogPanel() {
}
}
async function normalizeManualProductCatalog() {
setNormalizingManualProducts(true)
try {
const preview = await normalizeAdminManualProducts({ dryRun: true })
const data = preview.data
if (data.distinctSkuCount === 0) {
message.success('没有待归一的人工订单项')
return
}
modal.confirm({
title: '归一人工商品',
content: (
<Space direction="vertical" size={8}>
<span>
按内部 SKU(快手 skuNick / 拼多多 outSkuSn)新建最多{' '}
<strong>{data.distinctSkuCount}</strong> 个 <code>man:</code> 标准商品。
</span>
<span>
把 <strong>{data.pendingItemCount}</strong>{' '}
个人工订单项归一到对应商品,并补齐已有成本快照的商品维度。
</span>
<span className="muted">已归一过的订单项不会改动;重复执行是幂等的。</span>
</Space>
),
okText: '开始归一',
cancelText: '取消',
onOk: async () => {
const response = await normalizeAdminManualProducts({})
const result = response.data
message.success(
`已新建 ${result.createdProductCount} 个商品、归一 ${result.updatedItemCount} 个订单项${
result.resolvedExceptionCount > 0
? `、收口 ${result.resolvedExceptionCount} 条异常`
: ''
},剩余 ${result.remainingItemCount} 个未归一`,
)
refreshAll()
},
})
} catch (error) {
message.error(error instanceof Error ? error.message : '归一人工商品失败')
} finally {
setNormalizingManualProducts(false)
}
}
function openCreateProduct() {
setEditingProduct(null)
productForm.resetFields()
@@ -463,6 +511,13 @@ export function ProductCatalogPanel() {
>
按工单回填商品
</Button>
<Button
icon={<SyncOutlined />}
loading={normalizingManualProducts}
onClick={() => void normalizeManualProductCatalog()}
>
归一人工商品
</Button>
<Button
icon={<ReloadOutlined />}
loading={productsQuery.isFetching}
@@ -132,6 +132,20 @@ export function fetchAdminChannelProductMappings(params?: Record<string, unknown
return apiGet<ChannelProductMappingListResult>('/api/v1/admin/cost-center/mappings', params)
}
export function normalizeAdminManualProducts(payload: Record<string, unknown>) {
return apiPost<{
dryRun: boolean
range: { fromDate: string; toDate: string } | null
distinctSkuCount: number
pendingItemCount: number
createdProductCount: number
updatedItemCount: number
updatedSnapshotCount: number
resolvedExceptionCount: number
remainingItemCount: number
}>('/api/v1/admin/cost-center/manual-products/normalize', payload)
}
export function fetchAdminChannelMappingDiagnostics(params?: Record<string, unknown>) {
return apiGet<ChannelMappingDiagnostics>('/api/v1/admin/cost-center/mappings/diagnostics', params)
}