From a6fbb43955edc52f7221a183fd5ec19ad4172fda Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 5 Nov 2026 17:48:24 +0800 Subject: [PATCH 01/10] =?UTF-8?q?feat(cost-center):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E5=88=A0=E9=99=A4=E7=AC=AC=E4=B8=89=E6=96=B9=E5=8D=A1=E5=88=B8?= =?UTF-8?q?=E5=95=86=E5=93=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/routes/admin/cost-center-catalog.ts | 27 ++++ .../services/cost-center/cost-center.test.ts | 19 +++ .../cost-center/product-catalog-service.ts | 146 ++++++++++++++++++ .../admin/cost-center/VoucherSkuPanel.tsx | 77 ++++++--- .../src/services/admin/cost-center.ts | 14 ++ .../tests/voucher-sku-delete-ui.test.ts | 21 +++ 6 files changed, 285 insertions(+), 19 deletions(-) create mode 100644 apps/frontend/tests/voucher-sku-delete-ui.test.ts diff --git a/apps/backend/src/routes/admin/cost-center-catalog.ts b/apps/backend/src/routes/admin/cost-center-catalog.ts index 213d4cc7..a790e0e3 100644 --- a/apps/backend/src/routes/admin/cost-center-catalog.ts +++ b/apps/backend/src/routes/admin/cost-center-catalog.ts @@ -17,6 +17,7 @@ import { createAdminProduct, deleteAdminChannelMapping, deleteAdminProduct, + deleteAdminVoucherProduct, getAdminChannelMappingDiagnostics, listAdminChannelMappings, listAdminProducts, @@ -416,4 +417,30 @@ router.post( }), ) +router.delete( + '/cost-center/voucher-products/:productId', + requireAdminPermission('finance.config'), + createJsonHandler((req) => deleteAdminVoucherProduct(idOf(req, 'productId')), { + successMessage: '第三方卡券商品已处理', + errorMessage: '删除第三方卡券商品失败', + scope: '[admin/cost-center/voucher-products/:productId]', + audit: (req, data) => { + const result = data as { + action?: string + product?: { id?: number; canonicalSku?: string } + } + return { + action: + result.action === 'archived' ? 'voucher_product_archived' : 'voucher_product_deleted', + targetType: 'product_catalog', + targetId: String(result.product?.id || req.params.productId || ''), + data: { + canonicalSku: result.product?.canonicalSku || '', + action: result.action || '', + }, + } + }, + }), +) + export default router diff --git a/apps/backend/src/services/cost-center/cost-center.test.ts b/apps/backend/src/services/cost-center/cost-center.test.ts index 7633261d..d1c35136 100644 --- a/apps/backend/src/services/cost-center/cost-center.test.ts +++ b/apps/backend/src/services/cost-center/cost-center.test.ts @@ -291,6 +291,25 @@ test('成本模型与商品来源必须覆盖第三方卡券渠道', () => { assert.ok(productSourceFilterSql(1).includes("'tpv:%'")) }) +test('第三方卡券删除:无历史引用硬删除,有历史引用自动归档并停用规则', async () => { + const { readFile } = await import('node:fs/promises') + const [serviceSource, routeSource] = await Promise.all([ + readFile(new URL('./product-catalog-service.ts', import.meta.url), 'utf8'), + readFile(new URL('../../routes/admin/cost-center-catalog.ts', import.meta.url), 'utf8'), + ]) + const start = serviceSource.indexOf('export async function deleteAdminVoucherProduct') + const end = serviceSource.indexOf('/**\n * 批量归档旧人工归一商品', start) + const body = serviceSource.slice(start, end) + + assert.ok(body.includes('withTransaction'), '卡券删除必须在事务内完成') + assert.ok(body.includes("action: 'archived'"), '有历史引用时必须自动归档') + assert.ok(body.includes("status = 'archived'"), '归档必须保留商品和历史成本') + assert.ok(body.includes('UPDATE channel_product_mappings'), '归档后必须停用旧映射,避免继续命中') + assert.ok(body.includes('DELETE FROM cost_versions'), '无历史引用时应清理采购价版本') + assert.ok(body.includes("channel = 'third_party_voucher'"), '必须停用对应卡券渠道规则') + assert.ok(routeSource.includes("'/cost-center/voucher-products/:productId'")) +}) + test('人工客服筛选排除已有其他主渠道的旧 man 商品', () => { const sql = productSourceFilterSql(1) assert.ok(sql.includes(`$1 = 'manual'`)) diff --git a/apps/backend/src/services/cost-center/product-catalog-service.ts b/apps/backend/src/services/cost-center/product-catalog-service.ts index acf6257b..617e379d 100644 --- a/apps/backend/src/services/cost-center/product-catalog-service.ts +++ b/apps/backend/src/services/cost-center/product-catalog-service.ts @@ -439,6 +439,152 @@ export async function deleteAdminProduct(productId: number | string) { return { deleted: true, product: mapProduct(current) } } +/** + * 删除第三方卡券商品。 + * + * 卡券商品通常带采购价版本,不能套用普通商品“有任何成本版本就拒绝”的规则: + * - 没有任何订单项/映射/成本快照:删除未使用成本版本、渠道能力与卡券规则后硬删除; + * - 已有历史引用:归档商品并停用后续卡券规则,成本版本与快照全部保留。 + */ +export async function deleteAdminVoucherProduct(productId: number | string) { + const id = normalizeId(productId, '商品 ID') + return withTransaction(async (client) => { + const currentResult = await client.query( + `SELECT * FROM product_catalog WHERE id = $1 FOR UPDATE`, + [id], + ) + const current = requiredRow(currentResult.rows[0], '商品不存在') + if (!current.canonical_sku.startsWith('tpv:')) { + throw createHttpError('该商品不是第三方卡券商品', { + statusCode: 400, + errorCode: 'voucher_product_type_invalid', + }) + } + + const usageResult = await client.query<{ + mapping_count: number + snapshot_count: number + order_item_count: number + }>( + ` + SELECT + (SELECT COUNT(*)::int FROM channel_product_mappings WHERE canonical_product_id = $1) + AS mapping_count, + ( + SELECT COUNT(*)::int + FROM cost_snapshots + WHERE canonical_product_id = $1 + OR cost_version_id IN ( + SELECT id FROM cost_versions WHERE canonical_product_id = $1 + ) + ) + AS snapshot_count, + (SELECT COUNT(*)::int FROM order_items WHERE canonical_product_id = $1) + AS order_item_count + `, + [id], + ) + const usage = usageResult.rows[0] + const mappingCount = toNumber(usage?.mapping_count) + const snapshotCount = toNumber(usage?.snapshot_count) + const orderItemCount = toNumber(usage?.order_item_count) + const internalSku = current.canonical_sku.slice(4) + + await client.query( + ` + UPDATE internal_sku_channels + SET enabled = FALSE, + is_fallback = FALSE, + updated_at = $3 + WHERE channel = 'third_party_voucher' + AND ( + canonical_product_id = $1 + OR canonicalize_internal_sku(internal_sku) = canonicalize_internal_sku($2) + ) + `, + [id, internalSku, nowIso()], + ) + await client.query( + ` + UPDATE product_channel_capabilities + SET enabled = FALSE, + updated_at = $2 + WHERE product_id = $1 AND channel = 'third_party_voucher' + `, + [id, nowIso()], + ) + + if (mappingCount > 0 || snapshotCount > 0 || orderItemCount > 0) { + await client.query( + ` + UPDATE channel_product_mappings + SET enabled = FALSE, + note = CASE + WHEN note = '' THEN '第三方卡券商品删除后自动停用' + ELSE note + END, + updated_at = $2 + WHERE canonical_product_id = $1 + `, + [id, nowIso()], + ) + const archived = await client.query( + ` + UPDATE product_catalog + SET status = 'archived', + attributes_json = attributes_json || jsonb_build_object( + 'deletedAt', $2::text, + 'deletedBy', 'voucher_product_panel' + ), + updated_at = $2::timestamptz + WHERE id = $1 + RETURNING * + `, + [id, nowIso()], + ) + return { + action: 'archived' as const, + deleted: false, + product: mapProduct(requiredRow(archived.rows[0], '归档卡券商品失败')), + preserved: { + mappingCount, + snapshotCount, + orderItemCount, + }, + } + } + + const deletedVersions = await client.query( + `DELETE FROM cost_versions WHERE canonical_product_id = $1`, + [id], + ) + await client.query(`DELETE FROM product_channel_capabilities WHERE product_id = $1`, [id]) + await client.query( + ` + DELETE FROM internal_sku_channels + WHERE channel = 'third_party_voucher' + AND ( + canonical_product_id = $1 + OR canonicalize_internal_sku(internal_sku) = canonicalize_internal_sku($2) + ) + `, + [id, internalSku], + ) + await client.query('DELETE FROM product_catalog WHERE id = $1', [id]) + return { + action: 'deleted' as const, + deleted: true, + product: mapProduct(current), + deletedCostVersionCount: deletedVersions.rowCount || 0, + preserved: { + mappingCount: 0, + snapshotCount: 0, + orderItemCount: 0, + }, + } + }) +} + /** * 批量归档旧人工归一商品。 * diff --git a/apps/frontend/src/pages/admin/cost-center/VoucherSkuPanel.tsx b/apps/frontend/src/pages/admin/cost-center/VoucherSkuPanel.tsx index 4d57465a..cb024348 100644 --- a/apps/frontend/src/pages/admin/cost-center/VoucherSkuPanel.tsx +++ b/apps/frontend/src/pages/admin/cost-center/VoucherSkuPanel.tsx @@ -4,7 +4,7 @@ * 商品编码固定为 `tpv:`;成本写入 `sys_third_party_voucher_cost` 的 * 商品级版本,由成本快照管道消费。 */ -import { ReloadOutlined, SyncOutlined } from '@ant-design/icons' +import { DeleteOutlined, ReloadOutlined, SyncOutlined } from '@ant-design/icons' import { Button, Card, @@ -12,6 +12,7 @@ import { Input, InputNumber, Modal, + Popconfirm, Space, Tag, Typography, @@ -22,11 +23,13 @@ import { useState } from 'react' import PercentTable from '@/components/common/PercentTable' import { + deleteAdminVoucherProduct, ensureVoucherChannelRules, fetchAdminProductCatalog, setAdminProductPrice, } from '@/services/admin' import type { ProductCatalogItem } from '@/types/admin' +import { formatAdminDateTime } from '@/utils/admin-time' import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination' import { useCostCenter } from './CostCenterContext' @@ -65,6 +68,20 @@ export default function VoucherSkuPanel() { onError: (error) => message.error(error instanceof Error ? error.message : '保存失败'), }) + const deleteMutation = useMutation({ + mutationFn: (productId: number) => deleteAdminVoucherProduct(productId), + onSuccess: (response) => { + message.success( + response.data.action === 'archived' + ? '该商品已有历史引用,已归档并停用后续卡券规则;历史成本已保留' + : '卡券商品及采购价配置已删除', + ) + void queryClient.invalidateQueries({ queryKey: ['cost-center-voucher-products'] }) + refreshAll() + }, + onError: (error) => message.error(error instanceof Error ? error.message : '删除失败'), + }) + async function handleEnsureRules() { setEnsuringRules(true) try { @@ -99,28 +116,50 @@ export default function VoucherSkuPanel() { title: '生效时间', dataIndex: 'activeCostEffectiveFrom', width: 180, - render: (value?: string | null) => value || '—', + render: (value?: string | null) => + value ? {formatAdminDateTime(value)} : '—', }, { title: '操作', - width: 110, + width: 180, + fixed: 'right' as const, render: (_: unknown, row: ProductCatalogItem) => ( - + + + deleteMutation.mutate(row.id)} + > + + + ), }, ] diff --git a/apps/frontend/src/services/admin/cost-center.ts b/apps/frontend/src/services/admin/cost-center.ts index 8adbb34c..fbd06c08 100644 --- a/apps/frontend/src/services/admin/cost-center.ts +++ b/apps/frontend/src/services/admin/cost-center.ts @@ -555,3 +555,17 @@ export function ensureVoucherChannelRules() { '/api/v1/admin/cost-center/voucher-products/ensure-rules', ) } + +export function deleteAdminVoucherProduct(productId: number) { + return apiDelete<{ + action: 'deleted' | 'archived' + deleted: boolean + product: ProductCatalogItem + deletedCostVersionCount?: number + preserved: { + mappingCount: number + snapshotCount: number + orderItemCount: number + } + }>(`/api/v1/admin/cost-center/voucher-products/${productId}`) +} diff --git a/apps/frontend/tests/voucher-sku-delete-ui.test.ts b/apps/frontend/tests/voucher-sku-delete-ui.test.ts new file mode 100644 index 00000000..688a5159 --- /dev/null +++ b/apps/frontend/tests/voucher-sku-delete-ui.test.ts @@ -0,0 +1,21 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import test from 'node:test' + +test('第三方卡券面板提供安全删除入口并格式化生效时间', async () => { + const [panel, service] = await Promise.all([ + readFile( + new URL('../src/pages/admin/cost-center/VoucherSkuPanel.tsx', import.meta.url), + 'utf8', + ), + readFile(new URL('../src/services/admin/cost-center.ts', import.meta.url), 'utf8'), + ]) + + assert.ok(panel.includes('deleteAdminVoucherProduct')) + assert.ok(panel.includes('DeleteOutlined')) + assert.ok(panel.includes('Popconfirm')) + assert.ok(panel.includes('已有历史订单或成本快照时会自动归档')) + assert.ok(panel.includes('formatAdminDateTime(value)')) + assert.ok(service.includes('/api/v1/admin/cost-center/voucher-products/${productId}')) + assert.ok(service.includes("action: 'deleted' | 'archived'")) +}) From 8b9b4fc4adfcd5051e446b6b64adc464ccbee5b6 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 5 Nov 2026 17:58:57 +0800 Subject: [PATCH 02/10] =?UTF-8?q?refactor(cost-center):=20=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E4=BA=BA=E5=B7=A5=E6=A0=87=E5=87=86=E5=95=86=E5=93=81?= =?UTF-8?q?=E4=B8=8E=E6=88=90=E6=9C=AC=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...135_reconcile_manual_standard_products.sql | 190 ++++++++++++++++++ .../manual-fulfillment-config-service.ts | 180 ++++++++++++++--- .../manual-fulfillment-integrity.test.ts | 35 +++- .../cost-center/ManualSkuConfigPanel.tsx | 98 ++------- .../src/services/admin/cost-center.ts | 1 + .../tests/manual-sku-config-ui.test.ts | 17 ++ 6 files changed, 406 insertions(+), 115 deletions(-) create mode 100644 apps/backend/src/db/migrations/135_reconcile_manual_standard_products.sql create mode 100644 apps/frontend/tests/manual-sku-config-ui.test.ts diff --git a/apps/backend/src/db/migrations/135_reconcile_manual_standard_products.sql b/apps/backend/src/db/migrations/135_reconcile_manual_standard_products.sql new file mode 100644 index 00000000..20214dab --- /dev/null +++ b/apps/backend/src/db/migrations/135_reconcile_manual_standard_products.sql @@ -0,0 +1,190 @@ +-- 135_reconcile_manual_standard_products.sql +-- +-- 人工标准商品归一: +-- 1. 同一内部 SKU 因大小写/全半角残留出多条 active man: 商品时,只保留最早一条; +-- 2. 订单项、人工配置、成本版本、成本快照、渠道映射和渠道能力统一指向保留商品; +-- 3. 重复商品只归档,不删除历史成本与订单数据。 + +-- 已有配置/登记但缺少 man: 标准商品的 SKU,先补标准商品。 +-- 若该 SKU 已有其他启用主渠道,则不再补人工商品,避免重新制造跨渠道重复。 +WITH raw_keys AS ( + SELECT canonicalize_internal_sku(sku_key) AS sku_key, sku_key AS display_name + FROM manual_fulfillment_configs + UNION ALL + SELECT canonicalize_internal_sku(sku_key) AS sku_key, sku_key AS display_name + FROM manual_sku_registry +), +keys AS ( + SELECT sku_key, MAX(display_name) AS display_name + FROM raw_keys + GROUP BY 1 +), +missing AS ( + SELECT keys.* + FROM keys + WHERE keys.sku_key <> '' + AND NOT EXISTS ( + SELECT 1 + FROM product_catalog product + WHERE product.status = 'active' + AND product.canonical_sku LIKE 'man:%' + AND canonicalize_internal_sku(substring(product.canonical_sku from 5)) = keys.sku_key + ) + AND NOT EXISTS ( + SELECT 1 + FROM internal_sku_channels channel + WHERE channel.enabled = TRUE + AND channel.is_fallback = FALSE + AND channel.channel <> 'manual' + AND canonicalize_internal_sku(channel.internal_sku) = keys.sku_key + ) +) +INSERT INTO product_catalog ( + canonical_sku, canonical_name, category, unit_name, status, + attributes_json, created_at, updated_at +) +SELECT + 'man:' || sku_key, + COALESCE(NULLIF(display_name, ''), sku_key), + '人工', + '件', + 'active', + '{"source":"manual_config","auto_created":true,"reconciled":true}'::jsonb, + NOW(), + NOW() +FROM missing +ON CONFLICT (canonical_sku) DO UPDATE SET + status = 'active', + canonical_name = CASE + WHEN BTRIM(product_catalog.canonical_name) = '' THEN EXCLUDED.canonical_name + ELSE product_catalog.canonical_name + END, + updated_at = EXCLUDED.updated_at; + +DROP TABLE IF EXISTS pg_temp.tmp_manual_product_merge; + +CREATE TEMP TABLE tmp_manual_product_merge AS +WITH ranked AS ( + SELECT + id, + FIRST_VALUE(id) OVER ( + PARTITION BY canonicalize_internal_sku(substring(canonical_sku from 5)) + ORDER BY id + ) AS keeper_id, + ROW_NUMBER() OVER ( + PARTITION BY canonicalize_internal_sku(substring(canonical_sku from 5)) + ORDER BY id + ) AS candidate_rank + FROM product_catalog + WHERE status = 'active' AND canonical_sku LIKE 'man:%' +) +SELECT id AS duplicate_id, keeper_id +FROM ranked +WHERE candidate_rank > 1; + +CREATE UNIQUE INDEX ON tmp_manual_product_merge (duplicate_id); + +UPDATE order_items oi +SET canonical_product_id = merge.keeper_id, + updated_at = NOW() +FROM tmp_manual_product_merge merge +WHERE oi.canonical_product_id = merge.duplicate_id; + +UPDATE manual_fulfillment_configs cfg +SET canonical_product_id = merge.keeper_id, + updated_at = NOW() +FROM tmp_manual_product_merge merge +WHERE cfg.canonical_product_id = merge.duplicate_id; + +UPDATE cost_snapshots snapshot +SET canonical_product_id = merge.keeper_id +FROM tmp_manual_product_merge merge +WHERE snapshot.canonical_product_id = merge.duplicate_id; + +-- 无冲突版本迁到保留商品;若生效时间等唯一范围冲突,则留在归档商品上供历史快照追溯。 +UPDATE cost_versions version +SET canonical_product_id = merge.keeper_id, + updated_at = NOW() +FROM tmp_manual_product_merge merge +WHERE version.canonical_product_id = merge.duplicate_id + AND NOT EXISTS ( + SELECT 1 + FROM cost_versions keeper + WHERE keeper.canonical_product_id = merge.keeper_id + AND keeper.cost_item_id = version.cost_item_id + AND keeper.scope_type = version.scope_type + AND keeper.provider = version.provider + AND keeper.shop_id = version.shop_id + AND keeper.work_product_rule_id IS NOT DISTINCT FROM version.work_product_rule_id + AND keeper.effective_from = version.effective_from + ); + +DELETE FROM channel_product_mappings duplicate +USING tmp_manual_product_merge merge, channel_product_mappings keeper +WHERE duplicate.canonical_product_id = merge.duplicate_id + AND keeper.canonical_product_id = merge.keeper_id + AND keeper.provider = duplicate.provider + AND keeper.shop_id = duplicate.shop_id + AND keeper.platform_sku_code = duplicate.platform_sku_code + AND keeper.match_type = duplicate.match_type + AND keeper.platform_sku_name_key = duplicate.platform_sku_name_key; + +UPDATE channel_product_mappings mapping +SET canonical_product_id = merge.keeper_id, + updated_at = NOW() +FROM tmp_manual_product_merge merge +WHERE mapping.canonical_product_id = merge.duplicate_id; + +DELETE FROM product_channel_capabilities duplicate +USING tmp_manual_product_merge merge, product_channel_capabilities keeper +WHERE duplicate.product_id = merge.duplicate_id + AND keeper.product_id = merge.keeper_id + AND keeper.channel = duplicate.channel; + +UPDATE product_channel_capabilities capability +SET product_id = merge.keeper_id, + updated_at = NOW() +FROM tmp_manual_product_merge merge +WHERE capability.product_id = merge.duplicate_id; + +UPDATE internal_sku_channels channel +SET canonical_product_id = merge.keeper_id, + updated_at = NOW() +FROM tmp_manual_product_merge merge +WHERE channel.canonical_product_id = merge.duplicate_id; + +UPDATE product_catalog product +SET status = 'archived', + attributes_json = product.attributes_json || jsonb_build_object( + 'reconciledIntoProductId', merge.keeper_id, + 'reconciledAt', NOW() + ), + updated_at = NOW() +FROM tmp_manual_product_merge merge +WHERE product.id = merge.duplicate_id; + +-- 人工配置统一挂到唯一 active man: 标准商品。 +UPDATE manual_fulfillment_configs cfg +SET canonical_product_id = product.id, + updated_at = NOW() +FROM product_catalog product +WHERE product.status = 'active' + AND product.canonical_sku LIKE 'man:%' + AND canonicalize_internal_sku(substring(product.canonical_sku from 5)) + = canonicalize_internal_sku(cfg.sku_key) + AND cfg.canonical_product_id IS DISTINCT FROM product.id; + +UPDATE manual_sku_registry registry +SET config_id = cfg.id, + updated_at = NOW() +FROM manual_fulfillment_configs cfg +JOIN product_catalog product ON product.id = cfg.canonical_product_id +WHERE product.status = 'active' + AND product.canonical_sku LIKE 'man:%' + AND canonicalize_internal_sku(registry.sku_key) + = canonicalize_internal_sku(substring(product.canonical_sku from 5)); + +COMMENT ON COLUMN product_catalog.attributes_json IS + '标准商品扩展信息;人工商品归档时记录 reconciledIntoProductId 便于追溯'; + +DROP TABLE IF EXISTS pg_temp.tmp_manual_product_merge; diff --git a/apps/backend/src/services/cost-center/manual-fulfillment-config-service.ts b/apps/backend/src/services/cost-center/manual-fulfillment-config-service.ts index 48acc6c8..51659e89 100644 --- a/apps/backend/src/services/cost-center/manual-fulfillment-config-service.ts +++ b/apps/backend/src/services/cost-center/manual-fulfillment-config-service.ts @@ -13,6 +13,7 @@ import { normalizeManualCreditQuantity, parseManualChannelQuantity, } from './manual-credit-quantity.js' +import { productSourceFilterSql } from './cost-center-mappers.js' /** * 人工履约 SKU 配置服务。 @@ -223,9 +224,7 @@ export async function scanManualSkus(options: ScanOptions) { return { scanned: rows.length, registered: rows.length, fromDate, toDate } } -/** - * 列出人工 SKU 清单(登记表 + 配置状态)。 - */ +/** 列出人工标准商品清单(标准商品为唯一主键,配置与订单统计只做关联)。 */ export async function listManualSkus(input: Record = {}) { const unitPrices = await loadPlatformUnitPriceFen() const page = normalizePositiveInteger(input.page, 1, '页码') @@ -238,13 +237,16 @@ export async function listManualSkus(input: Record = {}) { const platformKey = String(input.platformKey || input.platform_key || '').trim() const provider = String(input.provider || '').trim() - const conditions: string[] = ['1 = 1'] - const params: unknown[] = [] + const conditions: string[] = [`p.status = 'active'`, `p.canonical_sku LIKE 'man:%'`] + const params: unknown[] = ['manual'] + conditions.push(productSourceFilterSql(params.length)) if (keyword) { params.push(`%${escapeLike(keyword)}%`) const placeholder = `$${params.length}` - conditions.push(`(r.sku_key ILIKE ${placeholder} OR r.display_name ILIKE ${placeholder})`) + conditions.push( + `(p.canonical_sku ILIKE ${placeholder} OR p.canonical_name ILIKE ${placeholder})`, + ) } if (status === 'configured') { conditions.push('cfg.id IS NOT NULL') @@ -266,7 +268,7 @@ export async function listManualSkus(input: Record = {}) { WHERE oi.fulfillment_status = 'assigned' AND oi.fulfillment_channel = 'manual' AND o.provider = $${params.length} AND resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) - = canonicalize_internal_sku(r.sku_key) + = canonicalize_internal_sku(substring(p.canonical_sku from 5)) )`) } @@ -274,9 +276,15 @@ export async function listManualSkus(input: Record = {}) { const countResult = await query<{ total: number }>( ` SELECT COUNT(*)::int AS total - FROM manual_sku_registry r - LEFT JOIN manual_fulfillment_configs cfg - ON canonicalize_internal_sku(cfg.sku_key) = canonicalize_internal_sku(r.sku_key) + FROM product_catalog p + LEFT JOIN LATERAL ( + SELECT * + FROM manual_fulfillment_configs cfg + WHERE canonicalize_internal_sku(cfg.sku_key) + = canonicalize_internal_sku(substring(p.canonical_sku from 5)) + ORDER BY cfg.id + LIMIT 1 + ) cfg ON TRUE WHERE ${where} `, params, @@ -284,22 +292,56 @@ export async function listManualSkus(input: Record = {}) { const listParams = [...params, pageSize, (page - 1) * pageSize] const result = await query( ` + WITH manual_stats AS ( + SELECT + resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) AS sku_key, + COUNT(DISTINCT o.shop_id)::int AS shop_count, + COUNT(DISTINCT o.id)::int AS order_count, + COUNT(*)::int AS item_count, + COALESCE(SUM(GREATEST(COALESCE(oi.revenue_amount_fen, 0), 0)), 0)::bigint + AS revenue_fen, + MIN((COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date) + AS first_seen_date, + MAX((COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date) + AS last_seen_date + FROM order_items oi + JOIN orders o ON o.id = oi.order_id + WHERE oi.fulfillment_status = 'assigned' + AND oi.fulfillment_channel = 'manual' + GROUP BY 1 + ) SELECT - r.id, r.sku_key, r.display_name, r.shop_count, r.order_count, r.item_count, - r.revenue_fen, r.first_seen_date, r.last_seen_date, - r.scan_from_date, r.scan_to_date, + p.id, + substring(p.canonical_sku from 5) AS sku_key, + p.canonical_name AS display_name, + COALESCE(manual_stats.shop_count, 0)::int AS shop_count, + COALESCE(manual_stats.order_count, 0)::int AS order_count, + COALESCE(manual_stats.item_count, 0)::int AS item_count, + COALESCE(manual_stats.revenue_fen, 0)::bigint AS revenue_fen, + manual_stats.first_seen_date, + manual_stats.last_seen_date, + manual_stats.first_seen_date AS scan_from_date, + manual_stats.last_seen_date AS scan_to_date, cfg.id AS config_id, cfg.platform_key AS config_platform_key, cfg.credit_quantity AS config_credit_quantity, cfg.cost_mode AS config_cost_mode, - cfg.canonical_product_id AS config_canonical_product_id, + COALESCE(cfg.canonical_product_id, p.id) AS config_canonical_product_id, cfg.enabled AS config_enabled, cfg.channels_json AS config_channels_json - FROM manual_sku_registry r - LEFT JOIN manual_fulfillment_configs cfg - ON canonicalize_internal_sku(cfg.sku_key) = canonicalize_internal_sku(r.sku_key) + FROM product_catalog p + LEFT JOIN LATERAL ( + SELECT * + FROM manual_fulfillment_configs cfg + WHERE canonicalize_internal_sku(cfg.sku_key) + = canonicalize_internal_sku(substring(p.canonical_sku from 5)) + ORDER BY cfg.id + LIMIT 1 + ) cfg ON TRUE + LEFT JOIN manual_stats + ON manual_stats.sku_key = canonicalize_internal_sku(substring(p.canonical_sku from 5)) WHERE ${where} - ORDER BY r.item_count DESC, r.sku_key ASC + ORDER BY COALESCE(manual_stats.item_count, 0) DESC, p.canonical_name ASC, p.id ASC LIMIT $${listParams.length - 1} OFFSET $${listParams.length} `, listParams, @@ -323,20 +365,50 @@ export async function getManualSkuOverview() { last_scan_at: string | null }>( ` + WITH manual_stats AS ( + SELECT + resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) AS sku_key, + COUNT(*)::int AS item_count, + MAX((COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date) + AS last_seen_date + FROM order_items oi + JOIN orders o ON o.id = oi.order_id + WHERE oi.fulfillment_status = 'assigned' + AND oi.fulfillment_channel = 'manual' + GROUP BY 1 + ), + manual_scope AS ( + SELECT p.id, p.canonical_sku, p.canonical_name, cfg.id AS config_id + FROM product_catalog p + LEFT JOIN LATERAL ( + SELECT cfg.id + FROM manual_fulfillment_configs cfg + WHERE canonicalize_internal_sku(cfg.sku_key) + = canonicalize_internal_sku(substring(p.canonical_sku from 5)) + ORDER BY cfg.id + LIMIT 1 + ) cfg ON TRUE + WHERE p.status = 'active' + AND p.canonical_sku LIKE 'man:%' + AND ${productSourceFilterSql(1)} + ) SELECT COUNT(*)::int AS total_sku, - COUNT(*) FILTER (WHERE cfg.id IS NOT NULL)::int AS configured_sku, - COUNT(*) FILTER (WHERE cfg.id IS NULL)::int AS unconfigured_sku, - COALESCE(SUM(r.item_count), 0)::bigint AS total_items, - COALESCE(SUM(r.item_count) FILTER (WHERE cfg.id IS NOT NULL), 0)::bigint + COUNT(*) FILTER (WHERE config_id IS NOT NULL)::int AS configured_sku, + COUNT(*) FILTER (WHERE config_id IS NULL)::int AS unconfigured_sku, + COALESCE(SUM(manual_stats.item_count), 0)::bigint AS total_items, + COALESCE(SUM(manual_stats.item_count) FILTER (WHERE config_id IS NOT NULL), 0)::bigint AS configured_items, - COALESCE(SUM(r.item_count) FILTER (WHERE cfg.id IS NULL), 0)::bigint + COALESCE(SUM(manual_stats.item_count) FILTER (WHERE config_id IS NULL), 0)::bigint AS unconfigured_items, - MAX(r.updated_at) AS last_scan_at - FROM manual_sku_registry r - LEFT JOIN manual_fulfillment_configs cfg - ON canonicalize_internal_sku(cfg.sku_key) = canonicalize_internal_sku(r.sku_key) + MAX(manual_stats.last_seen_date)::text AS last_scan_at + FROM manual_scope + LEFT JOIN manual_stats + ON manual_stats.sku_key = canonicalize_internal_sku( + substring(manual_scope.canonical_sku from 5) + ) `, + ['manual'], ) const row = result.rows[0] return { @@ -470,6 +542,7 @@ export async function saveManualFulfillmentConfig(payload: JsonObject) { }) await syncRegistryConfigId(config.sku_key, config.id) + await upsertManualRegistryConfig(config) // 同步渠道规则:人工配置就是「这个内部 SKU 走人工渠道」的唯一声明, // 少了这一步,新配的 SKU 会一直停在待分类。 await syncManualChannelRule(config) @@ -556,6 +629,7 @@ export type DeleteManualSkuResult = { deletedConfigs: number deletedRegistry: number deletedRules: number + archivedProducts: number refreshedDays: number skippedClosedDays: number } @@ -671,6 +745,22 @@ export async function deleteManualSkuClassification( RETURNING id`, [key], ) + const products = await client.query( + ` + UPDATE product_catalog + SET status = 'archived', + attributes_json = attributes_json || jsonb_build_object( + 'deletedAt', $2::text, + 'deletedBy', 'manual_sku_config' + ), + updated_at = $2::timestamptz + WHERE status = 'active' + AND canonical_sku LIKE 'man:%' + AND canonicalize_internal_sku(substring(canonical_sku from 5)) = $1 + RETURNING id + `, + [key, nowIso()], + ) if (itemIds.length > 0) { await client.query( @@ -703,6 +793,7 @@ export async function deleteManualSkuClassification( deletedConfigs: configs.rowCount || 0, deletedRegistry: registry.rowCount || 0, deletedRules: rules.rowCount || 0, + archivedProducts: products.rowCount || 0, dates: [...new Set(items.rows.map((row) => row.basis_date).filter(Boolean))], } }) @@ -714,6 +805,7 @@ export async function deleteManualSkuClassification( deletedConfigs: result.deletedConfigs, deletedRegistry: result.deletedRegistry, deletedRules: result.deletedRules, + archivedProducts: result.archivedProducts, refreshedDays: refreshed.days, skippedClosedDays: refreshed.skippedClosed, } @@ -840,6 +932,40 @@ async function syncRegistryConfigId(skuKey: string, configId: number | null) { ) } +/** 配置保存后确保登记表仍有同一 SKU 的记录,兼容旧扫描清单和运维查询。 */ +async function upsertManualRegistryConfig(config: { + id: number + sku_key: string + canonical_product_id: number | null +}) { + const key = canonicalizeInternalSku(config.sku_key) + if (!key) return + const now = nowIso() + await query( + ` + INSERT INTO manual_sku_registry ( + sku_key, display_name, config_id, created_at, updated_at + ) + SELECT + $1, + COALESCE(NULLIF(product.canonical_name, ''), $1), + $2, + $3, + $3 + FROM (SELECT 1) seed + LEFT JOIN product_catalog product ON product.id = $4 + ON CONFLICT (sku_key) DO UPDATE SET + display_name = CASE + WHEN BTRIM(manual_sku_registry.display_name) = '' THEN EXCLUDED.display_name + ELSE manual_sku_registry.display_name + END, + config_id = EXCLUDED.config_id, + updated_at = EXCLUDED.updated_at + `, + [key, config.id, now, config.canonical_product_id], + ) +} + /** 读取单个 SKU 的配置详情(含平台标签与成本折算)。 */ export async function getManualFulfillmentConfig(skuKey: string) { const unitPrices = await loadPlatformUnitPriceFen() diff --git a/apps/backend/src/services/finance/manual-fulfillment-integrity.test.ts b/apps/backend/src/services/finance/manual-fulfillment-integrity.test.ts index 85952ad6..aa428900 100644 --- a/apps/backend/src/services/finance/manual-fulfillment-integrity.test.ts +++ b/apps/backend/src/services/finance/manual-fulfillment-integrity.test.ts @@ -90,15 +90,20 @@ test('人工确认必须单事务:扣款与快照同事务,且使用 with-cl }) test('人工确认前必须自动匹配履约配置,配置保存/定时任务也要回填', async () => { - const [manualSource, configSource, jobSource, migrationSource] = await Promise.all([ - readFile(new URL('./manual-fulfillment-service.ts', import.meta.url), 'utf8'), - readFile( - new URL('../cost-center/manual-fulfillment-config-service.ts', import.meta.url), - 'utf8', - ), - readFile(new URL('../scheduler/finance-pipeline-jobs.ts', import.meta.url), 'utf8'), - readFile(new URL('../../db/migrations/106_manual_sku_key.sql', import.meta.url), 'utf8'), - ]) + const [manualSource, configSource, jobSource, migrationSource, reconcileMigration] = + await Promise.all([ + readFile(new URL('./manual-fulfillment-service.ts', import.meta.url), 'utf8'), + readFile( + new URL('../cost-center/manual-fulfillment-config-service.ts', import.meta.url), + 'utf8', + ), + readFile(new URL('../scheduler/finance-pipeline-jobs.ts', import.meta.url), 'utf8'), + readFile(new URL('../../db/migrations/106_manual_sku_key.sql', import.meta.url), 'utf8'), + readFile( + new URL('../../db/migrations/135_reconcile_manual_standard_products.sql', import.meta.url), + 'utf8', + ), + ]) assert.ok( manualSource.includes('matchManualFulfillmentConfigWithClient(client, orderItemId, now)'), '确认时必须按 SKU 自动挂渠道', @@ -137,6 +142,18 @@ test('人工确认前必须自动匹配履约配置,配置保存/定时任务 migrationSource.includes('sku_name'), '生成列必须保留 sku_code / sku_name 兜底', ) + const listStart = configSource.indexOf('export async function listManualSkus') + const listEnd = configSource.indexOf('export async function getManualSkuOverview') + const listBody = configSource.slice(listStart, listEnd) + assert.ok( + listBody.includes('FROM product_catalog p') && listBody.includes('productSourceFilterSql'), + '人工成本配置必须以 man: 标准商品为唯一清单来源', + ) + assert.ok( + reconcileMigration.includes('tmp_manual_product_merge') && + reconcileMigration.includes("status = 'archived'"), + '存量重复人工 SKU 必须合并到同一标准商品并归档重复项', + ) }) test('人工成本缺失由管道按配置与快照生成并收口', async () => { diff --git a/apps/frontend/src/pages/admin/cost-center/ManualSkuConfigPanel.tsx b/apps/frontend/src/pages/admin/cost-center/ManualSkuConfigPanel.tsx index 5834a7c2..97eff9c0 100644 --- a/apps/frontend/src/pages/admin/cost-center/ManualSkuConfigPanel.tsx +++ b/apps/frontend/src/pages/admin/cost-center/ManualSkuConfigPanel.tsx @@ -1,5 +1,5 @@ /** - * 人工 SKU 配置 Tab:扫描 → 配置 → 重新匹配。 + * 人工 SKU 配置 Tab:按人工标准商品配置渠道与成本。 * * 口径(2026-10-24 确认): * * 配置主键是**内部 SKU**(快手 skuNick / 拼多多 outSkuSn),商品名称仅供识别; @@ -16,8 +16,6 @@ import { MinusCircleOutlined, PlusOutlined, ReloadOutlined, - ScanOutlined, - SyncOutlined, } from '@ant-design/icons' import { Alert, @@ -25,7 +23,6 @@ import { Button, Card, Col, - DatePicker, Form, Input, InputNumber, @@ -48,9 +45,7 @@ import { fetchAdminManualPlatforms, fetchAdminManualSkuOverview, fetchAdminManualSkus, - rematchAdminManualFulfillments, saveAdminManualConfig, - scanAdminManualSkus, } from '@/services/admin' import type { ManualPlatformKey, ManualSkuItem } from '@/types/admin' import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination' @@ -59,8 +54,6 @@ import { formatCreditUnitPriceLabel, formatCreditUnitPriceYuan, } from '@/utils/credit-unit-price' -import dayjs from '@/lib/dayjs' -import type { Dayjs } from 'dayjs' type ConfigFormValues = { channels: Array<{ @@ -102,10 +95,6 @@ export function ManualSkuConfigPanel() { const [platformKey, setPlatformKey] = useState('') const [page, setPage] = useState(1) const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE) - const [scanRange, setScanRange] = useState<[Dayjs, Dayjs]>([ - dayjs().startOf('month'), - dayjs().endOf('month'), - ]) const [modalOpen, setModalOpen] = useState(false) const [editing, setEditing] = useState(null) @@ -133,25 +122,6 @@ export function ManualSkuConfigPanel() { const platforms = platformQuery.data?.data.items || [] const platformMap = new Map(platforms.map((item) => [item.key, item])) - const scanMutation = useMutation({ - mutationFn: () => - scanAdminManualSkus({ - fromDate: scanRange[0].format('YYYY-MM-DD'), - toDate: scanRange[1].format('YYYY-MM-DD'), - }), - onSuccess: (response) => { - const result = response.data - message.success(`扫描完成,登记 ${result.registered} 个内部 SKU`) - void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-manual-skus'] }) - void queryClient.invalidateQueries({ - queryKey: ['admin-cost-center-manual-skus-overview'], - }) - }, - onError: (error) => { - message.error(error instanceof Error ? error.message : '扫描失败') - }, - }) - const saveMutation = useMutation({ mutationFn: (values: ConfigFormValues) => saveAdminManualConfig({ @@ -183,7 +153,7 @@ export function ManualSkuConfigPanel() { mutationFn: (skuKey: string) => deleteAdminManualSku(skuKey), onSuccess: (response) => { message.success( - `人工分类已移除;${response.data.affectedItems} 个订单项已退回待分类,原始订单保留`, + `人工标准商品已归档;${response.data.affectedItems} 个订单项已退回待分类,原始订单保留`, ) void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-manual-skus'] }) void queryClient.invalidateQueries({ @@ -197,17 +167,6 @@ export function ManualSkuConfigPanel() { }, }) - const rematchMutation = useMutation({ - mutationFn: () => rematchAdminManualFulfillments(), - onSuccess: (response) => { - message.success(`重新匹配完成,回填 ${response.data.updatedCount} 个订单项`) - void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-manual-skus'] }) - }, - onError: (error) => { - message.error(error instanceof Error ? error.message : '重新匹配失败') - }, - }) - function openConfig(item: ManualSkuItem) { setEditing(item) const configuredChannels = item.config?.channels?.length @@ -232,16 +191,18 @@ export function ManualSkuConfigPanel() { const columns: TableColumnsType = [ { - title: '内部 SKU(配置主键)', + title: '人工标准商品', dataIndex: 'skuKey', - width: 280, + width: 300, render: (_, row) => (
- - {row.skuKey} - - - {row.displayName || '(无名称)'} + {row.displayName || row.skuKey} + + SKU:{row.skuKey}
), @@ -321,14 +282,14 @@ export function ManualSkuConfigPanel() { {row.configured ? '编辑' : '配置'} deleteMutation.mutate(row.skuKey)} > @@ -347,13 +308,13 @@ export function ManualSkuConfigPanel() { type="info" showIcon style={{ marginBottom: 14 }} - message="人工履约只需在这里配置一次" - description="保存时会自动创建或关联 man: 标准商品、同步统一渠道规则、回填最近 30 天订单,并重试待处理的人工渠道订单。消耗积分 × 平台换算率就是这单的人工成本,不需要再去标准商品或渠道规则页重复配置;消耗积分填 0 表示成本不固定,由客服在确认履约时手填。" + message="人工标准商品与成本配置一一对应" + description="这里直接列出人工客服范围内的 man: 标准商品。点“配置”选择实际履约渠道并填写每单成本即可;保存后会同步渠道规则、回填最近 30 天订单并重试待处理订单。消耗积分填 0 表示成本不固定,由客服确认时手填。" /> - + - { - if (value?.[0] && value?.[1]) setScanRange([value[0], value[1]]) - }} - /> - - { diff --git a/apps/frontend/src/services/admin/cost-center.ts b/apps/frontend/src/services/admin/cost-center.ts index fbd06c08..f0dd3780 100644 --- a/apps/frontend/src/services/admin/cost-center.ts +++ b/apps/frontend/src/services/admin/cost-center.ts @@ -403,6 +403,7 @@ export function deleteAdminManualSku(skuKey: string) { deletedConfigs: number deletedRegistry: number deletedRules: number + archivedProducts: number refreshedDays: number skippedClosedDays: number }>(`/api/v1/admin/cost-center/manual-skus/${encodeURIComponent(skuKey)}`) diff --git a/apps/frontend/tests/manual-sku-config-ui.test.ts b/apps/frontend/tests/manual-sku-config-ui.test.ts new file mode 100644 index 00000000..11157747 --- /dev/null +++ b/apps/frontend/tests/manual-sku-config-ui.test.ts @@ -0,0 +1,17 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import test from 'node:test' + +test('人工成本配置直接展示标准商品,不再要求按日期扫描内部 SKU', async () => { + const source = await readFile( + new URL('../src/pages/admin/cost-center/ManualSkuConfigPanel.tsx', import.meta.url), + 'utf8', + ) + + assert.ok(source.includes('人工标准商品与成本配置一一对应')) + assert.ok(source.includes("title: '人工标准商品'")) + assert.ok(source.includes('SKU:{row.skuKey}')) + assert.ok(!source.includes('扫描内部 SKU')) + assert.ok(!source.includes('DatePicker.RangePicker')) + assert.ok(!source.includes('rematchAdminManualFulfillments')) +}) From 89a68b57347dd3f1c331b0971f9e9737cf26fd85 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 5 Nov 2026 18:17:12 +0800 Subject: [PATCH 03/10] =?UTF-8?q?fix(fulfillment):=20=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E6=B8=A0=E9=81=93=E8=A7=84=E5=88=99=E4=B8=8E=E6=A0=87=E5=87=86?= =?UTF-8?q?=E5=95=86=E5=93=81=E7=BB=91=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...oncile_channel_products_and_work_costs.sql | 139 ++++++++++++++++ .../fulfillment/channel-assign-service.ts | 88 ++++++---- .../fulfillment-routing.integration.test.ts | 150 ++++++++++++++---- 3 files changed, 318 insertions(+), 59 deletions(-) create mode 100644 apps/backend/src/db/migrations/136_reconcile_channel_products_and_work_costs.sql diff --git a/apps/backend/src/db/migrations/136_reconcile_channel_products_and_work_costs.sql b/apps/backend/src/db/migrations/136_reconcile_channel_products_and_work_costs.sql new file mode 100644 index 00000000..c550830f --- /dev/null +++ b/apps/backend/src/db/migrations/136_reconcile_channel_products_and_work_costs.sql @@ -0,0 +1,139 @@ +-- 136_reconcile_channel_products_and_work_costs.sql +-- +-- 渠道商品与成本模型归一: +-- 1. 已启用的第三方卡券规则必须有 active tpv: 标准商品; +-- 2. 接单报酬成本版本不得挂在 man: 人工商品上;无快照引用的历史错误版本退休; +-- 3. 保留有快照引用的历史数据,不删除订单、成本版本或成本快照。 + +-- 卡券规则已有、但标准商品缺失时补建商品。 +INSERT INTO product_catalog ( + canonical_sku, canonical_name, category, unit_name, status, + attributes_json, created_at, updated_at +) +SELECT + 'tpv:' || canonicalize_internal_sku(channel.internal_sku), + BTRIM(channel.internal_sku), + '卡券', + '张', + 'active', + '{"source":"voucher_rule","auto_created":true,"reconciled":true}'::jsonb, + NOW(), + NOW() +FROM internal_sku_channels channel +WHERE channel.channel = 'third_party_voucher' + AND channel.enabled = TRUE + AND canonicalize_internal_sku(channel.internal_sku) <> '' + AND char_length('tpv:' || canonicalize_internal_sku(channel.internal_sku)) <= 64 + AND NOT EXISTS ( + SELECT 1 + FROM product_catalog product + WHERE product.status = 'active' + AND product.canonical_sku LIKE 'tpv:%' + AND canonicalize_internal_sku(substring(product.canonical_sku from 5)) + = canonicalize_internal_sku(channel.internal_sku) + ) + AND NOT EXISTS ( + SELECT 1 + FROM internal_sku_channels other + WHERE other.enabled = TRUE + AND other.is_fallback = FALSE + AND other.channel <> 'third_party_voucher' + AND canonicalize_internal_sku(other.internal_sku) + = canonicalize_internal_sku(channel.internal_sku) + ) +ON CONFLICT (canonical_sku) DO UPDATE SET + status = 'active', + canonical_name = CASE + WHEN BTRIM(product_catalog.canonical_name) = '' THEN EXCLUDED.canonical_name + ELSE product_catalog.canonical_name + END, + updated_at = EXCLUDED.updated_at; + +-- 卡券规则统一挂到 active 标准商品,并补齐商品渠道能力。 +UPDATE internal_sku_channels channel +SET canonical_product_id = product.id, + priority = 10, + is_fallback = FALSE, + config_json = channel.config_json || jsonb_build_object( + 'voucherSku', + canonicalize_internal_sku(channel.internal_sku) + ), + updated_at = NOW() +FROM product_catalog product +WHERE channel.channel = 'third_party_voucher' + AND channel.enabled = TRUE + AND product.status = 'active' + AND product.canonical_sku LIKE 'tpv:%' + AND canonicalize_internal_sku(substring(product.canonical_sku from 5)) + = canonicalize_internal_sku(channel.internal_sku) + AND ( + channel.canonical_product_id IS DISTINCT FROM product.id + OR channel.priority <> 10 + OR channel.is_fallback + ); + +INSERT INTO product_channel_capabilities ( + product_id, channel, priority, is_fallback, enabled, source, created_at, updated_at +) +SELECT + channel.canonical_product_id, + 'third_party_voucher', + 10, + FALSE, + TRUE, + 'voucher', + NOW(), + NOW() +FROM internal_sku_channels channel +JOIN product_catalog product + ON product.id = channel.canonical_product_id + AND product.status = 'active' +WHERE channel.channel = 'third_party_voucher' + AND channel.enabled = TRUE + AND channel.canonical_product_id IS NOT NULL +ON CONFLICT (product_id, channel) DO UPDATE SET + enabled = TRUE, + priority = 10, + is_fallback = FALSE, + updated_at = EXCLUDED.updated_at; + +-- 无法补齐标准商品的启用卡券规则必须停用,否则会继续把订单路由到无商品渠道。 +UPDATE internal_sku_channels channel +SET enabled = FALSE, + is_fallback = FALSE, + note = CASE + WHEN BTRIM(channel.note) = '' THEN '标准商品缺失,迁移 136 自动停用' + ELSE channel.note + END, + updated_at = NOW() +WHERE channel.channel = 'third_party_voucher' + AND channel.enabled = TRUE + AND NOT EXISTS ( + SELECT 1 + FROM product_catalog product + WHERE product.id = channel.canonical_product_id + AND product.status = 'active' + AND product.canonical_sku LIKE 'tpv:%' + ); + +-- 早期错误地把“接单报酬”成本挂到了人工商品。无快照引用时直接退休。 +UPDATE cost_versions version +SET status = 'retired', + change_reason = CASE + WHEN version.change_reason = '' THEN '清理错误挂到人工商品的接单报酬成本版本' + ELSE version.change_reason + END, + updated_at = NOW() +FROM cost_items item, + product_catalog product +WHERE version.cost_item_id = item.id + AND version.canonical_product_id = product.id + AND version.status = 'active' + AND item.cost_model = 'work_order_reward' + AND product.canonical_sku LIKE 'man:%' + AND NOT EXISTS ( + SELECT 1 FROM cost_snapshots snapshot WHERE snapshot.cost_version_id = version.id + ); + +COMMENT ON COLUMN internal_sku_channels.canonical_product_id IS + '渠道规则关联的标准商品;启用规则必须指向 active 标准商品,历史归档仍保留关联'; diff --git a/apps/backend/src/services/fulfillment/channel-assign-service.ts b/apps/backend/src/services/fulfillment/channel-assign-service.ts index 4e08ab3c..2fef575f 100644 --- a/apps/backend/src/services/fulfillment/channel-assign-service.ts +++ b/apps/backend/src/services/fulfillment/channel-assign-service.ts @@ -556,13 +556,13 @@ export async function assignOrderItemsAfterCarrierReady(input: { } else { await query( `UPDATE order_items - SET fulfillment_channel = $3, + SET fulfillment_channel = NULL, fulfillment_status = 'pending', fulfillment_source = 'operator_assigned', fulfillment_locked = TRUE, fulfillment_pending_reason = 'no_carrier', fulfillment_pending_detail = $2::jsonb, - canonical_product_id = COALESCE($4, canonical_product_id), + canonical_product_id = COALESCE($3, canonical_product_id), updated_at = NOW() WHERE id = $1 AND fulfillment_status <> 'archived'`, [ @@ -573,7 +573,6 @@ export async function assignOrderItemsAfterCarrierReady(input: { message: carrier.message, carrierIssue: carrier.reason, }), - input.channel, input.canonicalProductId || null, ], ) @@ -761,6 +760,14 @@ export async function switchChannelForOrderItems(input: { switchInternalSkus.length === 0 ? 'internal_sku_required' : 'multiple_internal_skus', }) } + const targetInternalSku = switchInternalSkus[0]! + const draftProductId = await ensureDraftProductForChannel(targetInternalSku, input.channel) + if (!draftProductId) { + throw createHttpError('渠道标准商品创建或启用失败,请先在商品配置中处理', { + statusCode: 409, + errorCode: 'channel_standard_product_required', + }) + } const items = ( await query(`SELECT * FROM order_items WHERE id = ANY($1::bigint[])`, [ids]) @@ -909,6 +916,7 @@ export async function switchChannelForOrderItems(input: { source: 'operator_assigned', lock: true, note: input.note, + canonicalProductId: draftProductId, }) await query( `UPDATE order_items @@ -917,12 +925,11 @@ export async function switchChannelForOrderItems(input: { [readyIds, input.adminUserId], ) if (assigned.updated > 0) { - const internalSku = switchInternalSkus[0]! - const carrier = await resolveInternalSkuCarrierConfig(internalSku, input.channel) + const carrier = await resolveInternalSkuCarrierConfig(targetInternalSku, input.channel) await setInternalSkuPrimaryChannel({ - internalSku, + internalSku: targetInternalSku, channel: input.channel, - canonicalProductId: carrier.canonicalProductId, + canonicalProductId: draftProductId, configJson: carrier.configJson, source: 'operator', note: input.note, @@ -1580,21 +1587,18 @@ export async function assignChannelByInternalSku(input: { [internalSku, input.channel], ) - const draftProductId = await ensureDraftProductForChannel(internalSku, input.channel).catch( - (error) => { - logWarn('[channel-assign]', '自动创建渠道草稿商品失败', { - internalSku, - channel: input.channel, - error: error instanceof Error ? error.message : String(error), - }) - return null - }, - ) + const draftProductId = await ensureDraftProductForChannel(internalSku, input.channel) + if (!draftProductId) { + throw createHttpError('渠道标准商品创建或启用失败,请先在商品配置中处理', { + statusCode: 409, + errorCode: 'channel_standard_product_required', + }) + } const carrier = await resolveInternalSkuCarrierConfig(internalSku, input.channel) await setInternalSkuPrimaryChannel({ internalSku, channel: input.channel, - canonicalProductId: carrier.canonicalProductId, + canonicalProductId: draftProductId, source: 'operator', configJson: { ...carrier.configJson, @@ -1818,7 +1822,18 @@ async function ensureDraftProductForChannel( $5::jsonb, $6, $6 ) ON CONFLICT (canonical_sku) DO UPDATE - SET updated_at = product_catalog.updated_at + SET status = 'active', + canonical_name = CASE + WHEN BTRIM(product_catalog.canonical_name) = '' THEN EXCLUDED.canonical_name + ELSE product_catalog.canonical_name + END, + attributes_json = ( + product_catalog.attributes_json + - 'deletedAt' + - 'deletedBy' + - 'archivedAt' + ) || jsonb_build_object('reactivatedAt', EXCLUDED.updated_at), + updated_at = EXCLUDED.updated_at RETURNING id, status `, [ @@ -1891,7 +1906,7 @@ async function resolveInternalSkuCarrierConfig( FROM product_catalog WHERE status = 'active' AND (canonical_sku LIKE $2 OR canonical_sku LIKE $3) - AND canonicalize_internal_sku(canonical_name) = $1 + AND canonicalize_internal_sku(substring(canonical_sku from 5)) = $1 ORDER BY id`, [key, `${prefixes[0]}%`, `${prefixes[1] || prefixes[0]}%`], ) @@ -1958,19 +1973,28 @@ export async function upsertChannelRule(input: { }) } - const priority = Math.trunc(Number(input.priority) || 100) + const draftProductId = await ensureDraftProductForChannel(matchKey, input.channel) + if (!draftProductId) { + throw createHttpError('渠道标准商品创建或启用失败,请先在商品配置中处理', { + statusCode: 409, + errorCode: 'channel_standard_product_required', + }) + } + + const priority = + input.channel === 'third_party_voucher' ? 10 : Math.trunc(Number(input.priority) || 100) const carrier = await resolveInternalSkuCarrierConfig(matchKey, input.channel) const id = await upsertInternalSkuChannel({ internalSku: matchKey, channel: input.channel, priority, - isFallback: priority >= 90, + isFallback: input.channel === 'third_party_voucher' ? false : priority >= 90, enabled: input.enabled !== false, source: source === 'operator' ? 'operator' : `rule:${source}`, note: String(input.note || '').slice(0, 300), adminUserId: input.adminUserId, replacePriority: true, - canonicalProductId: carrier.canonicalProductId, + canonicalProductId: draftProductId, configJson: carrier.configJson, }) return { id } @@ -2079,15 +2103,18 @@ export async function ensureVoucherRuleForSku(skuCode: string): Promise LIMIT 1`, [`tpv:${key}`], ) + const productId = Number(product.rows[0]?.id || 0) + if (!productId) return false await upsertInternalSkuChannel({ internalSku: key, channel: 'third_party_voucher', priority: 10, isFallback: false, enabled: true, - canonicalProductId: Number(product.rows[0]?.id || 0) || null, + canonicalProductId: productId, configJson: { voucherSku: key }, source: 'voucher', + replacePriority: true, }) return true } @@ -2110,7 +2137,8 @@ export async function ensureVoucherChannelRules(): Promise<{ FROM product_catalog product WHERE product.status = 'active' AND product.canonical_sku LIKE 'tpv:%' - AND canonicalize_internal_sku(channel.internal_sku) = canonicalize_internal_sku(product.canonical_name) + AND canonicalize_internal_sku(channel.internal_sku) = + canonicalize_internal_sku(substring(product.canonical_sku from 5)) AND channel.channel <> 'third_party_voucher' AND channel.enabled = TRUE AND channel.is_fallback = FALSE`, @@ -2122,17 +2150,21 @@ export async function ensureVoucherChannelRules(): Promise<{ canonical_product_id, config_json, source, created_at, updated_at ) SELECT - canonicalize_internal_sku(p.canonical_name), 'third_party_voucher', 10, FALSE, TRUE, + canonicalize_internal_sku(substring(p.canonical_sku from 5)), + 'third_party_voucher', 10, FALSE, TRUE, p.id, jsonb_build_object('voucherSku', substring(p.canonical_sku from 5)), 'voucher', NOW(), NOW() FROM product_catalog p WHERE p.status = 'active' AND p.canonical_sku LIKE 'tpv:%' - AND BTRIM(COALESCE(p.canonical_name, '')) <> '' + AND canonicalize_internal_sku(substring(p.canonical_sku from 5)) <> '' ON CONFLICT (internal_sku, channel) DO UPDATE SET + priority = 10, + is_fallback = FALSE, enabled = TRUE, - canonical_product_id = COALESCE(EXCLUDED.canonical_product_id, internal_sku_channels.canonical_product_id), + canonical_product_id = EXCLUDED.canonical_product_id, config_json = internal_sku_channels.config_json || EXCLUDED.config_json, + source = EXCLUDED.source, updated_at = NOW() RETURNING id `, diff --git a/apps/backend/src/services/fulfillment/fulfillment-routing.integration.test.ts b/apps/backend/src/services/fulfillment/fulfillment-routing.integration.test.ts index 46131fc6..4048286d 100644 --- a/apps/backend/src/services/fulfillment/fulfillment-routing.integration.test.ts +++ b/apps/backend/src/services/fulfillment/fulfillment-routing.integration.test.ts @@ -54,6 +54,8 @@ test( const retryWorkOrderSku = `集成测试重试接单-${suffix}` const manualRemoveSku = `集成测试移除人工-${suffix}` const protectedRuleSku = `集成测试强制删除-${suffix}` + const missingVoucherSku = `集成测试无商品卡券-${suffix}` + const reactivatedVoucherSku = `集成测试归档卡券-${suffix}` const retryWorkOrderRuleKey = `it-retry-${suffix}` const orderIds: number[] = [] const orderItemIds: number[] = [] @@ -69,8 +71,10 @@ test( retryWorkOrderSku, manualRemoveSku, protectedRuleSku, + missingVoucherSku, + reactivatedVoucherSku, ] - let generatedVoucherProductId: number | null = null + const createdVoucherProductIds: number[] = [] let autoManualProductId: number | null = null let retryWorkOrderRuleId: number | null = null let fallbackProfileId: number | null = null @@ -251,8 +255,6 @@ test( 'third_party_voucher', ) assert.equal(switchedItem.rows[0]?.item_snapshot_json.isConfigured, false) - generatedVoucherProductId = Number(switchedItem.rows[0]?.canonical_product_id || 0) || null - const missingAffiliateItemId = await createPendingOrderItem(missingAffiliateSku) const affiliateFailed = await assignChannelByInternalSku({ internalSku: missingAffiliateSku, @@ -644,13 +646,47 @@ test( ['closed', 'paid'], ) - await ensureVoucherRuleForSku(voucherSku) - const voucherChannel = await query<{ channel: string; source: string }>( - `SELECT channel, source FROM internal_sku_channels + assert.equal(await ensureVoucherRuleForSku(missingVoucherSku), false) + const missingVoucherRules = await query<{ count: number }>( + `SELECT count(*)::int AS count FROM internal_sku_channels + WHERE internal_sku = $1 AND channel = 'third_party_voucher'`, + [missingVoucherSku], + ) + assert.equal(missingVoucherRules.rows[0]?.count, 0) + + const voucherProduct = await query<{ id: number }>( + `INSERT INTO product_catalog ( + canonical_sku, canonical_name, category, unit_name, status, + attributes_json, created_at, updated_at + ) VALUES ($1, $2, '卡券', '张', 'active', '{}'::jsonb, NOW(), NOW()) + RETURNING id`, + [`tpv:${voucherSku}`, `卡券展示名-${suffix}`], + ) + createdVoucherProductIds.push(Number(voucherProduct.rows[0]!.id)) + assert.equal(await ensureVoucherRuleForSku(voucherSku), true) + const voucherChannel = await query<{ + channel: string + source: string + canonical_product_id: string | null + priority: number + is_fallback: boolean + config_json: Record + }>( + `SELECT channel, source, canonical_product_id, priority, is_fallback, config_json + FROM internal_sku_channels WHERE internal_sku = $1 ORDER BY channel`, [voucherSku], ) - assert.deepEqual(voucherChannel.rows, [{ channel: 'third_party_voucher', source: 'voucher' }]) + assert.equal(voucherChannel.rows.length, 1) + assert.equal(voucherChannel.rows[0]?.channel, 'third_party_voucher') + assert.equal(voucherChannel.rows[0]?.source, 'voucher') + assert.equal( + Number(voucherChannel.rows[0]?.canonical_product_id), + createdVoucherProductIds[0], + ) + assert.equal(voucherChannel.rows[0]?.priority, 10) + assert.equal(voucherChannel.rows[0]?.is_fallback, false) + assert.equal(voucherChannel.rows[0]?.config_json.voucherSku, voucherSku) const legacyVoucherRules = await query<{ count: number }>( `SELECT count(*)::int AS count FROM sku_channel_rules WHERE match_key = $1`, @@ -692,6 +728,50 @@ test( const deletedRules = await deleteChannelRules([savedRule.id]) assert.equal(deletedRules.deletedCount, 1) assert.deepEqual(deletedRules.deletedIds, [savedRule.id]) + + const archivedVoucherProduct = await query<{ id: number }>( + `INSERT INTO product_catalog ( + canonical_sku, canonical_name, category, unit_name, status, + attributes_json, created_at, updated_at + ) VALUES ( + $1, $2, '卡券', '张', 'archived', + '{"deletedAt":"2026-01-01T00:00:00.000Z","deletedBy":"integration_test"}'::jsonb, + NOW(), NOW() + ) + RETURNING id`, + [`tpv:${reactivatedVoucherSku}`, reactivatedVoucherSku], + ) + const archivedVoucherProductId = Number(archivedVoucherProduct.rows[0]!.id) + createdVoucherProductIds.push(archivedVoucherProductId) + const reactivatedRule = await upsertChannelRule({ + matchKeyType: 'internal_sku', + matchKey: reactivatedVoucherSku, + channel: 'third_party_voucher', + priority: 80, + enabled: true, + note: '集成测试重新激活归档商品', + adminUserId: null, + }) + const reactivated = await query<{ + status: string + attributes_json: Record + canonical_product_id: string | null + priority: number + is_fallback: boolean + }>( + `SELECT product.status, product.attributes_json, + channel.canonical_product_id, channel.priority, channel.is_fallback + FROM internal_sku_channels channel + JOIN product_catalog product ON product.id = channel.canonical_product_id + WHERE channel.id = $1`, + [reactivatedRule.id], + ) + assert.equal(reactivated.rows[0]?.status, 'active') + assert.equal(Number(reactivated.rows[0]?.canonical_product_id), archivedVoucherProductId) + assert.equal(reactivated.rows[0]?.priority, 10) + assert.equal(reactivated.rows[0]?.is_fallback, false) + assert.equal(reactivated.rows[0]?.attributes_json.deletedAt, undefined) + assert.equal(typeof reactivated.rows[0]?.attributes_json.reactivatedAt, 'string') } finally { if (orderItemIds.length > 0) { await query(`DELETE FROM cost_exception_items WHERE order_item_id = ANY($1::bigint[])`, [ @@ -731,32 +811,40 @@ test( WHERE provider = '91kaquan' AND shop_id = $1 AND match_key = ANY($2::text[])`, [shopId, internalSkus], ) - if (generatedVoucherProductId) { - await query(`DELETE FROM sales_daily_facts WHERE canonical_product_id = $1`, [ - generatedVoucherProductId, + const generatedProducts = await query<{ id: string }>( + `SELECT id + FROM product_catalog + WHERE canonicalize_internal_sku(substring(canonical_sku from 5)) = ANY($1::text[])`, + [internalSkus], + ) + const generatedProductIds = generatedProducts.rows.map((row) => Number(row.id)) + if (generatedProductIds.length > 0) { + await query( + `DELETE FROM cost_snapshots + WHERE canonical_product_id = ANY($1::bigint[]) + OR cost_version_id IN ( + SELECT id FROM cost_versions WHERE canonical_product_id = ANY($1::bigint[]) + )`, + [generatedProductIds], + ) + await query( + `DELETE FROM sales_daily_facts WHERE canonical_product_id = ANY($1::bigint[])`, + [generatedProductIds], + ) + await query( + `DELETE FROM channel_product_mappings WHERE canonical_product_id = ANY($1::bigint[])`, + [generatedProductIds], + ) + await query(`DELETE FROM cost_versions WHERE canonical_product_id = ANY($1::bigint[])`, [ + generatedProductIds, ]) - await query(`DELETE FROM cost_snapshots WHERE canonical_product_id = $1`, [ - generatedVoucherProductId, + await query( + `DELETE FROM product_channel_capabilities WHERE product_id = ANY($1::bigint[])`, + [generatedProductIds], + ) + await query(`DELETE FROM product_catalog WHERE id = ANY($1::bigint[])`, [ + generatedProductIds, ]) - await query(`DELETE FROM channel_product_mappings WHERE canonical_product_id = $1`, [ - generatedVoucherProductId, - ]) - await query(`DELETE FROM cost_versions WHERE canonical_product_id = $1`, [ - generatedVoucherProductId, - ]) - await query(`DELETE FROM product_catalog WHERE id = $1 AND canonical_sku = $2`, [ - generatedVoucherProductId, - `tpv:${manualSku}`, - ]) - } - if (autoManualProductId) { - await query(`DELETE FROM sales_daily_facts WHERE canonical_product_id = $1`, [ - autoManualProductId, - ]) - await query(`DELETE FROM cost_versions WHERE canonical_product_id = $1`, [ - autoManualProductId, - ]) - await query(`DELETE FROM product_catalog WHERE id = $1`, [autoManualProductId]) } if (retryWorkOrderRuleId) { await query(`DELETE FROM work_product_match_logs WHERE rule_id = $1`, [ From e8b76b3b73df6556fd528864971f28e2fed7f1a9 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 5 Nov 2026 18:36:10 +0800 Subject: [PATCH 04/10] =?UTF-8?q?fix(fulfillment):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E8=BD=BD=E4=BD=93=E9=87=8D=E8=AF=95=E5=B9=B6=E5=AE=8C=E5=96=84?= =?UTF-8?q?=E6=A0=B8=E9=94=80=E4=BB=BB=E5=8A=A1=E5=88=97=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/backend/src/repositories/task-repo.ts | 12 + .../src/routes/admin/fulfillment-channel.ts | 31 +++ .../src/services/admin/admin-read-service.ts | 1 + .../fulfillment/carrier-creation-service.ts | 74 ++++-- .../carrier-reconciliation-service.ts | 49 +++- .../fulfillment/channel-assign-service.ts | 50 +++- .../fulfillment-retry-guard.test.ts | 43 ++++ .../fulfillment-routing.integration.test.ts | 207 +++++++++++++++ .../voucher-consume-job-list.test.ts | 24 ++ .../voucher-consume-queue-service.ts | 47 +++- apps/backend/src/types/admin/read-inputs.ts | 1 + apps/backend/src/types/repository/inputs.ts | 1 + .../admin/AdminFulfillmentChannelPage.tsx | 242 +++++++++++++----- .../src/pages/admin/AdminTasksPage.tsx | 36 ++- .../panels/VoucherConsumePolicyPanel.tsx | 152 ++++++++++- .../src/services/admin/fulfillment-channel.ts | 19 +- .../tests/admin-task-current-view.test.ts | 12 + .../tests/fulfillment-pending-actions.test.ts | 25 ++ .../tests/voucher-consume-job-list-ui.test.ts | 23 ++ 19 files changed, 945 insertions(+), 104 deletions(-) create mode 100644 apps/backend/src/services/fulfillment/fulfillment-retry-guard.test.ts create mode 100644 apps/backend/src/services/fulfillment/voucher-consume-job-list.test.ts create mode 100644 apps/frontend/tests/admin-task-current-view.test.ts create mode 100644 apps/frontend/tests/fulfillment-pending-actions.test.ts create mode 100644 apps/frontend/tests/voucher-consume-job-list-ui.test.ts diff --git a/apps/backend/src/repositories/task-repo.ts b/apps/backend/src/repositories/task-repo.ts index 66bd067c..cd29492c 100644 --- a/apps/backend/src/repositories/task-repo.ts +++ b/apps/backend/src/repositories/task-repo.ts @@ -492,10 +492,22 @@ export function buildTaskListFilters({ roleId = '', dateFrom = '', dateTo = '', + currentOnly = false, }: TaskListQueryInput = {}): { whereClause: string; params: unknown[] } { const filters: string[] = [] const params: unknown[] = [] + if (currentOnly) { + filters.push(`ft.id = ( + SELECT latest.id + FROM fulfillment_tasks latest + WHERE latest.order_item_id = ft.order_item_id + AND latest.unit_index = ft.unit_index + ORDER BY latest.id DESC + LIMIT 1 + )`) + } + if (keyword) { const trimmedKeyword = String(keyword).trim() const isOrderNoLookup = isPlatformOrderIdLookupKeyword(trimmedKeyword) diff --git a/apps/backend/src/routes/admin/fulfillment-channel.ts b/apps/backend/src/routes/admin/fulfillment-channel.ts index 55f0f25f..1808aa46 100644 --- a/apps/backend/src/routes/admin/fulfillment-channel.ts +++ b/apps/backend/src/routes/admin/fulfillment-channel.ts @@ -20,6 +20,7 @@ import { retryPendingCarrierGroup, switchChannelForOrderItems, unlockChannel, + unlockPendingChannelGroup, upsertChannelRule, } from '../../services/fulfillment/channel-assign-service.js' import { @@ -86,6 +87,9 @@ router.get( createJsonHandler( (req) => listVoucherConsumeJobs({ + keyword: String(req.query.keyword || ''), + provider: String(req.query.provider || ''), + channel: String(req.query.channel || ''), status: String(req.query.status || ''), page: Number(req.query.page), pageSize: Number(req.query.pageSize), @@ -200,6 +204,8 @@ router.post( (req) => retryPendingCarrierGroup({ internalSku: String(bodyOf(req).internalSku || ''), + retryInactiveCarrier: bodyOf(req).retryInactiveCarrier === true, + includeLocked: bodyOf(req).includeLocked === true, }), { successMessage: '履约载体重试完成', @@ -215,6 +221,31 @@ router.post( ), ) +router.post( + '/fulfillment/pending/unlock', + requireAdminPermission('finance.config'), + createJsonHandler( + (req) => { + const body = bodyOf(req) + return unlockPendingChannelGroup({ + groupKey: String(body.groupKey || ''), + internalSku: String(body.internalSku || ''), + }) + }, + { + successMessage: '运营锁定已解除', + errorMessage: '解除运营锁定失败', + scope: '[admin/fulfillment/pending/unlock]', + audit: (req, data) => ({ + action: 'fulfillment.unlock_pending_group', + targetType: 'pending_group', + targetId: String(bodyOf(req).groupKey || bodyOf(req).internalSku || ''), + data: data as Record, + }), + }, + ), +) + router.get( '/fulfillment/internal-sku-mappings', requireAdminPermission('finance.view'), diff --git a/apps/backend/src/services/admin/admin-read-service.ts b/apps/backend/src/services/admin/admin-read-service.ts index cadd4e45..f5418ebe 100644 --- a/apps/backend/src/services/admin/admin-read-service.ts +++ b/apps/backend/src/services/admin/admin-read-service.ts @@ -348,6 +348,7 @@ export async function getAdminTasks( roleId: String(query.roleId || '').trim(), dateFrom: normalizeDateQuery(query.dateFrom), dateTo: normalizeDateQuery(query.dateTo, true), + currentOnly: String(query.currentOnly ?? 'true') !== 'false', }) const viewerContext = createAdminViewerContext(session) diff --git a/apps/backend/src/services/fulfillment/carrier-creation-service.ts b/apps/backend/src/services/fulfillment/carrier-creation-service.ts index 5b013571..0b82679d 100644 --- a/apps/backend/src/services/fulfillment/carrier-creation-service.ts +++ b/apps/backend/src/services/fulfillment/carrier-creation-service.ts @@ -54,7 +54,11 @@ async function hasAffiliateTask(orderItemId: number): Promise { return result.rows[0]?.exists === true } -const BLOCKED_INACTIVE_CARRIER_STATUSES = new Set(['closed', 'cancelled', 'manual_review']) +const BLOCKED_WORK_ORDER_STATUSES = new Set(['closed', 'cancelled', 'manual_review']) +const HARD_BLOCKED_INACTIVE_REASONS = new Set([ + 'carrier_task_user_refund', + 'carrier_task_cancelled', +]) async function findBlockingInactiveCarrier(input: { orderItemId: number @@ -64,16 +68,16 @@ async function findBlockingInactiveCarrier(input: { const result = input.channel === 'affiliate_dash' - ? await query<{ status: string }>( - `SELECT task_status AS status + ? await query<{ status: string; result_code: string }>( + `SELECT task_status AS status, result_code FROM fulfillment_tasks WHERE order_item_id = $1 AND executor_key = 'affiliate_dash' ORDER BY id DESC LIMIT 1`, [input.orderItemId], ) - : await query<{ status: string }>( - `SELECT status + : await query<{ status: string; result_code: string }>( + `SELECT status, '' AS result_code FROM work_orders WHERE order_item_id = $1 ORDER BY id DESC @@ -82,7 +86,14 @@ async function findBlockingInactiveCarrier(input: { ) const status = String(result.rows[0]?.status || '') - if (!BLOCKED_INACTIVE_CARRIER_STATUSES.has(status)) return null + const resultCode = String(result.rows[0]?.result_code || '') + const shouldBlock = + input.channel === 'affiliate_dash' + ? INACTIVE_CARRIER_TASK_STATUSES.includes( + status as (typeof INACTIVE_CARRIER_TASK_STATUSES)[number], + ) + : BLOCKED_WORK_ORDER_STATUSES.has(status) + if (!shouldBlock) return null if (status === 'manual_review') { return { @@ -91,6 +102,34 @@ async function findBlockingInactiveCarrier(input: { message: '已有履约载体进入人工复核,需先处理后再重试', } } + if (status === 'retry_pending' || status === 'failed') { + return { + status, + reason: 'carrier_task_retry_pending', + message: '已有履约任务失败待重试,需由运营确认后重新创建', + } + } + if (status === 'expired') { + return { + status, + reason: 'carrier_task_expired', + message: '已有履约任务已过期,需由运营确认后重新创建', + } + } + if (resultCode === 'USER_APPLY_REFUND') { + return { + status, + reason: 'carrier_task_user_refund', + message: '用户已申请退款,不能自动重新发货', + } + } + if (resultCode === 'affiliate_dash_cancelled') { + return { + status, + reason: 'carrier_task_cancelled', + message: '上游履约订单已取消,不能自动重新发货', + } + } return { status, reason: 'carrier_task_closed', @@ -101,6 +140,14 @@ async function findBlockingInactiveCarrier(input: { } } +function shouldBlockInactiveCarrier( + inactiveCarrier: { reason: string } | null, + retryInactiveCarrier: boolean, +): inactiveCarrier is { status: string; reason: string; message: string } { + if (!inactiveCarrier) return false + return !retryInactiveCarrier || HARD_BLOCKED_INACTIVE_REASONS.has(inactiveCarrier.reason) +} + function toResolvedItem(item: OrderItemRow): ResolvedFulfillmentItem { return { ...item, @@ -127,15 +174,8 @@ export async function ensureCarrierForOrderItem(input: { item: OrderItemRow channel: FulfillmentChannel retryInactiveCarrier?: boolean - allowInactiveTaskRetry?: boolean }): Promise { - const { - order, - item, - channel, - retryInactiveCarrier = false, - allowInactiveTaskRetry = false, - } = input + const { order, item, channel, retryInactiveCarrier = false } = input const existingCarrier = findConflictingCarrier( await loadActiveCarrierEvidenceForItem(Number(item.id)), channel, @@ -194,7 +234,7 @@ export async function ensureCarrierForOrderItem(input: { orderItemId: Number(item.id), channel, }) - if (inactiveCarrier && !retryInactiveCarrier) { + if (shouldBlockInactiveCarrier(inactiveCarrier, retryInactiveCarrier)) { return { ready: false, created: false, @@ -274,7 +314,7 @@ export async function ensureCarrierForOrderItem(input: { orderItemId: Number(item.id), channel, }) - if (inactiveCarrier && !retryInactiveCarrier) { + if (shouldBlockInactiveCarrier(inactiveCarrier, retryInactiveCarrier)) { return { ready: false, created: false, @@ -326,7 +366,7 @@ export async function ensureCarrierForOrderItem(input: { item_snapshot_json: snapshot, } as OrderItemRow, ], - { allowInactiveTaskRetry: retryInactiveCarrier || allowInactiveTaskRetry }, + { allowInactiveTaskRetry: retryInactiveCarrier }, ) } const ready = await hasAffiliateTask(Number(item.id)) diff --git a/apps/backend/src/services/fulfillment/carrier-reconciliation-service.ts b/apps/backend/src/services/fulfillment/carrier-reconciliation-service.ts index 1fcc4782..06cf2f44 100644 --- a/apps/backend/src/services/fulfillment/carrier-reconciliation-service.ts +++ b/apps/backend/src/services/fulfillment/carrier-reconciliation-service.ts @@ -76,7 +76,14 @@ async function loadCandidateItems(input: { SELECT 1 FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash' AND ft.task_status NOT IN (${INACTIVE_CARRIER_TASK_STATUSES.map((status) => `'${status}'`).join(', ')}) - )` + ) + AND COALESCE(( + SELECT ft.result_code + FROM fulfillment_tasks ft + WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash' + ORDER BY ft.id DESC + LIMIT 1 + ), '') NOT IN ('USER_APPLY_REFUND', 'affiliate_dash_cancelled')` : `oi.fulfillment_status = 'pending' ${input.includeLocked ? '' : 'AND oi.fulfillment_locked = FALSE'}` @@ -150,6 +157,38 @@ async function markPendingWithoutCarrier(input: { ) } +async function markAssignedMissingCarrierPending(input: { + itemId: number + previousChannel: string + internalSku: string + attempts: Array<{ channel: FulfillmentChannel; reason: string; message: string }> +}): Promise { + const carrierIssue = input.attempts[0]?.reason || 'carrier_attempt_failed' + await query( + ` + UPDATE order_items + SET fulfillment_channel = NULL, + fulfillment_status = 'pending', + fulfillment_source = 'auto_mapping', + fulfillment_locked = FALSE, + fulfillment_pending_reason = 'no_carrier', + fulfillment_pending_detail = $2::jsonb, + updated_at = NOW() + WHERE id = $1 AND fulfillment_status = 'assigned' + `, + [ + input.itemId, + JSON.stringify({ + internalSku: input.internalSku, + previousChannel: input.previousChannel, + carrierIssue, + carrierMessage: input.attempts[0]?.message || '', + attempts: input.attempts, + }), + ], + ) +} + async function selectSafeFallback(input: { orderItemId: number channel: FulfillmentChannel @@ -299,7 +338,6 @@ export async function reconcilePendingCarriers( item, channel: attempt.channel, retryInactiveCarrier: input.retryInactiveCarrier === true, - allowInactiveTaskRetry: true, }) if (!carrier.ready) { failures.push({ @@ -361,6 +399,13 @@ export async function reconcilePendingCarriers( internalSku: candidate.internal_sku, attempts: failures, }) + } else { + await markAssignedMissingCarrierPending({ + itemId: Number(item.id), + previousChannel: assignedChannel, + internalSku: candidate.internal_sku, + attempts: failures, + }) } } } diff --git a/apps/backend/src/services/fulfillment/channel-assign-service.ts b/apps/backend/src/services/fulfillment/channel-assign-service.ts index 2fef575f..e781feed 100644 --- a/apps/backend/src/services/fulfillment/channel-assign-service.ts +++ b/apps/backend/src/services/fulfillment/channel-assign-service.ts @@ -511,7 +511,6 @@ export async function assignOrderItemsAfterCarrierReady(input: { item, channel: input.channel, retryInactiveCarrier: true, - allowInactiveTaskRetry: true, }) } catch (error) { carrier = { @@ -1216,9 +1215,16 @@ export type RetryPendingCarrierGroupResult = { remaining: number } -/** 运营确认后重试整组载体:允许重开已关闭的历史尝试,并包含运营锁定的订单项。 */ +/** + * 运营确认后重试整组载体。 + * + * 默认只处理未锁定且没有历史终态任务的订单;重开旧任务必须显式传入 + * retryInactiveCarrier。锁定项需要先解除锁定,避免一次操作误伤整组。 + */ export async function retryPendingCarrierGroup(input: { internalSku: string + retryInactiveCarrier?: boolean + includeLocked?: boolean }): Promise { const internalSku = canonicalizeInternalSku(input.internalSku) if (!internalSku || !containsCjk(internalSku)) { @@ -1247,8 +1253,8 @@ export async function retryPendingCarrierGroup(input: { const carrier = await reconcilePendingCarriers({ orderItemIds: batchIds, limit: batchIds.length, - includeLocked: true, - retryInactiveCarrier: true, + includeLocked: input.includeLocked === true, + retryInactiveCarrier: input.retryInactiveCarrier === true, }) workOrdersCreated += carrier.workOrdersCreated affiliateTasksCreated += carrier.affiliateTasksCreated @@ -1258,7 +1264,7 @@ export async function retryPendingCarrierGroup(input: { batchSize: batchIds.length, maxBatches: 1, afterId: 0, - includeLocked: true, + includeLocked: input.includeLocked === true, }) assignedItemIds.push(...applied.assignedItemIds) } @@ -1303,6 +1309,40 @@ export async function retryPendingCarrierGroup(input: { } } +/** 解除一个待分类分组中的运营锁定,让自动路由或后续修复重新接管。 */ +export async function unlockPendingChannelGroup(input: { + groupKey?: string + internalSku?: string +}): Promise<{ updated: number }> { + const scope = resolvePendingGroupScopeInput(input) + if (!scope) { + throw createHttpError('缺少待分类队列分组标识', { + statusCode: 400, + errorCode: 'pending_group_key_required', + }) + } + + const params: unknown[] = [] + const where = ["oi.fulfillment_status = 'pending'", 'oi.fulfillment_locked = TRUE'] + if (scope.type === 'internal_sku') { + params.push(scope.internalSku) + where.push(`${INTERNAL_SKU_SQL} = $${params.length}`) + } else { + params.push(scope.provider, scope.shopId, scope.skuCode) + where.push(`o.provider = $1 AND o.shop_id = $2 AND oi.sku_code = $3`) + } + const result = await query( + `UPDATE order_items oi + SET fulfillment_locked = FALSE, + updated_at = NOW() + FROM orders o + WHERE o.id = oi.order_id + AND ${where.join(' AND ')}`, + params, + ) + return { updated: result.rowCount || 0 } +} + export type DeletePendingChannelGroupResult = { archivedItems: number preservedOrders: number diff --git a/apps/backend/src/services/fulfillment/fulfillment-retry-guard.test.ts b/apps/backend/src/services/fulfillment/fulfillment-retry-guard.test.ts new file mode 100644 index 00000000..599807ec --- /dev/null +++ b/apps/backend/src/services/fulfillment/fulfillment-retry-guard.test.ts @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +const carrierCreation = readFileSync( + new URL('./carrier-creation-service.ts', import.meta.url), + 'utf8', +) +const carrierReconciliation = readFileSync( + new URL('./carrier-reconciliation-service.ts', import.meta.url), + 'utf8', +) +const channelAssign = readFileSync(new URL('./channel-assign-service.ts', import.meta.url), 'utf8') +const taskRepo = readFileSync(new URL('../../repositories/task-repo.ts', import.meta.url), 'utf8') + +test('自动发货历史非活跃任务默认阻止后台自动重开', () => { + assert.ok(carrierCreation.includes('INACTIVE_CARRIER_TASK_STATUSES.includes')) + assert.ok(carrierCreation.includes("reason: 'carrier_task_retry_pending'")) + assert.ok(carrierCreation.includes('HARD_BLOCKED_INACTIVE_REASONS')) + assert.ok(carrierCreation.includes("'carrier_task_user_refund'")) + assert.ok(!carrierReconciliation.includes('allowInactiveTaskRetry: true')) +}) + +test('已分配但载体失效的订单会退回 pending/no_carrier', () => { + assert.ok(carrierReconciliation.includes('markAssignedMissingCarrierPending')) + assert.ok(carrierReconciliation.includes("fulfillment_status = 'pending'")) + assert.ok(carrierReconciliation.includes("fulfillment_pending_reason = 'no_carrier'")) + assert.ok(carrierReconciliation.includes("'USER_APPLY_REFUND', 'affiliate_dash_cancelled'")) +}) + +test('整组补载体默认不包含锁定项,也不强制重开历史任务', () => { + assert.ok(channelAssign.includes('retryInactiveCarrier?: boolean')) + assert.ok(channelAssign.includes('includeLocked?: boolean')) + assert.ok(channelAssign.includes('retryInactiveCarrier: input.retryInactiveCarrier === true')) + assert.ok(channelAssign.includes('includeLocked: input.includeLocked === true')) + assert.ok(channelAssign.includes('unlockPendingChannelGroup')) +}) + +test('任务列表默认只展示每个订单项单元的最新尝试', () => { + assert.ok(taskRepo.includes('currentOnly = false')) + assert.ok(taskRepo.includes('SELECT latest.id')) + assert.ok(taskRepo.includes('latest.unit_index = ft.unit_index')) +}) diff --git a/apps/backend/src/services/fulfillment/fulfillment-routing.integration.test.ts b/apps/backend/src/services/fulfillment/fulfillment-routing.integration.test.ts index 4048286d..f36e9765 100644 --- a/apps/backend/src/services/fulfillment/fulfillment-routing.integration.test.ts +++ b/apps/backend/src/services/fulfillment/fulfillment-routing.integration.test.ts @@ -29,11 +29,13 @@ test( listPendingChannelGroups, retryPendingCarrierGroup, switchChannelForOrderItems, + unlockPendingChannelGroup, upsertChannelRule, } = await import('./channel-assign-service.js') const { deleteManualSkuClassification, saveManualFulfillmentConfig } = await import('../cost-center/manual-fulfillment-config-service.js') const { runAffiliateFallback } = await import('./affiliate-fallback-service.js') + const { reconcilePendingCarriers } = await import('./carrier-reconciliation-service.js') const { retryPendingWorkOrderRouting } = await import('./pending-channel-repair-service.js') const { deleteInternalSkuMapping, @@ -43,6 +45,8 @@ test( } = await import('./internal-sku-mapping-service.js') const { resolveOrderItemForFulfillment } = await import('./product-resolution-service.js') const { updateOrderItemSourceSnapshot } = await import('../../repositories/order-item-repo.js') + const { listTasks } = await import('../../repositories/task-repo.js') + const { listVoucherConsumeJobs } = await import('./voucher-consume-queue-service.js') const suffix = `${Date.now()}_${Math.floor(Math.random() * 1_000_000)}` const shopId = `it-routing-${suffix}` const manualSku = `集成测试人工-${suffix}` @@ -56,6 +60,7 @@ test( const protectedRuleSku = `集成测试强制删除-${suffix}` const missingVoucherSku = `集成测试无商品卡券-${suffix}` const reactivatedVoucherSku = `集成测试归档卡券-${suffix}` + const retryPendingAffiliateSku = `集成测试失败待重试-${suffix}` const retryWorkOrderRuleKey = `it-retry-${suffix}` const orderIds: number[] = [] const orderItemIds: number[] = [] @@ -73,6 +78,7 @@ test( protectedRuleSku, missingVoucherSku, reactivatedVoucherSku, + retryPendingAffiliateSku, ] const createdVoucherProductIds: number[] = [] let autoManualProductId: number | null = null @@ -383,6 +389,7 @@ test( ) const retriedCarrier = await retryPendingCarrierGroup({ internalSku: retryWorkOrderSku, + retryInactiveCarrier: true, }) assert.equal(retriedCarrier.scanned, 1) assert.equal(retriedCarrier.assigned, 1) @@ -645,6 +652,21 @@ test( retryTaskRows.rows.map((row) => row.task_status), ['closed', 'paid'], ) + const currentTasksOnly = await listTasks({ + platformOrderId: retryTaskOrderRow.platform_order_id, + currentOnly: true, + page: 1, + pageSize: 20, + }) + assert.equal(currentTasksOnly.total, 1) + assert.equal(currentTasksOnly.items[0]?.task_status, 'paid') + const allTaskAttempts = await listTasks({ + platformOrderId: retryTaskOrderRow.platform_order_id, + currentOnly: false, + page: 1, + pageSize: 20, + }) + assert.equal(allTaskAttempts.total, 2) assert.equal(await ensureVoucherRuleForSku(missingVoucherSku), false) const missingVoucherRules = await query<{ count: number }>( @@ -772,6 +794,188 @@ test( assert.equal(reactivated.rows[0]?.is_fallback, false) assert.equal(reactivated.rows[0]?.attributes_json.deletedAt, undefined) assert.equal(typeof reactivated.rows[0]?.attributes_json.reactivatedAt, 'string') + + const retryPendingItemId = await createPendingOrderItem(retryPendingAffiliateSku) + await query( + `INSERT INTO internal_sku_channels ( + internal_sku, channel, priority, is_fallback, enabled, config_json, source + ) VALUES ( + $1, 'affiliate_dash', 10, FALSE, TRUE, + jsonb_build_object('affiliateSku', $1::text), 'integration_test' + )`, + [retryPendingAffiliateSku], + ) + await query( + `UPDATE order_items + SET fulfillment_status = 'assigned', + fulfillment_channel = 'affiliate_dash', + fulfillment_source = 'carrier_evidence', + updated_at = NOW() + WHERE id = $1`, + [retryPendingItemId], + ) + await query( + `INSERT INTO fulfillment_tasks ( + order_id, order_item_id, unit_index, task_no, provider, platform, + shop_id, shop_name, platform_order_id, profile_id, executor_key, + task_status, created_at, updated_at + ) + SELECT oi.order_id, oi.id, 1, $2, o.provider, o.platform, + o.shop_id, o.shop_name, o.platform_order_id, profile.id, 'affiliate_dash', + 'retry_pending', NOW(), NOW() + FROM order_items oi + JOIN orders o ON o.id = oi.order_id + JOIN LATERAL ( + SELECT id FROM fulfillment_profiles + WHERE profile_key = 'affiliate_dash' AND executor_key = 'affiliate_dash' + ORDER BY id LIMIT 1 + ) profile ON TRUE + WHERE oi.id = $1`, + [retryPendingItemId, `IT-RETRY-PENDING-${suffix}`], + ) + const blockedRetry = await reconcilePendingCarriers({ + orderItemIds: [retryPendingItemId], + includeAssignedMissingCarrier: true, + limit: 1, + }) + assert.equal(blockedRetry.affiliateTasksCreated, 0) + const retryPendingState = await query<{ + fulfillment_status: string + fulfillment_channel: string | null + fulfillment_pending_reason: string | null + fulfillment_pending_detail: Record + task_count: number + }>( + `SELECT + oi.fulfillment_status, + oi.fulfillment_channel, + oi.fulfillment_pending_reason, + oi.fulfillment_pending_detail, + (SELECT count(*)::int FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id) AS task_count + FROM order_items oi + WHERE oi.id = $1`, + [retryPendingItemId], + ) + assert.equal(retryPendingState.rows[0]?.fulfillment_status, 'pending') + assert.equal(retryPendingState.rows[0]?.fulfillment_channel, null) + assert.equal(retryPendingState.rows[0]?.fulfillment_pending_reason, 'no_carrier') + assert.equal( + retryPendingState.rows[0]?.fulfillment_pending_detail.carrierIssue, + 'carrier_task_retry_pending', + ) + assert.equal(retryPendingState.rows[0]?.task_count, 1) + await query( + `UPDATE order_items SET fulfillment_locked = TRUE, updated_at = NOW() WHERE id = $1`, + [retryPendingItemId], + ) + const unlockedPendingGroup = await unlockPendingChannelGroup({ + internalSku: retryPendingAffiliateSku, + }) + assert.equal(unlockedPendingGroup.updated, 1) + const unlockedPendingItem = await query<{ fulfillment_locked: boolean }>( + `SELECT fulfillment_locked FROM order_items WHERE id = $1`, + [retryPendingItemId], + ) + assert.equal(unlockedPendingItem.rows[0]?.fulfillment_locked, false) + + await query( + `UPDATE fulfillment_tasks + SET task_status = 'closed', + result_code = 'USER_APPLY_REFUND', + updated_at = NOW() + WHERE order_item_id = $1 AND executor_key = 'affiliate_dash'`, + [retryPendingItemId], + ) + await query( + `UPDATE order_items + SET fulfillment_status = 'pending', + fulfillment_channel = NULL, + fulfillment_source = 'auto_mapping', + fulfillment_pending_reason = NULL, + fulfillment_pending_detail = NULL, + updated_at = NOW() + WHERE id = $1`, + [retryPendingItemId], + ) + const protectedRetry = await retryPendingCarrierGroup({ + internalSku: retryPendingAffiliateSku, + retryInactiveCarrier: true, + }) + assert.equal(protectedRetry.affiliateTasksCreated, 0) + const protectedRetryState = await query<{ + fulfillment_status: string + fulfillment_pending_detail: Record + task_count: number + }>( + `SELECT + oi.fulfillment_status, + oi.fulfillment_pending_detail, + (SELECT count(*)::int FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id) AS task_count + FROM order_items oi + WHERE oi.id = $1`, + [retryPendingItemId], + ) + assert.equal(protectedRetryState.rows[0]?.fulfillment_status, 'pending') + assert.equal( + protectedRetryState.rows[0]?.fulfillment_pending_detail.carrierIssue, + 'carrier_task_user_refund', + ) + assert.equal(protectedRetryState.rows[0]?.task_count, 1) + + const voucherJobPrefix = `IT-VOUCHER-LIST-${suffix}` + const voucherJobs = await query<{ id: number }>( + `INSERT INTO voucher_consume_jobs ( + provider, channel, trigger_event, target_key, platform_order_id, + due_at, status, attempt_count, max_attempts, updated_at + ) VALUES + ('kuaishou_industry', 'affiliate_dash', 'order_received', $1, $2, NOW(), 'succeeded', 1, 8, NOW()), + ('kuaishou_industry', 'manual', 'order_received', $3, $4, NOW(), 'failed', 2, 8, NOW()), + ('douyin_mashida', 'manual', 'order_received', $5, $6, NOW(), 'failed', 1, 8, NOW()) + RETURNING id`, + [ + `${voucherJobPrefix}-KS-AFF`, + `${voucherJobPrefix}-KS-AFF-ORDER`, + `${voucherJobPrefix}-KS-MANUAL`, + `${voucherJobPrefix}-KS-MANUAL-ORDER`, + `${voucherJobPrefix}-DY-MANUAL`, + `${voucherJobPrefix}-DY-MANUAL-ORDER`, + ], + ) + const firstVoucherJobPage = await listVoucherConsumeJobs({ + keyword: voucherJobPrefix, + page: 1, + pageSize: 2, + }) + assert.equal(firstVoucherJobPage.total, 3) + assert.equal(firstVoucherJobPage.items.length, 2) + assert.equal(firstVoucherJobPage.page, 1) + assert.equal(firstVoucherJobPage.pageSize, 2) + const secondVoucherJobPage = await listVoucherConsumeJobs({ + keyword: voucherJobPrefix, + page: 2, + pageSize: 2, + }) + assert.equal(secondVoucherJobPage.total, 3) + assert.equal(secondVoucherJobPage.items.length, 1) + const filteredVoucherJobs = await listVoucherConsumeJobs({ + keyword: voucherJobPrefix, + provider: 'kuaishou_industry', + channel: 'manual', + status: 'failed', + page: 1, + pageSize: 20, + }) + assert.equal(filteredVoucherJobs.total, 1) + assert.equal(filteredVoucherJobs.items[0]?.provider, 'kuaishou_industry') + assert.equal(filteredVoucherJobs.items[0]?.channel, 'manual') + assert.equal(filteredVoucherJobs.items[0]?.status, 'failed') + const searchedVoucherJob = await listVoucherConsumeJobs({ + keyword: String(voucherJobs.rows[1]?.id || ''), + status: 'failed', + page: 1, + pageSize: 20, + }) + assert.ok(searchedVoucherJob.items.some((job) => job.id === voucherJobs.rows[1]?.id)) } finally { if (orderItemIds.length > 0) { await query(`DELETE FROM cost_exception_items WHERE order_item_id = ANY($1::bigint[])`, [ @@ -795,6 +999,9 @@ test( await query(`DELETE FROM orders WHERE id = ANY($1::bigint[])`, [orderIds]) } await query(`DELETE FROM cost_exceptions WHERE scope_key LIKE $1`, [`%${suffix}%`]) + await query(`DELETE FROM voucher_consume_jobs WHERE platform_order_id LIKE $1`, [ + `IT-VOUCHER-LIST-${suffix}%`, + ]) await query(`DELETE FROM manual_fulfillment_configs WHERE sku_key = ANY($1::text[])`, [ internalSkus, ]) diff --git a/apps/backend/src/services/fulfillment/voucher-consume-job-list.test.ts b/apps/backend/src/services/fulfillment/voucher-consume-job-list.test.ts new file mode 100644 index 00000000..30da99a5 --- /dev/null +++ b/apps/backend/src/services/fulfillment/voucher-consume-job-list.test.ts @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +const source = readFileSync(new URL('./voucher-consume-queue-service.ts', import.meta.url), 'utf8') + +test('核销任务列表支持关键词、平台、渠道和状态组合筛选', () => { + assert.ok(source.includes('keyword?: string')) + assert.ok(source.includes('provider?: string')) + assert.ok(source.includes('channel?: string')) + assert.ok(source.includes('status?: string')) + assert.ok(source.includes('provider = $')) + assert.ok(source.includes('channel = $')) + assert.ok(source.includes('status = $')) + assert.ok(source.includes('platform_order_id ILIKE')) + assert.ok(source.includes('target_key ILIKE')) +}) + +test('核销任务列表使用服务端总数和分页参数', () => { + assert.ok(source.includes('SELECT COUNT(*) AS total FROM voucher_consume_jobs')) + assert.ok(source.includes('LIMIT $')) + assert.ok(source.includes('OFFSET $')) + assert.ok(source.includes('ORDER BY updated_at DESC, id DESC')) +}) diff --git a/apps/backend/src/services/fulfillment/voucher-consume-queue-service.ts b/apps/backend/src/services/fulfillment/voucher-consume-queue-service.ts index ea852d31..d9f75f65 100644 --- a/apps/backend/src/services/fulfillment/voucher-consume-queue-service.ts +++ b/apps/backend/src/services/fulfillment/voucher-consume-queue-service.ts @@ -22,6 +22,8 @@ import { attachKuaishouIndustryVoucherToTask } from '../platforms/kuaishou-indus import { verifyCertificate } from '../redeem/certificate-service.js' import { logIntegration, logWarn } from '../../utils/logger.js' import { + VOUCHER_CONSUME_CHANNELS, + VOUCHER_CONSUME_PROVIDERS, getVoucherConsumePolicy, resolveVoucherConsumePolicy, type VoucherConsumeChannel, @@ -99,6 +101,13 @@ type VoucherOrderItemTargetRow = { const RETRY_DELAYS_SECONDS = [10, 30, 60, 300, 600, 900, 1800] const PROCESSING_LOCK_SECONDS = 120 +const VOUCHER_CONSUME_JOB_STATUSES = [ + 'pending', + 'processing', + 'succeeded', + 'failed', + 'cancelled', +] as const export async function scheduleVoucherConsumeForTask( task: TaskRow, @@ -443,6 +452,9 @@ export async function runVoucherConsumeDispatchJob( export async function listVoucherConsumeJobs( input: { + keyword?: string + provider?: string + channel?: string status?: string page?: number pageSize?: number @@ -450,10 +462,43 @@ export async function listVoucherConsumeJobs( ) { const page = clampInteger(input.page, 1, 1, Number.MAX_SAFE_INTEGER) const pageSize = clampInteger(input.pageSize, 20, 1, 100) - const status = String(input.status || '').trim() + const keyword = String(input.keyword || '') + .trim() + .slice(0, 100) + const providerInput = String(input.provider || '').trim() + const provider = (VOUCHER_CONSUME_PROVIDERS as readonly string[]).includes(providerInput) + ? providerInput + : '' + const channel = (VOUCHER_CONSUME_CHANNELS as readonly string[]).includes( + String(input.channel || '').trim(), + ) + ? String(input.channel).trim() + : '' + const status = (VOUCHER_CONSUME_JOB_STATUSES as readonly string[]).includes( + String(input.status || '').trim(), + ) + ? String(input.status).trim() + : '' const params: unknown[] = [] const where: string[] = [] + if (keyword) { + params.push(`%${keyword}%`) + where.push( + `(CAST(id AS TEXT) ILIKE $${params.length} + OR platform_order_id ILIKE $${params.length} + OR target_key ILIKE $${params.length} + OR CAST(order_id AS TEXT) ILIKE $${params.length})`, + ) + } + if (provider) { + params.push(provider) + where.push(`provider = $${params.length}`) + } + if (channel) { + params.push(channel) + where.push(`channel = $${params.length}`) + } if (status) { params.push(status) where.push(`status = $${params.length}`) diff --git a/apps/backend/src/types/admin/read-inputs.ts b/apps/backend/src/types/admin/read-inputs.ts index 6b0e681f..197d5e0b 100644 --- a/apps/backend/src/types/admin/read-inputs.ts +++ b/apps/backend/src/types/admin/read-inputs.ts @@ -32,6 +32,7 @@ export type AdminTaskListQueryInput = { roleId?: string dateFrom?: string dateTo?: string + currentOnly?: boolean | string } export type AdminEntityIdInput = number | string diff --git a/apps/backend/src/types/repository/inputs.ts b/apps/backend/src/types/repository/inputs.ts index 986b4652..788de813 100644 --- a/apps/backend/src/types/repository/inputs.ts +++ b/apps/backend/src/types/repository/inputs.ts @@ -62,6 +62,7 @@ export type TaskListQueryInput = { roleId?: string dateFrom?: string dateTo?: string + currentOnly?: boolean } export type TaskRuntimeContextPatch = { diff --git a/apps/frontend/src/pages/admin/AdminFulfillmentChannelPage.tsx b/apps/frontend/src/pages/admin/AdminFulfillmentChannelPage.tsx index 7deb2ffc..af002bbe 100644 --- a/apps/frontend/src/pages/admin/AdminFulfillmentChannelPage.tsx +++ b/apps/frontend/src/pages/admin/AdminFulfillmentChannelPage.tsx @@ -33,6 +33,7 @@ import { } from 'antd' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useEffect, useState } from 'react' +import { useNavigate } from 'react-router' import PercentTable from '@/components/common/PercentTable' import FinanceSubNav from '@/components/admin/FinanceSubNav' @@ -51,6 +52,7 @@ import { retryPendingCarriers, retryPendingWorkOrderRouting, runFinancePipeline, + unlockPendingChannelGroup, type FinancePipelineStatus, type InternalSkuMapping, type InternalSkuMappingMatchType, @@ -97,6 +99,24 @@ const ENABLED_FILTER_OPTIONS = [ { value: 'false', label: '停用' }, ] +const PENDING_CONFIG_REASONS = new Set([ + 'manual_config_missing', + 'work_rule_missing', + 'affiliate_mapping_missing', + 'carrier_config_ambiguous', +]) + +const PENDING_RETRYABLE_TERMINAL_REASONS = new Set([ + 'carrier_task_closed', + 'carrier_task_retry_pending', + 'carrier_task_expired', +]) + +const PENDING_NON_RETRYABLE_TERMINAL_REASONS = new Set([ + 'carrier_task_user_refund', + 'carrier_task_cancelled', +]) + const MATCH_KEY_OPTIONS = [{ value: 'internal_sku', label: '内部 SKU' }] const INTERNAL_SKU_MAPPING_TYPES: Array<{ @@ -131,6 +151,7 @@ export default function AdminFulfillmentChannelPage() { function PendingPanel() { const queryClient = useQueryClient() + const navigate = useNavigate() const [keyword, setKeyword] = useState('') const [page, setPage] = useState(1) const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE) @@ -195,8 +216,11 @@ function PendingPanel() { }) const retryCarrierMutation = useMutation({ - mutationFn: (row: PendingChannelGroup) => - retryPendingCarriers({ internalSku: row.internal_sku || '' }), + mutationFn: (input: { row: PendingChannelGroup; retryInactiveCarrier: boolean }) => + retryPendingCarriers({ + internalSku: input.row.internal_sku || '', + retryInactiveCarrier: input.retryInactiveCarrier, + }), onSuccess: (result) => { const data = result.data const detail = [ @@ -216,6 +240,19 @@ function PendingPanel() { onError: (error) => message.error(error instanceof Error ? error.message : '重试履约载体失败'), }) + const unlockMutation = useMutation({ + mutationFn: (row: PendingChannelGroup) => + unlockPendingChannelGroup({ + groupKey: row.group_key, + internalSku: row.internal_sku || '', + }), + onSuccess: (result) => { + message.success(`已解除 ${result.data.updated} 个订单项的运营锁定`) + void queryClient.invalidateQueries({ queryKey: ['fulfillment-pending'] }) + }, + onError: (error) => message.error(error instanceof Error ? error.message : '解除运营锁定失败'), + }) + const deleteMutation = useMutation({ mutationFn: (row: PendingChannelGroup) => deletePendingChannelGroup({ groupKey: row.group_key }), @@ -326,6 +363,10 @@ function PendingPanel() { carrier_task_manual_review: '载体人工复核', carrier_not_attempted: '尚未尝试补载体', carrier_attempt_failed: '载体创建失败', + carrier_task_retry_pending: '载体失败待确认重试', + carrier_task_expired: '载体已过期', + carrier_task_user_refund: '用户已申请退款', + carrier_task_cancelled: '上游履约已取消', manual_config_missing: '缺人工配置', work_rule_missing: '缺接单模板', affiliate_mapping_missing: '缺自动发货映射', @@ -345,7 +386,9 @@ function PendingPanel() { ) : null} {row.locked_item_count > 0 ? ( - 运营锁定 {row.locked_item_count} 项 + + 已人工指定渠道,等待补配置 {row.locked_item_count} 项 + ) : null} ) @@ -367,71 +410,146 @@ function PendingPanel() { { title: '指定渠道', width: 440, - render: (_: unknown, row: PendingChannelGroup) => ( - - setSelections((prev) => ({ ...prev, [rowKey(row)]: value }))} + /> + + {hasConfigIssue ? ( + + ) : null} + {hasManualReview ? ( + + ) : null} + {hasRetryableTerminal && !hasConfigIssue && hasUnlockedItems ? ( + ) : null} + {!hasConfigIssue && + !hasManualReview && + !hasRetryableTerminal && + !hasNonRetryableTerminal && + hasCarrierIssue && + row.configured_channels?.length && + hasUnlockedItems ? ( + + + + ) : null} + {row.locked_item_count > 0 ? ( + + ) : null} + + + + { + setPage(nextPageSize !== pageSize ? 1 : nextPage) + setPageSize(nextPageSize) + }, + })} + scroll={{ x: 1200 }} /> ) diff --git a/apps/frontend/src/services/admin/fulfillment-channel.ts b/apps/frontend/src/services/admin/fulfillment-channel.ts index a9284d78..9c2198e9 100644 --- a/apps/frontend/src/services/admin/fulfillment-channel.ts +++ b/apps/frontend/src/services/admin/fulfillment-channel.ts @@ -222,7 +222,14 @@ export function saveVoucherConsumePolicy(payload: VoucherConsumePolicy) { } export function fetchVoucherConsumeJobs( - params: { status?: string; page?: number; pageSize?: number } = {}, + params: { + keyword?: string + provider?: VoucherConsumeProvider + channel?: VoucherConsumeChannel + status?: VoucherConsumeJob['status'] + page?: number + pageSize?: number + } = {}, ) { return apiGet( '/api/v1/admin/fulfillment/voucher-consume-jobs', @@ -257,13 +264,21 @@ export function retryPendingWorkOrderRouting( ) } -export function retryPendingCarriers(payload: { internalSku: string }) { +export function retryPendingCarriers(payload: { + internalSku: string + retryInactiveCarrier?: boolean + includeLocked?: boolean +}) { return apiPost( '/api/v1/admin/fulfillment/pending/retry-carriers', payload, ) } +export function unlockPendingChannelGroup(payload: { groupKey?: string; internalSku?: string }) { + return apiPost<{ updated: number }>('/api/v1/admin/fulfillment/pending/unlock', payload) +} + export function deletePendingChannelGroup(payload: { groupKey: string }) { return apiPost( '/api/v1/admin/fulfillment/pending/delete', diff --git a/apps/frontend/tests/admin-task-current-view.test.ts b/apps/frontend/tests/admin-task-current-view.test.ts new file mode 100644 index 00000000..fcfcf5d0 --- /dev/null +++ b/apps/frontend/tests/admin-task-current-view.test.ts @@ -0,0 +1,12 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +const page = readFileSync(new URL('../src/pages/admin/AdminTasksPage.tsx', import.meta.url), 'utf8') + +test('任务页默认仅展示当前任务,并可切换全部历史尝试', () => { + assert.ok(page.includes("searchParams.get('scope') === 'all' ? 'all' : 'current'")) + assert.ok(page.includes('currentOnly: filters.scope !==')) + assert.ok(page.includes("label: '当前任务'")) + assert.ok(page.includes("label: '全部历史尝试'")) +}) diff --git a/apps/frontend/tests/fulfillment-pending-actions.test.ts b/apps/frontend/tests/fulfillment-pending-actions.test.ts new file mode 100644 index 00000000..1473b391 --- /dev/null +++ b/apps/frontend/tests/fulfillment-pending-actions.test.ts @@ -0,0 +1,25 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +const panel = readFileSync( + new URL('../src/pages/admin/AdminFulfillmentChannelPage.tsx', import.meta.url), + 'utf8', +) + +test('待分类队列按问题类型拆分动作', () => { + assert.ok(panel.includes('去补配置')) + assert.ok(panel.includes('处理人工复核')) + assert.ok(panel.includes('确认重试')) + assert.ok(panel.includes('解除锁定')) + assert.ok(panel.includes('等待补配置')) + assert.ok(panel.includes('用户已申请退款')) + assert.ok(panel.includes('上游履约已取消')) +}) + +test('重开历史载体必须二次确认且不会处理锁定项', () => { + assert.ok(panel.includes('确认重开履约载体')) + assert.ok(panel.includes('retryInactiveCarrier: true')) + assert.ok(panel.includes('不会重开失败或关闭的旧任务')) + assert.ok(panel.includes('unlockPendingChannelGroup')) +}) diff --git a/apps/frontend/tests/voucher-consume-job-list-ui.test.ts b/apps/frontend/tests/voucher-consume-job-list-ui.test.ts new file mode 100644 index 00000000..9a244f1c --- /dev/null +++ b/apps/frontend/tests/voucher-consume-job-list-ui.test.ts @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +const panel = readFileSync( + new URL('../src/pages/admin/panels/VoucherConsumePolicyPanel.tsx', import.meta.url), + 'utf8', +) + +test('最近核销任务提供关键词、平台、渠道和状态筛选', () => { + assert.ok(panel.includes('placeholder="任务号 / 订单号 / 平台单号"')) + assert.ok(panel.includes('placeholder="全部平台"')) + assert.ok(panel.includes('placeholder="全部渠道"')) + assert.ok(panel.includes('placeholder="全部状态"')) + assert.ok(panel.includes('resetJobFilters')) +}) + +test('最近核销任务使用服务端分页', () => { + assert.ok(panel.includes('buildAdminTablePagination')) + assert.ok(panel.includes('total: jobsQuery.data?.data.total')) + assert.ok(panel.includes('setPageSize(nextPageSize)')) + assert.ok(!panel.includes('pagination={false}\n scroll={{ x: 1200 }}')) +}) From ed450fac79ed72d3ba9a19186a15d9b51d73f7fe Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 5 Nov 2026 20:13:34 +0800 Subject: [PATCH 05/10] =?UTF-8?q?feat(fulfillment):=20=E5=BB=BA=E7=AB=8B?= =?UTF-8?q?=E5=B1=A5=E7=BA=A6=E7=BB=9F=E4=B8=80=E7=8A=B6=E6=80=81=E8=A7=86?= =?UTF-8?q?=E5=9B=BE=E4=B8=8E=E5=88=86=E7=B1=BB=E9=98=9F=E5=88=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/backend/package.json | 1 + .../scripts/audit-fulfillment-state.ts | 140 ++++ .../137_fulfillment_item_state_view.sql | 358 +++++++++ .../src/routes/admin/fulfillment-channel.ts | 7 + .../fulfillment/channel-assign-service.ts | 357 +++++++-- .../fulfillment-routing.integration.test.ts | 39 +- .../fulfillment-state-view.test.ts | 52 ++ .../admin/AdminFulfillmentChannelPage.tsx | 226 ++++-- .../src/services/admin/fulfillment-channel.ts | 29 +- .../tests/fulfillment-pending-actions.test.ts | 7 + docs/README.md | 5 +- docs/履约系统架构复盘与优化方案-2026-11-05.md | 741 ++++++++++++++++++ 12 files changed, 1783 insertions(+), 179 deletions(-) create mode 100644 apps/backend/scripts/audit-fulfillment-state.ts create mode 100644 apps/backend/src/db/migrations/137_fulfillment_item_state_view.sql create mode 100644 apps/backend/src/services/fulfillment/fulfillment-state-view.test.ts create mode 100644 docs/履约系统架构复盘与优化方案-2026-11-05.md diff --git a/apps/backend/package.json b/apps/backend/package.json index b3ec2500..c3232050 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -32,6 +32,7 @@ "test:industry:http": "tsx scripts/test-kuaishou-industry-http.ts", "test:industry:gen": "tsx scripts/gen-test-cases.ts", "test:industry:curl": "tsx scripts/curl-send-callback.ts", + "audit:fulfillment": "tsx scripts/audit-fulfillment-state.ts", "typecheck": "tsc -p tsconfig.json --noEmit", "check": "npm run format:check && npm run check:sql && npm run lint:check && npm run typecheck && npm test && npm run build", "start": "node dist/index.js", diff --git a/apps/backend/scripts/audit-fulfillment-state.ts b/apps/backend/scripts/audit-fulfillment-state.ts new file mode 100644 index 00000000..2b25056b --- /dev/null +++ b/apps/backend/scripts/audit-fulfillment-state.ts @@ -0,0 +1,140 @@ +#!/usr/bin/env tsx +/** + * 履约状态只读审计。 + * + * 用法: + * npm run audit:fulfillment + * npm run audit:fulfillment -- --strict + * + * --strict 会在关键异常大于 0 时返回失败,适合部署后和生产巡检。 + */ +import { closeDb, query } from '../src/db/client.js' + +const strict = process.argv.includes('--strict') + +type CountRow = { + label: string + count: number +} + +async function loadCounts(): Promise { + const result = await query<{ + pending_items: number + classification: number + configuration: number + carrier: number + manual_review: number + after_sales_pending: number + state_inconsistent: number + assigned_missing_carrier: number + }>( + ` + SELECT + COUNT(*) FILTER (WHERE issue_code <> '')::int AS pending_items, + COUNT(*) FILTER (WHERE issue_category = 'classification')::int + AS classification, + COUNT(*) FILTER (WHERE issue_category = 'configuration')::int + AS configuration, + COUNT(*) FILTER (WHERE issue_category = 'carrier')::int AS carrier, + COUNT(*) FILTER (WHERE issue_category = 'manual_review')::int + AS manual_review, + COUNT(*) FILTER ( + WHERE issue_category = 'after_sales' AND fulfillment_status = 'pending' + )::int AS after_sales_pending, + COUNT(*) FILTER (WHERE issue_code = 'state_inconsistent')::int + AS state_inconsistent, + COUNT(*) FILTER ( + WHERE fulfillment_status = 'assigned' AND carrier_status = 'missing' + )::int AS assigned_missing_carrier + FROM v_fulfillment_item_state + `, + ) + const row = result.rows[0] + return [ + { label: '待处理订单项', count: Number(row?.pending_items || 0) }, + { label: '未分类', count: Number(row?.classification || 0) }, + { label: '配置待补', count: Number(row?.configuration || 0) }, + { label: '载体异常', count: Number(row?.carrier || 0) }, + { label: '人工复核', count: Number(row?.manual_review || 0) }, + { label: '售后关闭仍进入履约队列', count: Number(row?.after_sales_pending || 0) }, + { label: '状态不一致', count: Number(row?.state_inconsistent || 0) }, + { label: '已分配但无有效载体', count: Number(row?.assigned_missing_carrier || 0) }, + ] +} + +async function loadIssueBreakdown(): Promise { + const result = await query<{ label: string; count: number }>( + ` + SELECT issue_code AS label, COUNT(*)::int AS count + FROM v_fulfillment_item_state + WHERE issue_code <> '' + GROUP BY issue_code + ORDER BY count DESC, issue_code + `, + ) + return result.rows +} + +async function loadRecentDuplicateAttempts(): Promise { + const result = await query<{ label: string; count: number }>( + ` + SELECT + '近 24 小时重复尝试订单项' AS label, + COUNT(*)::int AS count + FROM ( + SELECT order_item_id, unit_index + FROM fulfillment_tasks + WHERE created_at >= NOW() - INTERVAL '24 hours' + GROUP BY order_item_id, unit_index + HAVING COUNT(*) > 1 + ) duplicate + `, + ) + return result.rows +} + +async function main(): Promise { + const [counts, issues, duplicates] = await Promise.all([ + loadCounts(), + loadIssueBreakdown(), + loadRecentDuplicateAttempts(), + ]) + + console.info('[fulfillment-audit] 总览') + for (const row of counts) { + console.info(` ${row.label}: ${row.count}`) + } + + console.info('[fulfillment-audit] 异常分布') + if (issues.length === 0) { + console.info(' 无') + } else { + for (const row of issues) { + console.info(` ${row.label}: ${row.count}`) + } + } + + console.info('[fulfillment-audit] 尝试审计') + for (const row of duplicates) { + console.info(` ${row.label}: ${row.count}`) + } + + const critical = new Map(counts.map((row) => [row.label, Number(row.count || 0)])) + const duplicateCount = Number(duplicates[0]?.count || 0) + const hasCritical = + (critical.get('状态不一致') || 0) > 0 || + (critical.get('售后关闭仍进入履约队列') || 0) > 0 || + (critical.get('已分配但无有效载体') || 0) > 0 || + duplicateCount > 0 + + if (strict && hasCritical) { + process.exitCode = 1 + console.error('[fulfillment-audit] strict 模式检测到关键异常') + } +} + +try { + await main() +} finally { + await closeDb() +} diff --git a/apps/backend/src/db/migrations/137_fulfillment_item_state_view.sql b/apps/backend/src/db/migrations/137_fulfillment_item_state_view.sql new file mode 100644 index 00000000..e67aa542 --- /dev/null +++ b/apps/backend/src/db/migrations/137_fulfillment_item_state_view.sql @@ -0,0 +1,358 @@ +-- 137_fulfillment_item_state_view.sql +-- +-- 履约统一读模型: +-- 1. 每个订单项只有一行当前履约状态; +-- 2. 渠道、载体、最新尝试、异常、锁定原因分别表达; +-- 3. 退款、取消、人工复核、配置缺失不再混写成“无履约载体”。 + +CREATE OR REPLACE VIEW v_fulfillment_item_state AS +WITH active_items AS ( + SELECT + oi.*, + resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) AS internal_sku + FROM order_items oi + WHERE oi.fulfillment_status IN ('pending', 'assigned') +), +base AS ( + SELECT + oi.id AS order_item_id, + oi.order_id, + o.id AS local_order_id, + o.provider, + o.platform, + o.shop_id, + o.shop_name, + o.platform_order_id, + o.pay_status, + o.order_status, + oi.fulfillment_status AS item_fulfillment_status, + oi.fulfillment_channel AS stored_channel, + oi.fulfillment_source, + oi.fulfillment_locked, + oi.fulfillment_pending_reason, + oi.fulfillment_pending_detail, + oi.sku_code, + oi.sku_name, + oi.quantity, + oi.revenue_amount_fen, + oi.created_at, + oi.updated_at, + oi.internal_sku, + channel_plan.channel AS configured_channel, + channel_plan.config_json AS configured_channel_config, + COALESCE(channel_stats.channel_count, 0) AS channel_count, + COALESCE(latest_task.task_id, 0) AS latest_task_id, + latest_task.executor_key AS latest_task_executor_key, + latest_task.task_status AS latest_task_status, + latest_task.result_code AS latest_task_result_code, + latest_task.result_message AS latest_task_result_message, + latest_task.updated_at AS latest_task_updated_at, + COALESCE(latest_work_order.work_order_id, 0) AS latest_work_order_id, + latest_work_order.status AS latest_work_order_status, + latest_work_order.result_code AS latest_work_order_result_code, + latest_work_order.updated_at AS latest_work_order_updated_at, + EXISTS ( + SELECT 1 + FROM fulfillment_tasks active_task + WHERE active_task.order_item_id = oi.id + AND active_task.executor_key IN ('affiliate_dash', 'manual_dispatch') + AND active_task.task_status NOT IN ( + 'closed', 'expired', 'failed', 'manual_review', 'retry_pending', 'cancelled' + ) + ) AS has_active_task, + EXISTS ( + SELECT 1 + FROM work_orders active_work_order + WHERE active_work_order.order_item_id = oi.id + AND active_work_order.status <> 'cancelled' + ) AS has_active_work_order, + EXISTS ( + SELECT 1 + FROM manual_fulfillment_configs cfg + WHERE cfg.enabled = TRUE + AND canonicalize_internal_sku(cfg.sku_key) = oi.internal_sku + ) AS has_manual_config + FROM active_items oi + JOIN orders o ON o.id = oi.order_id + LEFT JOIN LATERAL ( + SELECT channel.channel, channel.config_json + FROM internal_sku_channels channel + WHERE channel.enabled = TRUE + AND canonicalize_internal_sku(channel.internal_sku) = oi.internal_sku + ORDER BY channel.is_fallback, channel.priority, channel.channel, channel.id + LIMIT 1 + ) channel_plan ON TRUE + LEFT JOIN LATERAL ( + SELECT COUNT(*)::int AS channel_count + FROM internal_sku_channels channel + WHERE channel.enabled = TRUE + AND canonicalize_internal_sku(channel.internal_sku) = oi.internal_sku + ) channel_stats ON TRUE + LEFT JOIN LATERAL ( + SELECT + task.id AS task_id, + task.executor_key, + task.task_status, + task.result_code, + task.result_message, + task.updated_at + FROM fulfillment_tasks task + WHERE task.order_item_id = oi.id + ORDER BY task.id DESC + LIMIT 1 + ) latest_task ON TRUE + LEFT JOIN LATERAL ( + SELECT + work_order.id AS work_order_id, + work_order.status, + ''::text AS result_code, + work_order.updated_at + FROM work_orders work_order + WHERE work_order.order_item_id = oi.id + ORDER BY work_order.id DESC + LIMIT 1 + ) latest_work_order ON TRUE +), +classified AS ( + SELECT + base.*, + COALESCE(base.stored_channel, base.configured_channel) AS intended_channel, + CASE + WHEN base.pay_status = 'refunded' + OR base.order_status IN ('refunded', 'cancelled') + OR base.latest_task_result_code = 'USER_APPLY_REFUND' + THEN TRUE + ELSE FALSE + END AS is_user_refund, + CASE + WHEN base.pay_status = 'cancelled' + OR base.order_status = 'cancelled' + OR base.latest_task_result_code = 'affiliate_dash_cancelled' + THEN TRUE + ELSE FALSE + END AS is_platform_cancelled + FROM base +), +current_attempt AS ( + SELECT + classified.*, + CASE + WHEN classified.latest_task_id > 0 + AND ( + classified.latest_work_order_id = 0 + OR classified.latest_task_updated_at >= classified.latest_work_order_updated_at + ) + THEN 'task' + WHEN classified.latest_work_order_id > 0 THEN 'work_order' + ELSE '' + END AS current_attempt_type, + CASE + WHEN classified.latest_task_id > 0 + AND ( + classified.latest_work_order_id = 0 + OR classified.latest_task_updated_at >= classified.latest_work_order_updated_at + ) + THEN classified.latest_task_id + WHEN classified.latest_work_order_id > 0 THEN classified.latest_work_order_id + ELSE 0 + END AS current_attempt_id, + CASE + WHEN classified.latest_task_id > 0 + AND ( + classified.latest_work_order_id = 0 + OR classified.latest_task_updated_at >= classified.latest_work_order_updated_at + ) + THEN classified.latest_task_status + WHEN classified.latest_work_order_id > 0 THEN classified.latest_work_order_status + ELSE '' + END AS current_attempt_status, + CASE + WHEN classified.latest_task_id > 0 + AND ( + classified.latest_work_order_id = 0 + OR classified.latest_task_updated_at >= classified.latest_work_order_updated_at + ) + THEN classified.latest_task_result_code + WHEN classified.latest_work_order_id > 0 THEN classified.latest_work_order_result_code + ELSE '' + END AS current_attempt_result_code + FROM classified +), +state_classified AS ( + SELECT + current_attempt.*, + CASE + WHEN current_attempt.intended_channel IN ('third_party_voucher', 'on_account') + THEN 'not_required' + WHEN current_attempt.intended_channel = 'manual' + AND current_attempt.has_manual_config + THEN 'ready' + WHEN current_attempt.has_active_task OR current_attempt.has_active_work_order + THEN 'ready' + WHEN current_attempt.latest_task_status IN ( + 'closed', 'expired', 'failed', 'manual_review', 'retry_pending', 'cancelled' + ) + THEN 'terminal' + WHEN current_attempt.latest_work_order_status = 'cancelled' + THEN 'terminal' + WHEN current_attempt.intended_channel IS NOT NULL + THEN 'missing' + ELSE 'missing' + END AS carrier_status, + CASE + WHEN COALESCE(current_attempt.internal_sku, '') = '' + THEN 'missing_internal_sku' + WHEN current_attempt.channel_count = 0 + THEN 'missing_channel' + ELSE 'configured' + END AS route_status + FROM current_attempt +), +issue_classified AS ( + SELECT + state_classified.*, + CASE + WHEN state_classified.is_user_refund THEN 'user_refund' + WHEN state_classified.is_platform_cancelled THEN 'platform_cancelled' + WHEN state_classified.route_status = 'missing_internal_sku' THEN 'missing_internal_sku' + WHEN state_classified.route_status = 'missing_channel' THEN 'missing_channel' + WHEN state_classified.intended_channel = 'manual' + AND NOT state_classified.has_manual_config + THEN 'manual_config_missing' + WHEN state_classified.intended_channel = 'work_order' + AND BTRIM(COALESCE( + state_classified.configured_channel_config->>'workRuleKey', + '' + )) = '' + THEN 'work_rule_missing' + WHEN state_classified.intended_channel = 'affiliate_dash' + AND BTRIM(COALESCE( + state_classified.configured_channel_config->>'affiliateSku', + '' + )) = '' + THEN 'affiliate_mapping_missing' + WHEN state_classified.carrier_status IN ('ready', 'not_required') THEN '' + WHEN state_classified.latest_task_status = 'manual_review' + THEN 'carrier_manual_review' + WHEN state_classified.latest_task_status IN ('failed', 'retry_pending') + THEN 'carrier_retry_pending' + WHEN state_classified.latest_task_status = 'expired' THEN 'carrier_expired' + WHEN state_classified.latest_task_status = 'closed' + OR state_classified.latest_work_order_status = 'cancelled' + THEN 'carrier_closed' + WHEN state_classified.item_fulfillment_status = 'assigned' + THEN 'state_inconsistent' + WHEN state_classified.intended_channel IN ( + 'affiliate_dash', 'work_order', 'manual' + ) + THEN 'carrier_missing' + ELSE 'state_inconsistent' + END AS issue_code + FROM state_classified +), +categorized AS ( + SELECT + issue_classified.*, + CASE + WHEN issue_code = '' THEN '' + WHEN issue_code IN ('user_refund', 'platform_cancelled') THEN 'after_sales' + WHEN issue_code = 'carrier_manual_review' THEN 'manual_review' + WHEN issue_code IN ('missing_internal_sku', 'missing_channel') THEN 'classification' + WHEN issue_code IN ( + 'manual_config_missing', 'work_rule_missing', 'affiliate_mapping_missing' + ) THEN 'configuration' + WHEN issue_code = 'state_inconsistent' THEN 'system' + ELSE 'carrier' + END AS issue_category + FROM issue_classified +) +SELECT + order_item_id, + order_id, + local_order_id, + provider, + platform, + shop_id, + shop_name, + platform_order_id, + pay_status, + order_status, + item_fulfillment_status AS fulfillment_status, + internal_sku, + intended_channel, + stored_channel, + configured_channel, + route_status, + carrier_status, + current_attempt_type, + current_attempt_id, + current_attempt_status, + current_attempt_result_code, + latest_task_id, + latest_task_executor_key, + latest_task_status, + latest_task_result_code, + latest_work_order_id, + latest_work_order_status, + issue_code, + issue_category, + CASE + WHEN issue_code = '' THEN FALSE + WHEN issue_code IN ( + 'user_refund', 'platform_cancelled', 'carrier_manual_review', + 'manual_config_missing', 'work_rule_missing', 'affiliate_mapping_missing', + 'missing_internal_sku', 'missing_channel' + ) THEN FALSE + ELSE TRUE + END AS retryable, + CASE + WHEN issue_code IN ('carrier_missing', 'state_inconsistent') THEN TRUE + ELSE FALSE + END AS auto_retryable, + CASE + WHEN issue_code IN ('user_refund', 'platform_cancelled', 'carrier_manual_review') + THEN 'support' + WHEN issue_code IN ( + 'missing_internal_sku', 'missing_channel', 'manual_config_missing', + 'work_rule_missing', 'affiliate_mapping_missing' + ) THEN 'operator' + WHEN issue_code = '' THEN '' + ELSE 'system' + END AS owner_role, + fulfillment_locked, + CASE + WHEN NOT fulfillment_locked THEN '' + ELSE COALESCE( + NULLIF(fulfillment_pending_detail->>'carrierIssue', ''), + NULLIF(fulfillment_pending_detail->>'reason', ''), + 'operator_assigned' + ) + END AS lock_reason, + fulfillment_pending_reason, + fulfillment_pending_detail, + fulfillment_source, + sku_code, + sku_name, + quantity, + revenue_amount_fen, + created_at, + updated_at, + GREATEST( + 0, + FLOOR(EXTRACT(EPOCH FROM (NOW() - created_at)) / 60) + )::bigint AS age_minutes +FROM categorized; + +COMMENT ON VIEW v_fulfillment_item_state IS + '履约统一读模型:每个订单项一行,拆分渠道、载体、当前尝试、结构化异常与锁定原因'; + +CREATE INDEX IF NOT EXISTS idx_order_items_active_fulfillment_state + ON order_items (id) + WHERE fulfillment_status IN ('pending', 'assigned'); + +CREATE INDEX IF NOT EXISTS idx_fulfillment_tasks_order_item_latest + ON fulfillment_tasks (order_item_id, id DESC); + +CREATE INDEX IF NOT EXISTS idx_work_orders_order_item_latest + ON work_orders (order_item_id, id DESC) + WHERE order_item_id IS NOT NULL; diff --git a/apps/backend/src/routes/admin/fulfillment-channel.ts b/apps/backend/src/routes/admin/fulfillment-channel.ts index 1808aa46..8384488f 100644 --- a/apps/backend/src/routes/admin/fulfillment-channel.ts +++ b/apps/backend/src/routes/admin/fulfillment-channel.ts @@ -139,6 +139,7 @@ router.get( page: Number(req.query.page), pageSize: Number(req.query.pageSize), keyword: String(req.query.keyword || ''), + issueCategory: String(req.query.issueCategory || ''), }), { successMessage: 'ok', @@ -156,6 +157,7 @@ router.post( deletePendingChannelGroup({ groupKey: String(bodyOf(req).groupKey || ''), internalSku: String(bodyOf(req).internalSku || ''), + issueCategory: String(bodyOf(req).issueCategory || ''), adminUserId: req.adminSession?.userId ?? null, }), { @@ -206,6 +208,9 @@ router.post( internalSku: String(bodyOf(req).internalSku || ''), retryInactiveCarrier: bodyOf(req).retryInactiveCarrier === true, includeLocked: bodyOf(req).includeLocked === true, + issueCodes: Array.isArray(bodyOf(req).issueCodes) + ? bodyOf(req).issueCodes.map((value: unknown) => String(value || '')) + : [], }), { successMessage: '履约载体重试完成', @@ -230,6 +235,7 @@ router.post( return unlockPendingChannelGroup({ groupKey: String(body.groupKey || ''), internalSku: String(body.internalSku || ''), + issueCategory: String(body.issueCategory || ''), }) }, { @@ -345,6 +351,7 @@ router.post( internalSku: String(body.internalSku || ''), mappedInternalSku: String(body.mappedInternalSku || ''), channel, + issueCategory: String(body.issueCategory || ''), adminUserId, note, }) diff --git a/apps/backend/src/services/fulfillment/channel-assign-service.ts b/apps/backend/src/services/fulfillment/channel-assign-service.ts index e781feed..a731ae07 100644 --- a/apps/backend/src/services/fulfillment/channel-assign-service.ts +++ b/apps/backend/src/services/fulfillment/channel-assign-service.ts @@ -1016,6 +1016,24 @@ export type PendingGroupRow = { revenue_fen: number first_seen_at: string last_seen_at: string + issue_breakdown: Array<{ + issue_code: string + issue_category: string + item_count: number + locked_item_count: number + retryable: boolean + auto_retryable: boolean + owner_role: string + }> + retryable_item_count: number + retryable_unlocked_item_count: number + auto_retryable_item_count: number + auto_retryable_unlocked_item_count: number + config_issue_item_count: number + manual_review_item_count: number + after_sales_item_count: number + system_issue_item_count: number + oldest_age_minutes: number } type PendingShopNameRow = { @@ -1042,21 +1060,64 @@ const PENDING_GROUP_KEY_SQL = ` END ` +const PENDING_STATE_GROUP_KEY_SQL = ` + CASE + WHEN COALESCE(BTRIM(v.internal_sku), '') <> '' + THEN 'sku:' || encode(convert_to(v.internal_sku, 'UTF8'), 'hex') + ELSE 'source:' || encode( + convert_to( + CONCAT_WS(CHR(31), v.provider, v.shop_id, v.sku_code), + 'UTF8' + ), + 'hex' + ) + END +` + +const RETRYABLE_CARRIER_ISSUES = [ + 'carrier_closed', + 'carrier_retry_pending', + 'carrier_expired', + 'carrier_missing', + 'state_inconsistent', +] as const + +const AUTO_RETRYABLE_CARRIER_ISSUES = ['carrier_missing', 'state_inconsistent'] as const +const PENDING_ISSUE_CATEGORIES = [ + 'classification', + 'configuration', + 'carrier', + 'manual_review', + 'after_sales', + 'system', +] as const + /** 待分类队列:按全局内部 SKU 聚合(跨平台、跨店铺合并为一行)。 */ export async function listPendingChannelGroups(input: { page?: number pageSize?: number keyword?: string + issueCategory?: string }): Promise<{ items: PendingGroupRow[]; total: number; page: number; pageSize: number }> { const page = Math.max(1, Math.trunc(Number(input.page) || 1)) const pageSize = Math.min(200, Math.max(1, Math.trunc(Number(input.pageSize) || 30))) const keyword = String(input.keyword || '').trim() + const issueCategoryInput = String(input.issueCategory || '').trim() + const issueCategory = (PENDING_ISSUE_CATEGORIES as readonly string[]).includes(issueCategoryInput) + ? issueCategoryInput + : '' const params: unknown[] = [] - const clauses = [`oi.fulfillment_status = 'pending'`] + const clauses = [`v.fulfillment_status = 'pending'`] + if (issueCategory) { + params.push(issueCategory) + clauses.push(`v.issue_category = $${params.length}`) + } if (keyword) { params.push(`%${keyword}%`) clauses.push( - `(${INTERNAL_SKU_SQL} ILIKE $${params.length} OR oi.sku_code ILIKE $${params.length} OR oi.sku_name ILIKE $${params.length} OR oi.manual_sku_key ILIKE $${params.length})`, + `(v.internal_sku ILIKE $${params.length} + OR v.sku_code ILIKE $${params.length} + OR v.sku_name ILIKE $${params.length})`, ) } const where = clauses.join(' AND ') @@ -1065,10 +1126,9 @@ export async function listPendingChannelGroups(input: { ` WITH pending_items AS ( SELECT - ${INTERNAL_SKU_SQL} AS internal_sku, - ${PENDING_GROUP_KEY_SQL} AS group_key - FROM order_items oi - JOIN orders o ON o.id = oi.order_id + v.internal_sku, + ${PENDING_STATE_GROUP_KEY_SQL} AS group_key + FROM v_fulfillment_item_state v WHERE ${where} ) SELECT COUNT(DISTINCT group_key)::int AS count @@ -1082,57 +1142,124 @@ export async function listPendingChannelGroups(input: { ` WITH pending_items AS ( SELECT - oi.id, - oi.order_id, - oi.sku_code, - oi.sku_name, - oi.fulfillment_locked, - oi.fulfillment_pending_reason, - oi.fulfillment_pending_detail, - oi.revenue_amount_fen, - oi.created_at, - o.provider, - o.shop_id, - ${INTERNAL_SKU_SQL} AS internal_sku, - ${PENDING_GROUP_KEY_SQL} AS group_key - FROM order_items oi - JOIN orders o ON o.id = oi.order_id + v.*, + ${PENDING_STATE_GROUP_KEY_SQL} AS group_key + FROM v_fulfillment_item_state v WHERE ${where} + ), + grouped AS ( + SELECT + group_key, + internal_sku, + MIN(sku_name) AS sku_name, + array_agg(DISTINCT provider) AS providers, + array_agg(DISTINCT shop_id) AS shop_ids, + array_agg(DISTINCT sku_code) AS sku_codes, + array_remove( + array_agg( + DISTINCT CASE + WHEN issue_code = 'missing_internal_sku' THEN 'no_internal_sku' + WHEN issue_code = 'missing_channel' THEN 'no_channel_capability' + WHEN issue_code = '' THEN NULL + ELSE 'no_carrier' + END + ), + NULL + ) AS pending_reasons, + array_remove(array_agg(DISTINCT issue_code), '') AS pending_subreasons, + COUNT(*) FILTER (WHERE fulfillment_locked)::int AS locked_item_count, + COUNT(*)::int AS item_count, + COALESCE(SUM(revenue_amount_fen), 0)::bigint AS revenue_fen, + MIN(created_at) AS first_seen_at, + MAX(created_at) AS last_seen_at, + COALESCE(MAX(age_minutes), 0)::bigint AS oldest_age_minutes, + COUNT(*) FILTER (WHERE retryable)::int AS retryable_item_count, + COUNT(*) FILTER (WHERE retryable AND NOT fulfillment_locked)::int + AS retryable_unlocked_item_count, + COUNT(*) FILTER (WHERE auto_retryable)::int AS auto_retryable_item_count, + COUNT(*) FILTER (WHERE auto_retryable AND NOT fulfillment_locked)::int + AS auto_retryable_unlocked_item_count, + COUNT(*) FILTER (WHERE issue_category = 'configuration')::int + AS config_issue_item_count, + COUNT(*) FILTER (WHERE issue_category = 'manual_review')::int + AS manual_review_item_count, + COUNT(*) FILTER (WHERE issue_category = 'after_sales')::int + AS after_sales_item_count, + COUNT(*) FILTER (WHERE issue_category = 'system')::int + AS system_issue_item_count + FROM pending_items + GROUP BY group_key, internal_sku + ), + issue_counts AS ( + SELECT + group_key, + issue_code, + issue_category, + bool_or(retryable) AS retryable, + bool_or(auto_retryable) AS auto_retryable, + MIN(owner_role) AS owner_role, + COUNT(*)::int AS item_count, + COUNT(*) FILTER (WHERE fulfillment_locked)::int AS locked_item_count + FROM pending_items + GROUP BY group_key, issue_code, issue_category + ), + issue_summary AS ( + SELECT + group_key, + jsonb_agg( + jsonb_build_object( + 'issue_code', issue_code, + 'issue_category', issue_category, + 'item_count', item_count, + 'locked_item_count', locked_item_count, + 'retryable', retryable, + 'auto_retryable', auto_retryable, + 'owner_role', owner_role + ) + ORDER BY item_count DESC, issue_code + ) AS issue_breakdown + FROM issue_counts + GROUP BY group_key ) SELECT - pi.group_key, - CASE WHEN pi.internal_sku <> '' THEN 'internal_sku' ELSE 'source_sku' END AS scope_type, - pi.internal_sku, - MIN(pi.sku_name) AS sku_name, - array_agg(DISTINCT pi.provider) AS providers, - array_agg(DISTINCT pi.shop_id) AS shop_ids, - array_agg(DISTINCT pi.sku_code) AS sku_codes, - array_remove(array_agg(DISTINCT pi.fulfillment_pending_reason), NULL) AS pending_reasons, - array_remove( - array_agg( - DISTINCT COALESCE( - pi.fulfillment_pending_detail->>'carrierIssue', - pi.fulfillment_pending_detail->>'reason' - ) - ), - NULL - ) AS pending_subreasons, + grouped.group_key, + CASE + WHEN grouped.internal_sku <> '' THEN 'internal_sku' + ELSE 'source_sku' + END AS scope_type, + grouped.internal_sku, + grouped.sku_name, + grouped.providers, + grouped.shop_ids, + grouped.sku_codes, + grouped.pending_reasons, + grouped.pending_subreasons, COALESCE(cfg.channels, ARRAY[]::text[]) AS configured_channels, - COUNT(*) FILTER (WHERE pi.fulfillment_locked)::int AS locked_item_count, - COUNT(*)::int AS item_count, - COALESCE(SUM(pi.revenue_amount_fen), 0)::bigint AS revenue_fen, - MIN(pi.created_at) AS first_seen_at, - MAX(pi.created_at) AS last_seen_at - FROM pending_items pi + grouped.locked_item_count, + grouped.item_count, + grouped.revenue_fen, + grouped.first_seen_at, + grouped.last_seen_at, + grouped.oldest_age_minutes, + grouped.retryable_item_count, + grouped.retryable_unlocked_item_count, + grouped.auto_retryable_item_count, + grouped.auto_retryable_unlocked_item_count, + grouped.config_issue_item_count, + grouped.manual_review_item_count, + grouped.after_sales_item_count, + grouped.system_issue_item_count, + COALESCE(issue_summary.issue_breakdown, '[]'::jsonb) AS issue_breakdown + FROM grouped + LEFT JOIN issue_summary ON issue_summary.group_key = grouped.group_key LEFT JOIN LATERAL ( SELECT array_agg(DISTINCT channel.channel ORDER BY channel.channel) AS channels FROM internal_sku_channels channel WHERE channel.enabled = TRUE AND canonicalize_internal_sku(channel.internal_sku) = - pi.internal_sku + grouped.internal_sku ) cfg ON TRUE - GROUP BY pi.group_key, pi.internal_sku, cfg.channels - ORDER BY item_count DESC, internal_sku + ORDER BY grouped.item_count DESC, grouped.internal_sku LIMIT $${params.length - 1} OFFSET $${params.length} `, params, @@ -1145,16 +1272,13 @@ export async function listPendingChannelGroups(input: { ` WITH pending_items AS ( SELECT - oi.id, - o.provider, - o.shop_id, - o.shop_name, - oi.sku_code, - ${INTERNAL_SKU_SQL} AS internal_sku, - ${PENDING_GROUP_KEY_SQL} AS group_key - FROM order_items oi - JOIN orders o ON o.id = oi.order_id - WHERE oi.fulfillment_status = 'pending' + v.provider, + v.shop_id, + v.shop_name, + ${PENDING_STATE_GROUP_KEY_SQL} AS group_key + FROM v_fulfillment_item_state v + WHERE v.fulfillment_status = 'pending' + AND ($2::text = '' OR v.issue_category = $2) ) SELECT group_key, @@ -1179,7 +1303,7 @@ export async function listPendingChannelGroups(input: { usage_count DESC, shop_name `, - [groupKeys], + [groupKeys, issueCategory], ) const seen = new Set() @@ -1225,6 +1349,7 @@ export async function retryPendingCarrierGroup(input: { internalSku: string retryInactiveCarrier?: boolean includeLocked?: boolean + issueCodes?: string[] }): Promise { const internalSku = canonicalizeInternalSku(input.internalSku) if (!internalSku || !containsCjk(internalSku)) { @@ -1234,13 +1359,37 @@ export async function retryPendingCarrierGroup(input: { }) } + const requestedIssueCodes = [ + ...new Set( + (input.issueCodes || []) + .map((value) => String(value || '').trim()) + .filter((value): value is (typeof RETRYABLE_CARRIER_ISSUES)[number] => + (RETRYABLE_CARRIER_ISSUES as readonly string[]).includes(value), + ), + ), + ] + if ((input.issueCodes || []).length > 0 && requestedIssueCodes.length === 0) { + throw createHttpError('所选问题不允许重试,请刷新后重试', { + statusCode: 409, + errorCode: 'fulfillment_issue_not_retryable', + }) + } + const issueCodes = + requestedIssueCodes.length > 0 + ? requestedIssueCodes + : input.retryInactiveCarrier === true + ? [...RETRYABLE_CARRIER_ISSUES] + : [...AUTO_RETRYABLE_CARRIER_ISSUES] + const pending = await query<{ id: number }>( - `SELECT oi.id - FROM order_items oi + `SELECT oi.order_item_id AS id + FROM v_fulfillment_item_state oi WHERE oi.fulfillment_status = 'pending' - AND ${INTERNAL_SKU_SQL} = $1 - ORDER BY oi.id`, - [internalSku], + AND oi.internal_sku = $1 + AND oi.issue_code = ANY($2::text[]) + AND ($3::boolean OR NOT oi.fulfillment_locked) + ORDER BY oi.order_item_id`, + [internalSku, issueCodes, input.includeLocked === true], ) const ids = pending.rows.map((row) => Number(row.id)) let workOrdersCreated = 0 @@ -1313,6 +1462,7 @@ export async function retryPendingCarrierGroup(input: { export async function unlockPendingChannelGroup(input: { groupKey?: string internalSku?: string + issueCategory?: string }): Promise<{ updated: number }> { const scope = resolvePendingGroupScopeInput(input) if (!scope) { @@ -1323,20 +1473,25 @@ export async function unlockPendingChannelGroup(input: { } const params: unknown[] = [] - const where = ["oi.fulfillment_status = 'pending'", 'oi.fulfillment_locked = TRUE'] + const where = ["state.fulfillment_status = 'pending'", 'state.fulfillment_locked = TRUE'] if (scope.type === 'internal_sku') { params.push(scope.internalSku) - where.push(`${INTERNAL_SKU_SQL} = $${params.length}`) + where.push(`state.internal_sku = $${params.length}`) } else { params.push(scope.provider, scope.shopId, scope.skuCode) - where.push(`o.provider = $1 AND o.shop_id = $2 AND oi.sku_code = $3`) + where.push(`state.provider = $1 AND state.shop_id = $2 AND state.sku_code = $3`) + } + const issueCategory = String(input.issueCategory || '').trim() + if ((PENDING_ISSUE_CATEGORIES as readonly string[]).includes(issueCategory)) { + params.push(issueCategory) + where.push(`state.issue_category = $${params.length}`) } const result = await query( - `UPDATE order_items oi + `UPDATE order_items item SET fulfillment_locked = FALSE, updated_at = NOW() - FROM orders o - WHERE o.id = oi.order_id + FROM v_fulfillment_item_state state + WHERE item.id = state.order_item_id AND ${where.join(' AND ')}`, params, ) @@ -1393,6 +1548,7 @@ function resolvePendingGroupScopeInput(input: { export async function deletePendingChannelGroup(input: { groupKey?: string internalSku?: string + issueCategory?: string adminUserId: number | null }): Promise { const scope = resolvePendingGroupScopeInput(input) @@ -1413,6 +1569,16 @@ export async function deletePendingChannelGroup(input: { itemParams.push(scope.provider, scope.shopId, scope.skuCode) itemWhere.push(`o.provider = $1 AND o.shop_id = $2 AND oi.sku_code = $3`) } + const issueCategory = String(input.issueCategory || '').trim() + if ((PENDING_ISSUE_CATEGORIES as readonly string[]).includes(issueCategory)) { + itemParams.push(issueCategory) + itemWhere.push(`EXISTS ( + SELECT 1 + FROM v_fulfillment_item_state state + WHERE state.order_item_id = oi.id + AND state.issue_category = $${itemParams.length} + )`) + } const items = await client.query<{ id: number; order_id: number; basis_date: string }>( ` SELECT @@ -1520,6 +1686,7 @@ export async function assignPendingChannelGroup(input: { internalSku?: string mappedInternalSku?: string channel: string + issueCategory?: string adminUserId: number | null note?: string | undefined }): Promise<{ @@ -1544,7 +1711,19 @@ export async function assignPendingChannelGroup(input: { errorCode: 'internal_sku_mapping_required', }) } - const updated = await query( + const updateParams: unknown[] = [scope.provider, scope.shopId, scope.skuCode, mappedInternalSku] + const issueCategory = String(input.issueCategory || '').trim() + let issueCategoryFilter = '' + if ((PENDING_ISSUE_CATEGORIES as readonly string[]).includes(issueCategory)) { + updateParams.push(issueCategory) + issueCategoryFilter = `AND EXISTS ( + SELECT 1 + FROM v_fulfillment_item_state state + WHERE state.order_item_id = oi.id + AND state.issue_category = $${updateParams.length} + )` + } + const updated = await query<{ id: number }>( ` UPDATE order_items oi SET spec_json = COALESCE(oi.spec_json, '{}'::jsonb) @@ -1556,8 +1735,10 @@ export async function assignPendingChannelGroup(input: { AND o.shop_id = $2 AND oi.sku_code = $3 AND oi.fulfillment_status = 'pending' + ${issueCategoryFilter} + RETURNING oi.id `, - [scope.provider, scope.shopId, scope.skuCode, mappedInternalSku], + updateParams, ) if ((updated.rowCount || 0) === 0) { throw createHttpError('待分类来源 SKU 已发生变化,请刷新后重试', { @@ -1568,6 +1749,8 @@ export async function assignPendingChannelGroup(input: { return assignChannelByInternalSku({ internalSku: mappedInternalSku, channel: input.channel, + issueCategory: input.issueCategory || '', + orderItemIds: updated.rows.map((row) => Number(row.id)), adminUserId: input.adminUserId, note: input.note, }) @@ -1575,6 +1758,7 @@ export async function assignPendingChannelGroup(input: { return assignChannelByInternalSku({ internalSku: scope.internalSku, channel: input.channel, + issueCategory: input.issueCategory || '', adminUserId: input.adminUserId, note: input.note, }) @@ -1584,6 +1768,8 @@ export async function assignPendingChannelGroup(input: { export async function assignChannelByInternalSku(input: { internalSku: string channel: string + issueCategory?: string + orderItemIds?: number[] adminUserId: number | null note?: string | undefined createRule?: boolean | undefined @@ -1612,19 +1798,32 @@ export async function assignChannelByInternalSku(input: { }) } + const issueCategory = String(input.issueCategory || '').trim() + const requestedOrderItemIds = [ + ...new Set( + (input.orderItemIds || []).map(Number).filter((id) => Number.isInteger(id) && id > 0), + ), + ] + const idParams = [...requestedOrderItemIds] const idsResult = await query<{ id: number }>( ` - SELECT oi.id - FROM orders o - JOIN order_items oi ON oi.order_id = o.id - WHERE o.id = oi.order_id - AND ${INTERNAL_SKU_SQL} = $1 + SELECT state.order_item_id AS id + FROM v_fulfillment_item_state state + WHERE state.internal_sku = $1 AND ( - oi.fulfillment_status = 'pending' - OR (oi.fulfillment_status = 'assigned' AND oi.fulfillment_channel = $2) + state.fulfillment_status = 'pending' + OR ( + state.fulfillment_status = 'assigned' + AND state.stored_channel = $2 + ) + ) + AND ($3::text = '' OR state.issue_category = $3) + AND ( + $4::boolean = FALSE + OR state.order_item_id = ANY($5::bigint[]) ) `, - [internalSku, input.channel], + [internalSku, input.channel, issueCategory, requestedOrderItemIds.length > 0, idParams], ) const draftProductId = await ensureDraftProductForChannel(internalSku, input.channel) diff --git a/apps/backend/src/services/fulfillment/fulfillment-routing.integration.test.ts b/apps/backend/src/services/fulfillment/fulfillment-routing.integration.test.ts index f36e9765..c5249f68 100644 --- a/apps/backend/src/services/fulfillment/fulfillment-routing.integration.test.ts +++ b/apps/backend/src/services/fulfillment/fulfillment-routing.integration.test.ts @@ -430,6 +430,24 @@ test( { id: crossShopB, name: '测试店铺 B' }, ], ) + const pendingClassification = await listPendingChannelGroups({ + keyword: crossShopSku, + issueCategory: 'classification', + page: 1, + pageSize: 10, + }) + assert.equal(pendingClassification.total, 1) + assert.equal( + pendingClassification.items[0]?.issue_breakdown[0]?.issue_code, + 'missing_channel', + ) + const pendingAfterSales = await listPendingChannelGroups({ + keyword: crossShopSku, + issueCategory: 'after_sales', + page: 1, + pageSize: 10, + }) + assert.equal(pendingAfterSales.total, 0) const deletedPending = await deletePendingChannelGroup({ internalSku: crossShopSku, @@ -897,14 +915,18 @@ test( WHERE id = $1`, [retryPendingItemId], ) - const protectedRetry = await retryPendingCarrierGroup({ - internalSku: retryPendingAffiliateSku, - retryInactiveCarrier: true, - }) - assert.equal(protectedRetry.affiliateTasksCreated, 0) + await assert.rejects( + () => + retryPendingCarrierGroup({ + internalSku: retryPendingAffiliateSku, + retryInactiveCarrier: true, + issueCodes: ['user_refund'], + }), + /不允许重试/, + ) const protectedRetryState = await query<{ fulfillment_status: string - fulfillment_pending_detail: Record + fulfillment_pending_detail: Record | null task_count: number }>( `SELECT @@ -916,10 +938,7 @@ test( [retryPendingItemId], ) assert.equal(protectedRetryState.rows[0]?.fulfillment_status, 'pending') - assert.equal( - protectedRetryState.rows[0]?.fulfillment_pending_detail.carrierIssue, - 'carrier_task_user_refund', - ) + assert.equal(protectedRetryState.rows[0]?.fulfillment_pending_detail, null) assert.equal(protectedRetryState.rows[0]?.task_count, 1) const voucherJobPrefix = `IT-VOUCHER-LIST-${suffix}` diff --git a/apps/backend/src/services/fulfillment/fulfillment-state-view.test.ts b/apps/backend/src/services/fulfillment/fulfillment-state-view.test.ts new file mode 100644 index 00000000..deee603c --- /dev/null +++ b/apps/backend/src/services/fulfillment/fulfillment-state-view.test.ts @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +const migration = readFileSync( + new URL('../../db/migrations/137_fulfillment_item_state_view.sql', import.meta.url), + 'utf8', +) +const channelAssign = readFileSync(new URL('./channel-assign-service.ts', import.meta.url), 'utf8') +const auditScript = readFileSync( + new URL('../../../scripts/audit-fulfillment-state.ts', import.meta.url), + 'utf8', +) + +test('履约读模型按订单项拆分渠道、载体、尝试、异常和锁定原因', () => { + assert.ok(migration.includes('CREATE OR REPLACE VIEW v_fulfillment_item_state')) + assert.ok(migration.includes('current_attempt_id')) + assert.ok(migration.includes('carrier_status')) + assert.ok(migration.includes('issue_code')) + assert.ok(migration.includes("'after_sales'")) + assert.ok(migration.includes("'manual_review'")) + assert.ok(migration.includes("fulfillment_status IN ('pending', 'assigned')")) +}) + +test('待分类查询使用统一读模型并返回异常数量', () => { + assert.ok(channelAssign.includes('FROM v_fulfillment_item_state v')) + assert.ok(channelAssign.includes('issue_breakdown')) + assert.ok(channelAssign.includes('retryable_unlocked_item_count')) + assert.ok(channelAssign.includes('auto_retryable_unlocked_item_count')) + assert.ok(channelAssign.includes('PENDING_ISSUE_CATEGORIES')) + assert.ok(channelAssign.includes('issueCategoryInput')) +}) + +test('重试必须按可重试 issueCode 限定,不能按整个 SKU 处理售后和复核项', () => { + assert.ok(channelAssign.includes('RETRYABLE_CARRIER_ISSUES')) + assert.ok(channelAssign.includes('issueCodes?: string[]')) + assert.ok(channelAssign.includes('oi.issue_code = ANY($2::text[])')) +}) + +test('保存渠道、解除锁定、归档和重试都按当前问题分类限定作用域', () => { + assert.ok(channelAssign.includes('issueCategory?: string')) + assert.ok(channelAssign.includes('state.issue_category = $')) + assert.ok(channelAssign.includes('issueCodes?: string[]')) + assert.ok(channelAssign.includes('orderItemIds?: number[]')) +}) + +test('履约审计脚本覆盖状态不一致、售后队列和重复尝试', () => { + assert.ok(auditScript.includes('已分配但无有效载体')) + assert.ok(auditScript.includes('售后关闭仍进入履约队列')) + assert.ok(auditScript.includes('近 24 小时重复尝试订单项')) + assert.ok(auditScript.includes('--strict')) +}) diff --git a/apps/frontend/src/pages/admin/AdminFulfillmentChannelPage.tsx b/apps/frontend/src/pages/admin/AdminFulfillmentChannelPage.tsx index af002bbe..7aea826c 100644 --- a/apps/frontend/src/pages/admin/AdminFulfillmentChannelPage.tsx +++ b/apps/frontend/src/pages/admin/AdminFulfillmentChannelPage.tsx @@ -22,6 +22,7 @@ import { Input, InputNumber, Modal, + Segmented, Select, Space, Switch, @@ -99,26 +100,18 @@ const ENABLED_FILTER_OPTIONS = [ { value: 'false', label: '停用' }, ] -const PENDING_CONFIG_REASONS = new Set([ - 'manual_config_missing', - 'work_rule_missing', - 'affiliate_mapping_missing', - 'carrier_config_ambiguous', -]) - -const PENDING_RETRYABLE_TERMINAL_REASONS = new Set([ - 'carrier_task_closed', - 'carrier_task_retry_pending', - 'carrier_task_expired', -]) - -const PENDING_NON_RETRYABLE_TERMINAL_REASONS = new Set([ - 'carrier_task_user_refund', - 'carrier_task_cancelled', -]) - const MATCH_KEY_OPTIONS = [{ value: 'internal_sku', label: '内部 SKU' }] +const PENDING_ISSUE_CATEGORY_OPTIONS = [ + { value: 'all', label: '全部问题' }, + { value: 'classification', label: '未分类' }, + { value: 'configuration', label: '配置待补' }, + { value: 'carrier', label: '载体处理' }, + { value: 'manual_review', label: '人工复核' }, + { value: 'after_sales', label: '售后关闭' }, + { value: 'system', label: '系统异常' }, +] + const INTERNAL_SKU_MAPPING_TYPES: Array<{ value: InternalSkuMappingMatchType label: string @@ -153,14 +146,15 @@ function PendingPanel() { const queryClient = useQueryClient() const navigate = useNavigate() const [keyword, setKeyword] = useState('') + const [issueCategory, setIssueCategory] = useState('') const [page, setPage] = useState(1) const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE) const [selections, setSelections] = useState>({}) const [internalSkuDrafts, setInternalSkuDrafts] = useState>({}) const query = useQuery({ - queryKey: ['fulfillment-pending', keyword, page, pageSize], - queryFn: () => fetchPendingChannelGroups({ page, pageSize, keyword }), + queryKey: ['fulfillment-pending', keyword, issueCategory, page, pageSize], + queryFn: () => fetchPendingChannelGroups({ page, pageSize, keyword, issueCategory }), }) const assignMutation = useMutation({ @@ -172,6 +166,7 @@ function PendingPanel() { ? '' : String(internalSkuDrafts[rowKey(row)] || '').trim(), channel: selections[rowKey(row)] || '', + issueCategory, }), onSuccess: (result) => { const productMessage = result.data.draftProductId ? '和标准商品' : '' @@ -216,10 +211,15 @@ function PendingPanel() { }) const retryCarrierMutation = useMutation({ - mutationFn: (input: { row: PendingChannelGroup; retryInactiveCarrier: boolean }) => + mutationFn: (input: { + row: PendingChannelGroup + retryInactiveCarrier: boolean + issueCodes: string[] + }) => retryPendingCarriers({ internalSku: input.row.internal_sku || '', retryInactiveCarrier: input.retryInactiveCarrier, + issueCodes: input.issueCodes, }), onSuccess: (result) => { const data = result.data @@ -245,6 +245,7 @@ function PendingPanel() { unlockPendingChannelGroup({ groupKey: row.group_key, internalSku: row.internal_sku || '', + issueCategory, }), onSuccess: (result) => { message.success(`已解除 ${result.data.updated} 个订单项的运营锁定`) @@ -255,7 +256,7 @@ function PendingPanel() { const deleteMutation = useMutation({ mutationFn: (row: PendingChannelGroup) => - deletePendingChannelGroup({ groupKey: row.group_key }), + deletePendingChannelGroup({ groupKey: row.group_key, issueCategory }), onSuccess: (result) => { const data = result.data message.success( @@ -372,19 +373,46 @@ function PendingPanel() { affiliate_mapping_missing: '缺自动发货映射', existing_carrier_conflict: '已有其它载体', carrier_config_ambiguous: '载体配置冲突', + missing_internal_sku: '缺内部 SKU', + missing_channel: '缺渠道配置', + user_refund: '用户已申请退款', + platform_cancelled: '上游履约已取消', + carrier_manual_review: '载体人工复核', + carrier_retry_pending: '载体失败待重试', + carrier_expired: '载体已过期', + carrier_closed: '载体已关闭', + carrier_missing: '缺履约载体', + state_inconsistent: '履约状态不一致', } + const breakdown = row.issue_breakdown || [] return ( - - {value?.length ? value.map((reason) => labels[reason] || reason).join(' / ') : '-'} - - {row.pending_subreasons?.length ? ( - - {row.pending_subreasons - .map((reason) => subreasonLabels[reason] || reason) - .join(' / ')} - - ) : null} + {breakdown.length > 0 ? ( + breakdown.map((issue) => ( + + {subreasonLabels[issue.issue_code] || issue.issue_code} {issue.item_count} 项 + {issue.locked_item_count > 0 ? ` · 锁定 ${issue.locked_item_count}` : ''} + + )) + ) : ( + <> + + {value?.length + ? value.map((reason) => labels[reason] || reason).join(' / ') + : '-'} + + {row.pending_subreasons?.length ? ( + + {row.pending_subreasons + .map((reason) => subreasonLabels[reason] || reason) + .join(' / ')} + + ) : null} + + )} {row.locked_item_count > 0 ? ( 已人工指定渠道,等待补配置 {row.locked_item_count} 项 @@ -411,16 +439,24 @@ function PendingPanel() { title: '指定渠道', width: 440, render: (_: unknown, row: PendingChannelGroup) => { - const subreasons = row.pending_subreasons || [] - const hasConfigIssue = subreasons.some((reason) => PENDING_CONFIG_REASONS.has(reason)) - const hasManualReview = subreasons.includes('carrier_task_manual_review') - const hasRetryableTerminal = subreasons.some((reason) => - PENDING_RETRYABLE_TERMINAL_REASONS.has(reason), - ) - const hasNonRetryableTerminal = subreasons.some((reason) => - PENDING_NON_RETRYABLE_TERMINAL_REASONS.has(reason), - ) - const hasCarrierIssue = row.pending_reasons.includes('no_carrier') + const issueBreakdown = row.issue_breakdown || [] + const retryableIssueCodes = issueBreakdown + .filter((issue) => issue.retryable) + .map((issue) => issue.issue_code) + const autoRetryableIssueCodes = issueBreakdown + .filter((issue) => issue.auto_retryable) + .map((issue) => issue.issue_code) + const hasConfigIssue = Number(row.config_issue_item_count || 0) > 0 + const hasManualReview = Number(row.manual_review_item_count || 0) > 0 + const hasAfterSales = Number(row.after_sales_item_count || 0) > 0 + const hasSystemIssue = Number(row.system_issue_item_count || 0) > 0 + const hasRetryable = Number(row.retryable_unlocked_item_count || 0) > 0 + const hasAutoRetryable = Number(row.auto_retryable_unlocked_item_count || 0) > 0 + const hasOnlyRetryableIssues = + issueBreakdown.length > 0 && issueBreakdown.every((issue) => issue.retryable) + const hasOnlyAutoRetryableIssues = + issueBreakdown.length > 0 && issueBreakdown.every((issue) => issue.auto_retryable) + const canRunScopedActions = Boolean(issueCategory) || issueBreakdown.length <= 1 const hasUnlockedItems = row.locked_item_count < row.item_count return ( @@ -439,24 +475,25 @@ function PendingPanel() { type="primary" disabled={ (!row.internal_sku && !String(internalSkuDrafts[rowKey(row)] || '').trim()) || - !selections[rowKey(row)] + !selections[rowKey(row)] || + !canRunScopedActions } loading={assignMutation.isPending} onClick={() => assignMutation.mutate(row)} > 保存渠道 - {hasConfigIssue ? ( + {canRunScopedActions && hasConfigIssue ? ( ) : null} - {hasManualReview ? ( + {canRunScopedActions && hasManualReview ? ( ) : null} - {hasRetryableTerminal && !hasConfigIssue && hasUnlockedItems ? ( + {canRunScopedActions && hasOnlyRetryableIssues && hasRetryable && hasUnlockedItems ? ( ) : null} - {!hasConfigIssue && - !hasManualReview && - !hasRetryableTerminal && - !hasNonRetryableTerminal && - hasCarrierIssue && + {canRunScopedActions && + hasOnlyAutoRetryableIssues && + hasAutoRetryable && + !hasAfterSales && + !hasSystemIssue && row.configured_channels?.length && hasUnlockedItems ? ( @@ -496,14 +533,15 @@ function PendingPanel() { retryCarrierMutation.mutate({ row, retryInactiveCarrier: false, + issueCodes: autoRetryableIssueCodes, }) } > - 补载体 + 补载体({row.auto_retryable_unlocked_item_count}) ) : null} - {row.locked_item_count > 0 ? ( + {canRunScopedActions && row.locked_item_count > 0 ? (