From 03566aee2b28f7bcbbc817401109a0fe24ca7390 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Mon, 2 Nov 2026 13:32:05 +0800 Subject: [PATCH] =?UTF-8?q?fix(finance):=20=E5=BE=85=E5=88=86=E7=B1=BB?= =?UTF-8?q?=E9=98=9F=E5=88=97=E6=8C=89=E5=86=85=E9=83=A8=E4=B8=AD=E6=96=87?= =?UTF-8?q?=20SKU=20=E5=8E=BB=E9=87=8D=E4=B8=8E=E5=88=86=E6=B5=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 同一内部 SKU 的不同平台 SKU 合并为一行(显示平台 SKU 列表) - 指定渠道按 internal_sku 批量更新,并生成 internal_sku 规则 - 渠道候选的 internal_sku 优先取中文 manual_sku_key/sku_name --- .../src/routes/admin/fulfillment-channel.ts | 13 +++ .../fulfillment/channel-assign-service.ts | 106 ++++++++++++++++-- .../admin/AdminFulfillmentChannelPage.tsx | 12 +- .../src/services/admin/fulfillment-channel.ts | 7 +- 4 files changed, 123 insertions(+), 15 deletions(-) diff --git a/apps/backend/src/routes/admin/fulfillment-channel.ts b/apps/backend/src/routes/admin/fulfillment-channel.ts index e6aa9fc1..58125a9a 100644 --- a/apps/backend/src/routes/admin/fulfillment-channel.ts +++ b/apps/backend/src/routes/admin/fulfillment-channel.ts @@ -8,6 +8,7 @@ import { Router } from 'express' import { + assignChannelByInternalSku, assignChannelBySku, assignChannelToOrderItems, deleteChannelRule, @@ -79,6 +80,18 @@ router.post( const adminUserId = req.adminSession?.userId ?? null const note = String(body.note || '') + if (body.internalSku) { + return assignChannelByInternalSku({ + provider: String(body.provider || ''), + shopId: String(body.shopId || ''), + internalSku: String(body.internalSku), + channel, + adminUserId, + note, + createRule: body.createRule !== false, + }) + } + if (body.skuCode) { return assignChannelBySku({ provider: String(body.provider || ''), diff --git a/apps/backend/src/services/fulfillment/channel-assign-service.ts b/apps/backend/src/services/fulfillment/channel-assign-service.ts index d6e90f3c..dc2ec145 100644 --- a/apps/backend/src/services/fulfillment/channel-assign-service.ts +++ b/apps/backend/src/services/fulfillment/channel-assign-service.ts @@ -32,6 +32,15 @@ import { * 4. 都没有 → 保持 pending(不再兜底成人工) */ +/** 内部 SKU:优先中文手动键,其次中文商品名,最后平台 SKU。 */ +const INTERNAL_SKU_SQL = ` + CASE + WHEN btrim(COALESCE(oi.manual_sku_key, '')) ~ '[一-龥]' THEN btrim(oi.manual_sku_key) + WHEN btrim(COALESCE(oi.sku_name, '')) ~ '[一-龥]' THEN btrim(oi.sku_name) + ELSE COALESCE(NULLIF(btrim(oi.manual_sku_key), ''), NULLIF(btrim(oi.sku_name), ''), oi.sku_code) + END +` + export type ChannelRule = { id: number provider: string @@ -50,6 +59,7 @@ export type ChannelCandidateItem = { sku_code: string sku_name: string manual_sku_key: string | null + internal_sku: string normalized_sku_name: string | null has_work_order: boolean has_affiliate_task: boolean @@ -76,7 +86,7 @@ function matchesScope(rule: ChannelRule, item: ChannelCandidateItem): boolean { function itemKey(item: ChannelCandidateItem, keyType: MatchKeyType): string { if (keyType === 'sku_code') return normalizeKey(item.sku_code) - if (keyType === 'internal_sku') return normalizeKey(item.manual_sku_key) + if (keyType === 'internal_sku') return normalizeKey(item.internal_sku || item.manual_sku_key) return normalizeKey(item.normalized_sku_name) || normalizeKey(item.sku_name).toLowerCase() } @@ -188,6 +198,7 @@ export async function assignPendingOrderItemChannels( oi.sku_code, oi.sku_name, oi.manual_sku_key, + ${INTERNAL_SKU_SQL} AS internal_sku, oi.normalized_sku_name, (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date::text AS basis_date, @@ -339,15 +350,16 @@ export async function unlockChannel(orderItemIds: number[]): Promise<{ updated: export type PendingGroupRow = { provider: string shop_id: string - sku_code: string + internal_sku: string sku_name: string + sku_codes: string[] item_count: number revenue_fen: number first_seen_at: string last_seen_at: string } -/** 待分类队列:按 SKU 聚合,而不是一单一行。 */ +/** 待分类队列:按内部 SKU 聚合(同一中文 SKU 的不同平台码合并为一行)。 */ export async function listPendingChannelGroups(input: { page?: number pageSize?: number @@ -360,7 +372,9 @@ export async function listPendingChannelGroups(input: { const clauses = [`oi.fulfillment_status = 'pending'`] if (keyword) { params.push(`%${keyword}%`) - clauses.push(`(oi.sku_code ILIKE $${params.length} OR oi.sku_name ILIKE $${params.length})`) + clauses.push( + `(oi.sku_code ILIKE $${params.length} OR oi.sku_name ILIKE $${params.length} OR oi.manual_sku_key ILIKE $${params.length})`, + ) } const where = clauses.join(' AND ') @@ -371,7 +385,7 @@ export async function listPendingChannelGroups(input: { FROM order_items oi JOIN orders o ON o.id = oi.order_id WHERE ${where} - GROUP BY o.provider, o.shop_id, oi.sku_code, oi.sku_name + GROUP BY o.provider, o.shop_id, ${INTERNAL_SKU_SQL} ) grouped `, params, @@ -383,8 +397,9 @@ export async function listPendingChannelGroups(input: { SELECT o.provider, o.shop_id, - oi.sku_code, - oi.sku_name, + ${INTERNAL_SKU_SQL} AS internal_sku, + MIN(oi.sku_name) AS sku_name, + array_agg(DISTINCT oi.sku_code) AS sku_codes, COUNT(*)::int AS item_count, COALESCE(SUM(oi.revenue_amount_fen), 0)::bigint AS revenue_fen, MIN(oi.created_at) AS first_seen_at, @@ -392,8 +407,8 @@ export async function listPendingChannelGroups(input: { FROM order_items oi JOIN orders o ON o.id = oi.order_id WHERE ${where} - GROUP BY o.provider, o.shop_id, oi.sku_code, oi.sku_name - ORDER BY item_count DESC, oi.sku_code + GROUP BY o.provider, o.shop_id, ${INTERNAL_SKU_SQL} + ORDER BY item_count DESC, internal_sku LIMIT $${params.length - 1} OFFSET $${params.length} `, params, @@ -402,6 +417,79 @@ export async function listPendingChannelGroups(input: { return { items: rows.rows, total: total.rows[0]?.count || 0, page, pageSize } } +/** 按内部 SKU 批量指定(同一中文 SKU 跨平台码一起分流)。 */ +export async function assignChannelByInternalSku(input: { + provider: string + shopId: string + internalSku: string + channel: string + adminUserId: number | null + note?: string + createRule?: boolean +}): Promise<{ updated: number; ruleCreated: boolean }> { + if (!isFulfillmentChannel(input.channel)) { + throw createHttpError('渠道无效', { statusCode: 400, errorCode: 'invalid_channel' }) + } + const internalSku = String(input.internalSku || '').trim() + if (!internalSku) { + throw createHttpError('内部 SKU 不能为空', { + statusCode: 400, + errorCode: 'internal_sku_required', + }) + } + + const idsResult = await query<{ id: number }>( + ` + UPDATE order_items oi + SET + fulfillment_channel = $4, + fulfillment_status = 'assigned', + fulfillment_source = 'operator_assigned', + fulfillment_locked = TRUE, + fulfillment_decided_by = $5, + fulfillment_decided_at = NOW(), + updated_at = NOW() + FROM orders o + WHERE o.id = oi.order_id + AND o.provider = $1 + AND o.shop_id = $2 + AND ${INTERNAL_SKU_SQL} = $3 + AND oi.fulfillment_status = 'pending' + RETURNING oi.id + `, + [input.provider, input.shopId, internalSku, input.channel, input.adminUserId], + ) + + let ruleCreated = false + if (input.createRule !== false) { + const rule = await upsertChannelRule({ + provider: input.provider, + shopId: input.shopId, + matchKeyType: 'internal_sku', + matchKey: internalSku, + channel: input.channel, + priority: 10, + source: 'operator', + enabled: true, + note: `运营在待分类队列指定:${String(input.note || '').slice(0, 120)}`, + adminUserId: input.adminUserId, + }) + ruleCreated = rule.id > 0 + } + + const ids = idsResult.rows.map((row) => Number(row.id)) + if (ids.length > 0) { + await normalizeProductsForItems(ids).catch(() => undefined) + const cost = await syncCostSnapshotsForItems(ids).catch(() => null) + const dates = [ + ...new Set([...(await orderItemBasisDates(ids)), ...(cost?.affectedDates || [])]), + ] + await refreshFactsForDates(dates).catch(() => undefined) + } + + return { updated: ids.length, ruleCreated } +} + /** 按 SKU 批量指定(待分类队列的主操作)。 */ export async function assignChannelBySku(input: { provider: string diff --git a/apps/frontend/src/pages/admin/AdminFulfillmentChannelPage.tsx b/apps/frontend/src/pages/admin/AdminFulfillmentChannelPage.tsx index d5e88180..5e4d7de7 100644 --- a/apps/frontend/src/pages/admin/AdminFulfillmentChannelPage.tsx +++ b/apps/frontend/src/pages/admin/AdminFulfillmentChannelPage.tsx @@ -96,7 +96,7 @@ function PendingPanel() { assignChannelBySku({ provider: row.provider, shopId: row.shop_id, - skuCode: row.sku_code, + internalSku: row.internal_sku, channel: selections[rowKey(row)] || '', }), onSuccess: (result) => { @@ -118,7 +118,13 @@ function PendingPanel() { const columns = [ { title: '来源', dataIndex: 'provider', width: 120 }, { title: '店铺', dataIndex: 'shop_id', width: 140 }, - { title: 'SKU', dataIndex: 'sku_code', width: 220 }, + { title: '内部SKU', dataIndex: 'internal_sku', width: 220 }, + { + title: '平台SKU', + dataIndex: 'sku_codes', + width: 180, + render: (value: string[] | undefined) => (value?.length ? value.join(' / ') : '-'), + }, { title: '商品名', dataIndex: 'sku_name', ellipsis: true }, { title: '订单数', dataIndex: 'item_count', width: 90 }, { @@ -510,5 +516,5 @@ function PipelinesPanel() { } function rowKey(row: PendingChannelGroup) { - return `${row.provider}|${row.shop_id}|${row.sku_code}` + return `${row.provider}|${row.shop_id}|${row.internal_sku}` } diff --git a/apps/frontend/src/services/admin/fulfillment-channel.ts b/apps/frontend/src/services/admin/fulfillment-channel.ts index 81cbd752..28e20d9e 100644 --- a/apps/frontend/src/services/admin/fulfillment-channel.ts +++ b/apps/frontend/src/services/admin/fulfillment-channel.ts @@ -1,11 +1,12 @@ import { apiDelete, apiGet, apiPost } from '@/lib/http' -/** 待分类队列(按 SKU 聚合)。 */ +/** 待分类队列(按内部 SKU 聚合)。 */ export type PendingChannelGroup = { provider: string shop_id: string - sku_code: string + internal_sku: string sku_name: string + sku_codes: string[] item_count: number revenue_fen: number first_seen_at: string @@ -94,7 +95,7 @@ export function fetchChannelExceptions( export function assignChannelBySku(payload: { provider: string shopId: string - skuCode: string + internalSku: string channel: string note?: string }) {