feat(fulfillment): 增加统一结算与多渠道人工履约

This commit is contained in:
yml2213
2026-11-05 16:14:56 +08:00
parent 548070c2ad
commit 4e83dbb84c
43 changed files with 1582 additions and 334 deletions
@@ -182,6 +182,10 @@ export async function ensureCarrierForOrderItem(input: {
return { ready: true, created: false, reason: 'carrier_not_required', message: '' }
}
if (channel === 'on_account') {
return { ready: true, created: false, reason: 'carrier_not_required', message: '' }
}
if (channel === 'work_order') {
if (await hasWorkOrder(Number(item.id))) {
return { ready: true, created: false, reason: 'work_order_exists', message: '' }
@@ -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 },
}
}
@@ -12,6 +12,7 @@ export const FULFILLMENT_CHANNELS = [
'affiliate_dash',
'third_party_voucher',
'manual',
'on_account',
] as const
export type FulfillmentChannel = (typeof FULFILLMENT_CHANNELS)[number]
@@ -51,6 +52,7 @@ export const CHANNEL_LABELS: Record<FulfillmentChannel, string> = {
affiliate_dash: '自动发货',
third_party_voucher: '第三方卡券',
manual: '人工客服',
on_account: '统一结算',
}
export const MATCH_KEY_TYPES = ['sku_code', 'internal_sku', 'sku_name'] as const
@@ -64,13 +66,17 @@ export type RuleSource = (typeof RULE_SOURCES)[number]
/** 渠道是否需要履约载体(卡券渠道不需要)。 */
export function channelNeedsCarrier(channel: FulfillmentChannel | null | undefined): boolean {
if (!channel) return false
return channel !== 'third_party_voucher'
return channel !== 'third_party_voucher' && channel !== 'on_account'
}
/** 渠道是否参与成本核算。 */
export function channelHasCostModel(channel: FulfillmentChannel | null | undefined): boolean {
return (
channel === 'affiliate_dash' || channel === 'work_order' || channel === 'third_party_voucher'
channel === 'affiliate_dash' ||
channel === 'work_order' ||
channel === 'third_party_voucher' ||
channel === 'manual' ||
channel === 'on_account'
)
}
@@ -200,7 +200,9 @@ export function applyIntakeChannelPlan(
reason:
channel === 'work_order'
? '内部 SKU 渠道配置指定接单平台'
: '内部 SKU 渠道配置指定第三方卡券',
: channel === 'on_account'
? '内部 SKU 渠道配置指定统一结算'
: '内部 SKU 渠道配置指定第三方卡券',
configured: false,
})
}
@@ -57,6 +57,7 @@ export const INTERNAL_SKU_CHANNEL_ORDER: readonly FulfillmentChannel[] = [
'work_order',
'third_party_voucher',
'manual',
'on_account',
]
export function compareFulfillmentChannel(a: FulfillmentChannel, b: FulfillmentChannel): number {
@@ -10,6 +10,7 @@ export const VOUCHER_CONSUME_CHANNELS = [
'work_order',
'manual',
'third_party_voucher',
'on_account',
] as const
export type VoucherConsumeChannel = (typeof VOUCHER_CONSUME_CHANNELS)[number]
@@ -150,6 +151,11 @@ function createDefaultPlatformPolicy(): VoucherPlatformConsumePolicy {
mode: 'after_order_received',
delaySeconds: 5,
},
on_account: {
enabled: false,
mode: 'manual',
delaySeconds: 0,
},
},
}
}
@@ -189,6 +195,10 @@ function normalizePlatformPolicy(
fallback.channels.third_party_voucher,
['manual', 'after_order_received'],
),
on_account: normalizeChannelPolicy(rawChannels.on_account, fallback.channels.on_account, [
'manual',
'after_order_received',
]),
},
}
}
@@ -858,7 +858,8 @@ function normalizeVoucherChannel(value: unknown): VoucherConsumeChannel | null {
channel === 'affiliate_dash' ||
channel === 'work_order' ||
channel === 'manual' ||
channel === 'third_party_voucher'
channel === 'third_party_voucher' ||
channel === 'on_account'
) {
return channel
}