feat(fulfillment): 支持删除待分类和强制删除渠道规则
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新匹配:把已配置项回填到历史人工订单项。
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user