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 f15205ae..4cbfc6ed 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 @@ -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 { + 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 回到未配置状态)。 */ diff --git a/apps/backend/src/services/fulfillment/fulfillment-routing.integration.test.ts b/apps/backend/src/services/fulfillment/fulfillment-routing.integration.test.ts index 97f35d17..1aadc058 100644 --- a/apps/backend/src/services/fulfillment/fulfillment-routing.integration.test.ts +++ b/apps/backend/src/services/fulfillment/fulfillment-routing.integration.test.ts @@ -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]) } diff --git a/apps/frontend/src/pages/admin/cost-center/CostCenterContext.ts b/apps/frontend/src/pages/admin/cost-center/CostCenterContext.ts index f7150151..f33ab919 100644 --- a/apps/frontend/src/pages/admin/cost-center/CostCenterContext.ts +++ b/apps/frontend/src/pages/admin/cost-center/CostCenterContext.ts @@ -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 diff --git a/apps/frontend/src/pages/admin/cost-center/CostCenterShell.tsx b/apps/frontend/src/pages/admin/cost-center/CostCenterShell.tsx index 9981a5f3..6c318303 100644 --- a/apps/frontend/src/pages/admin/cost-center/CostCenterShell.tsx +++ b/apps/frontend/src/pages/admin/cost-center/CostCenterShell.tsx @@ -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, diff --git a/apps/frontend/src/pages/admin/cost-center/ManualSkuConfigPanel.tsx b/apps/frontend/src/pages/admin/cost-center/ManualSkuConfigPanel.tsx index 2737722d..5f6b4b5b 100644 --- a/apps/frontend/src/pages/admin/cost-center/ManualSkuConfigPanel.tsx +++ b/apps/frontend/src/pages/admin/cost-center/ManualSkuConfigPanel.tsx @@ -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 表示成本不固定,由客服在确认履约时手填。" /> @@ -418,7 +422,7 @@ export function ManualSkuConfigPanel() { showIcon style={{ marginBottom: 14 }} message={`商品名称:${editing?.displayName || '(无)'}(仅供识别)`} - description="渠道提前配置好,客服确认履约时不需要选择。消耗积分填 0 表示成本不固定,客服会在确认履约时手填实际成本。" + description="保存后自动同步标准商品、统一渠道规则和订单匹配;不需要再去其它页面重复设置。消耗积分填 0 表示成本不固定,客服会在确认履约时手填实际成本。" /> form={form} layout="vertical"> () // 历史修复只处理最近 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 ( + + 以人工 SKU 配置为准 + + ) + } if (row.activeCostAmountFen !== null && row.activeCostAmountFen !== undefined) { return (
canConfig ? ( - + {row.source === 'manual' ? ( + + ) : ( + + )} {row.canonicalSku.startsWith('tpl:') ? (