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 { Router } from 'express'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
assignChannelByInternalSku,
|
||||||
assignChannelBySku,
|
assignChannelBySku,
|
||||||
assignChannelToOrderItems,
|
assignChannelToOrderItems,
|
||||||
deleteChannelRule,
|
deleteChannelRule,
|
||||||
@@ -79,6 +80,18 @@ router.post(
|
|||||||
const adminUserId = req.adminSession?.userId ?? null
|
const adminUserId = req.adminSession?.userId ?? null
|
||||||
const note = String(body.note || '')
|
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) {
|
if (body.skuCode) {
|
||||||
return assignChannelBySku({
|
return assignChannelBySku({
|
||||||
provider: String(body.provider || ''),
|
provider: String(body.provider || ''),
|
||||||
|
|||||||
@@ -32,6 +32,15 @@ import {
|
|||||||
* 4. 都没有 → 保持 pending(不再兜底成人工)
|
* 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 = {
|
export type ChannelRule = {
|
||||||
id: number
|
id: number
|
||||||
provider: string
|
provider: string
|
||||||
@@ -50,6 +59,7 @@ export type ChannelCandidateItem = {
|
|||||||
sku_code: string
|
sku_code: string
|
||||||
sku_name: string
|
sku_name: string
|
||||||
manual_sku_key: string | null
|
manual_sku_key: string | null
|
||||||
|
internal_sku: string
|
||||||
normalized_sku_name: string | null
|
normalized_sku_name: string | null
|
||||||
has_work_order: boolean
|
has_work_order: boolean
|
||||||
has_affiliate_task: boolean
|
has_affiliate_task: boolean
|
||||||
@@ -76,7 +86,7 @@ function matchesScope(rule: ChannelRule, item: ChannelCandidateItem): boolean {
|
|||||||
|
|
||||||
function itemKey(item: ChannelCandidateItem, keyType: MatchKeyType): string {
|
function itemKey(item: ChannelCandidateItem, keyType: MatchKeyType): string {
|
||||||
if (keyType === 'sku_code') return normalizeKey(item.sku_code)
|
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()
|
return normalizeKey(item.normalized_sku_name) || normalizeKey(item.sku_name).toLowerCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,6 +198,7 @@ export async function assignPendingOrderItemChannels(
|
|||||||
oi.sku_code,
|
oi.sku_code,
|
||||||
oi.sku_name,
|
oi.sku_name,
|
||||||
oi.manual_sku_key,
|
oi.manual_sku_key,
|
||||||
|
${INTERNAL_SKU_SQL} AS internal_sku,
|
||||||
oi.normalized_sku_name,
|
oi.normalized_sku_name,
|
||||||
(COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date::text
|
(COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date::text
|
||||||
AS basis_date,
|
AS basis_date,
|
||||||
@@ -339,15 +350,16 @@ export async function unlockChannel(orderItemIds: number[]): Promise<{ updated:
|
|||||||
export type PendingGroupRow = {
|
export type PendingGroupRow = {
|
||||||
provider: string
|
provider: string
|
||||||
shop_id: string
|
shop_id: string
|
||||||
sku_code: string
|
internal_sku: string
|
||||||
sku_name: string
|
sku_name: string
|
||||||
|
sku_codes: string[]
|
||||||
item_count: number
|
item_count: number
|
||||||
revenue_fen: number
|
revenue_fen: number
|
||||||
first_seen_at: string
|
first_seen_at: string
|
||||||
last_seen_at: string
|
last_seen_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 待分类队列:按 SKU 聚合,而不是一单一行。 */
|
/** 待分类队列:按内部 SKU 聚合(同一中文 SKU 的不同平台码合并为一行)。 */
|
||||||
export async function listPendingChannelGroups(input: {
|
export async function listPendingChannelGroups(input: {
|
||||||
page?: number
|
page?: number
|
||||||
pageSize?: number
|
pageSize?: number
|
||||||
@@ -360,7 +372,9 @@ export async function listPendingChannelGroups(input: {
|
|||||||
const clauses = [`oi.fulfillment_status = 'pending'`]
|
const clauses = [`oi.fulfillment_status = 'pending'`]
|
||||||
if (keyword) {
|
if (keyword) {
|
||||||
params.push(`%${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 ')
|
const where = clauses.join(' AND ')
|
||||||
|
|
||||||
@@ -371,7 +385,7 @@ export async function listPendingChannelGroups(input: {
|
|||||||
FROM order_items oi
|
FROM order_items oi
|
||||||
JOIN orders o ON o.id = oi.order_id
|
JOIN orders o ON o.id = oi.order_id
|
||||||
WHERE ${where}
|
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
|
) grouped
|
||||||
`,
|
`,
|
||||||
params,
|
params,
|
||||||
@@ -383,8 +397,9 @@ export async function listPendingChannelGroups(input: {
|
|||||||
SELECT
|
SELECT
|
||||||
o.provider,
|
o.provider,
|
||||||
o.shop_id,
|
o.shop_id,
|
||||||
oi.sku_code,
|
${INTERNAL_SKU_SQL} AS internal_sku,
|
||||||
oi.sku_name,
|
MIN(oi.sku_name) AS sku_name,
|
||||||
|
array_agg(DISTINCT oi.sku_code) AS sku_codes,
|
||||||
COUNT(*)::int AS item_count,
|
COUNT(*)::int AS item_count,
|
||||||
COALESCE(SUM(oi.revenue_amount_fen), 0)::bigint AS revenue_fen,
|
COALESCE(SUM(oi.revenue_amount_fen), 0)::bigint AS revenue_fen,
|
||||||
MIN(oi.created_at) AS first_seen_at,
|
MIN(oi.created_at) AS first_seen_at,
|
||||||
@@ -392,8 +407,8 @@ export async function listPendingChannelGroups(input: {
|
|||||||
FROM order_items oi
|
FROM order_items oi
|
||||||
JOIN orders o ON o.id = oi.order_id
|
JOIN orders o ON o.id = oi.order_id
|
||||||
WHERE ${where}
|
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}
|
||||||
ORDER BY item_count DESC, oi.sku_code
|
ORDER BY item_count DESC, internal_sku
|
||||||
LIMIT $${params.length - 1} OFFSET $${params.length}
|
LIMIT $${params.length - 1} OFFSET $${params.length}
|
||||||
`,
|
`,
|
||||||
params,
|
params,
|
||||||
@@ -402,6 +417,79 @@ export async function listPendingChannelGroups(input: {
|
|||||||
return { items: rows.rows, total: total.rows[0]?.count || 0, page, pageSize }
|
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 批量指定(待分类队列的主操作)。 */
|
/** 按 SKU 批量指定(待分类队列的主操作)。 */
|
||||||
export async function assignChannelBySku(input: {
|
export async function assignChannelBySku(input: {
|
||||||
provider: string
|
provider: string
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ function PendingPanel() {
|
|||||||
assignChannelBySku({
|
assignChannelBySku({
|
||||||
provider: row.provider,
|
provider: row.provider,
|
||||||
shopId: row.shop_id,
|
shopId: row.shop_id,
|
||||||
skuCode: row.sku_code,
|
internalSku: row.internal_sku,
|
||||||
channel: selections[rowKey(row)] || '',
|
channel: selections[rowKey(row)] || '',
|
||||||
}),
|
}),
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
@@ -118,7 +118,13 @@ function PendingPanel() {
|
|||||||
const columns = [
|
const columns = [
|
||||||
{ title: '来源', dataIndex: 'provider', width: 120 },
|
{ title: '来源', dataIndex: 'provider', width: 120 },
|
||||||
{ title: '店铺', dataIndex: 'shop_id', width: 140 },
|
{ 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: 'sku_name', ellipsis: true },
|
||||||
{ title: '订单数', dataIndex: 'item_count', width: 90 },
|
{ title: '订单数', dataIndex: 'item_count', width: 90 },
|
||||||
{
|
{
|
||||||
@@ -510,5 +516,5 @@ function PipelinesPanel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function rowKey(row: PendingChannelGroup) {
|
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'
|
import { apiDelete, apiGet, apiPost } from '@/lib/http'
|
||||||
|
|
||||||
/** 待分类队列(按 SKU 聚合)。 */
|
/** 待分类队列(按内部 SKU 聚合)。 */
|
||||||
export type PendingChannelGroup = {
|
export type PendingChannelGroup = {
|
||||||
provider: string
|
provider: string
|
||||||
shop_id: string
|
shop_id: string
|
||||||
sku_code: string
|
internal_sku: string
|
||||||
sku_name: string
|
sku_name: string
|
||||||
|
sku_codes: string[]
|
||||||
item_count: number
|
item_count: number
|
||||||
revenue_fen: number
|
revenue_fen: number
|
||||||
first_seen_at: string
|
first_seen_at: string
|
||||||
@@ -94,7 +95,7 @@ export function fetchChannelExceptions(
|
|||||||
export function assignChannelBySku(payload: {
|
export function assignChannelBySku(payload: {
|
||||||
provider: string
|
provider: string
|
||||||
shopId: string
|
shopId: string
|
||||||
skuCode: string
|
internalSku: string
|
||||||
channel: string
|
channel: string
|
||||||
note?: string
|
note?: string
|
||||||
}) {
|
}) {
|
||||||
|
|||||||
Reference in New Issue
Block a user