|
|
|
@@ -213,7 +213,7 @@ export function resolveChannelDecision(item: ChannelCandidateItem): ChannelDecis
|
|
|
|
|
pendingReason: '',
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (plan.primary.channel === 'third_party_voucher') {
|
|
|
|
|
if (plan.primary.channel === 'third_party_voucher' || plan.primary.channel === 'on_account') {
|
|
|
|
|
return {
|
|
|
|
|
channel: plan.primary.channel,
|
|
|
|
|
source: 'auto_mapping',
|
|
|
|
@@ -419,13 +419,14 @@ export async function assignPendingOrderItemChannels(
|
|
|
|
|
AS input(id, channel, source)
|
|
|
|
|
WHERE oi.id = input.id
|
|
|
|
|
AND oi.fulfillment_status = 'pending'
|
|
|
|
|
AND oi.fulfillment_locked = FALSE
|
|
|
|
|
AND (oi.fulfillment_locked = FALSE OR $4::boolean)
|
|
|
|
|
RETURNING oi.id
|
|
|
|
|
`,
|
|
|
|
|
[
|
|
|
|
|
assignments.map((row) => row.id),
|
|
|
|
|
assignments.map((row) => row.channel),
|
|
|
|
|
assignments.map((row) => row.source),
|
|
|
|
|
options.includeLocked === true,
|
|
|
|
|
],
|
|
|
|
|
)
|
|
|
|
|
const assignedItemIds = updated.rows
|
|
|
|
@@ -456,11 +457,18 @@ export async function assignOrderItemsAfterCarrierReady(input: {
|
|
|
|
|
source: FulfillmentSource
|
|
|
|
|
lock: boolean
|
|
|
|
|
note?: string | undefined
|
|
|
|
|
}): Promise<{ updated: number; carrierFailed: number }> {
|
|
|
|
|
canonicalProductId?: number | null
|
|
|
|
|
}): Promise<{
|
|
|
|
|
updated: number
|
|
|
|
|
carrierFailed: number
|
|
|
|
|
carrierFailures: Array<{ orderItemId: number; reason: string; message: string }>
|
|
|
|
|
}> {
|
|
|
|
|
const ids = [
|
|
|
|
|
...new Set(input.orderItemIds.map(Number).filter((id) => Number.isInteger(id) && id > 0)),
|
|
|
|
|
]
|
|
|
|
|
if (ids.length === 0) return { updated: 0, carrierFailed: 0 }
|
|
|
|
|
if (ids.length === 0) {
|
|
|
|
|
return { updated: 0, carrierFailed: 0, carrierFailures: [] }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const items = (
|
|
|
|
|
await query<OrderItemRow>(`SELECT * FROM order_items WHERE id = ANY($1::bigint[])`, [ids])
|
|
|
|
@@ -473,6 +481,7 @@ export async function assignOrderItemsAfterCarrierReady(input: {
|
|
|
|
|
)
|
|
|
|
|
const assignedIds: number[] = []
|
|
|
|
|
let carrierFailed = 0
|
|
|
|
|
const carrierFailures: Array<{ orderItemId: number; reason: string; message: string }> = []
|
|
|
|
|
|
|
|
|
|
for (const item of items) {
|
|
|
|
|
if (item.fulfillment_status === 'archived') continue
|
|
|
|
@@ -515,6 +524,11 @@ export async function assignOrderItemsAfterCarrierReady(input: {
|
|
|
|
|
|
|
|
|
|
if (!carrier.ready) {
|
|
|
|
|
carrierFailed += 1
|
|
|
|
|
carrierFailures.push({
|
|
|
|
|
orderItemId: Number(item.id),
|
|
|
|
|
reason: carrier.reason,
|
|
|
|
|
message: carrier.message,
|
|
|
|
|
})
|
|
|
|
|
if (item.fulfillment_status === 'assigned') {
|
|
|
|
|
// 运营切换渠道失败时恢复原渠道,宁可保留旧状态,也不制造渠道与载体不一致。
|
|
|
|
|
await query(
|
|
|
|
@@ -542,12 +556,13 @@ export async function assignOrderItemsAfterCarrierReady(input: {
|
|
|
|
|
} else {
|
|
|
|
|
await query(
|
|
|
|
|
`UPDATE order_items
|
|
|
|
|
SET fulfillment_channel = NULL,
|
|
|
|
|
SET fulfillment_channel = $3,
|
|
|
|
|
fulfillment_status = 'pending',
|
|
|
|
|
fulfillment_source = '',
|
|
|
|
|
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),
|
|
|
|
|
updated_at = NOW()
|
|
|
|
|
WHERE id = $1 AND fulfillment_status <> 'archived'`,
|
|
|
|
|
[
|
|
|
|
@@ -558,6 +573,8 @@ export async function assignOrderItemsAfterCarrierReady(input: {
|
|
|
|
|
message: carrier.message,
|
|
|
|
|
carrierIssue: carrier.reason,
|
|
|
|
|
}),
|
|
|
|
|
input.channel,
|
|
|
|
|
input.canonicalProductId || null,
|
|
|
|
|
],
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
@@ -570,12 +587,20 @@ export async function assignOrderItemsAfterCarrierReady(input: {
|
|
|
|
|
fulfillment_status = 'assigned',
|
|
|
|
|
fulfillment_source = $3,
|
|
|
|
|
fulfillment_locked = $4,
|
|
|
|
|
canonical_product_id = COALESCE($6, canonical_product_id),
|
|
|
|
|
fulfillment_reason = COALESCE(NULLIF($5, ''), fulfillment_reason),
|
|
|
|
|
fulfillment_pending_reason = NULL,
|
|
|
|
|
fulfillment_pending_detail = NULL,
|
|
|
|
|
updated_at = NOW()
|
|
|
|
|
WHERE id = $1 AND fulfillment_status <> 'archived'`,
|
|
|
|
|
[item.id, input.channel, input.source, input.lock, String(input.note || '').slice(0, 200)],
|
|
|
|
|
[
|
|
|
|
|
item.id,
|
|
|
|
|
input.channel,
|
|
|
|
|
input.source,
|
|
|
|
|
input.lock,
|
|
|
|
|
String(input.note || '').slice(0, 200),
|
|
|
|
|
input.canonicalProductId || null,
|
|
|
|
|
],
|
|
|
|
|
)
|
|
|
|
|
if ((updated.rowCount || 0) > 0) assignedIds.push(Number(item.id))
|
|
|
|
|
}
|
|
|
|
@@ -611,7 +636,11 @@ export async function assignOrderItemsAfterCarrierReady(input: {
|
|
|
|
|
[ids, input.channel, input.source, input.lock],
|
|
|
|
|
)
|
|
|
|
|
await syncAssignedChannelSnapshots(ids, input.channel, input.source)
|
|
|
|
|
return { updated: Number(final.rows[0]?.count || assignedIds.length), carrierFailed }
|
|
|
|
|
return {
|
|
|
|
|
updated: Number(final.rows[0]?.count || assignedIds.length),
|
|
|
|
|
carrierFailed,
|
|
|
|
|
carrierFailures,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function syncAssignedChannelSnapshots(
|
|
|
|
@@ -646,7 +675,9 @@ async function syncAssignedChannelSnapshots(
|
|
|
|
|
reason:
|
|
|
|
|
channel === 'work_order'
|
|
|
|
|
? '内部 SKU 渠道配置指定接单平台'
|
|
|
|
|
: '内部 SKU 渠道配置指定第三方卡券',
|
|
|
|
|
: channel === 'on_account'
|
|
|
|
|
? '内部 SKU 渠道配置指定统一结算'
|
|
|
|
|
: '内部 SKU 渠道配置指定第三方卡券',
|
|
|
|
|
matchedRule: null,
|
|
|
|
|
candidates: [],
|
|
|
|
|
skipped: [],
|
|
|
|
@@ -963,7 +994,9 @@ export async function unlockChannel(orderItemIds: number[]): Promise<{ updated:
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export type PendingGroupRow = {
|
|
|
|
|
internal_sku: string
|
|
|
|
|
group_key: string
|
|
|
|
|
scope_type: 'internal_sku' | 'source_sku'
|
|
|
|
|
internal_sku: string | null
|
|
|
|
|
sku_name: string
|
|
|
|
|
providers: string[]
|
|
|
|
|
shop_ids: string[]
|
|
|
|
@@ -980,7 +1013,7 @@ export type PendingGroupRow = {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type PendingShopNameRow = {
|
|
|
|
|
internal_sku: string
|
|
|
|
|
group_key: string
|
|
|
|
|
provider: string
|
|
|
|
|
shop_id: string
|
|
|
|
|
shop_name: string
|
|
|
|
@@ -989,6 +1022,20 @@ type PendingShopNameRow = {
|
|
|
|
|
|
|
|
|
|
type PendingGroupDbRow = Omit<PendingGroupRow, 'shops'>
|
|
|
|
|
|
|
|
|
|
const PENDING_GROUP_KEY_SQL = `
|
|
|
|
|
CASE
|
|
|
|
|
WHEN COALESCE(BTRIM(${INTERNAL_SKU_SQL}), '') <> ''
|
|
|
|
|
THEN 'sku:' || encode(convert_to(${INTERNAL_SKU_SQL}, 'UTF8'), 'hex')
|
|
|
|
|
ELSE 'source:' || encode(
|
|
|
|
|
convert_to(
|
|
|
|
|
CONCAT_WS(CHR(31), o.provider, o.shop_id, oi.sku_code),
|
|
|
|
|
'UTF8'
|
|
|
|
|
),
|
|
|
|
|
'hex'
|
|
|
|
|
)
|
|
|
|
|
END
|
|
|
|
|
`
|
|
|
|
|
|
|
|
|
|
/** 待分类队列:按全局内部 SKU 聚合(跨平台、跨店铺合并为一行)。 */
|
|
|
|
|
export async function listPendingChannelGroups(input: {
|
|
|
|
|
page?: number
|
|
|
|
@@ -1010,13 +1057,16 @@ export async function listPendingChannelGroups(input: {
|
|
|
|
|
|
|
|
|
|
const total = await query<{ count: number }>(
|
|
|
|
|
`
|
|
|
|
|
SELECT COUNT(*)::int AS count FROM (
|
|
|
|
|
SELECT 1
|
|
|
|
|
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
|
|
|
|
|
WHERE ${where}
|
|
|
|
|
GROUP BY ${INTERNAL_SKU_SQL}
|
|
|
|
|
) grouped
|
|
|
|
|
)
|
|
|
|
|
SELECT COUNT(DISTINCT group_key)::int AS count
|
|
|
|
|
FROM pending_items
|
|
|
|
|
`,
|
|
|
|
|
params,
|
|
|
|
|
)
|
|
|
|
@@ -1024,86 +1074,116 @@ export async function listPendingChannelGroups(input: {
|
|
|
|
|
params.push(pageSize, (page - 1) * pageSize)
|
|
|
|
|
const rows = await query<PendingGroupDbRow>(
|
|
|
|
|
`
|
|
|
|
|
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
|
|
|
|
|
WHERE ${where}
|
|
|
|
|
)
|
|
|
|
|
SELECT
|
|
|
|
|
${INTERNAL_SKU_SQL} AS internal_sku,
|
|
|
|
|
MIN(oi.sku_name) AS sku_name,
|
|
|
|
|
array_agg(DISTINCT o.provider) AS providers,
|
|
|
|
|
array_agg(DISTINCT o.shop_id) AS shop_ids,
|
|
|
|
|
array_agg(DISTINCT oi.sku_code) AS sku_codes,
|
|
|
|
|
array_remove(array_agg(DISTINCT oi.fulfillment_pending_reason), NULL) AS pending_reasons,
|
|
|
|
|
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(
|
|
|
|
|
oi.fulfillment_pending_detail->>'carrierIssue',
|
|
|
|
|
oi.fulfillment_pending_detail->>'reason'
|
|
|
|
|
pi.fulfillment_pending_detail->>'carrierIssue',
|
|
|
|
|
pi.fulfillment_pending_detail->>'reason'
|
|
|
|
|
)
|
|
|
|
|
),
|
|
|
|
|
NULL
|
|
|
|
|
) AS pending_subreasons,
|
|
|
|
|
COALESCE(cfg.channels, ARRAY[]::text[]) AS configured_channels,
|
|
|
|
|
COUNT(*) FILTER (WHERE oi.fulfillment_locked)::int AS locked_item_count,
|
|
|
|
|
COUNT(*) FILTER (WHERE pi.fulfillment_locked)::int AS locked_item_count,
|
|
|
|
|
COUNT(*)::int AS item_count,
|
|
|
|
|
COALESCE(SUM(oi.revenue_amount_fen), 0)::bigint AS revenue_fen,
|
|
|
|
|
MIN(oi.created_at) AS first_seen_at,
|
|
|
|
|
MAX(oi.created_at) AS last_seen_at
|
|
|
|
|
FROM order_items oi
|
|
|
|
|
JOIN orders o ON o.id = oi.order_id
|
|
|
|
|
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
|
|
|
|
|
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) =
|
|
|
|
|
${INTERNAL_SKU_SQL}
|
|
|
|
|
pi.internal_sku
|
|
|
|
|
) cfg ON TRUE
|
|
|
|
|
WHERE ${where}
|
|
|
|
|
GROUP BY ${INTERNAL_SKU_SQL}, cfg.channels
|
|
|
|
|
GROUP BY pi.group_key, pi.internal_sku, cfg.channels
|
|
|
|
|
ORDER BY item_count DESC, internal_sku
|
|
|
|
|
LIMIT $${params.length - 1} OFFSET $${params.length}
|
|
|
|
|
`,
|
|
|
|
|
params,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const internalSkus = rows.rows.map((row) => row.internal_sku)
|
|
|
|
|
const groupKeys = rows.rows.map((row) => row.group_key)
|
|
|
|
|
const shopsBySku = new Map<string, Array<{ id: string; name: string }>>()
|
|
|
|
|
if (internalSkus.length > 0) {
|
|
|
|
|
if (groupKeys.length > 0) {
|
|
|
|
|
const shopRows = await query<PendingShopNameRow>(
|
|
|
|
|
`
|
|
|
|
|
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'
|
|
|
|
|
)
|
|
|
|
|
SELECT
|
|
|
|
|
${INTERNAL_SKU_SQL} AS internal_sku,
|
|
|
|
|
o.provider,
|
|
|
|
|
o.shop_id,
|
|
|
|
|
COALESCE(NULLIF(BTRIM(o.shop_name), ''), o.shop_id) AS shop_name,
|
|
|
|
|
group_key,
|
|
|
|
|
provider,
|
|
|
|
|
shop_id,
|
|
|
|
|
COALESCE(NULLIF(BTRIM(shop_name), ''), shop_id) AS shop_name,
|
|
|
|
|
COUNT(*)::int AS usage_count
|
|
|
|
|
FROM order_items oi
|
|
|
|
|
JOIN orders o ON o.id = oi.order_id
|
|
|
|
|
WHERE oi.fulfillment_status = 'pending'
|
|
|
|
|
AND ${INTERNAL_SKU_SQL} = ANY($1::text[])
|
|
|
|
|
AND BTRIM(o.shop_id) <> ''
|
|
|
|
|
GROUP BY ${INTERNAL_SKU_SQL}, o.provider, o.shop_id, o.shop_name
|
|
|
|
|
FROM pending_items
|
|
|
|
|
WHERE group_key = ANY($1::text[])
|
|
|
|
|
AND BTRIM(shop_id) <> ''
|
|
|
|
|
GROUP BY group_key, provider, shop_id, shop_name
|
|
|
|
|
ORDER BY
|
|
|
|
|
internal_sku,
|
|
|
|
|
group_key,
|
|
|
|
|
provider,
|
|
|
|
|
shop_id,
|
|
|
|
|
CASE
|
|
|
|
|
WHEN BTRIM(o.shop_name) = '' OR BTRIM(o.shop_name) = o.shop_id THEN 3
|
|
|
|
|
WHEN BTRIM(o.shop_name) = '91卡券' OR BTRIM(o.shop_name) LIKE '快手店铺-%' THEN 2
|
|
|
|
|
WHEN BTRIM(o.shop_name) ~ '[一-龥]' THEN 0
|
|
|
|
|
WHEN BTRIM(shop_name) = '' OR BTRIM(shop_name) = shop_id THEN 3
|
|
|
|
|
WHEN BTRIM(shop_name) = '91卡券' OR BTRIM(shop_name) LIKE '快手店铺-%' THEN 2
|
|
|
|
|
WHEN BTRIM(shop_name) ~ '[一-龥]' THEN 0
|
|
|
|
|
ELSE 1
|
|
|
|
|
END,
|
|
|
|
|
usage_count DESC,
|
|
|
|
|
shop_name
|
|
|
|
|
`,
|
|
|
|
|
[internalSkus],
|
|
|
|
|
[groupKeys],
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const seen = new Set<string>()
|
|
|
|
|
for (const row of shopRows.rows) {
|
|
|
|
|
const shopKey = `${row.internal_sku}\u0000${row.shop_id}`
|
|
|
|
|
const shopKey = `${row.group_key}\u0000${row.shop_id}`
|
|
|
|
|
if (seen.has(shopKey)) continue
|
|
|
|
|
seen.add(shopKey)
|
|
|
|
|
const shops = shopsBySku.get(row.internal_sku) || []
|
|
|
|
|
const shops = shopsBySku.get(row.group_key) || []
|
|
|
|
|
shops.push({ id: row.shop_id, name: row.shop_name || row.shop_id })
|
|
|
|
|
shopsBySku.set(row.internal_sku, shops)
|
|
|
|
|
shopsBySku.set(row.group_key, shops)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -1111,7 +1191,7 @@ export async function listPendingChannelGroups(input: {
|
|
|
|
|
items: rows.rows.map((row) => ({
|
|
|
|
|
...row,
|
|
|
|
|
shops:
|
|
|
|
|
shopsBySku.get(row.internal_sku) ||
|
|
|
|
|
shopsBySku.get(row.group_key) ||
|
|
|
|
|
row.shop_ids.map((shopId) => ({ id: shopId, name: shopId })),
|
|
|
|
|
})),
|
|
|
|
|
total: total.rows[0]?.count || 0,
|
|
|
|
@@ -1225,23 +1305,67 @@ export type DeletePendingChannelGroupResult = {
|
|
|
|
|
skippedClosedDays: number
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type PendingGroupScope =
|
|
|
|
|
| { type: 'internal_sku'; internalSku: string }
|
|
|
|
|
| { type: 'source_sku'; provider: string; shopId: string; skuCode: string }
|
|
|
|
|
|
|
|
|
|
function resolvePendingGroupScopeInput(input: {
|
|
|
|
|
groupKey?: string
|
|
|
|
|
internalSku?: string
|
|
|
|
|
}): PendingGroupScope | null {
|
|
|
|
|
const groupKey = String(input.groupKey || '').trim()
|
|
|
|
|
if (groupKey) {
|
|
|
|
|
const match = /^(sku|source):([0-9a-f]+)$/i.exec(groupKey)
|
|
|
|
|
if (!match) return null
|
|
|
|
|
const scopeType = String(match[1] || '').toLowerCase()
|
|
|
|
|
const encoded = String(match[2] || '')
|
|
|
|
|
if (!encoded || encoded.length % 2 !== 0) return null
|
|
|
|
|
const decoded = Buffer.from(encoded, 'hex').toString('utf8')
|
|
|
|
|
if (scopeType === 'sku') {
|
|
|
|
|
const internalSku = canonicalizeInternalSku(decoded)
|
|
|
|
|
return internalSku ? { type: 'internal_sku', internalSku } : null
|
|
|
|
|
}
|
|
|
|
|
const parts = decoded.split(String.fromCharCode(31))
|
|
|
|
|
if (parts.length !== 3 || !parts[0]) return null
|
|
|
|
|
return {
|
|
|
|
|
type: 'source_sku',
|
|
|
|
|
provider: parts[0],
|
|
|
|
|
shopId: parts[1] || '',
|
|
|
|
|
skuCode: parts[2] || '',
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const internalSku = canonicalizeInternalSku(input.internalSku)
|
|
|
|
|
return internalSku ? { type: 'internal_sku', internalSku } : null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 删除待分类 SKU:只归档订单项并清理其成本/异常,不删除原始订单。
|
|
|
|
|
* 已关账月份拒绝操作,避免已固化财务口径与底层数据不一致。
|
|
|
|
|
* 已关账月份只跳过事实重算,不再阻止历史待办归档。
|
|
|
|
|
*/
|
|
|
|
|
export async function deletePendingChannelGroup(input: {
|
|
|
|
|
internalSku: string
|
|
|
|
|
groupKey?: string
|
|
|
|
|
internalSku?: string
|
|
|
|
|
adminUserId: number | null
|
|
|
|
|
}): Promise<DeletePendingChannelGroupResult> {
|
|
|
|
|
const internalSku = canonicalizeInternalSku(input.internalSku)
|
|
|
|
|
if (!internalSku || !containsCjk(internalSku)) {
|
|
|
|
|
throw createHttpError('必须使用包含中文的内部 SKU', {
|
|
|
|
|
const scope = resolvePendingGroupScopeInput(input)
|
|
|
|
|
if (!scope) {
|
|
|
|
|
throw createHttpError('缺少待分类队列分组标识', {
|
|
|
|
|
statusCode: 400,
|
|
|
|
|
errorCode: 'internal_sku_required',
|
|
|
|
|
errorCode: 'pending_group_key_required',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const result = await withTransaction(async (client) => {
|
|
|
|
|
const itemParams: unknown[] = []
|
|
|
|
|
const itemWhere = ["oi.fulfillment_status = 'pending'"]
|
|
|
|
|
if (scope.type === 'internal_sku') {
|
|
|
|
|
itemParams.push(scope.internalSku)
|
|
|
|
|
itemWhere.push(`${INTERNAL_SKU_SQL} = $${itemParams.length}`)
|
|
|
|
|
} else {
|
|
|
|
|
itemParams.push(scope.provider, scope.shopId, scope.skuCode)
|
|
|
|
|
itemWhere.push(`o.provider = $1 AND o.shop_id = $2 AND oi.sku_code = $3`)
|
|
|
|
|
}
|
|
|
|
|
const items = await client.query<{ id: number; order_id: number; basis_date: string }>(
|
|
|
|
|
`
|
|
|
|
|
SELECT
|
|
|
|
@@ -1251,12 +1375,11 @@ export async function deletePendingChannelGroup(input: {
|
|
|
|
|
AS basis_date
|
|
|
|
|
FROM order_items oi
|
|
|
|
|
JOIN orders o ON o.id = oi.order_id
|
|
|
|
|
WHERE oi.fulfillment_status = 'pending'
|
|
|
|
|
AND ${INTERNAL_SKU_SQL} = $1
|
|
|
|
|
WHERE ${itemWhere.join(' AND ')}
|
|
|
|
|
ORDER BY oi.id
|
|
|
|
|
FOR UPDATE OF oi
|
|
|
|
|
`,
|
|
|
|
|
[internalSku],
|
|
|
|
|
itemParams,
|
|
|
|
|
)
|
|
|
|
|
const itemIds = items.rows.map((row) => Number(row.id))
|
|
|
|
|
if (itemIds.length === 0) {
|
|
|
|
@@ -1269,29 +1392,6 @@ export async function deletePendingChannelGroup(input: {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const periodKeys = [
|
|
|
|
|
...new Set(items.rows.map((row) => String(row.basis_date || '').slice(0, 7)).filter(Boolean)),
|
|
|
|
|
]
|
|
|
|
|
const closedPeriods = await client.query<{ period_key: string }>(
|
|
|
|
|
`
|
|
|
|
|
SELECT period_key
|
|
|
|
|
FROM finance_periods
|
|
|
|
|
WHERE status = 'closed'
|
|
|
|
|
AND period_key = ANY($1::text[])
|
|
|
|
|
ORDER BY period_key
|
|
|
|
|
`,
|
|
|
|
|
[periodKeys],
|
|
|
|
|
)
|
|
|
|
|
if (closedPeriods.rows.length > 0) {
|
|
|
|
|
throw createHttpError(
|
|
|
|
|
`包含已关账期间(${closedPeriods.rows.map((row) => row.period_key).join('、')}),不能删除`,
|
|
|
|
|
{
|
|
|
|
|
statusCode: 409,
|
|
|
|
|
errorCode: 'finance_period_closed',
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const snapshots = await client.query(
|
|
|
|
|
`DELETE FROM cost_snapshots WHERE order_item_id = ANY($1::bigint[])`,
|
|
|
|
|
[itemIds],
|
|
|
|
@@ -1368,6 +1468,71 @@ export async function deletePendingChannelGroup(input: {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function assignPendingChannelGroup(input: {
|
|
|
|
|
groupKey?: string
|
|
|
|
|
internalSku?: string
|
|
|
|
|
mappedInternalSku?: string
|
|
|
|
|
channel: string
|
|
|
|
|
adminUserId: number | null
|
|
|
|
|
note?: string | undefined
|
|
|
|
|
}): Promise<{
|
|
|
|
|
updated: number
|
|
|
|
|
ruleCreated: boolean
|
|
|
|
|
carrierFailed: number
|
|
|
|
|
channelSaved: boolean
|
|
|
|
|
draftProductId: number | null
|
|
|
|
|
}> {
|
|
|
|
|
const scope = resolvePendingGroupScopeInput(input)
|
|
|
|
|
if (!scope) {
|
|
|
|
|
throw createHttpError('缺少待分类队列分组标识', {
|
|
|
|
|
statusCode: 400,
|
|
|
|
|
errorCode: 'pending_group_key_required',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
if (scope.type !== 'internal_sku') {
|
|
|
|
|
const mappedInternalSku = canonicalizeInternalSku(input.mappedInternalSku)
|
|
|
|
|
if (!mappedInternalSku || !containsCjk(mappedInternalSku)) {
|
|
|
|
|
throw createHttpError('该来源 SKU 缺少内部 SKU,请先填写包含中文的内部 SKU', {
|
|
|
|
|
statusCode: 409,
|
|
|
|
|
errorCode: 'internal_sku_mapping_required',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
const updated = await query(
|
|
|
|
|
`
|
|
|
|
|
UPDATE order_items oi
|
|
|
|
|
SET spec_json = COALESCE(oi.spec_json, '{}'::jsonb)
|
|
|
|
|
|| jsonb_build_object('skuNick', $4::text),
|
|
|
|
|
updated_at = NOW()
|
|
|
|
|
FROM orders o
|
|
|
|
|
WHERE o.id = oi.order_id
|
|
|
|
|
AND o.provider = $1
|
|
|
|
|
AND o.shop_id = $2
|
|
|
|
|
AND oi.sku_code = $3
|
|
|
|
|
AND oi.fulfillment_status = 'pending'
|
|
|
|
|
`,
|
|
|
|
|
[scope.provider, scope.shopId, scope.skuCode, mappedInternalSku],
|
|
|
|
|
)
|
|
|
|
|
if ((updated.rowCount || 0) === 0) {
|
|
|
|
|
throw createHttpError('待分类来源 SKU 已发生变化,请刷新后重试', {
|
|
|
|
|
statusCode: 409,
|
|
|
|
|
errorCode: 'pending_group_stale',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
return assignChannelByInternalSku({
|
|
|
|
|
internalSku: mappedInternalSku,
|
|
|
|
|
channel: input.channel,
|
|
|
|
|
adminUserId: input.adminUserId,
|
|
|
|
|
note: input.note,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
return assignChannelByInternalSku({
|
|
|
|
|
internalSku: scope.internalSku,
|
|
|
|
|
channel: input.channel,
|
|
|
|
|
adminUserId: input.adminUserId,
|
|
|
|
|
note: input.note,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** 按内部 SKU 批量指定(同一中文 SKU 跨平台码一起分流)。 */
|
|
|
|
|
export async function assignChannelByInternalSku(input: {
|
|
|
|
|
internalSku: string
|
|
|
|
@@ -1375,7 +1540,14 @@ export async function assignChannelByInternalSku(input: {
|
|
|
|
|
adminUserId: number | null
|
|
|
|
|
note?: string | undefined
|
|
|
|
|
createRule?: boolean | undefined
|
|
|
|
|
}): Promise<{ updated: number; ruleCreated: boolean; carrierFailed: number }> {
|
|
|
|
|
}): Promise<{
|
|
|
|
|
updated: number
|
|
|
|
|
ruleCreated: boolean
|
|
|
|
|
carrierFailed: number
|
|
|
|
|
channelSaved: boolean
|
|
|
|
|
draftProductId: number | null
|
|
|
|
|
carrierFailures: Array<{ orderItemId: number; reason: string; message: string }>
|
|
|
|
|
}> {
|
|
|
|
|
if (!isFulfillmentChannel(input.channel)) {
|
|
|
|
|
throw createHttpError('渠道无效', { statusCode: 400, errorCode: 'invalid_channel' })
|
|
|
|
|
}
|
|
|
|
@@ -1408,6 +1580,16 @@ 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 carrier = await resolveInternalSkuCarrierConfig(internalSku, input.channel)
|
|
|
|
|
await setInternalSkuPrimaryChannel({
|
|
|
|
|
internalSku,
|
|
|
|
@@ -1431,6 +1613,7 @@ export async function assignChannelByInternalSku(input: {
|
|
|
|
|
source: 'operator_assigned',
|
|
|
|
|
lock: true,
|
|
|
|
|
note: input.note,
|
|
|
|
|
canonicalProductId: draftProductId,
|
|
|
|
|
})
|
|
|
|
|
await query(
|
|
|
|
|
`UPDATE order_items
|
|
|
|
@@ -1439,7 +1622,12 @@ export async function assignChannelByInternalSku(input: {
|
|
|
|
|
[ids, input.adminUserId],
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return { ...applied, ruleCreated }
|
|
|
|
|
return {
|
|
|
|
|
...applied,
|
|
|
|
|
ruleCreated,
|
|
|
|
|
channelSaved: true,
|
|
|
|
|
draftProductId,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** 按平台 SKU 定位内部 SKU 后全局指定渠道。 */
|
|
|
|
@@ -1597,6 +1785,72 @@ export async function listChannelRulesPage(
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function ensureDraftProductForChannel(
|
|
|
|
|
internalSku: string,
|
|
|
|
|
channel: FulfillmentChannel,
|
|
|
|
|
): Promise<number | null> {
|
|
|
|
|
const key = canonicalizeInternalSku(internalSku)
|
|
|
|
|
if (!key) return null
|
|
|
|
|
const definition = {
|
|
|
|
|
affiliate_dash: { prefix: 'aff:', category: '自动发货', unit: '件' },
|
|
|
|
|
work_order: { prefix: 'tpl:', category: '接单', unit: '件' },
|
|
|
|
|
manual: { prefix: 'man:', category: '人工', unit: '件' },
|
|
|
|
|
on_account: { prefix: 'man:', category: '统一结算', unit: '件' },
|
|
|
|
|
third_party_voucher: { prefix: 'tpv:', category: '卡券', unit: '张' },
|
|
|
|
|
}[channel]
|
|
|
|
|
const canonicalSku = `${definition.prefix}${key}`
|
|
|
|
|
if (canonicalSku.length > 64) return null
|
|
|
|
|
|
|
|
|
|
const now = nowIso()
|
|
|
|
|
const result = await query<{ id: number; status: string }>(
|
|
|
|
|
`
|
|
|
|
|
INSERT INTO product_catalog (
|
|
|
|
|
canonical_sku,
|
|
|
|
|
canonical_name,
|
|
|
|
|
category,
|
|
|
|
|
unit_name,
|
|
|
|
|
status,
|
|
|
|
|
attributes_json,
|
|
|
|
|
created_at,
|
|
|
|
|
updated_at
|
|
|
|
|
) VALUES (
|
|
|
|
|
$1, $2, $3, $4, 'active',
|
|
|
|
|
$5::jsonb, $6, $6
|
|
|
|
|
)
|
|
|
|
|
ON CONFLICT (canonical_sku) DO UPDATE
|
|
|
|
|
SET updated_at = product_catalog.updated_at
|
|
|
|
|
RETURNING id, status
|
|
|
|
|
`,
|
|
|
|
|
[
|
|
|
|
|
canonicalSku,
|
|
|
|
|
key.slice(0, 120),
|
|
|
|
|
definition.category,
|
|
|
|
|
definition.unit,
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
source: channel,
|
|
|
|
|
auto_created: true,
|
|
|
|
|
draft: true,
|
|
|
|
|
pending_cost_config: true,
|
|
|
|
|
}),
|
|
|
|
|
now,
|
|
|
|
|
],
|
|
|
|
|
)
|
|
|
|
|
const product = result.rows[0]
|
|
|
|
|
const productId = Number(product?.id || 0)
|
|
|
|
|
if (!productId || product?.status !== 'active') return null
|
|
|
|
|
|
|
|
|
|
await query(
|
|
|
|
|
`
|
|
|
|
|
INSERT INTO product_channel_capabilities (
|
|
|
|
|
product_id, channel, priority, is_fallback, enabled, source
|
|
|
|
|
) VALUES ($1, $2, 10, FALSE, TRUE, 'auto')
|
|
|
|
|
ON CONFLICT (product_id, channel) DO NOTHING
|
|
|
|
|
`,
|
|
|
|
|
[productId, channel],
|
|
|
|
|
)
|
|
|
|
|
return productId
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function resolveInternalSkuCarrierConfig(
|
|
|
|
|
internalSku: string,
|
|
|
|
|
channel: string,
|
|
|
|
@@ -1625,7 +1879,13 @@ async function resolveInternalSkuCarrierConfig(
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const prefixes =
|
|
|
|
|
channel === 'affiliate_dash' ? ['aff:'] : channel === 'work_order' ? ['tpl:', 'wo:'] : ['tpv:']
|
|
|
|
|
channel === 'affiliate_dash'
|
|
|
|
|
? ['aff:']
|
|
|
|
|
: channel === 'work_order'
|
|
|
|
|
? ['tpl:', 'wo:']
|
|
|
|
|
: channel === 'on_account'
|
|
|
|
|
? ['man:']
|
|
|
|
|
: ['tpv:']
|
|
|
|
|
const product = await query<{ id: number; canonical_sku: string }>(
|
|
|
|
|
`SELECT id, canonical_sku
|
|
|
|
|
FROM product_catalog
|
|
|
|
@@ -1663,7 +1923,9 @@ async function resolveInternalSkuCarrierConfig(
|
|
|
|
|
? { affiliateSku: carrierKey }
|
|
|
|
|
: channel === 'work_order'
|
|
|
|
|
? { workRuleKey: carrierKey }
|
|
|
|
|
: { voucherSku: carrierKey },
|
|
|
|
|
: channel === 'on_account'
|
|
|
|
|
? { settlementSku: carrierKey }
|
|
|
|
|
: { voucherSku: carrierKey },
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|