feat(fulfillment): 支持删除待分类和强制删除渠道规则
This commit is contained in:
@@ -6,6 +6,7 @@
|
|||||||
import { Router } from 'express'
|
import { Router } from 'express'
|
||||||
import {
|
import {
|
||||||
deleteManualFulfillmentConfig,
|
deleteManualFulfillmentConfig,
|
||||||
|
deleteManualSkuClassification,
|
||||||
getManualFulfillmentConfig,
|
getManualFulfillmentConfig,
|
||||||
getManualSkuOverview,
|
getManualSkuOverview,
|
||||||
listManualPlatformOptions,
|
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(
|
router.get(
|
||||||
'/cost-center/manual-platforms',
|
'/cost-center/manual-platforms',
|
||||||
requireAdminPermission('finance.view'),
|
requireAdminPermission('finance.view'),
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ import {
|
|||||||
assignChannelByInternalSku,
|
assignChannelByInternalSku,
|
||||||
assignChannelBySku,
|
assignChannelBySku,
|
||||||
assignChannelToOrderItems,
|
assignChannelToOrderItems,
|
||||||
|
deletePendingChannelGroup,
|
||||||
deleteChannelRule,
|
deleteChannelRule,
|
||||||
deleteChannelRules,
|
deleteChannelRules,
|
||||||
|
forceDeleteChannelRules,
|
||||||
listChannelRulesPage,
|
listChannelRulesPage,
|
||||||
listPendingChannelGroups,
|
listPendingChannelGroups,
|
||||||
switchChannelForOrderItems,
|
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(
|
router.post(
|
||||||
'/fulfillment/pending/retry-work-orders',
|
'/fulfillment/pending/retry-work-orders',
|
||||||
requireAdminPermission('finance.config'),
|
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(
|
router.get(
|
||||||
'/fulfillment/pipeline/status',
|
'/fulfillment/pipeline/status',
|
||||||
requireAdminPermission('finance.view'),
|
requireAdminPermission('finance.view'),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { nowIso } from '../../utils/time.js'
|
|||||||
import { upsertInternalSkuChannel } from '../fulfillment/internal-sku-channel-service.js'
|
import { upsertInternalSkuChannel } from '../fulfillment/internal-sku-channel-service.js'
|
||||||
import { reconcilePendingCarriers } from '../fulfillment/carrier-reconciliation-service.js'
|
import { reconcilePendingCarriers } from '../fulfillment/carrier-reconciliation-service.js'
|
||||||
import { canonicalizeInternalSku, containsCjk } from '../fulfillment/internal-sku-resolver.js'
|
import { canonicalizeInternalSku, containsCjk } from '../fulfillment/internal-sku-resolver.js'
|
||||||
|
import { refreshFactsForDates } from '../finance/refresh-facts.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 人工履约 SKU 配置服务。
|
* 人工履约 SKU 配置服务。
|
||||||
@@ -517,6 +518,175 @@ export async function deleteManualFulfillmentConfig(skuKey: string) {
|
|||||||
return { skuKey: key, deleted: deleted.rowCount || 0 }
|
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
|
sku_name: string
|
||||||
providers: string[]
|
providers: string[]
|
||||||
shop_ids: string[]
|
shop_ids: string[]
|
||||||
|
shops: Array<{ id: string; name: string }>
|
||||||
sku_codes: string[]
|
sku_codes: string[]
|
||||||
pending_reasons: string[]
|
pending_reasons: string[]
|
||||||
item_count: number
|
item_count: number
|
||||||
@@ -910,6 +911,16 @@ export type PendingGroupRow = {
|
|||||||
last_seen_at: string
|
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 聚合(跨平台、跨店铺合并为一行)。 */
|
/** 待分类队列:按全局内部 SKU 聚合(跨平台、跨店铺合并为一行)。 */
|
||||||
export async function listPendingChannelGroups(input: {
|
export async function listPendingChannelGroups(input: {
|
||||||
page?: number
|
page?: number
|
||||||
@@ -943,7 +954,7 @@ export async function listPendingChannelGroups(input: {
|
|||||||
)
|
)
|
||||||
|
|
||||||
params.push(pageSize, (page - 1) * pageSize)
|
params.push(pageSize, (page - 1) * pageSize)
|
||||||
const rows = await query<PendingGroupRow>(
|
const rows = await query<PendingGroupDbRow>(
|
||||||
`
|
`
|
||||||
SELECT
|
SELECT
|
||||||
${INTERNAL_SKU_SQL} AS internal_sku,
|
${INTERNAL_SKU_SQL} AS internal_sku,
|
||||||
@@ -966,7 +977,213 @@ export async function listPendingChannelGroups(input: {
|
|||||||
params,
|
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 跨平台码一起分流)。 */
|
/** 按内部 SKU 批量指定(同一中文 SKU 跨平台码一起分流)。 */
|
||||||
@@ -1340,6 +1557,25 @@ export async function deleteChannelRules(idsInput: number[]): Promise<{
|
|||||||
return { deletedCount: result.deletedIds.length, ...result }
|
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> {
|
async function assertRulesDeletable(idsInput: number[]): Promise<void> {
|
||||||
const ids = [...new Set(idsInput.map(Number).filter((id) => Number.isInteger(id) && id > 0))]
|
const ids = [...new Set(idsInput.map(Number).filter((id) => Number.isInteger(id) && id > 0))]
|
||||||
if (ids.length === 0) return
|
if (ids.length === 0) return
|
||||||
|
|||||||
@@ -20,15 +20,17 @@ test(
|
|||||||
const { closeDb, query } = await import('../../db/client.js')
|
const { closeDb, query } = await import('../../db/client.js')
|
||||||
const {
|
const {
|
||||||
assignChannelByInternalSku,
|
assignChannelByInternalSku,
|
||||||
|
deletePendingChannelGroup,
|
||||||
deleteChannelRules,
|
deleteChannelRules,
|
||||||
ensureVoucherRuleForSku,
|
ensureVoucherRuleForSku,
|
||||||
|
forceDeleteChannelRules,
|
||||||
listChannelRules,
|
listChannelRules,
|
||||||
listChannelRulesPage,
|
listChannelRulesPage,
|
||||||
listPendingChannelGroups,
|
listPendingChannelGroups,
|
||||||
switchChannelForOrderItems,
|
switchChannelForOrderItems,
|
||||||
upsertChannelRule,
|
upsertChannelRule,
|
||||||
} = await import('./channel-assign-service.js')
|
} = await import('./channel-assign-service.js')
|
||||||
const { saveManualFulfillmentConfig } =
|
const { deleteManualSkuClassification, saveManualFulfillmentConfig } =
|
||||||
await import('../cost-center/manual-fulfillment-config-service.js')
|
await import('../cost-center/manual-fulfillment-config-service.js')
|
||||||
const { runAffiliateFallback } = await import('./affiliate-fallback-service.js')
|
const { runAffiliateFallback } = await import('./affiliate-fallback-service.js')
|
||||||
const { retryPendingWorkOrderRouting } = await import('./pending-channel-repair-service.js')
|
const { retryPendingWorkOrderRouting } = await import('./pending-channel-repair-service.js')
|
||||||
@@ -49,6 +51,8 @@ test(
|
|||||||
const fallbackSku = `集成测试兜底-${suffix}`
|
const fallbackSku = `集成测试兜底-${suffix}`
|
||||||
const autoManualSku = `集成测试自动人工-${suffix}`
|
const autoManualSku = `集成测试自动人工-${suffix}`
|
||||||
const retryWorkOrderSku = `集成测试重试接单-${suffix}`
|
const retryWorkOrderSku = `集成测试重试接单-${suffix}`
|
||||||
|
const manualRemoveSku = `集成测试移除人工-${suffix}`
|
||||||
|
const protectedRuleSku = `集成测试强制删除-${suffix}`
|
||||||
const retryWorkOrderRuleKey = `it-retry-${suffix}`
|
const retryWorkOrderRuleKey = `it-retry-${suffix}`
|
||||||
const orderIds: number[] = []
|
const orderIds: number[] = []
|
||||||
const orderItemIds: number[] = []
|
const orderItemIds: number[] = []
|
||||||
@@ -62,6 +66,8 @@ test(
|
|||||||
fallbackSku,
|
fallbackSku,
|
||||||
autoManualSku,
|
autoManualSku,
|
||||||
retryWorkOrderSku,
|
retryWorkOrderSku,
|
||||||
|
manualRemoveSku,
|
||||||
|
protectedRuleSku,
|
||||||
]
|
]
|
||||||
let generatedVoucherProductId: number | null = null
|
let generatedVoucherProductId: number | null = null
|
||||||
let autoManualProductId: number | null = null
|
let autoManualProductId: number | null = null
|
||||||
@@ -70,19 +76,26 @@ test(
|
|||||||
|
|
||||||
async function createPendingOrderItem(
|
async function createPendingOrderItem(
|
||||||
sku: string,
|
sku: string,
|
||||||
options: { provider?: string; platform?: string; shopId?: string } = {},
|
options: { provider?: string; platform?: string; shopId?: string; shopName?: string } = {},
|
||||||
) {
|
) {
|
||||||
const provider = options.provider || '91kaquan'
|
const provider = options.provider || '91kaquan'
|
||||||
const platform = options.platform || 'kuaishou-91'
|
const platform = options.platform || 'kuaishou-91'
|
||||||
const targetShopId = options.shopId || shopId
|
const targetShopId = options.shopId || shopId
|
||||||
|
const targetShopName = options.shopName || ''
|
||||||
const order = await query<{ id: number }>(
|
const order = await query<{ id: number }>(
|
||||||
`INSERT INTO orders (
|
`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
|
total_amount, paid_at, created_at, updated_at
|
||||||
) VALUES (
|
) VALUES (
|
||||||
$1, $2, $3, $4, 'paid', 'paid', 1000, NOW(), NOW(), NOW()
|
$1, $2, $3, $4, $5, 'paid', 'paid', 1000, NOW(), NOW(), NOW()
|
||||||
) RETURNING id`,
|
) 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)
|
const orderId = Number(order.rows[0]!.id)
|
||||||
orderIds.push(orderId)
|
orderIds.push(orderId)
|
||||||
@@ -343,11 +356,15 @@ test(
|
|||||||
|
|
||||||
const crossShopA = `it-routing-a-${suffix}`
|
const crossShopA = `it-routing-a-${suffix}`
|
||||||
const crossShopB = `it-routing-b-${suffix}`
|
const crossShopB = `it-routing-b-${suffix}`
|
||||||
await createPendingOrderItem(crossShopSku, { shopId: crossShopA })
|
await createPendingOrderItem(crossShopSku, {
|
||||||
|
shopId: crossShopA,
|
||||||
|
shopName: '测试店铺 A',
|
||||||
|
})
|
||||||
await createPendingOrderItem(crossShopSku, {
|
await createPendingOrderItem(crossShopSku, {
|
||||||
provider: 'pdd',
|
provider: 'pdd',
|
||||||
platform: 'pdd',
|
platform: 'pdd',
|
||||||
shopId: crossShopB,
|
shopId: crossShopB,
|
||||||
|
shopName: '测试店铺 B',
|
||||||
})
|
})
|
||||||
const pendingGroups = await listPendingChannelGroups({
|
const pendingGroups = await listPendingChannelGroups({
|
||||||
keyword: crossShopSku,
|
keyword: crossShopSku,
|
||||||
@@ -359,6 +376,86 @@ test(
|
|||||||
assert.equal(pendingGroups.items[0]?.item_count, 2)
|
assert.equal(pendingGroups.items[0]?.item_count, 2)
|
||||||
assert.deepEqual(pendingGroups.items[0]?.providers.sort(), ['91kaquan', 'pdd'])
|
assert.deepEqual(pendingGroups.items[0]?.providers.sort(), ['91kaquan', 'pdd'])
|
||||||
assert.deepEqual(pendingGroups.items[0]?.shop_ids.sort(), [crossShopA, crossShopB])
|
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)
|
const fallbackItemId = await createPendingOrderItem(fallbackSku)
|
||||||
await query(
|
await query(
|
||||||
@@ -539,6 +636,7 @@ test(
|
|||||||
await query(`DELETE FROM internal_sku_channels WHERE internal_sku = ANY($1::text[])`, [
|
await query(`DELETE FROM internal_sku_channels WHERE internal_sku = ANY($1::text[])`, [
|
||||||
internalSkus,
|
internalSkus,
|
||||||
])
|
])
|
||||||
|
await query(`DELETE FROM manual_sku_registry WHERE sku_key = ANY($1::text[])`, [internalSkus])
|
||||||
await query(
|
await query(
|
||||||
`DELETE FROM sku_channel_rules
|
`DELETE FROM sku_channel_rules
|
||||||
WHERE provider = '91kaquan' AND shop_id = $1 AND match_key = ANY($2::text[])`,
|
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 FinanceSubNav from '@/components/admin/FinanceSubNav'
|
||||||
import {
|
import {
|
||||||
assignChannelByInternalSku,
|
assignChannelByInternalSku,
|
||||||
|
deletePendingChannelGroup,
|
||||||
deleteInternalSkuMapping,
|
deleteInternalSkuMapping,
|
||||||
deleteChannelRules,
|
deleteChannelRules,
|
||||||
|
forceDeleteChannelRules,
|
||||||
fetchChannelExceptions,
|
fetchChannelExceptions,
|
||||||
fetchChannelRules,
|
fetchChannelRules,
|
||||||
fetchInternalSkuMappings,
|
fetchInternalSkuMappings,
|
||||||
@@ -176,6 +178,20 @@ function PendingPanel() {
|
|||||||
message.error(error instanceof Error ? error.message : '重新匹配接单模板失败'),
|
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({
|
const pipelineMutation = useMutation({
|
||||||
mutationFn: () => runFinancePipeline({ days: 3 }),
|
mutationFn: () => runFinancePipeline({ days: 3 }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -194,10 +210,27 @@ function PendingPanel() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '店铺',
|
title: '店铺',
|
||||||
dataIndex: 'shop_ids',
|
dataIndex: 'shops',
|
||||||
width: 180,
|
width: 210,
|
||||||
ellipsis: true,
|
render: (_: unknown, row: PendingChannelGroup) => {
|
||||||
render: (value: string[] | undefined) => value?.join(' / ') || '-',
|
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 },
|
{ title: '内部SKU', dataIndex: 'internal_sku', width: 220 },
|
||||||
{
|
{
|
||||||
@@ -236,9 +269,9 @@ function PendingPanel() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '指定渠道',
|
title: '指定渠道',
|
||||||
width: 320,
|
width: 360,
|
||||||
render: (_: unknown, row: PendingChannelGroup) => (
|
render: (_: unknown, row: PendingChannelGroup) => (
|
||||||
<Space>
|
<Space size={4}>
|
||||||
<Select
|
<Select
|
||||||
size="small"
|
size="small"
|
||||||
style={{ width: 180 }}
|
style={{ width: 180 }}
|
||||||
@@ -256,6 +289,33 @@ function PendingPanel() {
|
|||||||
>
|
>
|
||||||
指定
|
指定
|
||||||
</Button>
|
</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>
|
</Space>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -296,10 +356,10 @@ function PendingPanel() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Alert
|
<Alert
|
||||||
type="info"
|
type="warning"
|
||||||
showIcon
|
showIcon
|
||||||
style={{ marginBottom: 12 }}
|
style={{ marginBottom: 12 }}
|
||||||
message="待分类来自真实订单,系统不提供直接删除,避免掩盖未履约收入。配置错误请修正模板/映射后点「重试接单匹配」,或通过「指定渠道」完成分流;系统会先确认载体创建成功,再进入商品归一与成本核算。"
|
message="删除只会归档对应订单项并清理财务成本记录,不会删除原始订单。配置错误优先修正模板/映射后重试匹配;确认无需履约或核算时,可直接删除。"
|
||||||
/>
|
/>
|
||||||
<PercentTable
|
<PercentTable
|
||||||
rowKey={(row) => rowKey(row)}
|
rowKey={(row) => rowKey(row)}
|
||||||
@@ -614,6 +674,20 @@ function RulesPanel() {
|
|||||||
onError: (error) => message.error(error instanceof Error ? error.message : '删除规则失败'),
|
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() {
|
function resetFilters() {
|
||||||
setKeyword('')
|
setKeyword('')
|
||||||
setChannelFilter('')
|
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 = [
|
const columns = [
|
||||||
{ title: '作用范围', width: 110, render: () => <Tag color="blue">全局</Tag> },
|
{ title: '作用范围', width: 110, render: () => <Tag color="blue">全局</Tag> },
|
||||||
{
|
{
|
||||||
@@ -676,26 +763,30 @@ function RulesPanel() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
width: 100,
|
width: 120,
|
||||||
render: (_: unknown, row: ChannelRule) => {
|
render: (_: unknown, row: ChannelRule) => {
|
||||||
const button = (
|
if (!canDeleteChannelRule(row)) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
danger
|
||||||
|
size="small"
|
||||||
|
loading={forceDeleteMutation.isPending}
|
||||||
|
onClick={() => confirmForceDeleteRules([row.id])}
|
||||||
|
>
|
||||||
|
强制删除
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
<Button
|
<Button
|
||||||
danger
|
danger
|
||||||
size="small"
|
size="small"
|
||||||
loading={deleteMutation.isPending}
|
loading={deleteMutation.isPending}
|
||||||
disabled={!canDeleteChannelRule(row)}
|
|
||||||
onClick={() => confirmDeleteRules([row.id])}
|
onClick={() => confirmDeleteRules([row.id])}
|
||||||
>
|
>
|
||||||
删除
|
删除
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
return canDeleteChannelRule(row) ? (
|
|
||||||
button
|
|
||||||
) : (
|
|
||||||
<Tooltip title="系统生成或由来源配置维护,不能在统一渠道页直接删除;配置有误时请新增同 SKU 的正确规则覆盖。">
|
|
||||||
<span>{button}</span>
|
|
||||||
</Tooltip>
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -709,10 +800,10 @@ function RulesPanel() {
|
|||||||
danger
|
danger
|
||||||
icon={<DeleteOutlined />}
|
icon={<DeleteOutlined />}
|
||||||
disabled={selectedRuleIds.length === 0}
|
disabled={selectedRuleIds.length === 0}
|
||||||
loading={deleteMutation.isPending}
|
loading={forceDeleteMutation.isPending}
|
||||||
onClick={() => confirmDeleteRules(selectedRuleIds)}
|
onClick={() => confirmForceDeleteRules(selectedRuleIds)}
|
||||||
>
|
>
|
||||||
批量删除{selectedRuleIds.length ? ` (${selectedRuleIds.length})` : ''}
|
批量强制删除{selectedRuleIds.length ? ` (${selectedRuleIds.length})` : ''}
|
||||||
</Button>
|
</Button>
|
||||||
<Button icon={<ReloadOutlined />} onClick={() => void query.refetch()}>
|
<Button icon={<ReloadOutlined />} onClick={() => void query.refetch()}>
|
||||||
刷新
|
刷新
|
||||||
@@ -726,8 +817,8 @@ function RulesPanel() {
|
|||||||
<Typography.Paragraph type="secondary">
|
<Typography.Paragraph type="secondary">
|
||||||
配置按内部 SKU 全局生效,会同时影响所有平台和店铺。每条内部 SKU 只能有一个主渠道;
|
配置按内部 SKU 全局生效,会同时影响所有平台和店铺。每条内部 SKU 只能有一个主渠道;
|
||||||
其他渠道请设置 90 以上优先级作为兜底。接单平台的具体模板由统一配置中的 workRuleKey 控制。
|
其他渠道请设置 90 以上优先级作为兜底。接单平台的具体模板由统一配置中的 workRuleKey 控制。
|
||||||
来源为商品能力迁移、人工配置、卡券或历史回填的系统规则受保护,配置有误时请新增同 SKU
|
来源为商品能力迁移、人工配置、卡券或历史回填的系统规则默认受保护;确认配置错误时可使用
|
||||||
的正确规则覆盖。
|
「强制删除」,但对应来源后续同步时仍可能重新生成。
|
||||||
</Typography.Paragraph>
|
</Typography.Paragraph>
|
||||||
<Space wrap style={{ marginBottom: 12 }}>
|
<Space wrap style={{ marginBottom: 12 }}>
|
||||||
<Input
|
<Input
|
||||||
@@ -791,7 +882,6 @@ function RulesPanel() {
|
|||||||
rowSelection={{
|
rowSelection={{
|
||||||
selectedRowKeys: selectedRuleIds,
|
selectedRowKeys: selectedRuleIds,
|
||||||
onChange: (keys) => setSelectedRuleIds(keys.map(Number)),
|
onChange: (keys) => setSelectedRuleIds(keys.map(Number)),
|
||||||
getCheckboxProps: (row: ChannelRule) => ({ disabled: !canDeleteChannelRule(row) }),
|
|
||||||
}}
|
}}
|
||||||
pagination={buildAdminTablePagination({
|
pagination={buildAdminTablePagination({
|
||||||
current: query.data?.data?.page || page,
|
current: query.data?.data?.page || page,
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
deleteAdminManualConfig,
|
deleteAdminManualSku,
|
||||||
fetchAdminManualPlatforms,
|
fetchAdminManualPlatforms,
|
||||||
fetchAdminManualSkuOverview,
|
fetchAdminManualSkuOverview,
|
||||||
fetchAdminManualSkus,
|
fetchAdminManualSkus,
|
||||||
@@ -161,13 +161,17 @@ export function ManualSkuConfigPanel() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: (skuKey: string) => deleteAdminManualConfig(skuKey),
|
mutationFn: (skuKey: string) => deleteAdminManualSku(skuKey),
|
||||||
onSuccess: () => {
|
onSuccess: (response) => {
|
||||||
message.success('配置已删除')
|
message.success(
|
||||||
|
`人工分类已移除;${response.data.affectedItems} 个订单项已退回待分类,原始订单保留`,
|
||||||
|
)
|
||||||
void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-manual-skus'] })
|
void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-manual-skus'] })
|
||||||
void queryClient.invalidateQueries({
|
void queryClient.invalidateQueries({
|
||||||
queryKey: ['admin-cost-center-manual-skus-overview'],
|
queryKey: ['admin-cost-center-manual-skus-overview'],
|
||||||
})
|
})
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['fulfillment-rules'] })
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['fulfillment-pending'] })
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
message.error(error instanceof Error ? error.message : '删除失败')
|
message.error(error instanceof Error ? error.message : '删除失败')
|
||||||
@@ -259,24 +263,24 @@ export function ManualSkuConfigPanel() {
|
|||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
width: 130,
|
width: 150,
|
||||||
fixed: 'right',
|
fixed: 'right',
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<Space size={4}>
|
<Space size={4}>
|
||||||
<Button size="small" icon={<EditOutlined />} onClick={() => openConfig(row)}>
|
<Button size="small" icon={<EditOutlined />} onClick={() => openConfig(row)}>
|
||||||
{row.configured ? '编辑' : '配置'}
|
{row.configured ? '编辑' : '配置'}
|
||||||
</Button>
|
</Button>
|
||||||
{row.configured ? (
|
<Popconfirm
|
||||||
<Popconfirm
|
title="移除该人工分类?"
|
||||||
title="删除该 SKU 的配置?"
|
description="会删除人工登记与配置、撤销人工渠道规则、清除已生成的人工成本快照,并把关联订单项退回待分类;原始订单不会删除。"
|
||||||
description="删除后该 SKU 回到未配置状态,已回填的订单项不再带配置。"
|
okText="确认移除"
|
||||||
okText="删除"
|
cancelText="取消"
|
||||||
cancelText="取消"
|
onConfirm={() => deleteMutation.mutate(row.skuKey)}
|
||||||
onConfirm={() => deleteMutation.mutate(row.skuKey)}
|
>
|
||||||
>
|
<Button size="small" danger icon={<DeleteOutlined />}>
|
||||||
<Button size="small" danger icon={<DeleteOutlined />} />
|
移除
|
||||||
</Popconfirm>
|
</Button>
|
||||||
) : null}
|
</Popconfirm>
|
||||||
</Space>
|
</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 } = {}) {
|
export function rematchAdminManualFulfillments(payload: { skuKey?: string } = {}) {
|
||||||
return apiPost<ManualRematchResult>('/api/v1/admin/cost-center/manual-configs/rematch', payload)
|
return apiPost<ManualRematchResult>('/api/v1/admin/cost-center/manual-configs/rematch', payload)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export type PendingChannelGroup = {
|
|||||||
sku_name: string
|
sku_name: string
|
||||||
providers: string[]
|
providers: string[]
|
||||||
shop_ids: string[]
|
shop_ids: string[]
|
||||||
|
shops: Array<{ id: string; name: string }>
|
||||||
sku_codes: string[]
|
sku_codes: string[]
|
||||||
pending_reasons: string[]
|
pending_reasons: string[]
|
||||||
item_count: number
|
item_count: number
|
||||||
@@ -99,6 +100,15 @@ export type PendingWorkOrderRetryResult = {
|
|||||||
skipReasons: Record<string, number>
|
skipReasons: Record<string, number>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type DeletePendingChannelGroupResult = {
|
||||||
|
archivedItems: number
|
||||||
|
preservedOrders: number
|
||||||
|
deletedCostSnapshots: number
|
||||||
|
resolvedExceptions: number
|
||||||
|
refreshedDays: number
|
||||||
|
skippedClosedDays: number
|
||||||
|
}
|
||||||
|
|
||||||
export type FinancePipelineStatus = {
|
export type FinancePipelineStatus = {
|
||||||
pipeline: string
|
pipeline: string
|
||||||
label: 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: {
|
export function assignChannelByInternalSku(payload: {
|
||||||
internalSku: string
|
internalSku: string
|
||||||
channel: string
|
channel: string
|
||||||
@@ -252,6 +269,14 @@ export function deleteChannelRules(ids: number[]) {
|
|||||||
}>('/api/v1/admin/fulfillment/rules/batch-delete', { ids })
|
}>('/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() {
|
export function fetchPipelineStatus() {
|
||||||
return apiGet<FinancePipelineStatus[]>('/api/v1/admin/fulfillment/pipeline/status')
|
return apiGet<FinancePipelineStatus[]>('/api/v1/admin/fulfillment/pipeline/status')
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user