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
@@ -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,
}
}
/**
* 重新匹配:把已配置项回填到历史人工订单项。
*