fix(finance): 待分类队列按内部中文 SKU 去重与分流

- 同一内部 SKU 的不同平台 SKU 合并为一行(显示平台 SKU 列表)
- 指定渠道按 internal_sku 批量更新,并生成 internal_sku 规则
- 渠道候选的 internal_sku 优先取中文 manual_sku_key/sku_name
This commit is contained in:
yml2213
2026-11-02 13:32:05 +08:00
parent fbd5f8f74e
commit 03566aee2b
4 changed files with 123 additions and 15 deletions
@@ -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 || ''),
@@ -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
@@ -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}`
}
@@ -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
}) {