feat(cost-center): 人工 SKU 配置自动同步商品与订单
This commit is contained in:
@@ -5,6 +5,7 @@ import { createHttpError } from '../../utils/http.js'
|
||||
import { formatShanghaiDate, shiftDate } from '../../utils/finance-date-range.js'
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
import { upsertInternalSkuChannel } from '../fulfillment/internal-sku-channel-service.js'
|
||||
import { reconcilePendingCarriers } from '../fulfillment/carrier-reconciliation-service.js'
|
||||
import { canonicalizeInternalSku, containsCjk } from '../fulfillment/internal-sku-resolver.js'
|
||||
|
||||
/**
|
||||
@@ -361,6 +362,49 @@ export async function saveManualFulfillmentConfig(payload: JsonObject) {
|
||||
[skuKey],
|
||||
)
|
||||
const existingId = Number(existing.rows[0]?.id || 0)
|
||||
let resolvedProductId = canonicalProductId
|
||||
|
||||
// 人工配置本身就是该 SKU 的标准商品声明。没有显式关联时自动创建/复用
|
||||
// man:<内部 SKU>,避免运营再去「标准商品」手工建一次。
|
||||
if (!resolvedProductId) {
|
||||
const registry = await client.query<{ display_name: string }>(
|
||||
`SELECT display_name FROM manual_sku_registry
|
||||
WHERE canonicalize_internal_sku(sku_key) = $1
|
||||
LIMIT 1`,
|
||||
[skuKey],
|
||||
)
|
||||
const canonicalSku = `man:${skuKey}`
|
||||
if (canonicalSku.length > 64) {
|
||||
throw createHttpError('内部 SKU 过长,无法生成标准商品编码', {
|
||||
statusCode: 400,
|
||||
errorCode: 'manual_product_sku_too_long',
|
||||
})
|
||||
}
|
||||
const canonicalName =
|
||||
String(registry.rows[0]?.display_name || skuKey)
|
||||
.trim()
|
||||
.slice(0, 120) || skuKey.slice(0, 120)
|
||||
const product = 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, '人工', '件', 'active',
|
||||
'{"source":"manual_config","auto_created":true}'::jsonb, $3, $3
|
||||
)
|
||||
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
|
||||
RETURNING id`,
|
||||
[canonicalSku, canonicalName, now],
|
||||
)
|
||||
resolvedProductId = Number(product.rows[0]?.id || 0) || null
|
||||
}
|
||||
|
||||
const result = existingId
|
||||
? await client.query(
|
||||
`UPDATE manual_fulfillment_configs
|
||||
@@ -372,7 +416,7 @@ export async function saveManualFulfillmentConfig(payload: JsonObject) {
|
||||
[
|
||||
existingId,
|
||||
skuKey,
|
||||
canonicalProductId,
|
||||
resolvedProductId,
|
||||
platformKey,
|
||||
creditQuantity,
|
||||
costMode,
|
||||
@@ -387,7 +431,7 @@ export async function saveManualFulfillmentConfig(payload: JsonObject) {
|
||||
cost_mode, enabled, note, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8)
|
||||
RETURNING *`,
|
||||
[skuKey, canonicalProductId, platformKey, creditQuantity, costMode, enabled, note, now],
|
||||
[skuKey, resolvedProductId, platformKey, creditQuantity, costMode, enabled, note, now],
|
||||
)
|
||||
return requiredRow(result.rows[0], '保存人工履约配置失败')
|
||||
})
|
||||
@@ -396,16 +440,53 @@ export async function saveManualFulfillmentConfig(payload: JsonObject) {
|
||||
// 同步渠道规则:人工配置就是「这个内部 SKU 走人工渠道」的唯一声明,
|
||||
// 少了这一步,新配的 SKU 会一直停在待分类。
|
||||
await syncManualChannelRule(config)
|
||||
// 配置补全后,失败的 pending 会因 locked=true 无法被定时任务重试,这里主动解锁并重试。
|
||||
const pendingAssigned = await retryPendingManualItems(config.sku_key)
|
||||
// 配置保存后立刻回填该 SKU 最近 30 天的订单项,避免必须再点一次「重新匹配」。
|
||||
// 历史更早的订单项不自动回填(历史人工成本不补录);需要时再显式点「重新匹配」。
|
||||
const today = formatShanghaiDate(new Date())
|
||||
await rematchManualFulfillments({
|
||||
const rematched = await rematchManualFulfillments({
|
||||
skuKey: config.sku_key,
|
||||
onlyUnmatched: true,
|
||||
dateFrom: shiftDate(today, -29),
|
||||
dateTo: today,
|
||||
})
|
||||
return mapManualConfig(config, unitPrices)
|
||||
return {
|
||||
...mapManualConfig(config, unitPrices),
|
||||
syncSummary: {
|
||||
canonicalProductId: config.canonical_product_id ? Number(config.canonical_product_id) : null,
|
||||
pendingAssigned,
|
||||
rematchedItems: rematched.updatedCount,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function retryPendingManualItems(skuKey: string): Promise<number> {
|
||||
const key = canonicalizeInternalSku(skuKey)
|
||||
if (!key) return 0
|
||||
const candidates = await query<{ id: number }>(
|
||||
`SELECT oi.id
|
||||
FROM order_items oi
|
||||
WHERE oi.fulfillment_status = 'pending'
|
||||
AND resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) = $1
|
||||
ORDER BY oi.id
|
||||
LIMIT 500`,
|
||||
[key],
|
||||
)
|
||||
const ids = candidates.rows.map((row) => Number(row.id))
|
||||
if (ids.length === 0) return 0
|
||||
await query(
|
||||
`UPDATE order_items
|
||||
SET fulfillment_locked = FALSE, updated_at = NOW()
|
||||
WHERE id = ANY($1::bigint[]) AND fulfillment_status = 'pending'`,
|
||||
[ids],
|
||||
)
|
||||
const result = await reconcilePendingCarriers({
|
||||
orderItemIds: ids,
|
||||
limit: ids.length,
|
||||
allowFallback: true,
|
||||
})
|
||||
return result.fallbacksSelected
|
||||
}
|
||||
|
||||
/** 删除一份人工履约配置(SKU 回到未配置状态)。 */
|
||||
|
||||
@@ -28,6 +28,8 @@ test(
|
||||
switchChannelForOrderItems,
|
||||
upsertChannelRule,
|
||||
} = await import('./channel-assign-service.js')
|
||||
const { saveManualFulfillmentConfig } =
|
||||
await import('../cost-center/manual-fulfillment-config-service.js')
|
||||
const { runAffiliateFallback } = await import('./affiliate-fallback-service.js')
|
||||
const {
|
||||
deleteInternalSkuMapping,
|
||||
@@ -44,6 +46,7 @@ test(
|
||||
const voucherSku = `集成测试卡券-${suffix}`
|
||||
const triggerSku = `集成测试规范-${suffix}`
|
||||
const fallbackSku = `集成测试兜底-${suffix}`
|
||||
const autoManualSku = `集成测试自动人工-${suffix}`
|
||||
const orderIds: number[] = []
|
||||
const orderItemIds: number[] = []
|
||||
const crossShopSku = `集成测试跨店-${suffix}`
|
||||
@@ -54,8 +57,10 @@ test(
|
||||
crossShopSku,
|
||||
triggerSku,
|
||||
fallbackSku,
|
||||
autoManualSku,
|
||||
]
|
||||
let generatedVoucherProductId: number | null = null
|
||||
let autoManualProductId: number | null = null
|
||||
let fallbackProfileId: number | null = null
|
||||
|
||||
async function createPendingOrderItem(
|
||||
@@ -248,6 +253,39 @@ test(
|
||||
assert.equal(pendingItem.rows[0]?.fulfillment_channel, null)
|
||||
assert.equal(pendingItem.rows[0]?.fulfillment_pending_reason, 'no_carrier')
|
||||
|
||||
const autoManualItemId = await createPendingOrderItem(autoManualSku)
|
||||
const missingManual = await assignChannelByInternalSku({
|
||||
internalSku: autoManualSku,
|
||||
channel: 'manual',
|
||||
adminUserId: null,
|
||||
note: '集成测试缺人工配置',
|
||||
})
|
||||
assert.equal(missingManual.updated, 0)
|
||||
assert.equal(missingManual.carrierFailed, 1)
|
||||
const savedManual = await saveManualFulfillmentConfig({
|
||||
skuKey: autoManualSku,
|
||||
platformKey: 'fund_cash',
|
||||
creditQuantity: 69,
|
||||
note: '集成测试一次配置',
|
||||
})
|
||||
autoManualProductId = Number(savedManual.canonicalProductId || 0) || null
|
||||
assert.ok(autoManualProductId)
|
||||
assert.ok(savedManual.syncSummary.pendingAssigned >= 1)
|
||||
const autoManualItem = await query<{
|
||||
fulfillment_status: string
|
||||
fulfillment_channel: string
|
||||
fulfillment_config_id: number | null
|
||||
canonical_product_id: number | null
|
||||
}>(
|
||||
`SELECT fulfillment_status, fulfillment_channel, fulfillment_config_id, canonical_product_id
|
||||
FROM order_items WHERE id = $1`,
|
||||
[autoManualItemId],
|
||||
)
|
||||
assert.equal(autoManualItem.rows[0]?.fulfillment_status, 'assigned')
|
||||
assert.equal(autoManualItem.rows[0]?.fulfillment_channel, 'manual')
|
||||
assert.ok(Number(autoManualItem.rows[0]?.fulfillment_config_id) > 0)
|
||||
assert.equal(Number(autoManualItem.rows[0]?.canonical_product_id), autoManualProductId)
|
||||
|
||||
const crossShopA = `it-routing-a-${suffix}`
|
||||
const crossShopB = `it-routing-b-${suffix}`
|
||||
await createPendingOrderItem(crossShopSku, { shopId: crossShopA })
|
||||
@@ -460,6 +498,15 @@ test(
|
||||
`tpv:${manualSku}`,
|
||||
])
|
||||
}
|
||||
if (autoManualProductId) {
|
||||
await query(`DELETE FROM sales_daily_facts WHERE canonical_product_id = $1`, [
|
||||
autoManualProductId,
|
||||
])
|
||||
await query(`DELETE FROM cost_versions WHERE canonical_product_id = $1`, [
|
||||
autoManualProductId,
|
||||
])
|
||||
await query(`DELETE FROM product_catalog WHERE id = $1`, [autoManualProductId])
|
||||
}
|
||||
if (fallbackProfileId) {
|
||||
await query(`DELETE FROM fulfillment_profiles WHERE id = $1`, [fallbackProfileId])
|
||||
}
|
||||
|
||||
@@ -11,11 +11,15 @@ import type { CostItem, CostModel, CostPreview, ProductSource } from '@/types/ad
|
||||
|
||||
import type { CostScope } from './shared'
|
||||
|
||||
export type CostCenterTab = 'products' | 'mappings' | 'cost' | 'exceptions'
|
||||
|
||||
export type CostCenterContextValue = {
|
||||
canConfig: boolean
|
||||
/** 当前来源筛选:all / affiliate_dash / work_order / manual。 */
|
||||
scope: CostScope
|
||||
setScope: (scope: CostScope) => void
|
||||
activeTab: CostCenterTab
|
||||
setActiveTab: (tab: CostCenterTab) => void
|
||||
scopeLabel: string
|
||||
/** 商品/映射按 canonical_sku 前缀筛选。 */
|
||||
productSourceFilter?: ProductSource
|
||||
|
||||
@@ -34,7 +34,7 @@ import { ChannelMappingPanel } from './ChannelMappingPanel'
|
||||
import CostConfigPanel from './CostConfigPanel'
|
||||
import { CostExceptionPanel } from './CostExceptionPanel'
|
||||
import { ProductCatalogPanel } from './ProductCatalogPanel'
|
||||
import { CostCenterContext } from './CostCenterContext'
|
||||
import { CostCenterContext, type CostCenterTab } from './CostCenterContext'
|
||||
import type { CostScope } from './shared'
|
||||
|
||||
const SCOPES: Array<{ key: CostScope; label: string; hint: string }> = [
|
||||
@@ -61,8 +61,7 @@ const SCOPES: Array<{ key: CostScope; label: string; hint: string }> = [
|
||||
},
|
||||
]
|
||||
|
||||
const TABS = ['products', 'mappings', 'cost', 'exceptions'] as const
|
||||
type CostCenterTab = (typeof TABS)[number]
|
||||
const TABS: readonly CostCenterTab[] = ['products', 'mappings', 'cost', 'exceptions']
|
||||
|
||||
function isCostCenterTab(value: unknown): value is CostCenterTab {
|
||||
return typeof value === 'string' && (TABS as readonly string[]).includes(value)
|
||||
@@ -130,6 +129,8 @@ export default function CostCenterShell() {
|
||||
canConfig,
|
||||
scope,
|
||||
setScope,
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
scopeLabel,
|
||||
productSourceFilter,
|
||||
channelFilter,
|
||||
|
||||
@@ -143,13 +143,17 @@ export function ManualSkuConfigPanel() {
|
||||
creditQuantity: values.creditQuantity,
|
||||
note: values.note || '',
|
||||
}),
|
||||
onSuccess: () => {
|
||||
message.success('配置已保存')
|
||||
onSuccess: (response) => {
|
||||
const summary = response.data.syncSummary
|
||||
const syncHint = summary
|
||||
? `;自动关联标准商品并重试 ${summary.pendingAssigned} 个待处理订单项`
|
||||
: ''
|
||||
message.success(`配置已保存${syncHint}`)
|
||||
setModalOpen(false)
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-manual-skus'] })
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ['admin-cost-center-manual-skus-overview'],
|
||||
})
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-cost-center'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['cost-center-voucher-products'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['fulfillment-rules'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['fulfillment-pending'] })
|
||||
},
|
||||
onError: (error) => {
|
||||
message.error(error instanceof Error ? error.message : '保存失败')
|
||||
@@ -290,8 +294,8 @@ export function ManualSkuConfigPanel() {
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 14 }}
|
||||
message="配置主键是内部 SKU,不是商品名称"
|
||||
description="扫描后列出所有走到人工履约的内部 SKU。运营在这里配置「履约渠道 + 消耗积分」,保存后点「重新匹配」回填到历史订单项。消耗积分 × 平台换算率就是这单的人工成本,确认履约时直接扣减,不需要再去成本版本里配一次;消耗积分填 0 表示成本不固定,由客服在确认履约时手填。"
|
||||
message="人工履约只需在这里配置一次"
|
||||
description="保存时会自动创建或关联 man: 标准商品、同步统一渠道规则、回填最近 30 天订单,并重试待处理的人工渠道订单。消耗积分 × 平台换算率就是这单的人工成本,不需要再去标准商品或渠道规则页重复配置;消耗积分填 0 表示成本不固定,由客服在确认履约时手填。"
|
||||
/>
|
||||
|
||||
<Row gutter={12} style={{ marginBottom: 12 }}>
|
||||
@@ -418,7 +422,7 @@ export function ManualSkuConfigPanel() {
|
||||
showIcon
|
||||
style={{ marginBottom: 14 }}
|
||||
message={`商品名称:${editing?.displayName || '(无)'}(仅供识别)`}
|
||||
description="渠道提前配置好,客服确认履约时不需要选择。消耗积分填 0 表示成本不固定,客服会在确认履约时手填实际成本。"
|
||||
description="保存后自动同步标准商品、统一渠道规则和订单匹配;不需要再去其它页面重复设置。消耗积分填 0 表示成本不固定,客服会在确认履约时手填实际成本。"
|
||||
/>
|
||||
<Form<ConfigFormValues> form={form} layout="vertical">
|
||||
<Form.Item
|
||||
|
||||
@@ -95,7 +95,7 @@ function DisabledTemplateSyncOption({
|
||||
export function ProductCatalogPanel() {
|
||||
const { message, modal } = App.useApp()
|
||||
const queryClient = useQueryClient()
|
||||
const { canConfig, productSourceFilter, refreshAll } = useCostCenter()
|
||||
const { canConfig, productSourceFilter, refreshAll, setScope, setActiveTab } = useCostCenter()
|
||||
const [productForm] = Form.useForm<ProductFormValues>()
|
||||
// 历史修复只处理最近 30 天,避免很早以前的乱 SKU 再次进入商品与映射。
|
||||
const [backfillRange, setBackfillRange] = useState<[Dayjs, Dayjs]>([
|
||||
@@ -635,6 +635,13 @@ export function ProductCatalogPanel() {
|
||||
dataIndex: 'costVersionCount',
|
||||
width: 140,
|
||||
render: (value: number, row) => {
|
||||
if (row.source === 'manual') {
|
||||
return (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
以人工 SKU 配置为准
|
||||
</Typography.Text>
|
||||
)
|
||||
}
|
||||
if (row.activeCostAmountFen !== null && row.activeCostAmountFen !== undefined) {
|
||||
return (
|
||||
<div
|
||||
@@ -700,15 +707,29 @@ export function ProductCatalogPanel() {
|
||||
render: (_, row) =>
|
||||
canConfig ? (
|
||||
<Space size={2} wrap>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<DollarOutlined />}
|
||||
style={{ paddingInline: 4 }}
|
||||
onClick={() => openSetProductPrice(row)}
|
||||
>
|
||||
{row.activeCostAmountFen ? '改价' : '配置成本'}
|
||||
</Button>
|
||||
{row.source === 'manual' ? (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
style={{ paddingInline: 4 }}
|
||||
onClick={() => {
|
||||
setScope('manual')
|
||||
setActiveTab('cost')
|
||||
}}
|
||||
>
|
||||
人工履约配置
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<DollarOutlined />}
|
||||
style={{ paddingInline: 4 }}
|
||||
onClick={() => openSetProductPrice(row)}
|
||||
>
|
||||
{row.activeCostAmountFen ? '改价' : '配置成本'}
|
||||
</Button>
|
||||
)}
|
||||
{row.canonicalSku.startsWith('tpl:') ? (
|
||||
<Button
|
||||
type="link"
|
||||
|
||||
@@ -419,6 +419,11 @@ export interface ManualFulfillmentConfig {
|
||||
note?: string
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
syncSummary?: {
|
||||
canonicalProductId: number | null
|
||||
pendingAssigned: number
|
||||
rematchedItems: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface ManualSkuListResult {
|
||||
|
||||
Reference in New Issue
Block a user