fix(finance): 待分类队列按内部中文 SKU 去重与分流
- 同一内部 SKU 的不同平台 SKU 合并为一行(显示平台 SKU 列表) - 指定渠道按 internal_sku 批量更新,并生成 internal_sku 规则 - 渠道候选的 internal_sku 优先取中文 manual_sku_key/sku_name
This commit is contained in:
@@ -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
|
||||
}) {
|
||||
|
||||
Reference in New Issue
Block a user