feat(fulfillment): 支持删除待分类和强制删除渠道规则

This commit is contained in:
yml2213
2026-11-04 13:52:11 +08:00
parent 209fa657ed
commit c31fa7db76
9 changed files with 760 additions and 48 deletions
@@ -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'),
@@ -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<string, unknown>,
}),
},
),
)
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<string, unknown>,
},
}),
},
),
)
router.get(
'/fulfillment/pipeline/status',
requireAdminPermission('finance.view'),
@@ -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<DeleteManualSkuResult> {
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,
}
}
/**
* 重新匹配:把已配置项回填到历史人工订单项。
*
@@ -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<PendingGroupRow, 'shops'>
/** 待分类队列:按全局内部 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<PendingGroupRow>(
const rows = await query<PendingGroupDbRow>(
`
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<string, Array<{ id: string; name: string }>>()
if (internalSkus.length > 0) {
const shopRows = await query<PendingShopNameRow>(
`
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<string>()
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<DeletePendingChannelGroupResult> {
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<void> {
const ids = [...new Set(idsInput.map(Number).filter((id) => Number.isInteger(id) && id > 0))]
if (ids.length === 0) return
@@ -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[])`,
@@ -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 (
<Space direction="vertical" size={2}>
{shops.map((shop) => (
<span key={`${shop.id}-${shop.name}`}>
<Typography.Text>{shop.name}</Typography.Text>
{shop.name !== shop.id ? (
<Typography.Text type="secondary"> · {shop.id}</Typography.Text>
) : null}
</span>
))}
</Space>
)
},
},
{ title: '内部SKU', dataIndex: 'internal_sku', width: 220 },
{
@@ -236,9 +269,9 @@ function PendingPanel() {
},
{
title: '指定渠道',
width: 320,
width: 360,
render: (_: unknown, row: PendingChannelGroup) => (
<Space>
<Space size={4}>
<Select
size="small"
style={{ width: 180 }}
@@ -256,6 +289,33 @@ function PendingPanel() {
>
指定
</Button>
<Tooltip title="删除待分类记录,不删除原始订单">
<Button
danger
icon={<DeleteOutlined />}
loading={deleteMutation.isPending}
onClick={() =>
Modal.confirm({
title: '删除待分类记录',
content: (
<div>
<p>
将归档 <strong>{row.item_count}</strong> 个订单项,并清除已生成的成本快照、
分类异常,重算对应销售事实与总账。
</p>
<p style={{ marginBottom: 0 }}>
原始订单和订单商品不会被删除;已关账期间禁止删除。
</p>
</div>
),
okText: '确认删除',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: () => deleteMutation.mutateAsync(row),
})
}
/>
</Tooltip>
</Space>
),
},
@@ -296,10 +356,10 @@ function PendingPanel() {
}
>
<Alert
type="info"
type="warning"
showIcon
style={{ marginBottom: 12 }}
message="待分类来自真实订单,系统不提供直接删除,避免掩盖未履约收入。配置错误请修正模板/映射后点「重试接单匹配」,或通过「指定渠道」完成分流;系统会先确认载体创建成功,再进入商品归一与成本核算。"
message="删除只会归档对应订单项并清理财务成本记录,不会删除原始订单。配置错误优先修正模板/映射后重试匹配;确认无需履约或核算时,可直接删除。"
/>
<PercentTable
rowKey={(row) => rowKey(row)}
@@ -614,6 +674,20 @@ function RulesPanel() {
onError: (error) => message.error(error instanceof Error ? error.message : '删除规则失败'),
})
const forceDeleteMutation = useMutation({
mutationFn: (ids: number[]) => forceDeleteChannelRules(ids),
onSuccess: (result) => {
const missingHint = result.data.missingIds.length
? `,${result.data.missingIds.length} 条已不存在`
: ''
message.success(`已强制删除 ${result.data.deletedCount} 条渠道规则${missingHint}`)
setSelectedRuleIds([])
void queryClient.invalidateQueries({ queryKey: ['fulfillment-rules'] })
void queryClient.invalidateQueries({ queryKey: ['fulfillment-pending'] })
},
onError: (error) => message.error(error instanceof Error ? error.message : '强制删除规则失败'),
})
function resetFilters() {
setKeyword('')
setChannelFilter('')
@@ -635,6 +709,19 @@ function RulesPanel() {
})
}
function confirmForceDeleteRules(ids: number[]) {
if (ids.length === 0) return
Modal.confirm({
title: ids.length === 1 ? '强制删除渠道规则' : `批量强制删除 ${ids.length} 条渠道规则`,
content:
'将跳过系统来源保护,只删除统一渠道规则本身。历史订单和成本数据不会被删除;对应来源后续同步时,规则仍可能重新生成。',
okText: '确认强制删除',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: () => forceDeleteMutation.mutateAsync(ids),
})
}
const columns = [
{ title: '作用范围', width: 110, render: () => <Tag color="blue">全局</Tag> },
{
@@ -676,26 +763,30 @@ function RulesPanel() {
},
{
title: '操作',
width: 100,
width: 120,
render: (_: unknown, row: ChannelRule) => {
const button = (
if (!canDeleteChannelRule(row)) {
return (
<Button
danger
size="small"
loading={forceDeleteMutation.isPending}
onClick={() => confirmForceDeleteRules([row.id])}
>
强制删除
</Button>
)
}
return (
<Button
danger
size="small"
loading={deleteMutation.isPending}
disabled={!canDeleteChannelRule(row)}
onClick={() => confirmDeleteRules([row.id])}
>
删除
</Button>
)
return canDeleteChannelRule(row) ? (
button
) : (
<Tooltip title="系统生成或由来源配置维护,不能在统一渠道页直接删除;配置有误时请新增同 SKU 的正确规则覆盖。">
<span>{button}</span>
</Tooltip>
)
},
},
]
@@ -709,10 +800,10 @@ function RulesPanel() {
danger
icon={<DeleteOutlined />}
disabled={selectedRuleIds.length === 0}
loading={deleteMutation.isPending}
onClick={() => confirmDeleteRules(selectedRuleIds)}
loading={forceDeleteMutation.isPending}
onClick={() => confirmForceDeleteRules(selectedRuleIds)}
>
批量删除{selectedRuleIds.length ? ` (${selectedRuleIds.length})` : ''}
批量强制删除{selectedRuleIds.length ? ` (${selectedRuleIds.length})` : ''}
</Button>
<Button icon={<ReloadOutlined />} onClick={() => void query.refetch()}>
刷新
@@ -726,8 +817,8 @@ function RulesPanel() {
<Typography.Paragraph type="secondary">
配置按内部 SKU 全局生效,会同时影响所有平台和店铺。每条内部 SKU 只能有一个主渠道;
其他渠道请设置 90 以上优先级作为兜底。接单平台的具体模板由统一配置中的 workRuleKey 控制。
来源为商品能力迁移、人工配置、卡券或历史回填的系统规则受保护,配置有误时请新增同 SKU
的正确规则覆盖。
来源为商品能力迁移、人工配置、卡券或历史回填的系统规则默认受保护;确认配置错误时可使用
「强制删除」,但对应来源后续同步时仍可能重新生成。
</Typography.Paragraph>
<Space wrap style={{ marginBottom: 12 }}>
<Input
@@ -791,7 +882,6 @@ function RulesPanel() {
rowSelection={{
selectedRowKeys: selectedRuleIds,
onChange: (keys) => setSelectedRuleIds(keys.map(Number)),
getCheckboxProps: (row: ChannelRule) => ({ disabled: !canDeleteChannelRule(row) }),
}}
pagination={buildAdminTablePagination({
current: query.data?.data?.page || page,
@@ -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) => (
<Space size={4}>
<Button size="small" icon={<EditOutlined />} onClick={() => openConfig(row)}>
{row.configured ? '编辑' : '配置'}
</Button>
{row.configured ? (
<Popconfirm
title="删除该 SKU 的配置?"
description="删除后该 SKU 回到未配置状态,已回填的订单项不再带配置。"
okText="删除"
cancelText="取消"
onConfirm={() => deleteMutation.mutate(row.skuKey)}
>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
) : null}
<Popconfirm
title="移除该人工分类?"
description="会删除人工登记与配置、撤销人工渠道规则、清除已生成的人工成本快照,并把关联订单项退回待分类;原始订单不会删除。"
okText="确认移除"
cancelText="取消"
onConfirm={() => deleteMutation.mutate(row.skuKey)}
>
<Button size="small" danger icon={<DeleteOutlined />}>
移除
</Button>
</Popconfirm>
</Space>
),
},
@@ -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<ManualRematchResult>('/api/v1/admin/cost-center/manual-configs/rematch', payload)
}
@@ -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<string, number>
}
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<DeletePendingChannelGroupResult>(
'/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<FinancePipelineStatus[]>('/api/v1/admin/fulfillment/pipeline/status')
}