From c31fa7db76fb55979eb63bad21ebbdaed7c3fbc4 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Wed, 4 Nov 2026 13:52:11 +0800 Subject: [PATCH] =?UTF-8?q?feat(fulfillment):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E5=88=A0=E9=99=A4=E5=BE=85=E5=88=86=E7=B1=BB=E5=92=8C=E5=BC=BA?= =?UTF-8?q?=E5=88=B6=E5=88=A0=E9=99=A4=E6=B8=A0=E9=81=93=E8=A7=84=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/routes/admin/cost-center-manual.ts | 30 +++ .../src/routes/admin/fulfillment-channel.ts | 47 ++++ .../manual-fulfillment-config-service.ts | 170 +++++++++++++ .../fulfillment/channel-assign-service.ts | 240 +++++++++++++++++- .../fulfillment-routing.integration.test.ts | 110 +++++++- .../admin/AdminFulfillmentChannelPage.tsx | 138 ++++++++-- .../cost-center/ManualSkuConfigPanel.tsx | 36 +-- .../src/services/admin/cost-center.ts | 12 + .../src/services/admin/fulfillment-channel.ts | 25 ++ 9 files changed, 760 insertions(+), 48 deletions(-) diff --git a/apps/backend/src/routes/admin/cost-center-manual.ts b/apps/backend/src/routes/admin/cost-center-manual.ts index 4ab72f9a..eee407c8 100644 --- a/apps/backend/src/routes/admin/cost-center-manual.ts +++ b/apps/backend/src/routes/admin/cost-center-manual.ts @@ -6,6 +6,7 @@ import { Router } from 'express' import { deleteManualFulfillmentConfig, + deleteManualSkuClassification, getManualFulfillmentConfig, getManualSkuOverview, listManualPlatformOptions, @@ -43,6 +44,35 @@ router.get( }), ) +router.delete( + '/cost-center/manual-skus/:skuKey', + requireAdminPermission('finance.config'), + createJsonHandler((req) => deleteManualSkuClassification(String(req.params.skuKey || '')), { + successMessage: '人工分类已移除', + errorMessage: '移除人工分类失败', + scope: '[admin/cost-center/manual-skus/:skuKey]', + audit: (req, data) => { + const result = data as { + skuKey?: string + affectedItems?: number + deletedConfigs?: number + deletedRules?: number + } + return { + action: 'manual_sku_classification_deleted', + targetType: 'manual_sku_registry', + targetId: String(req.params.skuKey || ''), + data: { + skuKey: result.skuKey || '', + affectedItems: result.affectedItems || 0, + deletedConfigs: result.deletedConfigs || 0, + deletedRules: result.deletedRules || 0, + }, + } + }, + }), +) + router.get( '/cost-center/manual-platforms', requireAdminPermission('finance.view'), diff --git a/apps/backend/src/routes/admin/fulfillment-channel.ts b/apps/backend/src/routes/admin/fulfillment-channel.ts index 9e93055b..928b70c9 100644 --- a/apps/backend/src/routes/admin/fulfillment-channel.ts +++ b/apps/backend/src/routes/admin/fulfillment-channel.ts @@ -11,8 +11,10 @@ import { assignChannelByInternalSku, assignChannelBySku, assignChannelToOrderItems, + deletePendingChannelGroup, deleteChannelRule, deleteChannelRules, + forceDeleteChannelRules, listChannelRulesPage, listPendingChannelGroups, switchChannelForOrderItems, @@ -60,6 +62,29 @@ router.get( ), ) +router.post( + '/fulfillment/pending/delete', + requireAdminPermission('finance.config'), + createJsonHandler( + (req) => + deletePendingChannelGroup({ + internalSku: String(bodyOf(req).internalSku || ''), + adminUserId: req.adminSession?.userId ?? null, + }), + { + successMessage: '待分类记录已删除', + errorMessage: '删除待分类记录失败', + scope: '[admin/fulfillment/pending/delete]', + audit: (req, data) => ({ + action: 'fulfillment.delete_pending_group', + targetType: 'internal_sku', + targetId: String(bodyOf(req).internalSku || ''), + data: data as Record, + }), + }, + ), +) + router.post( '/fulfillment/pending/retry-work-orders', requireAdminPermission('finance.config'), @@ -349,6 +374,28 @@ router.post( }), ) +router.post( + '/fulfillment/rules/force-delete', + requireAdminPermission('finance.config'), + createJsonHandler( + (req) => forceDeleteChannelRules(Array.isArray(req.body?.ids) ? req.body.ids : []), + { + successMessage: '渠道规则已强制删除', + errorMessage: '强制删除渠道规则失败', + scope: '[admin/fulfillment/rules/force-delete]', + audit: (req, data) => ({ + action: 'fulfillment.force_delete_rules', + targetType: 'internal_sku_channel', + targetId: '', + data: { + ids: Array.isArray(req.body?.ids) ? req.body.ids : [], + result: data as Record, + }, + }), + }, + ), +) + router.get( '/fulfillment/pipeline/status', requireAdminPermission('finance.view'), 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 4cbfc6ed..99ba48ec 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 @@ -7,6 +7,7 @@ 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' +import { refreshFactsForDates } from '../finance/refresh-facts.js' /** * 人工履约 SKU 配置服务。 @@ -517,6 +518,175 @@ export async function deleteManualFulfillmentConfig(skuKey: string) { return { skuKey: key, deleted: deleted.rowCount || 0 } } +export type DeleteManualSkuResult = { + skuKey: string + affectedItems: number + deletedConfigs: number + deletedRegistry: number + deletedRules: number + refreshedDays: number + skippedClosedDays: number +} + +/** + * 移除人工分类(含未配置项):删除派生登记、配置和人工规则, + * 并把当前仍落在 manual 的关联订单项退回待分类,等待重新走正确渠道。 + * 原始订单与订单项不会删除。 + */ +export async function deleteManualSkuClassification( + skuKey: string, +): Promise { + const key = canonicalizeInternalSku(skuKey) + if (!key || !containsCjk(key)) { + throw createHttpError('必须使用包含中文的内部 SKU', { + statusCode: 400, + errorCode: 'manual_config_sku_key_required', + }) + } + + const result = await withTransaction(async (client) => { + const items = await client.query<{ id: number; basis_date: string }>( + ` + SELECT + oi.id, + (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date::text + AS basis_date + FROM order_items oi + JOIN orders o ON o.id = oi.order_id + WHERE oi.fulfillment_status = 'assigned' + AND oi.fulfillment_channel = 'manual' + AND resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) = $1 + ORDER BY oi.id + FOR UPDATE OF oi + `, + [key], + ) + const itemIds = items.rows.map((row) => Number(row.id)) + const periodKeys = [ + ...new Set(items.rows.map((row) => String(row.basis_date || '').slice(0, 7)).filter(Boolean)), + ] + if (periodKeys.length > 0) { + const closedPeriods = await client.query<{ period_key: string }>( + ` + SELECT period_key + FROM finance_periods + WHERE status = 'closed' AND period_key = ANY($1::text[]) + ORDER BY period_key + `, + [periodKeys], + ) + if (closedPeriods.rows.length > 0) { + throw createHttpError( + `包含已关账期间(${closedPeriods.rows.map((row) => row.period_key).join('、')}),不能删除人工分类`, + { + statusCode: 409, + errorCode: 'finance_period_closed', + }, + ) + } + } + + if (itemIds.length > 0) { + await client.query(`DELETE FROM cost_snapshots WHERE order_item_id = ANY($1::bigint[])`, [ + itemIds, + ]) + const removedLinks = await client.query<{ exception_id: number }>( + ` + DELETE FROM cost_exception_items + WHERE order_item_id = ANY($1::bigint[]) + RETURNING exception_id + `, + [itemIds], + ) + const exceptionIds = [ + ...new Set(removedLinks.rows.map((row) => Number(row.exception_id)).filter(Boolean)), + ] + await client.query( + ` + UPDATE cost_exceptions ce + SET status = 'resolved', + resolved_at = NOW(), + item_count = 0, + last_seen_at = NOW() + WHERE ( + ce.order_item_id = ANY($1::bigint[]) + OR ce.id = ANY($2::bigint[]) + ) + AND ce.status <> 'resolved' + AND NOT EXISTS ( + SELECT 1 FROM cost_exception_items i WHERE i.exception_id = ce.id + ) + `, + [itemIds, exceptionIds], + ) + } + + const configs = await client.query( + `DELETE FROM manual_fulfillment_configs + WHERE canonicalize_internal_sku(sku_key) = $1 + RETURNING id`, + [key], + ) + const registry = await client.query( + `DELETE FROM manual_sku_registry + WHERE canonicalize_internal_sku(sku_key) = $1 + RETURNING id`, + [key], + ) + const rules = await client.query( + `DELETE FROM internal_sku_channels + WHERE canonicalize_internal_sku(internal_sku) = $1 AND channel = 'manual' + RETURNING id`, + [key], + ) + + if (itemIds.length > 0) { + await client.query( + ` + UPDATE order_items + SET fulfillment_channel = NULL, + fulfillment_status = 'pending', + fulfillment_source = '', + fulfillment_locked = FALSE, + fulfillment_config_id = NULL, + fulfillment_pending_reason = 'no_channel_capability', + fulfillment_pending_detail = jsonb_build_object( + 'reason', 'manual_classification_removed', + 'removedAt', NOW() + ), + fulfillment_reason = '运营移除错误的人工分类', + fulfillment_decided_by = NULL, + fulfillment_decided_at = NULL, + updated_at = NOW() + WHERE id = ANY($1::bigint[]) + AND fulfillment_status = 'assigned' + AND fulfillment_channel = 'manual' + `, + [itemIds], + ) + } + + return { + affectedItems: itemIds.length, + deletedConfigs: configs.rowCount || 0, + deletedRegistry: registry.rowCount || 0, + deletedRules: rules.rowCount || 0, + dates: [...new Set(items.rows.map((row) => row.basis_date).filter(Boolean))], + } + }) + + const refreshed = await refreshFactsForDates(result.dates) + return { + skuKey: key, + affectedItems: result.affectedItems, + deletedConfigs: result.deletedConfigs, + deletedRegistry: result.deletedRegistry, + deletedRules: result.deletedRules, + refreshedDays: refreshed.days, + skippedClosedDays: refreshed.skippedClosed, + } +} + /** * 重新匹配:把已配置项回填到历史人工订单项。 * diff --git a/apps/backend/src/services/fulfillment/channel-assign-service.ts b/apps/backend/src/services/fulfillment/channel-assign-service.ts index 5683274b..1e37d6ea 100644 --- a/apps/backend/src/services/fulfillment/channel-assign-service.ts +++ b/apps/backend/src/services/fulfillment/channel-assign-service.ts @@ -902,6 +902,7 @@ export type PendingGroupRow = { sku_name: string providers: string[] shop_ids: string[] + shops: Array<{ id: string; name: string }> sku_codes: string[] pending_reasons: string[] item_count: number @@ -910,6 +911,16 @@ export type PendingGroupRow = { last_seen_at: string } +type PendingShopNameRow = { + internal_sku: string + provider: string + shop_id: string + shop_name: string + usage_count: number +} + +type PendingGroupDbRow = Omit + /** 待分类队列:按全局内部 SKU 聚合(跨平台、跨店铺合并为一行)。 */ export async function listPendingChannelGroups(input: { page?: number @@ -943,7 +954,7 @@ export async function listPendingChannelGroups(input: { ) params.push(pageSize, (page - 1) * pageSize) - const rows = await query( + const rows = await query( ` SELECT ${INTERNAL_SKU_SQL} AS internal_sku, @@ -966,7 +977,213 @@ export async function listPendingChannelGroups(input: { params, ) - return { items: rows.rows, total: total.rows[0]?.count || 0, page, pageSize } + const internalSkus = rows.rows.map((row) => row.internal_sku) + const shopsBySku = new Map>() + if (internalSkus.length > 0) { + const shopRows = await query( + ` + SELECT + ${INTERNAL_SKU_SQL} AS internal_sku, + o.provider, + o.shop_id, + COALESCE(NULLIF(BTRIM(o.shop_name), ''), o.shop_id) AS shop_name, + COUNT(*)::int AS usage_count + FROM order_items oi + JOIN orders o ON o.id = oi.order_id + WHERE oi.fulfillment_status = 'pending' + AND ${INTERNAL_SKU_SQL} = ANY($1::text[]) + AND BTRIM(o.shop_id) <> '' + GROUP BY ${INTERNAL_SKU_SQL}, o.provider, o.shop_id, o.shop_name + ORDER BY + internal_sku, + provider, + shop_id, + CASE + WHEN BTRIM(o.shop_name) = '' OR BTRIM(o.shop_name) = o.shop_id THEN 3 + WHEN BTRIM(o.shop_name) = '91卡券' OR BTRIM(o.shop_name) LIKE '快手店铺-%' THEN 2 + WHEN BTRIM(o.shop_name) ~ '[一-龥]' THEN 0 + ELSE 1 + END, + usage_count DESC, + shop_name + `, + [internalSkus], + ) + + const seen = new Set() + for (const row of shopRows.rows) { + const shopKey = `${row.internal_sku}\u0000${row.shop_id}` + if (seen.has(shopKey)) continue + seen.add(shopKey) + const shops = shopsBySku.get(row.internal_sku) || [] + shops.push({ id: row.shop_id, name: row.shop_name || row.shop_id }) + shopsBySku.set(row.internal_sku, shops) + } + } + + return { + items: rows.rows.map((row) => ({ + ...row, + shops: + shopsBySku.get(row.internal_sku) || + row.shop_ids.map((shopId) => ({ id: shopId, name: shopId })), + })), + total: total.rows[0]?.count || 0, + page, + pageSize, + } +} + +export type DeletePendingChannelGroupResult = { + archivedItems: number + preservedOrders: number + deletedCostSnapshots: number + resolvedExceptions: number + refreshedDays: number + skippedClosedDays: number +} + +/** + * 删除待分类 SKU:只归档订单项并清理其成本/异常,不删除原始订单。 + * 已关账月份拒绝操作,避免已固化财务口径与底层数据不一致。 + */ +export async function deletePendingChannelGroup(input: { + internalSku: string + adminUserId: number | null +}): Promise { + const internalSku = canonicalizeInternalSku(input.internalSku) + if (!internalSku || !containsCjk(internalSku)) { + throw createHttpError('必须使用包含中文的内部 SKU', { + statusCode: 400, + errorCode: 'internal_sku_required', + }) + } + + const result = await withTransaction(async (client) => { + const items = await client.query<{ id: number; order_id: number; basis_date: string }>( + ` + SELECT + oi.id, + oi.order_id, + (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date::text + AS basis_date + FROM order_items oi + JOIN orders o ON o.id = oi.order_id + WHERE oi.fulfillment_status = 'pending' + AND ${INTERNAL_SKU_SQL} = $1 + ORDER BY oi.id + FOR UPDATE OF oi + `, + [internalSku], + ) + const itemIds = items.rows.map((row) => Number(row.id)) + if (itemIds.length === 0) { + return { + archivedItems: 0, + preservedOrders: 0, + deletedCostSnapshots: 0, + resolvedExceptions: 0, + dates: [] as string[], + } + } + + const periodKeys = [ + ...new Set(items.rows.map((row) => String(row.basis_date || '').slice(0, 7)).filter(Boolean)), + ] + const closedPeriods = await client.query<{ period_key: string }>( + ` + SELECT period_key + FROM finance_periods + WHERE status = 'closed' + AND period_key = ANY($1::text[]) + ORDER BY period_key + `, + [periodKeys], + ) + if (closedPeriods.rows.length > 0) { + throw createHttpError( + `包含已关账期间(${closedPeriods.rows.map((row) => row.period_key).join('、')}),不能删除`, + { + statusCode: 409, + errorCode: 'finance_period_closed', + }, + ) + } + + const snapshots = await client.query( + `DELETE FROM cost_snapshots WHERE order_item_id = ANY($1::bigint[])`, + [itemIds], + ) + + const removedLinks = await client.query<{ exception_id: number }>( + ` + DELETE FROM cost_exception_items + WHERE order_item_id = ANY($1::bigint[]) + RETURNING exception_id + `, + [itemIds], + ) + const exceptionIds = [ + ...new Set(removedLinks.rows.map((row) => Number(row.exception_id)).filter(Boolean)), + ] + const resolved = await client.query( + ` + UPDATE cost_exceptions ce + SET status = 'resolved', + resolved_at = NOW(), + item_count = 0, + last_seen_at = NOW() + WHERE ( + ce.order_item_id = ANY($1::bigint[]) + OR ce.id = ANY($2::bigint[]) + ) + AND ce.status <> 'resolved' + AND NOT EXISTS ( + SELECT 1 + FROM cost_exception_items i + WHERE i.exception_id = ce.id + ) + `, + [itemIds, exceptionIds], + ) + + const archived = await client.query( + ` + UPDATE order_items + SET fulfillment_channel = NULL, + fulfillment_status = 'archived', + fulfillment_source = 'cutover_archive', + fulfillment_locked = TRUE, + fulfillment_reason = '运营从待分类队列删除', + fulfillment_pending_reason = NULL, + fulfillment_pending_detail = NULL, + fulfillment_decided_by = $2, + fulfillment_decided_at = NOW(), + updated_at = NOW() + WHERE id = ANY($1::bigint[]) + AND fulfillment_status = 'pending' + `, + [itemIds, input.adminUserId], + ) + + return { + archivedItems: archived.rowCount || 0, + preservedOrders: new Set(items.rows.map((row) => Number(row.order_id))).size, + deletedCostSnapshots: snapshots.rowCount || 0, + resolvedExceptions: resolved.rowCount || 0, + dates: [...new Set(items.rows.map((row) => row.basis_date).filter(Boolean))], + } + }) + + const refreshed = await refreshFactsForDates(result.dates) + return { + archivedItems: result.archivedItems, + preservedOrders: result.preservedOrders, + deletedCostSnapshots: result.deletedCostSnapshots, + resolvedExceptions: result.resolvedExceptions, + refreshedDays: refreshed.days, + skippedClosedDays: refreshed.skippedClosed, + } } /** 按内部 SKU 批量指定(同一中文 SKU 跨平台码一起分流)。 */ @@ -1340,6 +1557,25 @@ export async function deleteChannelRules(idsInput: number[]): Promise<{ return { deletedCount: result.deletedIds.length, ...result } } +/** + * 运营强制删除:跳过系统来源保护,只删除规则本身,不改历史订单渠道。 + * 对应来源的同步任务后续仍可能重新生成规则。 + */ +export async function forceDeleteChannelRules(idsInput: number[]): Promise<{ + deletedCount: number + deletedIds: number[] + missingIds: number[] +}> { + if (!Array.isArray(idsInput) || idsInput.length === 0) { + throw createHttpError('请选择要强制删除的渠道规则', { + statusCode: 400, + errorCode: 'channel_rule_ids_required', + }) + } + const result = await deleteInternalSkuChannelsByIds(idsInput) + return { deletedCount: result.deletedIds.length, ...result } +} + async function assertRulesDeletable(idsInput: number[]): Promise { const ids = [...new Set(idsInput.map(Number).filter((id) => Number.isInteger(id) && id > 0))] if (ids.length === 0) return 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 4b0a4c30..e6141693 100644 --- a/apps/backend/src/services/fulfillment/fulfillment-routing.integration.test.ts +++ b/apps/backend/src/services/fulfillment/fulfillment-routing.integration.test.ts @@ -20,15 +20,17 @@ test( const { closeDb, query } = await import('../../db/client.js') const { assignChannelByInternalSku, + deletePendingChannelGroup, deleteChannelRules, ensureVoucherRuleForSku, + forceDeleteChannelRules, listChannelRules, listChannelRulesPage, listPendingChannelGroups, switchChannelForOrderItems, upsertChannelRule, } = await import('./channel-assign-service.js') - const { saveManualFulfillmentConfig } = + const { deleteManualSkuClassification, saveManualFulfillmentConfig } = await import('../cost-center/manual-fulfillment-config-service.js') const { runAffiliateFallback } = await import('./affiliate-fallback-service.js') const { retryPendingWorkOrderRouting } = await import('./pending-channel-repair-service.js') @@ -49,6 +51,8 @@ test( const fallbackSku = `集成测试兜底-${suffix}` const autoManualSku = `集成测试自动人工-${suffix}` const retryWorkOrderSku = `集成测试重试接单-${suffix}` + const manualRemoveSku = `集成测试移除人工-${suffix}` + const protectedRuleSku = `集成测试强制删除-${suffix}` const retryWorkOrderRuleKey = `it-retry-${suffix}` const orderIds: number[] = [] const orderItemIds: number[] = [] @@ -62,6 +66,8 @@ test( fallbackSku, autoManualSku, retryWorkOrderSku, + manualRemoveSku, + protectedRuleSku, ] let generatedVoucherProductId: number | null = null let autoManualProductId: number | null = null @@ -70,19 +76,26 @@ test( async function createPendingOrderItem( sku: string, - options: { provider?: string; platform?: string; shopId?: string } = {}, + options: { provider?: string; platform?: string; shopId?: string; shopName?: string } = {}, ) { const provider = options.provider || '91kaquan' const platform = options.platform || 'kuaishou-91' const targetShopId = options.shopId || shopId + const targetShopName = options.shopName || '' const order = await query<{ id: number }>( `INSERT INTO orders ( - provider, platform, shop_id, platform_order_id, order_status, pay_status, + provider, platform, shop_id, shop_name, platform_order_id, order_status, pay_status, total_amount, paid_at, created_at, updated_at ) VALUES ( - $1, $2, $3, $4, 'paid', 'paid', 1000, NOW(), NOW(), NOW() + $1, $2, $3, $4, $5, 'paid', 'paid', 1000, NOW(), NOW(), NOW() ) RETURNING id`, - [provider, platform, targetShopId, `IT-ROUTING-${suffix}-${orderItemIds.length + 1}`], + [ + provider, + platform, + targetShopId, + targetShopName, + `IT-ROUTING-${suffix}-${orderItemIds.length + 1}`, + ], ) const orderId = Number(order.rows[0]!.id) orderIds.push(orderId) @@ -343,11 +356,15 @@ test( const crossShopA = `it-routing-a-${suffix}` const crossShopB = `it-routing-b-${suffix}` - await createPendingOrderItem(crossShopSku, { shopId: crossShopA }) + await createPendingOrderItem(crossShopSku, { + shopId: crossShopA, + shopName: '测试店铺 A', + }) await createPendingOrderItem(crossShopSku, { provider: 'pdd', platform: 'pdd', shopId: crossShopB, + shopName: '测试店铺 B', }) const pendingGroups = await listPendingChannelGroups({ keyword: crossShopSku, @@ -359,6 +376,86 @@ test( assert.equal(pendingGroups.items[0]?.item_count, 2) assert.deepEqual(pendingGroups.items[0]?.providers.sort(), ['91kaquan', 'pdd']) assert.deepEqual(pendingGroups.items[0]?.shop_ids.sort(), [crossShopA, crossShopB]) + assert.deepEqual( + pendingGroups.items[0]?.shops.sort((left, right) => left.id.localeCompare(right.id)), + [ + { id: crossShopA, name: '测试店铺 A' }, + { id: crossShopB, name: '测试店铺 B' }, + ], + ) + + const deletedPending = await deletePendingChannelGroup({ + internalSku: crossShopSku, + adminUserId: null, + }) + assert.equal(deletedPending.archivedItems, 2) + assert.equal(deletedPending.preservedOrders, 2) + assert.equal( + ( + await listPendingChannelGroups({ + keyword: crossShopSku, + page: 1, + pageSize: 10, + }) + ).total, + 0, + ) + const preservedOrders = await query<{ count: number }>( + `SELECT COUNT(*)::int AS count FROM orders WHERE id = ANY($1::bigint[])`, + [orderIds], + ) + assert.ok(preservedOrders.rows[0].count >= 2) + + const manualRemoveItemId = await createPendingOrderItem(manualRemoveSku, { + shopId: crossShopA, + shopName: '测试店铺 A', + }) + await query( + `UPDATE order_items + SET fulfillment_status = 'assigned', fulfillment_channel = 'manual', + fulfillment_source = 'carrier_evidence', fulfillment_config_id = NULL, + updated_at = NOW() + WHERE id = $1`, + [manualRemoveItemId], + ) + await query( + `INSERT INTO manual_sku_registry ( + sku_key, display_name, shop_count, order_count, item_count, revenue_fen, + first_seen_date, last_seen_date, scan_from_date, scan_to_date, created_at, updated_at + ) VALUES ($1, $1, 1, 1, 1, 1000, CURRENT_DATE, CURRENT_DATE, CURRENT_DATE, CURRENT_DATE, NOW(), NOW())`, + [manualRemoveSku], + ) + await query( + `INSERT INTO internal_sku_channels ( + internal_sku, channel, priority, is_fallback, enabled, source, config_json + ) VALUES ($1, 'manual', 90, TRUE, TRUE, 'manual_config', '{}'::jsonb)`, + [manualRemoveSku], + ) + const deletedManual = await deleteManualSkuClassification(manualRemoveSku) + assert.equal(deletedManual.affectedItems, 1) + assert.equal(deletedManual.deletedRegistry, 1) + assert.equal(deletedManual.deletedRules, 1) + const removedManualItem = await query<{ + fulfillment_status: string + fulfillment_channel: string | null + }>(`SELECT fulfillment_status, fulfillment_channel FROM order_items WHERE id = $1`, [ + manualRemoveItemId, + ]) + assert.equal(removedManualItem.rows[0]?.fulfillment_status, 'pending') + assert.equal(removedManualItem.rows[0]?.fulfillment_channel, null) + + const protectedRule = await query<{ id: number }>( + `INSERT INTO internal_sku_channels ( + internal_sku, channel, priority, is_fallback, enabled, source, config_json + ) VALUES ($1, 'manual', 70, FALSE, TRUE, 'manual_config', '{}'::jsonb) + RETURNING id`, + [protectedRuleSku], + ) + const protectedRuleId = Number(protectedRule.rows[0]!.id) + await assert.rejects(() => deleteChannelRules([protectedRuleId]), /不能在渠道页直接删除/) + const forcedDeleted = await forceDeleteChannelRules([protectedRuleId]) + assert.equal(forcedDeleted.deletedCount, 1) + assert.deepEqual(forcedDeleted.deletedIds, [protectedRuleId]) const fallbackItemId = await createPendingOrderItem(fallbackSku) await query( @@ -539,6 +636,7 @@ test( await query(`DELETE FROM internal_sku_channels WHERE internal_sku = ANY($1::text[])`, [ internalSkus, ]) + await query(`DELETE FROM manual_sku_registry WHERE sku_key = ANY($1::text[])`, [internalSkus]) await query( `DELETE FROM sku_channel_rules WHERE provider = '91kaquan' AND shop_id = $1 AND match_key = ANY($2::text[])`, diff --git a/apps/frontend/src/pages/admin/AdminFulfillmentChannelPage.tsx b/apps/frontend/src/pages/admin/AdminFulfillmentChannelPage.tsx index 5c315d65..8567ad9a 100644 --- a/apps/frontend/src/pages/admin/AdminFulfillmentChannelPage.tsx +++ b/apps/frontend/src/pages/admin/AdminFulfillmentChannelPage.tsx @@ -38,8 +38,10 @@ import PercentTable from '@/components/common/PercentTable' import FinanceSubNav from '@/components/admin/FinanceSubNav' import { assignChannelByInternalSku, + deletePendingChannelGroup, deleteInternalSkuMapping, deleteChannelRules, + forceDeleteChannelRules, fetchChannelExceptions, fetchChannelRules, fetchInternalSkuMappings, @@ -176,6 +178,20 @@ function PendingPanel() { message.error(error instanceof Error ? error.message : '重新匹配接单模板失败'), }) + const deleteMutation = useMutation({ + mutationFn: (row: PendingChannelGroup) => + deletePendingChannelGroup({ internalSku: row.internal_sku }), + onSuccess: (result) => { + const data = result.data + message.success( + `已删除 ${data.archivedItems} 个待分类订单项,原始订单保留;财务事实已重算 ${data.refreshedDays} 天`, + ) + void queryClient.invalidateQueries({ queryKey: ['fulfillment-pending'] }) + void queryClient.invalidateQueries({ queryKey: ['fulfillment-exceptions'] }) + }, + onError: (error) => message.error(error instanceof Error ? error.message : '删除失败'), + }) + const pipelineMutation = useMutation({ mutationFn: () => runFinancePipeline({ days: 3 }), onSuccess: () => { @@ -194,10 +210,27 @@ function PendingPanel() { }, { title: '店铺', - dataIndex: 'shop_ids', - width: 180, - ellipsis: true, - render: (value: string[] | undefined) => value?.join(' / ') || '-', + dataIndex: 'shops', + width: 210, + render: (_: unknown, row: PendingChannelGroup) => { + const shops = + row.shops?.length > 0 + ? row.shops + : (row.shop_ids || []).map((shopId) => ({ id: shopId, name: shopId })) + if (shops.length === 0) return '-' + return ( + + {shops.map((shop) => ( + + {shop.name} + {shop.name !== shop.id ? ( + · {shop.id} + ) : null} + + ))} + + ) + }, }, { title: '内部SKU', dataIndex: 'internal_sku', width: 220 }, { @@ -236,9 +269,9 @@ function PendingPanel() { }, { title: '指定渠道', - width: 320, + width: 360, render: (_: unknown, row: PendingChannelGroup) => ( - + setSelectedRuleIds(keys.map(Number)), - getCheckboxProps: (row: ChannelRule) => ({ disabled: !canDeleteChannelRule(row) }), }} pagination={buildAdminTablePagination({ current: query.data?.data?.page || page, diff --git a/apps/frontend/src/pages/admin/cost-center/ManualSkuConfigPanel.tsx b/apps/frontend/src/pages/admin/cost-center/ManualSkuConfigPanel.tsx index 5f6b4b5b..f67bab5e 100644 --- a/apps/frontend/src/pages/admin/cost-center/ManualSkuConfigPanel.tsx +++ b/apps/frontend/src/pages/admin/cost-center/ManualSkuConfigPanel.tsx @@ -42,7 +42,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useState } from 'react' import { - deleteAdminManualConfig, + deleteAdminManualSku, fetchAdminManualPlatforms, fetchAdminManualSkuOverview, fetchAdminManualSkus, @@ -161,13 +161,17 @@ export function ManualSkuConfigPanel() { }) const deleteMutation = useMutation({ - mutationFn: (skuKey: string) => deleteAdminManualConfig(skuKey), - onSuccess: () => { - message.success('配置已删除') + mutationFn: (skuKey: string) => deleteAdminManualSku(skuKey), + onSuccess: (response) => { + message.success( + `人工分类已移除;${response.data.affectedItems} 个订单项已退回待分类,原始订单保留`, + ) void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-manual-skus'] }) void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-manual-skus-overview'], }) + void queryClient.invalidateQueries({ queryKey: ['fulfillment-rules'] }) + void queryClient.invalidateQueries({ queryKey: ['fulfillment-pending'] }) }, onError: (error) => { message.error(error instanceof Error ? error.message : '删除失败') @@ -259,24 +263,24 @@ export function ManualSkuConfigPanel() { { title: '操作', key: 'actions', - width: 130, + width: 150, fixed: 'right', render: (_, row) => ( - {row.configured ? ( - deleteMutation.mutate(row.skuKey)} - > - + ), }, diff --git a/apps/frontend/src/services/admin/cost-center.ts b/apps/frontend/src/services/admin/cost-center.ts index 9e780390..f637bc1d 100644 --- a/apps/frontend/src/services/admin/cost-center.ts +++ b/apps/frontend/src/services/admin/cost-center.ts @@ -393,6 +393,18 @@ export function deleteAdminManualConfig(skuKey: string) { ) } +export function deleteAdminManualSku(skuKey: string) { + return apiDelete<{ + skuKey: string + affectedItems: number + deletedConfigs: number + deletedRegistry: number + deletedRules: number + refreshedDays: number + skippedClosedDays: number + }>(`/api/v1/admin/cost-center/manual-skus/${encodeURIComponent(skuKey)}`) +} + export function rematchAdminManualFulfillments(payload: { skuKey?: string } = {}) { return apiPost('/api/v1/admin/cost-center/manual-configs/rematch', payload) } diff --git a/apps/frontend/src/services/admin/fulfillment-channel.ts b/apps/frontend/src/services/admin/fulfillment-channel.ts index f3ec1f42..f21445a1 100644 --- a/apps/frontend/src/services/admin/fulfillment-channel.ts +++ b/apps/frontend/src/services/admin/fulfillment-channel.ts @@ -6,6 +6,7 @@ export type PendingChannelGroup = { sku_name: string providers: string[] shop_ids: string[] + shops: Array<{ id: string; name: string }> sku_codes: string[] pending_reasons: string[] item_count: number @@ -99,6 +100,15 @@ export type PendingWorkOrderRetryResult = { skipReasons: Record } +export type DeletePendingChannelGroupResult = { + archivedItems: number + preservedOrders: number + deletedCostSnapshots: number + resolvedExceptions: number + refreshedDays: number + skippedClosedDays: number +} + export type FinancePipelineStatus = { pipeline: string label: string @@ -148,6 +158,13 @@ export function retryPendingWorkOrderRouting( ) } +export function deletePendingChannelGroup(payload: { internalSku: string }) { + return apiPost( + '/api/v1/admin/fulfillment/pending/delete', + payload, + ) +} + export function assignChannelByInternalSku(payload: { internalSku: string channel: string @@ -252,6 +269,14 @@ export function deleteChannelRules(ids: number[]) { }>('/api/v1/admin/fulfillment/rules/batch-delete', { ids }) } +export function forceDeleteChannelRules(ids: number[]) { + return apiPost<{ + deletedCount: number + deletedIds: number[] + missingIds: number[] + }>('/api/v1/admin/fulfillment/rules/force-delete', { ids }) +} + export function fetchPipelineStatus() { return apiGet('/api/v1/admin/fulfillment/pipeline/status') }