feat(fulfillment): 支持删除待分类和强制删除渠道规则
This commit is contained in:
@@ -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')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user