merge: 合并 main 并保留寄卖内部匹配与客户退款
This commit is contained in:
@@ -144,6 +144,7 @@ const ORDER_ITEM_FULFILLMENT_CHANNELS = new Set<AdminOrderItemFulfillmentMode>([
|
||||
'third_party_voucher',
|
||||
'legacy_platform',
|
||||
'manual',
|
||||
'on_account',
|
||||
'pending',
|
||||
'archived',
|
||||
])
|
||||
|
||||
@@ -21,6 +21,7 @@ export const ADMIN_PERMISSION_KEYS = [
|
||||
'worker_order.assign',
|
||||
'worker_order.return_to_hall',
|
||||
'worker_order.cancel',
|
||||
'worker_order.customer_refund',
|
||||
'worker_order.cancel_share',
|
||||
'worker_order.delete',
|
||||
'worker_order.publish',
|
||||
|
||||
@@ -152,6 +152,7 @@ const ADMIN_ORDER_FULFILLMENT_CHANNELS = new Set([
|
||||
'affiliate_dash',
|
||||
'third_party_voucher',
|
||||
'manual',
|
||||
'on_account',
|
||||
'legacy_platform',
|
||||
'mixed',
|
||||
'pending',
|
||||
|
||||
@@ -64,6 +64,7 @@ export type FulfillmentChannel =
|
||||
| 'affiliate_dash'
|
||||
| 'third_party_voucher'
|
||||
| 'manual'
|
||||
| 'on_account'
|
||||
| 'pending'
|
||||
| 'archived'
|
||||
| 'unclassified'
|
||||
@@ -73,6 +74,7 @@ export const FULFILLMENT_CHANNELS = new Set<string>([
|
||||
'affiliate_dash',
|
||||
'third_party_voucher',
|
||||
'manual',
|
||||
'on_account',
|
||||
'pending',
|
||||
'archived',
|
||||
'unclassified',
|
||||
|
||||
@@ -70,7 +70,7 @@ export function costModelAppliesTo(costModel: string, channel: string) {
|
||||
if (costModel === 'affiliate_purchase') return channel === 'affiliate_dash'
|
||||
if (costModel === 'work_order_reward') return channel === 'work_order'
|
||||
if (costModel === 'third_party_voucher_cost') return channel === 'third_party_voucher'
|
||||
if (costModel === 'manual_actual') return channel === 'manual'
|
||||
if (costModel === 'manual_actual') return channel === 'manual' || channel === 'on_account'
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ export function costModelScopeSql(channelExpression: string): string {
|
||||
OR (ci.cost_model = 'third_party_voucher_cost'
|
||||
AND ${channelExpression} = 'third_party_voucher')
|
||||
OR (ci.cost_model = 'manual_actual'
|
||||
AND ${channelExpression} = 'manual')
|
||||
AND ${channelExpression} IN ('manual', 'on_account'))
|
||||
)`
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ import type {
|
||||
MissingCostItem,
|
||||
OrderItemCostContext,
|
||||
} from './cost-center-shared.js'
|
||||
import { calculateManualCostFen, parseManualChannelQuantity } from './manual-credit-quantity.js'
|
||||
|
||||
export async function previewAdminCost(payload: JsonObject = {}) {
|
||||
const context = await resolveCostContext(payload)
|
||||
@@ -138,14 +139,14 @@ async function resolveManualCostHint(orderItemId: number | null) {
|
||||
)
|
||||
const row = result.rows[0]
|
||||
if (!row) return null
|
||||
const creditQuantity = Number(row.credit_quantity || 0)
|
||||
const creditQuantity = parseManualChannelQuantity(row.credit_quantity, row.platform_key) ?? 0
|
||||
const unitPriceFen = Number(row.unit_price_fen || 1)
|
||||
return {
|
||||
platformKey: row.platform_key,
|
||||
creditQuantity,
|
||||
unitPriceFen,
|
||||
costMode: row.cost_mode === 'fixed' ? ('fixed' as const) : ('variable' as const),
|
||||
costFen: creditQuantity > 0 ? creditQuantity * unitPriceFen : null,
|
||||
costFen: creditQuantity > 0 ? calculateManualCostFen(creditQuantity, unitPriceFen) : null,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -319,11 +319,11 @@ const CANDIDATE_SQL = `
|
||||
wo.reward_amount
|
||||
FROM order_items oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
LEFT JOIN work_orders wo ON wo.order_item_id = oi.id
|
||||
LEFT JOIN work_orders wo ON wo.order_item_id = oi.id AND wo.status <> 'cancelled'
|
||||
LEFT JOIN work_product_rules wpr ON wpr.id = wo.product_rule_id
|
||||
WHERE oi.fulfillment_status = 'assigned'
|
||||
AND oi.fulfillment_channel IN (
|
||||
'affiliate_dash', 'third_party_voucher', 'work_order', 'manual'
|
||||
'affiliate_dash', 'third_party_voucher', 'work_order', 'manual', 'on_account'
|
||||
)
|
||||
`
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
calculateManualCostFen,
|
||||
normalizeManualCreditQuantity,
|
||||
parseManualChannelQuantity,
|
||||
parseManualCreditQuantity,
|
||||
} from './manual-credit-quantity.js'
|
||||
|
||||
test('人工履约现金金额支持两位小数', () => {
|
||||
assert.equal(normalizeManualCreditQuantity('12.345'), 12.35)
|
||||
assert.equal(parseManualCreditQuantity('12.34'), 12.34)
|
||||
assert.equal(parseManualCreditQuantity(-1), null)
|
||||
})
|
||||
|
||||
test('积分渠道拒绝小数,现金渠道允许小数', () => {
|
||||
assert.equal(parseManualChannelQuantity('2.5', 'nanhang'), null)
|
||||
assert.equal(parseManualChannelQuantity('2', 'nanhang'), 2)
|
||||
assert.equal(parseManualChannelQuantity('2.5', 'fund_cash'), 2.5)
|
||||
assert.equal(parseManualChannelQuantity('2.5', 'on_account'), 2.5)
|
||||
})
|
||||
|
||||
test('人工履约现金成本按分四舍五入', () => {
|
||||
assert.equal(calculateManualCostFen(12.34, 100), 1234)
|
||||
assert.equal(calculateManualCostFen(0.015, 100), 2)
|
||||
})
|
||||
|
||||
test('负数和超出数据库精度的数量返回非法', () => {
|
||||
assert.equal(parseManualCreditQuantity('abc'), null)
|
||||
assert.equal(parseManualCreditQuantity(-0.01), null)
|
||||
assert.equal(parseManualCreditQuantity('1000000000000000'), null)
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
const QUANTITY_SCALE = 100
|
||||
const MAX_QUANTITY = 99_999_999_999.99
|
||||
const CASH_CHANNELS = new Set(['fund_cash', 'on_account'])
|
||||
|
||||
/**
|
||||
* 人工履约的现金金额统一保留 2 位小数。
|
||||
* 积分渠道仍只接受整数,由配置入口按平台单独校验。
|
||||
*/
|
||||
export function normalizeManualCreditQuantity(value: unknown): number {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) return 0
|
||||
const normalized = Math.round((parsed + Number.EPSILON) * QUANTITY_SCALE) / QUANTITY_SCALE
|
||||
return Math.min(MAX_QUANTITY, normalized)
|
||||
}
|
||||
|
||||
/** 解析接口入参;非法值返回 null,由调用方给出明确的业务错误。 */
|
||||
export function parseManualCreditQuantity(value: unknown): number | null {
|
||||
const parsed = parseManualSignedCreditQuantity(value)
|
||||
return parsed !== null && parsed >= 0 ? parsed : null
|
||||
}
|
||||
|
||||
/** 积分渠道只接受整数;备用金现金和统一结算接受两位小数。 */
|
||||
export function parseManualChannelQuantity(value: unknown, platformKey: string): number | null {
|
||||
const parsed = parseManualCreditQuantity(value)
|
||||
if (parsed === null) return null
|
||||
if (!CASH_CHANNELS.has(platformKey) && !Number.isInteger(parsed)) return null
|
||||
return parsed
|
||||
}
|
||||
|
||||
function parseManualSignedCreditQuantity(value: unknown): number | null {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isFinite(parsed) || Math.abs(parsed) > MAX_QUANTITY) return null
|
||||
return Math.round((parsed + Number.EPSILON) * QUANTITY_SCALE) / QUANTITY_SCALE
|
||||
}
|
||||
|
||||
/** 成本金额始终落库为整数分;不足 1 分时按四舍五入处理。 */
|
||||
export function calculateManualCostFen(creditQuantity: unknown, unitPriceFen: unknown): number {
|
||||
const quantity = normalizeManualCreditQuantity(creditQuantity)
|
||||
const unitPrice = Math.max(0, Math.trunc(Number(unitPriceFen) || 0))
|
||||
return Math.round(quantity * unitPrice)
|
||||
}
|
||||
@@ -8,6 +8,11 @@ import { upsertInternalSkuChannel } from '../fulfillment/internal-sku-channel-se
|
||||
import { reconcilePendingCarriers } from '../fulfillment/carrier-reconciliation-service.js'
|
||||
import { canonicalizeInternalSku, containsCjk } from '../fulfillment/internal-sku-resolver.js'
|
||||
import { refreshFactsForDates } from '../finance/refresh-facts.js'
|
||||
import {
|
||||
calculateManualCostFen,
|
||||
normalizeManualCreditQuantity,
|
||||
parseManualChannelQuantity,
|
||||
} from './manual-credit-quantity.js'
|
||||
|
||||
/**
|
||||
* 人工履约 SKU 配置服务。
|
||||
@@ -15,7 +20,7 @@ import { refreshFactsForDates } from '../finance/refresh-facts.js'
|
||||
* 口径(2026-10-24 确认):
|
||||
* * 配置主键优先取内部 SKU(快手 spec_json.skuNick / 拼多多 spec_json.outSkuSn),
|
||||
* 缺失时按 sku_code → sku_name 兜底;同名/同码算同一个商品,跨渠道共用一份配置;
|
||||
* * 配置项:履约渠道(南航/乐玩/ROCK/备用金现金)+ 消耗积分 + 成本模式;
|
||||
* * 配置项:履约渠道(南航/乐玩/ROCK/备用金现金/统一结算)+ 消耗量 + 成本模式;
|
||||
* * 换算率固定:1 积分 = 1 分钱;
|
||||
* * 消耗积分 0 = 成本不固定,由客服在确认履约时手填实际成本;
|
||||
* * 「重新匹配」= 把配置回填到历史人工订单项,幂等可反复执行。
|
||||
@@ -23,7 +28,7 @@ import { refreshFactsForDates } from '../finance/refresh-facts.js'
|
||||
* 没有匹配引擎、没有打分:这就是一张带统计的 SKU 配置清单。
|
||||
*/
|
||||
|
||||
export const MANUAL_PLATFORM_KEYS = ['nanhang', 'lewan', 'rock', 'fund_cash'] as const
|
||||
export const MANUAL_PLATFORM_KEYS = ['nanhang', 'lewan', 'rock', 'fund_cash', 'on_account'] as const
|
||||
export type ManualPlatformKey = (typeof MANUAL_PLATFORM_KEYS)[number]
|
||||
|
||||
/**
|
||||
@@ -42,6 +47,7 @@ const PLATFORM_UNIT_PRICE_FEN: Record<ManualPlatformKey, number> = {
|
||||
lewan: 1,
|
||||
rock: 100,
|
||||
fund_cash: 100,
|
||||
on_account: 100,
|
||||
}
|
||||
|
||||
const PLATFORM_LABELS: Record<ManualPlatformKey, string> = {
|
||||
@@ -49,6 +55,7 @@ const PLATFORM_LABELS: Record<ManualPlatformKey, string> = {
|
||||
lewan: '乐玩后台',
|
||||
rock: 'ROCK 后台',
|
||||
fund_cash: '备用金现金',
|
||||
on_account: '统一结算',
|
||||
}
|
||||
|
||||
/** 读取平台档案里配置的换算率;档案不存在时用默认表。 */
|
||||
@@ -86,10 +93,18 @@ type ManualSkuRow = {
|
||||
scan_to_date: string | null
|
||||
config_id: number | null
|
||||
config_platform_key: string | null
|
||||
config_credit_quantity: number | null
|
||||
config_credit_quantity: number | string | null
|
||||
config_cost_mode: string | null
|
||||
config_canonical_product_id: number | null
|
||||
config_enabled: boolean | null
|
||||
config_channels_json: unknown
|
||||
}
|
||||
|
||||
type ManualChannelConfig = {
|
||||
platformKey: ManualPlatformKey
|
||||
creditQuantity: number
|
||||
costMode: 'fixed' | 'variable'
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -238,7 +253,10 @@ export async function listManualSkus(input: Record<string, unknown> = {}) {
|
||||
}
|
||||
if (platformKey) {
|
||||
params.push(platformKey)
|
||||
conditions.push(`cfg.platform_key = $${params.length}`)
|
||||
conditions.push(`(
|
||||
cfg.platform_key = $${params.length}
|
||||
OR cfg.channels_json @> jsonb_build_array(jsonb_build_object('platformKey', $${params.length}))
|
||||
)`)
|
||||
}
|
||||
if (provider) {
|
||||
params.push(provider)
|
||||
@@ -275,7 +293,8 @@ export async function listManualSkus(input: Record<string, unknown> = {}) {
|
||||
cfg.credit_quantity AS config_credit_quantity,
|
||||
cfg.cost_mode AS config_cost_mode,
|
||||
cfg.canonical_product_id AS config_canonical_product_id,
|
||||
cfg.enabled AS config_enabled
|
||||
cfg.enabled AS config_enabled,
|
||||
cfg.channels_json AS config_channels_json
|
||||
FROM manual_sku_registry r
|
||||
LEFT JOIN manual_fulfillment_configs cfg
|
||||
ON canonicalize_internal_sku(cfg.sku_key) = canonicalize_internal_sku(r.sku_key)
|
||||
@@ -340,8 +359,10 @@ export async function saveManualFulfillmentConfig(payload: JsonObject) {
|
||||
errorCode: 'manual_config_sku_key_required',
|
||||
})
|
||||
}
|
||||
const platformKey = normalizePlatformKey(payload.platformKey ?? payload.platform_key)
|
||||
const creditQuantity = normalizeCreditQuantity(payload.creditQuantity ?? payload.credit_quantity)
|
||||
const channels = normalizeManualChannels(payload)
|
||||
const primaryChannel = channels[0]!
|
||||
const platformKey = primaryChannel.platformKey
|
||||
const creditQuantity = primaryChannel.creditQuantity
|
||||
const canonicalProductId = normalizeOptionalId(
|
||||
payload.canonicalProductId ?? payload.canonical_product_id,
|
||||
)
|
||||
@@ -349,7 +370,7 @@ export async function saveManualFulfillmentConfig(payload: JsonObject) {
|
||||
const note = optionalText(payload.note, 300)
|
||||
|
||||
// 消耗积分为 0 = 成本不固定,客服确认履约时手填。
|
||||
const costMode = creditQuantity > 0 ? 'fixed' : 'variable'
|
||||
const costMode = primaryChannel.costMode
|
||||
|
||||
const unitPrices = await loadPlatformUnitPriceFen()
|
||||
const now = nowIso()
|
||||
@@ -411,7 +432,7 @@ export async function saveManualFulfillmentConfig(payload: JsonObject) {
|
||||
`UPDATE manual_fulfillment_configs
|
||||
SET sku_key = $2, canonical_product_id = $3, platform_key = $4,
|
||||
credit_quantity = $5, cost_mode = $6, enabled = $7, note = $8,
|
||||
updated_at = $9
|
||||
channels_json = $9::jsonb, updated_at = $10
|
||||
WHERE id = $1
|
||||
RETURNING *`,
|
||||
[
|
||||
@@ -423,16 +444,27 @@ export async function saveManualFulfillmentConfig(payload: JsonObject) {
|
||||
costMode,
|
||||
enabled,
|
||||
note,
|
||||
JSON.stringify(channels),
|
||||
now,
|
||||
],
|
||||
)
|
||||
: await client.query(
|
||||
`INSERT INTO manual_fulfillment_configs (
|
||||
sku_key, canonical_product_id, platform_key, credit_quantity,
|
||||
cost_mode, enabled, note, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8)
|
||||
cost_mode, enabled, note, channels_json, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9, $9)
|
||||
RETURNING *`,
|
||||
[skuKey, resolvedProductId, platformKey, creditQuantity, costMode, enabled, note, now],
|
||||
[
|
||||
skuKey,
|
||||
resolvedProductId,
|
||||
platformKey,
|
||||
creditQuantity,
|
||||
costMode,
|
||||
enabled,
|
||||
note,
|
||||
JSON.stringify(channels),
|
||||
now,
|
||||
],
|
||||
)
|
||||
return requiredRow(result.rows[0], '保存人工履约配置失败')
|
||||
})
|
||||
@@ -537,8 +569,8 @@ export async function deleteManualSkuClassification(
|
||||
skuKey: string,
|
||||
): Promise<DeleteManualSkuResult> {
|
||||
const key = canonicalizeInternalSku(skuKey)
|
||||
if (!key || !containsCjk(key)) {
|
||||
throw createHttpError('必须使用包含中文的内部 SKU', {
|
||||
if (!key) {
|
||||
throw createHttpError('内部 SKU 不能为空', {
|
||||
statusCode: 400,
|
||||
errorCode: 'manual_config_sku_key_required',
|
||||
})
|
||||
@@ -755,7 +787,7 @@ export async function rematchManualFulfillments(input: Record<string, unknown> =
|
||||
}
|
||||
|
||||
/**
|
||||
* 为已配置的人工 SKU 生成/刷新成本版本(按 1 积分 = 1 分钱换算)。
|
||||
* 为已配置的人工 SKU 生成/刷新固定成本版本(按各平台积分换算率折算)。
|
||||
*
|
||||
* 成本版本是「成本金额」的唯一真源;积分消耗量记在配置里,
|
||||
* 两者按固定换算率保持一致。
|
||||
@@ -776,8 +808,8 @@ async function attachCostVersionsForManualConfigs() {
|
||||
)
|
||||
SELECT
|
||||
$1, 'product', '', '', cfg.canonical_product_id, NULL,
|
||||
(cfg.credit_quantity * (fp.config_json->>'rechargeUnitPriceFen')::bigint), 0,
|
||||
'1970-01-01T00:00:00+08:00', 'manual', 'active',
|
||||
ROUND(cfg.credit_quantity * (fp.config_json->>'rechargeUnitPriceFen')::bigint, 0)::bigint,
|
||||
0, '1970-01-01T00:00:00+08:00', 'manual', 'active',
|
||||
'人工履约配置:积分按平台换算率折算',
|
||||
NOW(), NOW()
|
||||
FROM manual_fulfillment_configs cfg
|
||||
@@ -817,10 +849,11 @@ export async function getManualFulfillmentConfig(skuKey: string) {
|
||||
sku_key: string
|
||||
canonical_product_id: number | null
|
||||
platform_key: string
|
||||
credit_quantity: number
|
||||
credit_quantity: number | string
|
||||
cost_mode: string
|
||||
enabled: boolean
|
||||
note: string
|
||||
channels_json: unknown
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}>(
|
||||
@@ -842,16 +875,86 @@ export async function listManualPlatformOptions() {
|
||||
items: MANUAL_PLATFORM_KEYS.map((key) => ({
|
||||
key,
|
||||
label: PLATFORM_LABELS[key],
|
||||
isCredit: key !== 'fund_cash',
|
||||
isCredit: key !== 'fund_cash' && key !== 'on_account',
|
||||
creditUnitPriceFen: unitPrices[key],
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeStoredChannels(input: {
|
||||
channels_json: unknown
|
||||
platform_key: string
|
||||
credit_quantity: number | string
|
||||
cost_mode: string
|
||||
enabled: boolean
|
||||
}): ManualChannelConfig[] {
|
||||
const raw = Array.isArray(input.channels_json) ? input.channels_json : []
|
||||
const parsed: ManualChannelConfig[] = []
|
||||
const seen = new Set<ManualPlatformKey>()
|
||||
for (const item of raw) {
|
||||
if (!item || typeof item !== 'object' || Array.isArray(item)) continue
|
||||
const row = item as Record<string, unknown>
|
||||
const key = String(row.platformKey || row.platform_key || '').trim() as ManualPlatformKey
|
||||
if (!MANUAL_PLATFORM_KEYS.includes(key) || seen.has(key)) continue
|
||||
seen.add(key)
|
||||
const creditQuantity =
|
||||
parseManualChannelQuantity(row.creditQuantity ?? row.credit_quantity ?? 0, key) ?? 0
|
||||
parsed.push({
|
||||
platformKey: key,
|
||||
creditQuantity,
|
||||
costMode: creditQuantity > 0 ? 'fixed' : 'variable',
|
||||
enabled: row.enabled !== false,
|
||||
})
|
||||
}
|
||||
if (parsed.length > 0) return parsed
|
||||
|
||||
const key = String(input.platform_key || '').trim() as ManualPlatformKey
|
||||
if (!MANUAL_PLATFORM_KEYS.includes(key)) return []
|
||||
const creditQuantity = parseManualChannelQuantity(input.credit_quantity, key) ?? 0
|
||||
return [
|
||||
{
|
||||
platformKey: key,
|
||||
creditQuantity,
|
||||
costMode: creditQuantity > 0 ? 'fixed' : 'variable',
|
||||
enabled: input.enabled !== false,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function mapConfiguredChannels(
|
||||
channels: ManualChannelConfig[],
|
||||
unitPrices: Record<ManualPlatformKey, number>,
|
||||
) {
|
||||
return channels.map((channel) => {
|
||||
const creditUnitPriceFen = unitPrices[channel.platformKey] ?? DEFAULT_CREDIT_UNIT_PRICE_FEN
|
||||
return {
|
||||
platformKey: channel.platformKey,
|
||||
platformLabel: PLATFORM_LABELS[channel.platformKey] || channel.platformKey,
|
||||
creditQuantity: channel.creditQuantity,
|
||||
costMode: channel.costMode,
|
||||
costFen: calculateManualCostFen(channel.creditQuantity, creditUnitPriceFen),
|
||||
creditUnitPriceFen,
|
||||
enabled: channel.enabled,
|
||||
isFundCash: channel.platformKey === 'fund_cash',
|
||||
isOnAccount: channel.platformKey === 'on_account',
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function mapManualSku(row: ManualSkuRow, unitPrices: Record<ManualPlatformKey, number>) {
|
||||
const creditQuantity = Number(row.config_credit_quantity || 0)
|
||||
const creditQuantity = normalizeManualCreditQuantity(row.config_credit_quantity)
|
||||
const platformKey = (row.config_platform_key || '') as ManualPlatformKey
|
||||
const unitPriceFen = unitPrices[platformKey] ?? DEFAULT_CREDIT_UNIT_PRICE_FEN
|
||||
const channels =
|
||||
row.config_id === null
|
||||
? []
|
||||
: normalizeStoredChannels({
|
||||
channels_json: row.config_channels_json,
|
||||
platform_key: row.config_platform_key || '',
|
||||
credit_quantity: row.config_credit_quantity || 0,
|
||||
cost_mode: row.config_cost_mode || 'variable',
|
||||
enabled: row.config_enabled !== false,
|
||||
})
|
||||
return {
|
||||
id: Number(row.id),
|
||||
skuKey: row.sku_key,
|
||||
@@ -873,12 +976,13 @@ function mapManualSku(row: ManualSkuRow, unitPrices: Record<ManualPlatformKey, n
|
||||
platformLabel: PLATFORM_LABELS[platformKey] || '',
|
||||
creditQuantity,
|
||||
costMode: row.config_cost_mode || 'variable',
|
||||
costFen: creditQuantity * unitPriceFen,
|
||||
costFen: calculateManualCostFen(creditQuantity, unitPriceFen),
|
||||
creditUnitPriceFen: unitPriceFen,
|
||||
canonicalProductId: row.config_canonical_product_id
|
||||
? Number(row.config_canonical_product_id)
|
||||
: null,
|
||||
enabled: row.config_enabled !== false,
|
||||
channels: mapConfiguredChannels(channels, unitPrices),
|
||||
}
|
||||
: null,
|
||||
}
|
||||
@@ -890,18 +994,20 @@ function mapManualConfig(
|
||||
sku_key: string
|
||||
canonical_product_id: number | null
|
||||
platform_key: string
|
||||
credit_quantity: number
|
||||
credit_quantity: number | string
|
||||
cost_mode: string
|
||||
enabled: boolean
|
||||
note: string
|
||||
channels_json: unknown
|
||||
created_at: string
|
||||
updated_at: string
|
||||
},
|
||||
unitPrices: Record<ManualPlatformKey, number>,
|
||||
) {
|
||||
const creditQuantity = Number(row.credit_quantity || 0)
|
||||
const creditQuantity = normalizeManualCreditQuantity(row.credit_quantity)
|
||||
const platformKey = row.platform_key as ManualPlatformKey
|
||||
const unitPriceFen = unitPrices[platformKey] ?? DEFAULT_CREDIT_UNIT_PRICE_FEN
|
||||
const channels = normalizeStoredChannels(row)
|
||||
return {
|
||||
id: Number(row.id),
|
||||
skuKey: row.sku_key,
|
||||
@@ -910,9 +1016,10 @@ function mapManualConfig(
|
||||
platformLabel: PLATFORM_LABELS[platformKey] || row.platform_key,
|
||||
creditQuantity,
|
||||
costMode: row.cost_mode,
|
||||
costFen: creditQuantity * unitPriceFen,
|
||||
costFen: calculateManualCostFen(creditQuantity, unitPriceFen),
|
||||
creditUnitPriceFen: unitPriceFen,
|
||||
enabled: row.enabled,
|
||||
channels: mapConfiguredChannels(channels, unitPrices),
|
||||
note: row.note || '',
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
@@ -930,14 +1037,67 @@ function normalizePlatformKey(value: unknown): ManualPlatformKey {
|
||||
return text as ManualPlatformKey
|
||||
}
|
||||
|
||||
function normalizeCreditQuantity(value: unknown) {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isFinite(parsed) || parsed < 0 || !Number.isInteger(parsed)) {
|
||||
throw createHttpError('消耗积分必须是非负整数', {
|
||||
statusCode: 400,
|
||||
errorCode: 'manual_config_credit_invalid',
|
||||
function normalizeManualChannels(payload: JsonObject): ManualChannelConfig[] {
|
||||
const rawChannels = Array.isArray(payload.channels) ? payload.channels : []
|
||||
const source =
|
||||
rawChannels.length > 0
|
||||
? rawChannels
|
||||
: [
|
||||
{
|
||||
platformKey: payload.platformKey ?? payload.platform_key,
|
||||
creditQuantity: payload.creditQuantity ?? payload.credit_quantity,
|
||||
enabled: payload.enabled,
|
||||
},
|
||||
]
|
||||
const seen = new Set<ManualPlatformKey>()
|
||||
const channels: ManualChannelConfig[] = []
|
||||
|
||||
for (const raw of source) {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) continue
|
||||
const item = raw as JsonObject
|
||||
const platformKey = normalizePlatformKey(item.platformKey ?? item.platform_key)
|
||||
if (seen.has(platformKey)) continue
|
||||
seen.add(platformKey)
|
||||
const creditQuantity = normalizeCreditQuantity(
|
||||
item.creditQuantity ?? item.credit_quantity ?? 0,
|
||||
platformKey,
|
||||
)
|
||||
channels.push({
|
||||
platformKey,
|
||||
creditQuantity,
|
||||
costMode: creditQuantity > 0 ? 'fixed' : 'variable',
|
||||
enabled: item.enabled === false ? false : true,
|
||||
})
|
||||
}
|
||||
|
||||
if (channels.length === 0) {
|
||||
throw createHttpError('请至少配置一个履约渠道', {
|
||||
statusCode: 400,
|
||||
errorCode: 'manual_config_channels_required',
|
||||
})
|
||||
}
|
||||
if (channels.length > 10) {
|
||||
throw createHttpError('单个 SKU 最多配置 10 个履约渠道', {
|
||||
statusCode: 400,
|
||||
errorCode: 'manual_config_channels_too_many',
|
||||
})
|
||||
}
|
||||
return channels
|
||||
}
|
||||
|
||||
function normalizeCreditQuantity(value: unknown, platformKey: ManualPlatformKey) {
|
||||
const parsed = parseManualChannelQuantity(value, platformKey)
|
||||
if (parsed === null) {
|
||||
throw createHttpError(
|
||||
platformKey === 'fund_cash' || platformKey === 'on_account'
|
||||
? '金额必须是大于等于 0 且最多 2 位小数的数字'
|
||||
: '积分渠道的消耗积分必须是非负整数',
|
||||
{
|
||||
statusCode: 400,
|
||||
errorCode: 'manual_config_credit_invalid',
|
||||
},
|
||||
)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
|
||||
@@ -822,7 +822,7 @@ export async function getAdminChannelMappingDiagnostics(payload: JsonObject = {}
|
||||
|
||||
const modes = channel
|
||||
? [channel]
|
||||
: ['work_order', 'affiliate_dash', 'third_party_voucher', 'manual']
|
||||
: ['work_order', 'affiliate_dash', 'third_party_voucher', 'manual', 'on_account']
|
||||
// 顺序跑:并行会同时扫 cost_exceptions,实测 4 路并发比串行更慢(I/O 争抢)。
|
||||
const aggregates: Array<{ groupTotal: number; itemTotal: number }> = []
|
||||
for (const mode of modes) {
|
||||
@@ -874,7 +874,7 @@ export async function listAdminUnmappedMappingGroups(payload: JsonObject = {}) {
|
||||
// 按履约方式拆开统计:命中索引,单模式约 90ms;「全部」时顺序跑四种模式。
|
||||
const modes = channel
|
||||
? [channel]
|
||||
: ['work_order', 'affiliate_dash', 'third_party_voucher', 'manual']
|
||||
: ['work_order', 'affiliate_dash', 'third_party_voucher', 'manual', 'on_account']
|
||||
const totals: Array<{ groupTotal: number; itemTotal: number }> = []
|
||||
for (const mode of modes) {
|
||||
const totalParams = [...params, mode]
|
||||
|
||||
@@ -325,7 +325,7 @@ export async function normalizeOrderItemProducts(
|
||||
cfg.canonical_product_id AS manual_config_product_id
|
||||
FROM order_items oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
LEFT JOIN work_orders wo ON wo.order_item_id = oi.id
|
||||
LEFT JOIN work_orders wo ON wo.order_item_id = oi.id AND wo.status <> 'cancelled'
|
||||
LEFT JOIN work_product_rules wpr ON wpr.id = wo.product_rule_id
|
||||
LEFT JOIN manual_fulfillment_configs cfg ON cfg.id = oi.fulfillment_config_id
|
||||
WHERE oi.fulfillment_status = 'assigned'
|
||||
@@ -479,7 +479,7 @@ export async function normalizeProductsForItems(itemIds: number[]): Promise<{
|
||||
cfg.canonical_product_id AS manual_config_product_id
|
||||
FROM order_items oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
LEFT JOIN work_orders wo ON wo.order_item_id = oi.id
|
||||
LEFT JOIN work_orders wo ON wo.order_item_id = oi.id AND wo.status <> 'cancelled'
|
||||
LEFT JOIN work_product_rules wpr ON wpr.id = wo.product_rule_id
|
||||
LEFT JOIN manual_fulfillment_configs cfg ON cfg.id = oi.fulfillment_config_id
|
||||
WHERE oi.fulfillment_status = 'assigned'
|
||||
|
||||
@@ -728,7 +728,7 @@ async function loadTemplateProductBootstrapData(range: { fromDate: string; toDat
|
||||
COUNT(DISTINCT oi.id)::int AS item_count
|
||||
FROM order_items oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
JOIN work_orders wo ON wo.order_item_id = oi.id
|
||||
JOIN work_orders wo ON wo.order_item_id = oi.id AND wo.status <> 'cancelled'
|
||||
JOIN work_product_rules r ON r.id = wo.product_rule_id AND r.enabled = TRUE
|
||||
WHERE wo.product_rule_id IS NOT NULL
|
||||
AND (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date
|
||||
|
||||
@@ -33,7 +33,7 @@ function resolveWorkOrderRange(payload: Record<string, unknown>) {
|
||||
export async function backfillAdminWorkOrderProductSkus(payload: Record<string, unknown> = {}) {
|
||||
const now = nowIso()
|
||||
const range = resolveWorkOrderRange(payload)
|
||||
// order_item_id 有唯一索引,这里可以直接 count,避免 DISTINCT ON 被
|
||||
// 未取消工单对 order_item_id 有唯一索引,这里可以直接 count,避免 DISTINCT ON 被
|
||||
// 三个子查询反复物化后撞 15 秒语句超时。
|
||||
const result = await query<WorkOrderSkuBackfillRow>(
|
||||
`
|
||||
@@ -51,6 +51,7 @@ export async function backfillAdminWorkOrderProductSkus(payload: Record<string,
|
||||
JOIN order_items oi ON oi.id = wo.order_item_id
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
WHERE wo.order_item_id IS NOT NULL
|
||||
AND wo.status <> 'cancelled'
|
||||
AND (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date
|
||||
BETWEEN $1::date AND $2::date
|
||||
`,
|
||||
@@ -73,6 +74,7 @@ export async function backfillAdminWorkOrderProductSkus(payload: Record<string,
|
||||
JOIN order_items oi ON oi.id = wo.order_item_id
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
WHERE wo.order_item_id IS NOT NULL
|
||||
AND wo.status <> 'cancelled'
|
||||
AND (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date
|
||||
BETWEEN $1::date AND $2::date
|
||||
AND wo.order_item_id > $3
|
||||
@@ -125,7 +127,10 @@ export async function backfillAdminWorkOrderProductSkus(payload: Record<string,
|
||||
AND btrim(COALESCE(oi.normalized_sku_name, '')) <> ''
|
||||
AND (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date
|
||||
BETWEEN $2::date AND $3::date
|
||||
AND EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM work_orders wo
|
||||
WHERE wo.order_item_id = oi.id AND wo.status <> 'cancelled'
|
||||
)
|
||||
GROUP BY 1
|
||||
),
|
||||
missing AS (
|
||||
@@ -452,6 +457,7 @@ export async function syncAdminWorkOrderTemplateCostVersions(ruleId?: number) {
|
||||
FROM work_orders wo
|
||||
JOIN rule_version rv ON rv.work_product_rule_id = wo.product_rule_id
|
||||
WHERE cs.order_item_id = wo.order_item_id
|
||||
AND wo.status <> 'cancelled'
|
||||
AND cs.cost_item_id = rv.cost_item_id
|
||||
AND cs.superseded_at IS NULL AND reversed_at IS NULL
|
||||
AND cs.cost_version_id <> rv.cost_version_id
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,67 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import test from 'node:test'
|
||||
|
||||
test('客户退款第一版覆盖范围与原子关闭动作已声明', async () => {
|
||||
const source = await readFile(new URL('./customer-refund-service.ts', import.meta.url), 'utf8')
|
||||
for (const status of [
|
||||
'pending_material',
|
||||
'unassigned',
|
||||
'open',
|
||||
'in_progress',
|
||||
'pending_acceptance',
|
||||
'problem',
|
||||
]) {
|
||||
assert.ok(source.includes(`'${status}'`), `缺少工单状态 ${status}`)
|
||||
}
|
||||
assert.ok(source.includes("pay_status = 'refunded'"))
|
||||
assert.ok(source.includes("order_status = 'refunded'"))
|
||||
assert.ok(source.includes('work_order_shares'))
|
||||
assert.ok(source.includes('voucher_consume_jobs'))
|
||||
assert.ok(source.includes('redeem_certificates'))
|
||||
assert.ok(source.includes('findResumableRefundCancelOperation'))
|
||||
assert.ok(source.includes('customer_refund_processing'))
|
||||
assert.ok(source.includes("status: 'platform_approved'"))
|
||||
assert.ok(source.includes('refundFee'))
|
||||
assert.ok(source.includes('queryType: 2'))
|
||||
assert.ok(
|
||||
source.includes('afterSale.operate') || source.includes('approveDouyinMashidaAfterSale'),
|
||||
)
|
||||
})
|
||||
|
||||
test('抖音退款创建和关闭消息会进入自动发现白名单', async () => {
|
||||
const [configSource, notifySource] = await Promise.all([
|
||||
readFile(
|
||||
new URL('../platforms/douyin-mashida/source-config-service.ts', import.meta.url),
|
||||
'utf8',
|
||||
),
|
||||
readFile(new URL('../platforms/douyin-mashida/notify-service.ts', import.meta.url), 'utf8'),
|
||||
])
|
||||
assert.ok(configSource.includes('refund_RefundCreated'))
|
||||
assert.ok(configSource.includes('refund_RefundClosed'))
|
||||
assert.ok(notifySource.includes('bindPlatformAfterSalesToLocalOrder'))
|
||||
})
|
||||
|
||||
test('客户退款操作表和平台售后表具备幂等唯一键', async () => {
|
||||
const migration = await readFile(
|
||||
new URL('../../db/migrations/134_customer_refund_operations.sql', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
assert.ok(migration.includes('uq_platform_after_sales_external'))
|
||||
assert.ok(migration.includes('uq_refund_cancel_operations_external'))
|
||||
assert.ok(migration.includes('afterSale.operate') || migration.includes('平台售后'))
|
||||
assert.ok(migration.includes('平台同意、本地退款、业务关闭'))
|
||||
})
|
||||
|
||||
test('客户退款使用独立权限,客服默认可用且可由管理员收回', async () => {
|
||||
const [routeSource, migration] = await Promise.all([
|
||||
readFile(new URL('../../routes/admin/worker-platform/orders.ts', import.meta.url), 'utf8'),
|
||||
readFile(
|
||||
new URL('../../db/migrations/134_customer_refund_operations.sql', import.meta.url),
|
||||
'utf8',
|
||||
),
|
||||
])
|
||||
assert.ok(routeSource.includes("requireAdminPermission('worker_order.customer_refund')"))
|
||||
assert.ok(migration.includes("'worker_order.customer_refund'"))
|
||||
assert.ok(migration.includes("('support')"))
|
||||
})
|
||||
@@ -21,6 +21,17 @@ test('人工成本:固定成本按配置积分 × 平台换算率', () => {
|
||||
)
|
||||
})
|
||||
|
||||
test('人工成本:备用金和统一结算金额支持两位小数', () => {
|
||||
assert.deepEqual(
|
||||
resolveManualFulfillmentCost({
|
||||
creditQuantity: 12.34,
|
||||
unitPriceFen: 100,
|
||||
providedCostFen: undefined,
|
||||
}),
|
||||
{ costFen: 1234, costMode: 'fixed' },
|
||||
)
|
||||
})
|
||||
|
||||
test('人工成本:成本不固定必须手填正数金额', () => {
|
||||
assert.deepEqual(
|
||||
resolveManualFulfillmentCost({
|
||||
@@ -107,6 +118,18 @@ test('人工确认前必须自动匹配履约配置,配置保存/定时任务
|
||||
configSource.includes('manual_sku_key'),
|
||||
'人工 SKU 匹配必须统一走 order_items.manual_sku_key 生成列',
|
||||
)
|
||||
assert.ok(
|
||||
configSource.includes('amount_fen, rate_bps') &&
|
||||
configSource.includes("0, '1970-01-01T00:00:00+08:00', 'manual', 'active'"),
|
||||
'人工履约固定成本版本必须同时写入 amount_fen 和 rate_bps,避免保存成功但后处理报错',
|
||||
)
|
||||
const deleteStart = configSource.indexOf('export async function deleteManualSkuClassification')
|
||||
const deleteEnd = configSource.indexOf('export async function rematchManualFulfillments')
|
||||
const deleteBody = configSource.slice(deleteStart, deleteEnd)
|
||||
assert.ok(
|
||||
!deleteBody.includes('containsCjk(key)'),
|
||||
'移除错误人工分类不能要求 SKU 含中文,否则纯数字等脏数据永远删不掉',
|
||||
)
|
||||
assert.ok(
|
||||
migrationSource.includes("spec_json->>'skuNick'") &&
|
||||
migrationSource.includes("spec_json->>'outSkuSn'") &&
|
||||
|
||||
@@ -16,6 +16,12 @@ import {
|
||||
} from '../cost-center/platform-credit-service.js'
|
||||
import { rebuildChannelSalesDailyFacts } from './sales-facts-channel-service.js'
|
||||
import { syncFinanceLedgerFromFacts } from './finance-ledger-service.js'
|
||||
import { scheduleVoucherConsumeForOrderItems } from '../fulfillment/voucher-consume-queue-service.js'
|
||||
import {
|
||||
calculateManualCostFen,
|
||||
normalizeManualCreditQuantity,
|
||||
parseManualChannelQuantity,
|
||||
} from '../cost-center/manual-credit-quantity.js'
|
||||
|
||||
/**
|
||||
* 人工履约待办与「确认履约」。
|
||||
@@ -36,6 +42,92 @@ const PLATFORM_LABELS: Record<string, string> = {
|
||||
lewan: '乐玩后台',
|
||||
rock: 'ROCK 后台',
|
||||
fund_cash: '备用金现金',
|
||||
on_account: '统一结算',
|
||||
}
|
||||
|
||||
const MANUAL_PLATFORM_KEYS = ['nanhang', 'lewan', 'rock', 'fund_cash', 'on_account'] as const
|
||||
type ManualExecutionPlatformKey = (typeof MANUAL_PLATFORM_KEYS)[number]
|
||||
|
||||
type ManualConfiguredChannel = {
|
||||
platformKey: ManualExecutionPlatformKey
|
||||
platformLabel: string
|
||||
creditQuantity: number
|
||||
creditUnitPriceFen: number
|
||||
costFen: number | null
|
||||
costMode: 'fixed' | 'variable'
|
||||
isFundCash: boolean
|
||||
isOnAccount: boolean
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
async function loadManualPlatformUnitPrices(): Promise<Record<ManualExecutionPlatformKey, number>> {
|
||||
const result = await query<{ profile_key: string; config_json: Record<string, unknown> }>(
|
||||
`SELECT profile_key, config_json FROM fulfillment_profiles WHERE profile_key LIKE 'manual_%'`,
|
||||
)
|
||||
const prices: Record<ManualExecutionPlatformKey, number> = {
|
||||
nanhang: 1,
|
||||
lewan: 1,
|
||||
rock: 100,
|
||||
fund_cash: 100,
|
||||
on_account: 100,
|
||||
}
|
||||
for (const row of result.rows) {
|
||||
const key = row.profile_key.replace(/^manual_/, '') as ManualExecutionPlatformKey
|
||||
const price = Number(row.config_json?.rechargeUnitPriceFen)
|
||||
if (key in prices && Number.isInteger(price) && price > 0) {
|
||||
prices[key] = price
|
||||
}
|
||||
}
|
||||
return prices
|
||||
}
|
||||
|
||||
function resolveManualConfiguredChannels(input: {
|
||||
channelsJson: unknown
|
||||
legacyPlatformKey: unknown
|
||||
legacyCreditQuantity: unknown
|
||||
legacyCostMode: unknown
|
||||
legacyEnabled: unknown
|
||||
unitPrices: Record<ManualExecutionPlatformKey, number>
|
||||
}): ManualConfiguredChannel[] {
|
||||
const raw = Array.isArray(input.channelsJson) ? input.channelsJson : []
|
||||
const source =
|
||||
raw.length > 0
|
||||
? raw
|
||||
: [
|
||||
{
|
||||
platformKey: input.legacyPlatformKey,
|
||||
creditQuantity: input.legacyCreditQuantity,
|
||||
costMode: input.legacyCostMode,
|
||||
enabled: input.legacyEnabled,
|
||||
},
|
||||
]
|
||||
const seen = new Set<ManualExecutionPlatformKey>()
|
||||
const channels: ManualConfiguredChannel[] = []
|
||||
|
||||
for (const item of source) {
|
||||
if (!item || typeof item !== 'object' || Array.isArray(item)) continue
|
||||
const row = item as Record<string, unknown>
|
||||
const platformKey = String(
|
||||
row.platformKey ?? row.platform_key ?? '',
|
||||
).trim() as ManualExecutionPlatformKey
|
||||
if (!MANUAL_PLATFORM_KEYS.includes(platformKey) || seen.has(platformKey)) continue
|
||||
if (row.enabled === false) continue
|
||||
seen.add(platformKey)
|
||||
const creditQuantity = parseManualChannelQuantity(row.creditQuantity, platformKey) ?? 0
|
||||
const unitPriceFen = input.unitPrices[platformKey]
|
||||
channels.push({
|
||||
platformKey,
|
||||
platformLabel: PLATFORM_LABELS[platformKey] || platformKey,
|
||||
creditQuantity,
|
||||
creditUnitPriceFen: unitPriceFen,
|
||||
costFen: creditQuantity > 0 ? calculateManualCostFen(creditQuantity, unitPriceFen) : null,
|
||||
costMode: creditQuantity > 0 ? 'fixed' : 'variable',
|
||||
isFundCash: platformKey === 'fund_cash',
|
||||
isOnAccount: platformKey === 'on_account',
|
||||
enabled: true,
|
||||
})
|
||||
}
|
||||
return channels
|
||||
}
|
||||
|
||||
type PendingRow = {
|
||||
@@ -54,6 +146,7 @@ type PendingRow = {
|
||||
credit_quantity: string
|
||||
cost_mode: string
|
||||
credit_unit_price_fen: string
|
||||
config_channels_json: unknown
|
||||
fulfilled_at: string | null
|
||||
}
|
||||
|
||||
@@ -66,6 +159,7 @@ type ConfirmContextRow = {
|
||||
shop_id: string
|
||||
sku_code: string
|
||||
sku_name: string
|
||||
internal_sku: string
|
||||
revenue_fen: string
|
||||
basis_at: string
|
||||
canonical_product_id: number | null
|
||||
@@ -75,6 +169,7 @@ type ConfirmContextRow = {
|
||||
credit_quantity: string
|
||||
cost_mode: string
|
||||
credit_unit_price_fen: string
|
||||
config_channels_json: unknown
|
||||
work_order_id: number | null
|
||||
work_order_no: string | null
|
||||
work_product_rule_id: number | null
|
||||
@@ -90,6 +185,7 @@ export async function listManualFulfillmentPending(
|
||||
input: Record<string, unknown> = {},
|
||||
session: AdminSession,
|
||||
) {
|
||||
const unitPrices = await loadManualPlatformUnitPrices()
|
||||
const page = normalizePositiveInteger(input.page, 1, '页码')
|
||||
const pageSize = Math.min(
|
||||
100,
|
||||
@@ -172,6 +268,7 @@ export async function listManualFulfillmentPending(
|
||||
cfg.platform_key,
|
||||
cfg.credit_quantity,
|
||||
cfg.cost_mode,
|
||||
cfg.channels_json AS config_channels_json,
|
||||
COALESCE((fp.config_json->>'rechargeUnitPriceFen')::bigint, 1) AS credit_unit_price_fen,
|
||||
fulfilled.fulfilled_at
|
||||
FROM order_items oi
|
||||
@@ -186,7 +283,7 @@ export async function listManualFulfillmentPending(
|
||||
)
|
||||
|
||||
return {
|
||||
items: result.rows.map(mapPending),
|
||||
items: result.rows.map((row) => mapPending(row, unitPrices)),
|
||||
pagination: { page, pageSize, total: Number(countResult.rows[0]?.total || 0) },
|
||||
}
|
||||
}
|
||||
@@ -203,6 +300,7 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm
|
||||
'请选择订单项',
|
||||
)
|
||||
const now = nowIso()
|
||||
const platformUnitPrices = await loadManualPlatformUnitPrices()
|
||||
|
||||
// 钱与成本必须同一事务:先锁订单项、复查未履约,再扣款、最后写快照。
|
||||
// 任何一步失败(余额不足、成本版本缺失、重复快照)都整体回滚,不会留下「扣了钱没记成本」。
|
||||
@@ -222,14 +320,16 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm
|
||||
o.shop_id,
|
||||
oi.sku_code,
|
||||
oi.sku_name,
|
||||
resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) AS internal_sku,
|
||||
COALESCE(oi.revenue_amount_fen, 0)::bigint AS revenue_fen,
|
||||
COALESCE(o.paid_at, o.created_at) AS basis_at,
|
||||
oi.canonical_product_id,
|
||||
COALESCE(oi.fulfillment_channel, CASE WHEN oi.fulfillment_status = 'archived' THEN 'archived' ELSE 'pending' END) AS channel,
|
||||
oi.fulfillment_config_id AS config_id,
|
||||
COALESCE(cfg.platform_key, '') AS platform_key,
|
||||
COALESCE(cfg.credit_quantity, 0)::bigint AS credit_quantity,
|
||||
COALESCE(cfg.credit_quantity, 0) AS credit_quantity,
|
||||
COALESCE(cfg.cost_mode, 'variable') AS cost_mode,
|
||||
cfg.channels_json AS config_channels_json,
|
||||
COALESCE((fp.config_json->>'rechargeUnitPriceFen')::bigint, 1) AS credit_unit_price_fen,
|
||||
wo.id AS work_order_id,
|
||||
wo.work_order_no,
|
||||
@@ -283,13 +383,30 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm
|
||||
errorCode: 'manual_fulfillment_config_missing',
|
||||
})
|
||||
}
|
||||
const platformKey = row.platform_key as PlatformCreditKey | 'fund_cash'
|
||||
if (platformKey !== 'fund_cash' && !PLATFORM_CREDIT_KEYS.includes(platformKey)) {
|
||||
throw createHttpError('该商品的人工履约平台配置无效,请重新配置', {
|
||||
const configuredChannels = resolveManualConfiguredChannels({
|
||||
channelsJson: row.config_channels_json,
|
||||
legacyPlatformKey: row.platform_key,
|
||||
legacyCreditQuantity: row.credit_quantity,
|
||||
legacyCostMode: row.cost_mode,
|
||||
legacyEnabled: true,
|
||||
unitPrices: platformUnitPrices,
|
||||
})
|
||||
if (configuredChannels.length === 0) {
|
||||
throw createHttpError('该商品没有可用的人工履约渠道,请重新配置', {
|
||||
statusCode: 409,
|
||||
errorCode: 'manual_fulfillment_config_invalid',
|
||||
})
|
||||
}
|
||||
const requestedPlatformKey = String(payload.platformKey ?? payload.platform_key ?? '').trim()
|
||||
const selectedChannel =
|
||||
configuredChannels.find((channel) => channel.platformKey === requestedPlatformKey) ||
|
||||
(configuredChannels.length === 1 ? configuredChannels[0] : undefined)
|
||||
if (!selectedChannel) {
|
||||
throw createHttpError('请选择本次实际执行的履约渠道', {
|
||||
statusCode: 400,
|
||||
errorCode: 'manual_fulfillment_platform_required',
|
||||
})
|
||||
}
|
||||
|
||||
// 锁内复查:并发/双击的第二个请求会等锁,醒来后看到快照直接拒绝,不会重复扣款。
|
||||
const existing = await client.query<{ id: number }>(
|
||||
@@ -305,20 +422,26 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm
|
||||
})
|
||||
}
|
||||
|
||||
const creditQuantity = Number(row.credit_quantity || 0)
|
||||
const unitPriceFen = Number(row.credit_unit_price_fen || 1)
|
||||
if (platformKey !== 'fund_cash' && creditQuantity <= 0) {
|
||||
const platformKey = selectedChannel.platformKey
|
||||
const creditQuantity = selectedChannel.creditQuantity
|
||||
const unitPriceFen = selectedChannel.creditUnitPriceFen
|
||||
if (platformKey !== 'fund_cash' && platformKey !== 'on_account' && creditQuantity <= 0) {
|
||||
throw createHttpError('该人工 SKU 的积分配置无效,请先在成本中心重新配置', {
|
||||
statusCode: 409,
|
||||
errorCode: 'manual_fulfillment_config_invalid',
|
||||
})
|
||||
}
|
||||
|
||||
const costPlan = resolveManualFulfillmentCost({
|
||||
creditQuantity,
|
||||
unitPriceFen,
|
||||
providedCostFen: payload.costFen ?? payload.cost_fen ?? payload.amountFen,
|
||||
})
|
||||
const providedCostFen = payload.costFen ?? payload.cost_fen ?? payload.amountFen
|
||||
const costPlan =
|
||||
platformKey === 'on_account' &&
|
||||
(providedCostFen === undefined || providedCostFen === null || providedCostFen === '')
|
||||
? ({ costFen: 0, costMode: 'variable' } as const)
|
||||
: resolveManualFulfillmentCost({
|
||||
creditQuantity,
|
||||
unitPriceFen,
|
||||
providedCostFen,
|
||||
})
|
||||
const costFen = costPlan.costFen
|
||||
const payeeName = optionalText(payload.payeeName ?? payload.payee_name, 120)
|
||||
const note = optionalText(payload.note, 300)
|
||||
@@ -341,9 +464,12 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm
|
||||
let fundExpenseId: number | null = null
|
||||
let creditEntryId: number | null = null
|
||||
let balanceAfter: number | null = null
|
||||
let subjectSource: 'manual_expense' | 'platform_credit'
|
||||
let onAccountBillId: number | null = null
|
||||
let subjectSource: 'manual_expense' | 'platform_credit' | 'on_account'
|
||||
|
||||
if (platformKey === 'fund_cash') {
|
||||
if (platformKey === 'on_account') {
|
||||
subjectSource = 'on_account'
|
||||
} else if (platformKey === 'fund_cash') {
|
||||
// 备用金现金:复用唯一写入口,但把 client 传进去,和快照共用同一个事务。
|
||||
const fundAccountId = await resolveFundAccountId(payload, session, client)
|
||||
const { writeFundExpense } = await import('../admin/admin-fund-service.js')
|
||||
@@ -456,11 +582,49 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm
|
||||
})
|
||||
}
|
||||
|
||||
if (platformKey === 'on_account') {
|
||||
const billNo = `OAB${Date.now()}${orderItemId}`
|
||||
const bill = await client.query<{ id: number }>(
|
||||
`
|
||||
INSERT INTO on_account_bills (
|
||||
bill_no, order_id, order_item_id, sku_key, canonical_product_id,
|
||||
platform_order_id, amount_fen, status, payee_name, note, metadata_json,
|
||||
created_by_admin_user_id, created_at, updated_at
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, 'pending', $8, $9, $10::jsonb, $11, $12, $12
|
||||
)
|
||||
RETURNING id
|
||||
`,
|
||||
[
|
||||
billNo,
|
||||
row.order_id_resolved,
|
||||
orderItemId,
|
||||
String(row.internal_sku || row.sku_name || '').trim(),
|
||||
row.canonical_product_id,
|
||||
row.platform_order_id,
|
||||
costFen,
|
||||
payeeName,
|
||||
note,
|
||||
JSON.stringify({
|
||||
platformKey,
|
||||
configId: Number(row.config_id),
|
||||
creditQuantity,
|
||||
costMode: costPlan.costMode,
|
||||
orderItemId,
|
||||
}),
|
||||
session.userId,
|
||||
now,
|
||||
],
|
||||
)
|
||||
onAccountBillId = Number(bill.rows[0]?.id || 0) || null
|
||||
}
|
||||
|
||||
// 人工确认即渠道成立:写新三列,避免订单一直停在 pending。
|
||||
const finalChannel = platformKey === 'on_account' ? 'on_account' : 'manual'
|
||||
await client.query(
|
||||
`
|
||||
UPDATE order_items
|
||||
SET fulfillment_channel = 'manual',
|
||||
SET fulfillment_channel = $3,
|
||||
fulfillment_status = 'assigned',
|
||||
fulfillment_source = 'manual_confirm',
|
||||
fulfillment_decided_at = $2,
|
||||
@@ -468,7 +632,7 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm
|
||||
WHERE id = $1
|
||||
AND fulfillment_status <> 'archived'
|
||||
`,
|
||||
[orderItemId, now],
|
||||
[orderItemId, now, finalChannel],
|
||||
)
|
||||
|
||||
// 人工缺成本异常立即收口,不等下一轮定时任务。
|
||||
@@ -507,9 +671,11 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm
|
||||
fundExpenseId,
|
||||
creditEntryId,
|
||||
balanceAfter,
|
||||
onAccountBillId,
|
||||
}
|
||||
})
|
||||
|
||||
await scheduleVoucherConsumeForOrderItems([orderItemId], 'order_received').catch(() => undefined)
|
||||
// 成本已落快照,立刻重算该业务日的事实与总账;失败不阻断确认(定时任务会兜底)。
|
||||
await refreshFactsForOrderItem(orderItemId)
|
||||
return result
|
||||
@@ -752,10 +918,13 @@ export function resolveManualFulfillmentCost(input: {
|
||||
unitPriceFen: number
|
||||
providedCostFen?: unknown
|
||||
}): { costFen: number; costMode: 'fixed' | 'variable' } {
|
||||
const creditQuantity = Math.max(0, Math.trunc(Number(input.creditQuantity) || 0))
|
||||
const creditQuantity = normalizeManualCreditQuantity(input.creditQuantity)
|
||||
if (creditQuantity > 0) {
|
||||
const unitPriceFen = Math.max(0, Math.trunc(Number(input.unitPriceFen) || 0))
|
||||
return { costFen: creditQuantity * unitPriceFen, costMode: 'fixed' }
|
||||
return {
|
||||
costFen: calculateManualCostFen(creditQuantity, unitPriceFen),
|
||||
costMode: 'fixed',
|
||||
}
|
||||
}
|
||||
if (
|
||||
input.providedCostFen === undefined ||
|
||||
@@ -777,10 +946,16 @@ export function resolveManualFulfillmentCost(input: {
|
||||
return { costFen, costMode: 'variable' }
|
||||
}
|
||||
|
||||
function mapPending(row: PendingRow) {
|
||||
const creditQuantity = Number(row.credit_quantity || 0)
|
||||
const unitPriceFen = Number(row.credit_unit_price_fen || 1)
|
||||
const isFundCash = row.platform_key === 'fund_cash'
|
||||
function mapPending(row: PendingRow, unitPrices: Record<ManualExecutionPlatformKey, number>) {
|
||||
const channels = resolveManualConfiguredChannels({
|
||||
channelsJson: row.config_channels_json,
|
||||
legacyPlatformKey: row.platform_key,
|
||||
legacyCreditQuantity: row.credit_quantity,
|
||||
legacyCostMode: row.cost_mode,
|
||||
legacyEnabled: true,
|
||||
unitPrices,
|
||||
})
|
||||
const primary = channels[0]
|
||||
return {
|
||||
orderItemId: Number(row.order_item_id),
|
||||
orderId: Number(row.order_id),
|
||||
@@ -793,14 +968,16 @@ function mapPending(row: PendingRow) {
|
||||
revenueFen: Number(row.revenue_fen || 0),
|
||||
paidAt: row.paid_at,
|
||||
configId: Number(row.config_id),
|
||||
platformKey: row.platform_key,
|
||||
platformLabel: PLATFORM_LABELS[row.platform_key] || row.platform_key,
|
||||
isFundCash,
|
||||
creditQuantity,
|
||||
creditUnitPriceFen: unitPriceFen,
|
||||
costMode: row.cost_mode || 'variable',
|
||||
platformKey: primary?.platformKey || '',
|
||||
platformLabel: primary?.platformLabel || '',
|
||||
isFundCash: primary?.isFundCash === true,
|
||||
isOnAccount: primary?.isOnAccount === true,
|
||||
creditQuantity: primary?.creditQuantity || 0,
|
||||
creditUnitPriceFen: primary?.creditUnitPriceFen || 1,
|
||||
costMode: primary?.costMode || 'variable',
|
||||
/** 固定成本的金额;成本不固定时为 null,客服要手填。 */
|
||||
costFen: creditQuantity > 0 ? creditQuantity * unitPriceFen : null,
|
||||
costFen: primary?.costFen ?? null,
|
||||
channels,
|
||||
fulfilled: Boolean(row.fulfilled_at),
|
||||
fulfilledAt: row.fulfilled_at || null,
|
||||
}
|
||||
|
||||
@@ -635,6 +635,8 @@ export async function approveAndRecordKuaishouRefund(
|
||||
refundedAt?: string | undefined
|
||||
sellerId?: string | undefined
|
||||
refundHandingWay?: unknown
|
||||
status?: unknown
|
||||
negotiateStatus?: unknown
|
||||
desc?: string | undefined
|
||||
},
|
||||
session: AdminSession | null,
|
||||
@@ -770,6 +772,8 @@ export async function approveAndRecordKuaishouRefund(
|
||||
refundAmount: amountFen,
|
||||
desc: input.desc || input.note || '',
|
||||
refundHandingWay: input.refundHandingWay,
|
||||
status: input.status,
|
||||
negotiateStatus: input.negotiateStatus,
|
||||
...(input.sellerId ? { sellerId: input.sellerId } : {}),
|
||||
})
|
||||
await updatePlatformRefundIntent(intent.id, 'platform_approved')
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { query, withTransaction } from '../../db/client.js'
|
||||
import { syncCostSnapshotsForItems } from '../cost-center/cost-snapshot-service.js'
|
||||
import { refreshFactsForDates } from '../finance/refresh-facts.js'
|
||||
import { scheduleVoucherConsumeForOrderItems } from './voucher-consume-queue-service.js'
|
||||
|
||||
/**
|
||||
* 自动发货失败 → 人工兜底。
|
||||
@@ -156,6 +157,7 @@ export async function runAffiliateFallback(
|
||||
for (const id of switchedIds) {
|
||||
await syncCostSnapshotsForItems([id]).catch(() => undefined)
|
||||
}
|
||||
await scheduleVoucherConsumeForOrderItems(switchedIds, 'order_received').catch(() => undefined)
|
||||
await refreshFactsForDates([...affectedDates]).catch(() => undefined)
|
||||
|
||||
return { scanned: candidates.rows.length, switched: switchedIds.length }
|
||||
|
||||
@@ -54,6 +54,53 @@ async function hasAffiliateTask(orderItemId: number): Promise<boolean> {
|
||||
return result.rows[0]?.exists === true
|
||||
}
|
||||
|
||||
const BLOCKED_INACTIVE_CARRIER_STATUSES = new Set(['closed', 'cancelled', 'manual_review'])
|
||||
|
||||
async function findBlockingInactiveCarrier(input: {
|
||||
orderItemId: number
|
||||
channel: FulfillmentChannel
|
||||
}): Promise<{ status: string; reason: string; message: string } | null> {
|
||||
if (input.channel !== 'affiliate_dash' && input.channel !== 'work_order') return null
|
||||
|
||||
const result =
|
||||
input.channel === 'affiliate_dash'
|
||||
? await query<{ status: string }>(
|
||||
`SELECT task_status AS status
|
||||
FROM fulfillment_tasks
|
||||
WHERE order_item_id = $1 AND executor_key = 'affiliate_dash'
|
||||
ORDER BY id DESC
|
||||
LIMIT 1`,
|
||||
[input.orderItemId],
|
||||
)
|
||||
: await query<{ status: string }>(
|
||||
`SELECT status
|
||||
FROM work_orders
|
||||
WHERE order_item_id = $1
|
||||
ORDER BY id DESC
|
||||
LIMIT 1`,
|
||||
[input.orderItemId],
|
||||
)
|
||||
|
||||
const status = String(result.rows[0]?.status || '')
|
||||
if (!BLOCKED_INACTIVE_CARRIER_STATUSES.has(status)) return null
|
||||
|
||||
if (status === 'manual_review') {
|
||||
return {
|
||||
status,
|
||||
reason: 'carrier_task_manual_review',
|
||||
message: '已有履约载体进入人工复核,需先处理后再重试',
|
||||
}
|
||||
}
|
||||
return {
|
||||
status,
|
||||
reason: 'carrier_task_closed',
|
||||
message:
|
||||
status === 'cancelled'
|
||||
? '已有履约载体已取消,需由运营确认后重新创建'
|
||||
: '已有履约载体已关闭,需由运营确认后重新创建',
|
||||
}
|
||||
}
|
||||
|
||||
function toResolvedItem(item: OrderItemRow): ResolvedFulfillmentItem {
|
||||
return {
|
||||
...item,
|
||||
@@ -79,8 +126,16 @@ export async function ensureCarrierForOrderItem(input: {
|
||||
order: OrderRow
|
||||
item: OrderItemRow
|
||||
channel: FulfillmentChannel
|
||||
retryInactiveCarrier?: boolean
|
||||
allowInactiveTaskRetry?: boolean
|
||||
}): Promise<EnsureCarrierResult> {
|
||||
const { order, item, channel } = input
|
||||
const {
|
||||
order,
|
||||
item,
|
||||
channel,
|
||||
retryInactiveCarrier = false,
|
||||
allowInactiveTaskRetry = false,
|
||||
} = input
|
||||
const existingCarrier = findConflictingCarrier(
|
||||
await loadActiveCarrierEvidenceForItem(Number(item.id)),
|
||||
channel,
|
||||
@@ -127,10 +182,26 @@ 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: '' }
|
||||
}
|
||||
const inactiveCarrier = await findBlockingInactiveCarrier({
|
||||
orderItemId: Number(item.id),
|
||||
channel,
|
||||
})
|
||||
if (inactiveCarrier && !retryInactiveCarrier) {
|
||||
return {
|
||||
ready: false,
|
||||
created: false,
|
||||
reason: inactiveCarrier.reason,
|
||||
message: inactiveCarrier.message,
|
||||
}
|
||||
}
|
||||
const internalSku = canonicalizeInternalSku(
|
||||
resolveInternalSkuKey({
|
||||
manualSkuKey: item.manual_sku_key,
|
||||
@@ -179,6 +250,7 @@ export async function ensureCarrierForOrderItem(input: {
|
||||
autoOnly: true,
|
||||
skipChannelAssign: true,
|
||||
preferredWorkRuleKey,
|
||||
retryCancelledWorkOrder: retryInactiveCarrier,
|
||||
})
|
||||
const ready = (await hasWorkOrder(Number(item.id))) || result.created.length > 0
|
||||
if (ready) {
|
||||
@@ -198,6 +270,18 @@ export async function ensureCarrierForOrderItem(input: {
|
||||
if (await hasAffiliateTask(Number(item.id))) {
|
||||
return { ready: true, created: false, reason: 'affiliate_task_exists', message: '' }
|
||||
}
|
||||
const inactiveCarrier = await findBlockingInactiveCarrier({
|
||||
orderItemId: Number(item.id),
|
||||
channel,
|
||||
})
|
||||
if (inactiveCarrier && !retryInactiveCarrier) {
|
||||
return {
|
||||
ready: false,
|
||||
created: false,
|
||||
reason: inactiveCarrier.reason,
|
||||
message: inactiveCarrier.message,
|
||||
}
|
||||
}
|
||||
|
||||
const resolved = await applyIntakeChannelDecision(
|
||||
toResolvedItem(item),
|
||||
@@ -232,14 +316,18 @@ export async function ensureCarrierForOrderItem(input: {
|
||||
)
|
||||
const before = await hasAffiliateTask(Number(item.id))
|
||||
if (!before) {
|
||||
await syncDeliveryTasksForOrder(order, [
|
||||
{
|
||||
...item,
|
||||
sku_code: String(resolved.skuCode || item.sku_code),
|
||||
spec_json: resolved.spec || {},
|
||||
item_snapshot_json: snapshot,
|
||||
} as OrderItemRow,
|
||||
])
|
||||
await syncDeliveryTasksForOrder(
|
||||
order,
|
||||
[
|
||||
{
|
||||
...item,
|
||||
sku_code: String(resolved.skuCode || item.sku_code),
|
||||
spec_json: resolved.spec || {},
|
||||
item_snapshot_json: snapshot,
|
||||
} as OrderItemRow,
|
||||
],
|
||||
{ allowInactiveTaskRetry: retryInactiveCarrier || allowInactiveTaskRetry },
|
||||
)
|
||||
}
|
||||
const ready = await hasAffiliateTask(Number(item.id))
|
||||
return {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
planInternalSkuChannels,
|
||||
type InternalSkuChannelRow,
|
||||
} from './internal-sku-channel-service.js'
|
||||
import { scheduleVoucherConsumeForOrderItems } from './voucher-consume-queue-service.js'
|
||||
|
||||
type CarrierCandidate = {
|
||||
id: number
|
||||
@@ -49,11 +50,21 @@ async function loadCandidateItems(input: {
|
||||
limit: number
|
||||
orderItemIds?: number[]
|
||||
assignedMissingCarrier?: boolean
|
||||
afterId?: number
|
||||
includeLocked?: boolean
|
||||
}): Promise<CarrierCandidate[]> {
|
||||
const ids = (input.orderItemIds || [])
|
||||
.map(Number)
|
||||
.filter((value) => Number.isInteger(value) && value > 0)
|
||||
const idFilter = ids.length > 0 ? 'AND oi.id = ANY($2::bigint[])' : ''
|
||||
const params: unknown[] = [input.limit]
|
||||
let idFilter = ''
|
||||
if (ids.length > 0) {
|
||||
params.push(ids)
|
||||
idFilter = `AND oi.id = ANY($${params.length}::bigint[])`
|
||||
} else if (Number(input.afterId || 0) > 0) {
|
||||
params.push(Math.max(0, Math.trunc(Number(input.afterId))))
|
||||
idFilter = `AND oi.id > $${params.length}`
|
||||
}
|
||||
const statusFilter = input.assignedMissingCarrier
|
||||
? `oi.fulfillment_status = 'assigned'
|
||||
AND oi.fulfillment_channel IN ('affiliate_dash', 'work_order')
|
||||
@@ -66,10 +77,9 @@ async function loadCandidateItems(input: {
|
||||
WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash'
|
||||
AND ft.task_status NOT IN (${INACTIVE_CARRIER_TASK_STATUSES.map((status) => `'${status}'`).join(', ')})
|
||||
)`
|
||||
: `oi.fulfillment_status = 'pending' AND oi.fulfillment_locked = FALSE`
|
||||
: `oi.fulfillment_status = 'pending'
|
||||
${input.includeLocked ? '' : 'AND oi.fulfillment_locked = FALSE'}`
|
||||
|
||||
const params: unknown[] = [input.limit]
|
||||
if (ids.length > 0) params.push(ids)
|
||||
const result = await query<CarrierCandidate>(
|
||||
`
|
||||
SELECT
|
||||
@@ -119,6 +129,7 @@ async function markPendingWithoutCarrier(input: {
|
||||
internalSku: string
|
||||
attempts: Array<{ channel: FulfillmentChannel; reason: string; message: string }>
|
||||
}): Promise<void> {
|
||||
const carrierIssue = input.attempts[0]?.reason || 'carrier_attempt_failed'
|
||||
await query(
|
||||
`
|
||||
UPDATE order_items
|
||||
@@ -131,6 +142,8 @@ async function markPendingWithoutCarrier(input: {
|
||||
input.itemId,
|
||||
JSON.stringify({
|
||||
internalSku: input.internalSku,
|
||||
carrierIssue,
|
||||
carrierMessage: input.attempts[0]?.message || '',
|
||||
attempts: input.attempts,
|
||||
}),
|
||||
],
|
||||
@@ -176,7 +189,13 @@ async function selectSafeFallback(input: {
|
||||
input.includeAssignedMissingCarrier,
|
||||
],
|
||||
)
|
||||
return (result.rowCount || 0) > 0
|
||||
const updated = (result.rowCount || 0) > 0
|
||||
if (updated) {
|
||||
await scheduleVoucherConsumeForOrderItems([input.orderItemId], 'order_received').catch(
|
||||
() => undefined,
|
||||
)
|
||||
}
|
||||
return updated
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -191,6 +210,9 @@ export async function reconcilePendingCarriers(
|
||||
orderItemIds?: number[]
|
||||
includeAssignedMissingCarrier?: boolean
|
||||
allowFallback?: boolean
|
||||
afterId?: number
|
||||
includeLocked?: boolean
|
||||
retryInactiveCarrier?: boolean
|
||||
} = {},
|
||||
): Promise<{
|
||||
scanned: number
|
||||
@@ -199,11 +221,14 @@ export async function reconcilePendingCarriers(
|
||||
fallbacksSelected: number
|
||||
failed: number
|
||||
remaining: number
|
||||
nextCursor: number
|
||||
}> {
|
||||
const limit = Math.min(500, Math.max(1, Math.trunc(Number(input.limit) || 200)))
|
||||
const rows = await loadCandidateItems({
|
||||
limit,
|
||||
assignedMissingCarrier: input.includeAssignedMissingCarrier === true,
|
||||
...(input.afterId != null ? { afterId: input.afterId } : {}),
|
||||
...(input.includeLocked ? { includeLocked: true } : {}),
|
||||
...(input.orderItemIds ? { orderItemIds: input.orderItemIds } : {}),
|
||||
})
|
||||
|
||||
@@ -273,6 +298,8 @@ export async function reconcilePendingCarriers(
|
||||
order,
|
||||
item,
|
||||
channel: attempt.channel,
|
||||
retryInactiveCarrier: input.retryInactiveCarrier === true,
|
||||
allowInactiveTaskRetry: true,
|
||||
})
|
||||
if (!carrier.ready) {
|
||||
failures.push({
|
||||
@@ -364,5 +391,10 @@ export async function reconcilePendingCarriers(
|
||||
fallbacksSelected,
|
||||
failed,
|
||||
remaining: Number(remainingResult.rows[0]?.count || 0),
|
||||
nextCursor: input.orderItemIds?.length
|
||||
? 0
|
||||
: rows.length < limit
|
||||
? 0
|
||||
: Number(rows[rows.length - 1]?.id || 0),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createTaskEvent } from '../../repositories/task-event-repo.js'
|
||||
import { createWorkOrderEvent } from '../../repositories/worker-platform/work-order-event-repo.js'
|
||||
import type { OrderItemRow } from '../../types/repository/rows.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
import { logWarn } from '../../utils/logger.js'
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
import { upsertAggregatedException } from '../cost-center/cost-exception-service.js'
|
||||
import { readWatermarkItemId } from '../watermark-store.js'
|
||||
@@ -30,8 +31,10 @@ import {
|
||||
type InternalSkuChannelRow,
|
||||
} from './internal-sku-channel-service.js'
|
||||
import { ensureCarrierForOrderItem } from './carrier-creation-service.js'
|
||||
import { reconcilePendingCarriers } from './carrier-reconciliation-service.js'
|
||||
import { TASK_STATUS } from '../../domain/task-status.js'
|
||||
import { canonicalizeInternalSku, containsCjk } from './internal-sku-resolver.js'
|
||||
import { scheduleVoucherConsumeForOrderItems } from './voucher-consume-queue-service.js'
|
||||
|
||||
/**
|
||||
* 管道 A:渠道判定。
|
||||
@@ -116,12 +119,23 @@ function resolvePendingReason(item: ChannelCandidateItem): {
|
||||
detail: { internalSku: item.internal_sku },
|
||||
}
|
||||
}
|
||||
const primary = plan.primary
|
||||
const primaryConfig = primary.config_json || {}
|
||||
const carrierIssue =
|
||||
primary.channel === 'manual' && !Number(primaryConfig.manualConfigId || 0)
|
||||
? 'manual_config_missing'
|
||||
: primary.channel === 'work_order' && !String(primaryConfig.workRuleKey || '').trim()
|
||||
? 'work_rule_missing'
|
||||
: primary.channel === 'affiliate_dash' && !String(primaryConfig.affiliateSku || '').trim()
|
||||
? 'affiliate_mapping_missing'
|
||||
: 'carrier_not_attempted'
|
||||
return {
|
||||
reason: 'no_carrier',
|
||||
detail: {
|
||||
internalSku: item.internal_sku,
|
||||
channel: plan.primary.channel,
|
||||
channel: primary.channel,
|
||||
fallbacks: plan.fallbacks.map((channel) => channel.channel),
|
||||
carrierIssue,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -199,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',
|
||||
@@ -234,7 +248,13 @@ export type AssignChannelsResult = {
|
||||
|
||||
/** 批量判定:默认只扫 pending 且未锁定的订单项。 */
|
||||
export async function assignPendingOrderItemChannels(
|
||||
options: { batchSize?: number; maxBatches?: number; afterId?: number } = {},
|
||||
options: {
|
||||
batchSize?: number
|
||||
maxBatches?: number
|
||||
afterId?: number
|
||||
orderItemIds?: number[]
|
||||
includeLocked?: boolean
|
||||
} = {},
|
||||
): Promise<AssignChannelsResult> {
|
||||
const batchSize = Math.min(5000, Math.max(1, Math.trunc(options.batchSize || 500)))
|
||||
const maxBatches = Math.min(200, Math.max(1, Math.trunc(options.maxBatches || 20)))
|
||||
@@ -257,8 +277,20 @@ export async function assignPendingOrderItemChannels(
|
||||
pendingReasons: {},
|
||||
}
|
||||
const affectedDates = new Set<string>()
|
||||
const requestedIds = [
|
||||
...new Set(
|
||||
(options.orderItemIds || []).map(Number).filter((id) => Number.isInteger(id) && id > 0),
|
||||
),
|
||||
]
|
||||
const params: unknown[] = [cursor, batchSize]
|
||||
let requestedFilter = ''
|
||||
if (requestedIds.length > 0) {
|
||||
params.push(requestedIds)
|
||||
requestedFilter = `AND oi.id = ANY($${params.length}::bigint[])`
|
||||
}
|
||||
|
||||
for (let batch = 0; batch < maxBatches; batch += 1) {
|
||||
params[0] = cursor
|
||||
const rows = await query<ChannelCandidateItem>(
|
||||
`
|
||||
SELECT
|
||||
@@ -288,12 +320,13 @@ export async function assignPendingOrderItemChannels(
|
||||
FROM order_items oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
WHERE oi.fulfillment_status = 'pending'
|
||||
AND oi.fulfillment_locked = FALSE
|
||||
${options.includeLocked ? '' : 'AND oi.fulfillment_locked = FALSE'}
|
||||
AND oi.id > $1
|
||||
${requestedFilter}
|
||||
ORDER BY oi.id
|
||||
LIMIT $2
|
||||
`,
|
||||
[cursor, batchSize],
|
||||
params,
|
||||
)
|
||||
|
||||
if (rows.rows.length === 0) {
|
||||
@@ -353,7 +386,13 @@ export async function assignPendingOrderItemChannels(
|
||||
await query(
|
||||
`UPDATE order_items
|
||||
SET fulfillment_pending_reason = $2,
|
||||
fulfillment_pending_detail = $3,
|
||||
fulfillment_pending_detail = CASE
|
||||
WHEN $2 = 'no_carrier'
|
||||
AND COALESCE(fulfillment_pending_detail->>'carrierIssue', '')
|
||||
NOT IN ('', 'carrier_not_attempted')
|
||||
THEN fulfillment_pending_detail
|
||||
ELSE $3::jsonb
|
||||
END,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1 AND fulfillment_status = 'pending'`,
|
||||
[item.id, pending.reason, JSON.stringify(pending.detail)],
|
||||
@@ -365,7 +404,7 @@ export async function assignPendingOrderItemChannels(
|
||||
}
|
||||
|
||||
if (assignments.length > 0) {
|
||||
const updated = await query(
|
||||
const updated = await query<{ id: number }>(
|
||||
`
|
||||
UPDATE order_items oi
|
||||
SET
|
||||
@@ -380,16 +419,30 @@ 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,
|
||||
],
|
||||
)
|
||||
result.assigned += updated.rowCount || 0
|
||||
result.assignedItemIds = assignments.map((row) => row.id).slice(0, 2000)
|
||||
const assignedItemIds = updated.rows
|
||||
.map((row) => Number(row.id))
|
||||
.filter((id) => id > 0)
|
||||
.slice(0, 2000)
|
||||
result.assigned += assignedItemIds.length
|
||||
result.assignedItemIds = assignedItemIds
|
||||
await scheduleVoucherConsumeForOrderItems(assignedItemIds, 'order_received').catch(
|
||||
(error) => {
|
||||
logWarn('[channel-assign]', '订单收到后安排自动核销失败', {
|
||||
orderItemIds: assignedItemIds,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -404,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])
|
||||
@@ -421,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
|
||||
@@ -445,7 +506,13 @@ export async function assignOrderItemsAfterCarrierReady(input: {
|
||||
|
||||
let carrier
|
||||
try {
|
||||
carrier = await ensureCarrierForOrderItem({ order, item, channel: input.channel })
|
||||
carrier = await ensureCarrierForOrderItem({
|
||||
order,
|
||||
item,
|
||||
channel: input.channel,
|
||||
retryInactiveCarrier: true,
|
||||
allowInactiveTaskRetry: true,
|
||||
})
|
||||
} catch (error) {
|
||||
carrier = {
|
||||
ready: false,
|
||||
@@ -457,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(
|
||||
@@ -484,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'`,
|
||||
[
|
||||
@@ -498,7 +571,10 @@ export async function assignOrderItemsAfterCarrierReady(input: {
|
||||
channel: input.channel,
|
||||
reason: carrier.reason,
|
||||
message: carrier.message,
|
||||
carrierIssue: carrier.reason,
|
||||
}),
|
||||
input.channel,
|
||||
input.canonicalProductId || null,
|
||||
],
|
||||
)
|
||||
}
|
||||
@@ -511,17 +587,31 @@ 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))
|
||||
}
|
||||
|
||||
if (assignedIds.length > 0) {
|
||||
await scheduleVoucherConsumeForOrderItems(assignedIds, 'order_received').catch((error) => {
|
||||
logWarn('[channel-assign]', '指定渠道后安排自动核销失败', {
|
||||
orderItemIds: assignedIds,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
})
|
||||
await normalizeProductsForItems(assignedIds).catch(() => undefined)
|
||||
const cost = await syncCostSnapshotsForItems(assignedIds).catch(() => null)
|
||||
const dates = [
|
||||
@@ -546,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(
|
||||
@@ -581,7 +675,9 @@ async function syncAssignedChannelSnapshots(
|
||||
reason:
|
||||
channel === 'work_order'
|
||||
? '内部 SKU 渠道配置指定接单平台'
|
||||
: '内部 SKU 渠道配置指定第三方卡券',
|
||||
: channel === 'on_account'
|
||||
? '内部 SKU 渠道配置指定统一结算'
|
||||
: '内部 SKU 渠道配置指定第三方卡券',
|
||||
matchedRule: null,
|
||||
candidates: [],
|
||||
skipped: [],
|
||||
@@ -898,13 +994,18 @@ 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[]
|
||||
shops: Array<{ id: string; name: string }>
|
||||
sku_codes: string[]
|
||||
pending_reasons: string[]
|
||||
pending_subreasons: string[]
|
||||
configured_channels: string[]
|
||||
locked_item_count: number
|
||||
item_count: number
|
||||
revenue_fen: number
|
||||
first_seen_at: string
|
||||
@@ -912,7 +1013,7 @@ export type PendingGroupRow = {
|
||||
}
|
||||
|
||||
type PendingShopNameRow = {
|
||||
internal_sku: string
|
||||
group_key: string
|
||||
provider: string
|
||||
shop_id: string
|
||||
shop_name: string
|
||||
@@ -921,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
|
||||
@@ -942,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,
|
||||
)
|
||||
@@ -956,68 +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(
|
||||
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 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
|
||||
WHERE ${where}
|
||||
GROUP BY ${INTERNAL_SKU_SQL}
|
||||
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) =
|
||||
pi.internal_sku
|
||||
) cfg ON TRUE
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1025,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,
|
||||
@@ -1034,6 +1200,102 @@ export async function listPendingChannelGroups(input: {
|
||||
}
|
||||
}
|
||||
|
||||
export type RetryPendingCarrierGroupResult = {
|
||||
scanned: number
|
||||
workOrdersCreated: number
|
||||
affiliateTasksCreated: number
|
||||
assigned: number
|
||||
failed: number
|
||||
remaining: number
|
||||
}
|
||||
|
||||
/** 运营确认后重试整组载体:允许重开已关闭的历史尝试,并包含运营锁定的订单项。 */
|
||||
export async function retryPendingCarrierGroup(input: {
|
||||
internalSku: string
|
||||
}): Promise<RetryPendingCarrierGroupResult> {
|
||||
const internalSku = canonicalizeInternalSku(input.internalSku)
|
||||
if (!internalSku || !containsCjk(internalSku)) {
|
||||
throw createHttpError('必须使用包含中文的内部 SKU', {
|
||||
statusCode: 400,
|
||||
errorCode: 'internal_sku_required',
|
||||
})
|
||||
}
|
||||
|
||||
const pending = await query<{ id: number }>(
|
||||
`SELECT oi.id
|
||||
FROM order_items oi
|
||||
WHERE oi.fulfillment_status = 'pending'
|
||||
AND ${INTERNAL_SKU_SQL} = $1
|
||||
ORDER BY oi.id`,
|
||||
[internalSku],
|
||||
)
|
||||
const ids = pending.rows.map((row) => Number(row.id))
|
||||
let workOrdersCreated = 0
|
||||
let affiliateTasksCreated = 0
|
||||
let failed = 0
|
||||
const assignedItemIds: number[] = []
|
||||
|
||||
for (let offset = 0; offset < ids.length; offset += 500) {
|
||||
const batchIds = ids.slice(offset, offset + 500)
|
||||
const carrier = await reconcilePendingCarriers({
|
||||
orderItemIds: batchIds,
|
||||
limit: batchIds.length,
|
||||
includeLocked: true,
|
||||
retryInactiveCarrier: true,
|
||||
})
|
||||
workOrdersCreated += carrier.workOrdersCreated
|
||||
affiliateTasksCreated += carrier.affiliateTasksCreated
|
||||
failed += carrier.failed
|
||||
const applied = await assignPendingOrderItemChannels({
|
||||
orderItemIds: batchIds,
|
||||
batchSize: batchIds.length,
|
||||
maxBatches: 1,
|
||||
afterId: 0,
|
||||
includeLocked: true,
|
||||
})
|
||||
assignedItemIds.push(...applied.assignedItemIds)
|
||||
}
|
||||
|
||||
await query(
|
||||
`UPDATE order_items oi
|
||||
SET fulfillment_config_id = cfg.id,
|
||||
canonical_product_id = COALESCE(oi.canonical_product_id, cfg.canonical_product_id),
|
||||
updated_at = NOW()
|
||||
FROM manual_fulfillment_configs cfg
|
||||
WHERE oi.id = ANY($1::bigint[])
|
||||
AND oi.fulfillment_status = 'assigned'
|
||||
AND oi.fulfillment_channel = 'manual'
|
||||
AND cfg.enabled = TRUE
|
||||
AND canonicalize_internal_sku(cfg.sku_key) =
|
||||
resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code)`,
|
||||
[ids],
|
||||
)
|
||||
if (assignedItemIds.length > 0) {
|
||||
await normalizeProductsForItems(assignedItemIds).catch(() => undefined)
|
||||
const cost = await syncCostSnapshotsForItems(assignedItemIds).catch(() => null)
|
||||
const dates = [
|
||||
...new Set([...(await orderItemBasisDates(assignedItemIds)), ...(cost?.affectedDates || [])]),
|
||||
]
|
||||
await refreshFactsForDates(dates).catch(() => undefined)
|
||||
}
|
||||
const final = await query<{ assigned: number; remaining: number }>(
|
||||
`SELECT
|
||||
count(*) FILTER (WHERE fulfillment_status = 'assigned')::int AS assigned,
|
||||
count(*) FILTER (WHERE fulfillment_status = 'pending')::int AS remaining
|
||||
FROM order_items
|
||||
WHERE id = ANY($1::bigint[])`,
|
||||
[ids],
|
||||
)
|
||||
return {
|
||||
scanned: ids.length,
|
||||
workOrdersCreated,
|
||||
affiliateTasksCreated,
|
||||
assigned: Number(final.rows[0]?.assigned || 0),
|
||||
failed,
|
||||
remaining: Number(final.rows[0]?.remaining || 0),
|
||||
}
|
||||
}
|
||||
|
||||
export type DeletePendingChannelGroupResult = {
|
||||
archivedItems: number
|
||||
preservedOrders: number
|
||||
@@ -1043,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
|
||||
@@ -1069,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) {
|
||||
@@ -1087,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],
|
||||
@@ -1186,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
|
||||
@@ -1193,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' })
|
||||
}
|
||||
@@ -1226,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,
|
||||
@@ -1249,6 +1613,7 @@ export async function assignChannelByInternalSku(input: {
|
||||
source: 'operator_assigned',
|
||||
lock: true,
|
||||
note: input.note,
|
||||
canonicalProductId: draftProductId,
|
||||
})
|
||||
await query(
|
||||
`UPDATE order_items
|
||||
@@ -1257,7 +1622,12 @@ export async function assignChannelByInternalSku(input: {
|
||||
[ids, input.adminUserId],
|
||||
)
|
||||
|
||||
return { ...applied, ruleCreated }
|
||||
return {
|
||||
...applied,
|
||||
ruleCreated,
|
||||
channelSaved: true,
|
||||
draftProductId,
|
||||
}
|
||||
}
|
||||
|
||||
/** 按平台 SKU 定位内部 SKU 后全局指定渠道。 */
|
||||
@@ -1415,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,
|
||||
@@ -1443,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
|
||||
@@ -1481,7 +1923,9 @@ async function resolveInternalSkuCarrierConfig(
|
||||
? { affiliateSku: carrierKey }
|
||||
: channel === 'work_order'
|
||||
? { workRuleKey: carrierKey }
|
||||
: { voucherSku: carrierKey },
|
||||
: channel === 'on_account'
|
||||
? { settlementSku: carrierKey }
|
||||
: { voucherSku: carrierKey },
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* 与旧 `channel` 的区别:
|
||||
* · 渠道只表达「谁来做」,不包含 pending/archived 这类状态;
|
||||
* · 「待分类」是状态,不是渠道;
|
||||
* · 「第三方卡券」是渠道,但没有履约载体,只做统计。
|
||||
* · 「第三方卡券」由外部卡券库履约,不建内部履约载体;平台电子凭证仍可配置核销。
|
||||
*/
|
||||
|
||||
export const FULFILLMENT_CHANNELS = [
|
||||
@@ -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]
|
||||
@@ -30,6 +31,10 @@ export const INACTIVE_CARRIER_TASK_STATUSES = [
|
||||
'cancelled',
|
||||
] as const
|
||||
|
||||
export function isInactiveCarrierTaskStatus(value: unknown): boolean {
|
||||
return (INACTIVE_CARRIER_TASK_STATUSES as readonly string[]).includes(String(value || ''))
|
||||
}
|
||||
|
||||
export const FULFILLMENT_SOURCES = [
|
||||
'auto_rule',
|
||||
'auto_mapping',
|
||||
@@ -47,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
|
||||
@@ -60,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'
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { buildOrderClaimUrl } from '../../claim/claim-service.js'
|
||||
import { TASK_STATUS } from '../../../domain/task-status.js'
|
||||
import { logWarn } from '../../../utils/logger.js'
|
||||
import { prepareAffiliateDashTask } from '../affiliate-dash/index.js'
|
||||
import { scheduleVoucherConsumeForTask } from '../voucher-consume-queue-service.js'
|
||||
import {
|
||||
FULFILLMENT_EXECUTOR_KEYS,
|
||||
type FulfillmentDeliveryLink,
|
||||
@@ -32,7 +34,18 @@ async function preparePaidTask(
|
||||
})) || task
|
||||
}
|
||||
|
||||
return await prepareAffiliateDashTask(workingTask)
|
||||
const prepared = await prepareAffiliateDashTask(workingTask)
|
||||
const claimToken = String(prepared?.primary_claim_token || prepared?.claim_token || '').trim()
|
||||
if (prepared && prepared.task_status !== TASK_STATUS.MANUAL_REVIEW && claimToken) {
|
||||
await scheduleVoucherConsumeForTask(prepared, 'link_generated').catch((error) => {
|
||||
logWarn('[affiliate-dash-executor]', '领取链接生成后安排自动核销失败', {
|
||||
taskId: prepared.id,
|
||||
platformOrderId: prepared.platform_order_id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
})
|
||||
}
|
||||
return prepared
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'affiliate-dash 订单创建失败'
|
||||
const updatedTask = await deps.updateTask(task.id, {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { logWarn } from '../../../utils/logger.js'
|
||||
import { TASK_STATUS } from '../../../domain/task-status.js'
|
||||
import { autoConsumeKuaishouIndustryVouchersForTask } from '../../platforms/kuaishou-industry/voucher-auto-consume-service.js'
|
||||
import { scheduleVoucherConsumeForTask } from '../voucher-consume-queue-service.js'
|
||||
import {
|
||||
FULFILLMENT_EXECUTOR_KEYS,
|
||||
type FulfillmentExecutor,
|
||||
@@ -32,10 +32,8 @@ async function preparePaidTask(
|
||||
})
|
||||
|
||||
const effectiveTask = updatedTask || task
|
||||
await autoConsumeKuaishouIndustryVouchersForTask(effectiveTask, {
|
||||
source: 'manual_dispatch_prepared',
|
||||
}).catch((error) => {
|
||||
logWarn('[manual-executor]', '人工派单准备后自动推进电子凭证核销失败', {
|
||||
await scheduleVoucherConsumeForTask(effectiveTask, 'task_created').catch((error) => {
|
||||
logWarn('[manual-executor]', '人工派单准备后安排自动核销失败', {
|
||||
taskId: effectiveTask.id,
|
||||
platformOrderId: effectiveTask.platform_order_id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
|
||||
@@ -27,6 +27,7 @@ test(
|
||||
listChannelRules,
|
||||
listChannelRulesPage,
|
||||
listPendingChannelGroups,
|
||||
retryPendingCarrierGroup,
|
||||
switchChannelForOrderItems,
|
||||
upsertChannelRule,
|
||||
} = await import('./channel-assign-service.js')
|
||||
@@ -117,15 +118,18 @@ test(
|
||||
internal_sku: string | null
|
||||
platform_only: string | null
|
||||
cjk_extension: string | null
|
||||
case_variant: string | null
|
||||
}>(
|
||||
`SELECT
|
||||
resolve_internal_sku('platform-code', ' 套装-浪漫 天命 ', 'platform-code') AS internal_sku,
|
||||
resolve_internal_sku('platform-code', 'Platform Title', 'platform-code') AS platform_only,
|
||||
resolve_internal_sku('', '𠀀', '') AS cjk_extension`,
|
||||
resolve_internal_sku('', '𠀀', '') AS cjk_extension,
|
||||
resolve_internal_sku('', '黑色ROCK长袖T恤', '') AS case_variant`,
|
||||
)
|
||||
assert.equal(canonicalResolution.rows[0]?.internal_sku, '套装-浪漫天命')
|
||||
assert.equal(canonicalResolution.rows[0]?.platform_only, null)
|
||||
assert.equal(canonicalResolution.rows[0]?.cjk_extension, '𠀀')
|
||||
assert.equal(canonicalResolution.rows[0]?.case_variant, '黑色rock长袖t恤')
|
||||
|
||||
await query(
|
||||
`INSERT INTO internal_sku_channels (internal_sku, channel, priority, is_fallback, enabled, source)
|
||||
@@ -331,6 +335,12 @@ test(
|
||||
)`,
|
||||
[retryWorkOrderRuleId, retryWorkOrderSku],
|
||||
)
|
||||
await query(
|
||||
`INSERT INTO internal_sku_channels (
|
||||
internal_sku, channel, priority, is_fallback, enabled, config_json, source
|
||||
) VALUES ($1, 'work_order', 10, FALSE, TRUE, $2::jsonb, 'integration_test')`,
|
||||
[retryWorkOrderSku, JSON.stringify({ workRuleKey: retryWorkOrderRuleKey })],
|
||||
)
|
||||
const retriedWorkOrders = await retryPendingWorkOrderRouting({
|
||||
internalSku: retryWorkOrderSku,
|
||||
})
|
||||
@@ -353,6 +363,34 @@ test(
|
||||
assert.equal(retriedItem.rows[0]?.fulfillment_status, 'assigned')
|
||||
assert.equal(retriedItem.rows[0]?.fulfillment_channel, 'work_order')
|
||||
assert.equal(retriedItem.rows[0]?.work_rule_key, retryWorkOrderRuleKey)
|
||||
await query(
|
||||
`UPDATE work_orders SET status = 'cancelled', updated_at = NOW()
|
||||
WHERE order_item_id = $1`,
|
||||
[retryWorkOrderItemId],
|
||||
)
|
||||
await query(
|
||||
`UPDATE order_items
|
||||
SET fulfillment_status = 'pending', fulfillment_channel = NULL,
|
||||
fulfillment_source = 'auto_mapping', fulfillment_pending_reason = 'no_carrier',
|
||||
fulfillment_pending_detail = $2::jsonb, updated_at = NOW()
|
||||
WHERE id = $1`,
|
||||
[
|
||||
retryWorkOrderItemId,
|
||||
JSON.stringify({ carrierIssue: 'carrier_task_closed', channel: 'work_order' }),
|
||||
],
|
||||
)
|
||||
const retriedCarrier = await retryPendingCarrierGroup({
|
||||
internalSku: retryWorkOrderSku,
|
||||
})
|
||||
assert.equal(retriedCarrier.scanned, 1)
|
||||
assert.equal(retriedCarrier.assigned, 1)
|
||||
assert.equal(retriedCarrier.remaining, 0)
|
||||
const activeWorkOrders = await query<{ count: number }>(
|
||||
`SELECT count(*)::int AS count FROM work_orders
|
||||
WHERE order_item_id = $1 AND status <> 'cancelled'`,
|
||||
[retryWorkOrderItemId],
|
||||
)
|
||||
assert.equal(activeWorkOrders.rows[0]?.count, 1)
|
||||
|
||||
const crossShopA = `it-routing-a-${suffix}`
|
||||
const crossShopB = `it-routing-b-${suffix}`
|
||||
@@ -555,6 +593,57 @@ test(
|
||||
Number(manualFallbackConfig.rows[0]!.id),
|
||||
)
|
||||
|
||||
const retryTaskItemId = await createPendingOrderItem(autoManualSku)
|
||||
const retryTaskOrder = await query<{
|
||||
order_id: number
|
||||
provider: string
|
||||
platform: string
|
||||
shop_id: string
|
||||
shop_name: string
|
||||
platform_order_id: string
|
||||
}>(
|
||||
`SELECT oi.order_id, o.provider, o.platform, o.shop_id, o.shop_name, o.platform_order_id
|
||||
FROM order_items oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
WHERE oi.id = $1`,
|
||||
[retryTaskItemId],
|
||||
)
|
||||
const retryTaskOrderRow = retryTaskOrder.rows[0]!
|
||||
const insertRetryTask = (taskNo: string, status: string) =>
|
||||
query(
|
||||
`INSERT INTO fulfillment_tasks (
|
||||
order_id, order_item_id, unit_index, task_no, provider, platform,
|
||||
shop_id, shop_name, platform_order_id, profile_id, executor_key,
|
||||
task_status, created_at, updated_at
|
||||
) VALUES ($1, $2, 1, $3, $4, $5, $6, $7, $8, $9, 'affiliate_dash', $10, NOW(), NOW())`,
|
||||
[
|
||||
retryTaskOrderRow.order_id,
|
||||
retryTaskItemId,
|
||||
taskNo,
|
||||
retryTaskOrderRow.provider,
|
||||
retryTaskOrderRow.platform,
|
||||
retryTaskOrderRow.shop_id,
|
||||
retryTaskOrderRow.shop_name,
|
||||
retryTaskOrderRow.platform_order_id,
|
||||
fallbackProfileId,
|
||||
status,
|
||||
],
|
||||
)
|
||||
await insertRetryTask(`IT-AFF-CLOSED-${suffix}`, 'closed')
|
||||
await insertRetryTask(`IT-AFF-ACTIVE-${suffix}`, 'paid')
|
||||
await assert.rejects(
|
||||
() => insertRetryTask(`IT-AFF-DUPLICATE-${suffix}`, 'claimed'),
|
||||
/duplicate key value violates unique constraint/,
|
||||
)
|
||||
const retryTaskRows = await query<{ task_status: string }>(
|
||||
`SELECT task_status FROM fulfillment_tasks WHERE order_item_id = $1 ORDER BY id`,
|
||||
[retryTaskItemId],
|
||||
)
|
||||
assert.deepEqual(
|
||||
retryTaskRows.rows.map((row) => row.task_status),
|
||||
['closed', 'paid'],
|
||||
)
|
||||
|
||||
await ensureVoucherRuleForSku(voucherSku)
|
||||
const voucherChannel = await query<{ channel: string; source: string }>(
|
||||
`SELECT channel, source FROM internal_sku_channels
|
||||
|
||||
@@ -200,7 +200,9 @@ export function applyIntakeChannelPlan(
|
||||
reason:
|
||||
channel === 'work_order'
|
||||
? '内部 SKU 渠道配置指定接单平台'
|
||||
: '内部 SKU 渠道配置指定第三方卡券',
|
||||
: channel === 'on_account'
|
||||
? '内部 SKU 渠道配置指定统一结算'
|
||||
: '内部 SKU 渠道配置指定第三方卡券',
|
||||
configured: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -52,4 +52,8 @@ test('内部 SKU:没有中文时返回空,平台码和英文码不能作为
|
||||
test('内部 SKU 规范化:NFKC + 去全部空白 + 小写', () => {
|
||||
assert.equal(canonicalizeInternalSku(' 套装-浪漫天命 '), '套装-浪漫天命')
|
||||
assert.equal(canonicalizeInternalSku(' M416-仓鼠 灰灰 '), 'm416-仓鼠灰灰')
|
||||
assert.equal(
|
||||
canonicalizeInternalSku('黑色ROCK长袖T恤'),
|
||||
canonicalizeInternalSku('黑色rock长袖t恤'),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -37,6 +37,14 @@ export async function retryPendingWorkOrderRouting(
|
||||
`SELECT oi.*
|
||||
FROM order_items oi
|
||||
WHERE oi.fulfillment_status = 'pending'
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM internal_sku_channels channel
|
||||
WHERE channel.enabled = TRUE
|
||||
AND channel.channel = 'work_order'
|
||||
AND canonicalize_internal_sku(channel.internal_sku) =
|
||||
resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code)
|
||||
)
|
||||
${skuClause}
|
||||
ORDER BY oi.id
|
||||
LIMIT $1`,
|
||||
@@ -71,6 +79,7 @@ export async function retryPendingWorkOrderRouting(
|
||||
source: 'admin_pending_retry',
|
||||
autoOnly: true,
|
||||
skipChannelAssign: true,
|
||||
retryCancelledWorkOrder: true,
|
||||
})
|
||||
createdWorkOrders += result.createdCount
|
||||
for (const skipped of result.skipped) {
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
createDefaultVoucherConsumePolicy,
|
||||
normalizeVoucherConsumePolicy,
|
||||
resolveVoucherConsumePolicy,
|
||||
} from './voucher-consume-policy-service.js'
|
||||
|
||||
test('默认核销策略:自动发货 5 秒,工单发布或指派后 10 秒,人工任务 10 秒', () => {
|
||||
const policy = createDefaultVoucherConsumePolicy()
|
||||
|
||||
assert.deepEqual(policy.platforms.kuaishou_industry.channels.affiliate_dash, {
|
||||
enabled: true,
|
||||
mode: 'after_link_generated',
|
||||
delaySeconds: 5,
|
||||
})
|
||||
assert.deepEqual(policy.platforms.douyin_mashida.channels.work_order, {
|
||||
enabled: true,
|
||||
mode: 'after_work_order_published_or_assigned',
|
||||
delaySeconds: 10,
|
||||
})
|
||||
assert.deepEqual(policy.platforms.kuaishou_industry.channels.manual, {
|
||||
enabled: true,
|
||||
mode: 'after_task_created',
|
||||
delaySeconds: 10,
|
||||
})
|
||||
assert.deepEqual(policy.platforms.kuaishou_industry.channels.third_party_voucher, {
|
||||
enabled: true,
|
||||
mode: 'after_order_received',
|
||||
delaySeconds: 5,
|
||||
})
|
||||
})
|
||||
|
||||
test('工单默认策略同时匹配发布和指派,且不匹配链接生成', () => {
|
||||
const policy = createDefaultVoucherConsumePolicy()
|
||||
|
||||
assert.equal(
|
||||
resolveVoucherConsumePolicy({
|
||||
provider: 'kuaishou_industry',
|
||||
channel: 'work_order',
|
||||
event: 'work_order_published',
|
||||
policy,
|
||||
}).enabled,
|
||||
true,
|
||||
)
|
||||
assert.equal(
|
||||
resolveVoucherConsumePolicy({
|
||||
provider: 'douyin_mashida',
|
||||
channel: 'work_order',
|
||||
event: 'work_order_assigned',
|
||||
policy,
|
||||
}).enabled,
|
||||
true,
|
||||
)
|
||||
assert.equal(
|
||||
resolveVoucherConsumePolicy({
|
||||
provider: 'douyin_mashida',
|
||||
channel: 'work_order',
|
||||
event: 'link_generated',
|
||||
policy,
|
||||
}).enabled,
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
test('收到订单后事件可匹配四个渠道,第三方卡券默认延迟 5 秒', () => {
|
||||
const policy = createDefaultVoucherConsumePolicy()
|
||||
for (const channel of [
|
||||
'affiliate_dash',
|
||||
'work_order',
|
||||
'manual',
|
||||
'third_party_voucher',
|
||||
] as const) {
|
||||
const configured = structuredClone(policy)
|
||||
configured.platforms.kuaishou_industry.channels[channel] = {
|
||||
enabled: true,
|
||||
mode: 'after_order_received',
|
||||
delaySeconds: channel === 'third_party_voucher' ? 5 : 15,
|
||||
}
|
||||
const resolved = resolveVoucherConsumePolicy({
|
||||
provider: 'kuaishou_industry',
|
||||
channel,
|
||||
event: 'order_received',
|
||||
policy: configured,
|
||||
})
|
||||
assert.equal(resolved.enabled, true)
|
||||
assert.equal(resolved.delaySeconds, channel === 'third_party_voucher' ? 5 : 15)
|
||||
}
|
||||
})
|
||||
|
||||
test('策略归一化会丢弃非法模式并限制延迟范围', () => {
|
||||
const policy = normalizeVoucherConsumePolicy({
|
||||
enabled: true,
|
||||
platforms: {
|
||||
kuaishou_industry: {
|
||||
enabled: true,
|
||||
channels: {
|
||||
affiliate_dash: {
|
||||
enabled: true,
|
||||
mode: 'after_work_order_assigned',
|
||||
delaySeconds: 99999,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
assert.deepEqual(policy.platforms.kuaishou_industry.channels.affiliate_dash, {
|
||||
enabled: true,
|
||||
mode: 'after_link_generated',
|
||||
delaySeconds: 3600,
|
||||
})
|
||||
assert.equal(policy.platforms.douyin_mashida.enabled, true)
|
||||
})
|
||||
|
||||
test('旧版第三方卡券停用配置自动升级为收到订单后 5 秒', () => {
|
||||
const policy = normalizeVoucherConsumePolicy({
|
||||
enabled: true,
|
||||
platforms: {
|
||||
kuaishou_industry: {
|
||||
enabled: true,
|
||||
channels: {
|
||||
third_party_voucher: {
|
||||
enabled: false,
|
||||
mode: 'manual',
|
||||
delaySeconds: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
assert.equal(policy.version, 2)
|
||||
assert.deepEqual(policy.platforms.kuaishou_industry.channels.third_party_voucher, {
|
||||
enabled: true,
|
||||
mode: 'after_order_received',
|
||||
delaySeconds: 5,
|
||||
})
|
||||
})
|
||||
|
||||
test('新版明确关闭第三方卡券时继续尊重用户配置', () => {
|
||||
const policy = normalizeVoucherConsumePolicy({
|
||||
version: 2,
|
||||
enabled: true,
|
||||
platforms: {
|
||||
kuaishou_industry: {
|
||||
enabled: true,
|
||||
channels: {
|
||||
third_party_voucher: {
|
||||
enabled: false,
|
||||
mode: 'manual',
|
||||
delaySeconds: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
assert.deepEqual(policy.platforms.kuaishou_industry.channels.third_party_voucher, {
|
||||
enabled: false,
|
||||
mode: 'manual',
|
||||
delaySeconds: 0,
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,252 @@
|
||||
import { APP_CONFIG_KEYS } from '../../config/app-config-keys.js'
|
||||
import type { JsonObject } from '../../types/json.js'
|
||||
import { readAppConfigEntry, saveAppConfigEntry } from '../config/app-config-store.js'
|
||||
|
||||
export const VOUCHER_CONSUME_PROVIDERS = ['kuaishou_industry', 'douyin_mashida'] as const
|
||||
export type VoucherConsumeProvider = (typeof VOUCHER_CONSUME_PROVIDERS)[number]
|
||||
|
||||
export const VOUCHER_CONSUME_CHANNELS = [
|
||||
'affiliate_dash',
|
||||
'work_order',
|
||||
'manual',
|
||||
'third_party_voucher',
|
||||
'on_account',
|
||||
] as const
|
||||
export type VoucherConsumeChannel = (typeof VOUCHER_CONSUME_CHANNELS)[number]
|
||||
|
||||
export const VOUCHER_CONSUME_EVENTS = [
|
||||
'order_received',
|
||||
'link_generated',
|
||||
'task_created',
|
||||
'work_order_published',
|
||||
'work_order_assigned',
|
||||
] as const
|
||||
export type VoucherConsumeEvent = (typeof VOUCHER_CONSUME_EVENTS)[number]
|
||||
|
||||
export const VOUCHER_CONSUME_MODES = [
|
||||
'manual',
|
||||
'after_order_received',
|
||||
'after_link_generated',
|
||||
'after_task_created',
|
||||
'after_work_order_published',
|
||||
'after_work_order_assigned',
|
||||
'after_work_order_published_or_assigned',
|
||||
] as const
|
||||
export type VoucherConsumeMode = (typeof VOUCHER_CONSUME_MODES)[number]
|
||||
|
||||
export type VoucherChannelConsumePolicy = {
|
||||
enabled: boolean
|
||||
mode: VoucherConsumeMode
|
||||
delaySeconds: number
|
||||
}
|
||||
|
||||
export type VoucherPlatformConsumePolicy = {
|
||||
enabled: boolean
|
||||
channels: Record<VoucherConsumeChannel, VoucherChannelConsumePolicy>
|
||||
}
|
||||
|
||||
export type VoucherConsumePolicy = {
|
||||
version: number
|
||||
enabled: boolean
|
||||
platforms: Record<VoucherConsumeProvider, VoucherPlatformConsumePolicy>
|
||||
}
|
||||
|
||||
export function createDefaultVoucherConsumePolicy(): VoucherConsumePolicy {
|
||||
return {
|
||||
version: 2,
|
||||
enabled: true,
|
||||
platforms: {
|
||||
kuaishou_industry: createDefaultPlatformPolicy(),
|
||||
douyin_mashida: createDefaultPlatformPolicy(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function getVoucherConsumePolicy(): VoucherConsumePolicy {
|
||||
return readAppConfigEntry({
|
||||
configKey: APP_CONFIG_KEYS.voucherConsumePolicy,
|
||||
fallback: createDefaultVoucherConsumePolicy,
|
||||
normalize: normalizeVoucherConsumePolicy,
|
||||
})
|
||||
}
|
||||
|
||||
export async function saveVoucherConsumePolicy(rawValue: unknown): Promise<VoucherConsumePolicy> {
|
||||
return saveAppConfigEntry({
|
||||
configKey: APP_CONFIG_KEYS.voucherConsumePolicy,
|
||||
value: rawValue,
|
||||
normalize: normalizeVoucherConsumePolicy,
|
||||
})
|
||||
}
|
||||
|
||||
export function normalizeVoucherConsumePolicy(rawValue: unknown): VoucherConsumePolicy {
|
||||
const defaults = createDefaultVoucherConsumePolicy()
|
||||
const source = isPlainObject(rawValue) ? rawValue : {}
|
||||
const rawPlatforms = isPlainObject(source.platforms) ? source.platforms : {}
|
||||
const version = normalizeInteger(source.version, 1, 1, 2)
|
||||
|
||||
return {
|
||||
version: 2,
|
||||
enabled: normalizeBoolean(source.enabled, defaults.enabled),
|
||||
platforms: {
|
||||
kuaishou_industry: normalizePlatformPolicy(
|
||||
rawPlatforms.kuaishou_industry,
|
||||
defaults.platforms.kuaishou_industry,
|
||||
version,
|
||||
),
|
||||
douyin_mashida: normalizePlatformPolicy(
|
||||
rawPlatforms.douyin_mashida,
|
||||
defaults.platforms.douyin_mashida,
|
||||
version,
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveVoucherConsumePolicy(input: {
|
||||
provider: VoucherConsumeProvider
|
||||
channel: VoucherConsumeChannel
|
||||
event: VoucherConsumeEvent
|
||||
policy?: VoucherConsumePolicy
|
||||
}): { enabled: boolean; delaySeconds: number; mode: VoucherConsumeMode } {
|
||||
const policy = input.policy || getVoucherConsumePolicy()
|
||||
const platform = policy.platforms[input.provider]
|
||||
const channel = platform.channels[input.channel]
|
||||
|
||||
if (!policy.enabled || !platform.enabled || !channel.enabled || channel.mode === 'manual') {
|
||||
return { enabled: false, delaySeconds: 0, mode: channel.mode }
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: modeMatchesEvent(channel.mode, input.event),
|
||||
delaySeconds: channel.delaySeconds,
|
||||
mode: channel.mode,
|
||||
}
|
||||
}
|
||||
|
||||
export function isVoucherConsumeProvider(value: unknown): value is VoucherConsumeProvider {
|
||||
return (VOUCHER_CONSUME_PROVIDERS as readonly string[]).includes(String(value || '').trim())
|
||||
}
|
||||
|
||||
function createDefaultPlatformPolicy(): VoucherPlatformConsumePolicy {
|
||||
return {
|
||||
enabled: true,
|
||||
channels: {
|
||||
affiliate_dash: {
|
||||
enabled: true,
|
||||
mode: 'after_link_generated',
|
||||
delaySeconds: 5,
|
||||
},
|
||||
work_order: {
|
||||
enabled: true,
|
||||
mode: 'after_work_order_published_or_assigned',
|
||||
delaySeconds: 10,
|
||||
},
|
||||
manual: {
|
||||
enabled: true,
|
||||
mode: 'after_task_created',
|
||||
delaySeconds: 10,
|
||||
},
|
||||
third_party_voucher: {
|
||||
enabled: true,
|
||||
mode: 'after_order_received',
|
||||
delaySeconds: 5,
|
||||
},
|
||||
on_account: {
|
||||
enabled: false,
|
||||
mode: 'manual',
|
||||
delaySeconds: 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePlatformPolicy(
|
||||
rawValue: unknown,
|
||||
fallback: VoucherPlatformConsumePolicy,
|
||||
version: number,
|
||||
): VoucherPlatformConsumePolicy {
|
||||
const source = isPlainObject(rawValue) ? rawValue : {}
|
||||
const rawChannels = isPlainObject(source.channels) ? source.channels : {}
|
||||
const legacyThirdPartyDisabled = isLegacyThirdPartyDisabled(rawValue, version)
|
||||
|
||||
return {
|
||||
enabled: normalizeBoolean(source.enabled, fallback.enabled),
|
||||
channels: {
|
||||
affiliate_dash: normalizeChannelPolicy(
|
||||
rawChannels.affiliate_dash,
|
||||
fallback.channels.affiliate_dash,
|
||||
['manual', 'after_order_received', 'after_link_generated', 'after_task_created'],
|
||||
),
|
||||
work_order: normalizeChannelPolicy(rawChannels.work_order, fallback.channels.work_order, [
|
||||
'manual',
|
||||
'after_order_received',
|
||||
'after_task_created',
|
||||
'after_work_order_published',
|
||||
'after_work_order_assigned',
|
||||
'after_work_order_published_or_assigned',
|
||||
]),
|
||||
manual: normalizeChannelPolicy(rawChannels.manual, fallback.channels.manual, [
|
||||
'manual',
|
||||
'after_order_received',
|
||||
'after_task_created',
|
||||
]),
|
||||
third_party_voucher: normalizeChannelPolicy(
|
||||
legacyThirdPartyDisabled ? {} : rawChannels.third_party_voucher,
|
||||
fallback.channels.third_party_voucher,
|
||||
['manual', 'after_order_received'],
|
||||
),
|
||||
on_account: normalizeChannelPolicy(rawChannels.on_account, fallback.channels.on_account, [
|
||||
'manual',
|
||||
'after_order_received',
|
||||
]),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function isLegacyThirdPartyDisabled(rawPlatform: unknown, version: number): boolean {
|
||||
if (version >= 2 || !isPlainObject(rawPlatform)) return false
|
||||
const rawChannels = isPlainObject(rawPlatform.channels) ? rawPlatform.channels : {}
|
||||
const raw = isPlainObject(rawChannels.third_party_voucher) ? rawChannels.third_party_voucher : {}
|
||||
return raw.enabled === false && String(raw.mode || '').trim() === 'manual'
|
||||
}
|
||||
|
||||
function normalizeChannelPolicy(
|
||||
rawValue: unknown,
|
||||
fallback: VoucherChannelConsumePolicy,
|
||||
allowedModes: readonly VoucherConsumeMode[],
|
||||
): VoucherChannelConsumePolicy {
|
||||
const source = isPlainObject(rawValue) ? rawValue : {}
|
||||
const mode = String(source.mode || '').trim() as VoucherConsumeMode
|
||||
const fallbackMode = allowedModes.includes(fallback.mode) ? fallback.mode : 'manual'
|
||||
|
||||
return {
|
||||
enabled: normalizeBoolean(source.enabled, fallback.enabled),
|
||||
mode: allowedModes.includes(mode) ? mode : fallbackMode,
|
||||
delaySeconds: normalizeInteger(source.delaySeconds, fallback.delaySeconds, 0, 3600),
|
||||
}
|
||||
}
|
||||
|
||||
function modeMatchesEvent(mode: VoucherConsumeMode, event: VoucherConsumeEvent): boolean {
|
||||
if (mode === 'manual') return false
|
||||
if (mode === 'after_order_received') return event === 'order_received'
|
||||
if (mode === 'after_link_generated') return event === 'link_generated'
|
||||
if (mode === 'after_task_created') return event === 'task_created'
|
||||
if (mode === 'after_work_order_published') return event === 'work_order_published'
|
||||
if (mode === 'after_work_order_assigned') return event === 'work_order_assigned'
|
||||
return event === 'work_order_published' || event === 'work_order_assigned'
|
||||
}
|
||||
|
||||
function normalizeBoolean(value: unknown, fallback: boolean): boolean {
|
||||
if (value === undefined || value === null || value === '') return fallback
|
||||
return value !== false && String(value) !== 'false'
|
||||
}
|
||||
|
||||
function normalizeInteger(value: unknown, fallback: number, min: number, max: number): number {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isFinite(parsed)) return fallback
|
||||
return Math.min(max, Math.max(min, Math.trunc(parsed)))
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is JsonObject {
|
||||
return Object.prototype.toString.call(value) === '[object Object]'
|
||||
}
|
||||
@@ -0,0 +1,932 @@
|
||||
import { query } from '../../db/client.js'
|
||||
import { getOrderById } from '../../repositories/order-repo.js'
|
||||
import { getOrderItemById, listOrderItemsByOrderId } from '../../repositories/order-item-repo.js'
|
||||
import { getTaskById, listTasksByOrderId } from '../../repositories/task-repo.js'
|
||||
import { listKuaishouIndustryVouchersByOid } from '../../repositories/kuaishou-industry-voucher-repo.js'
|
||||
import {
|
||||
findRedeemCertificateByOrder,
|
||||
listRedeemCertificatesByLocalOrder,
|
||||
type RedeemCertificateRow,
|
||||
} from '../../repositories/redeem-certificate-repo.js'
|
||||
import type { WorkOrderRow } from '../../repositories/worker-platform/index.js'
|
||||
import {
|
||||
DOUYIN_MASHIDA_PROVIDER,
|
||||
isDouyinMashidaProvider,
|
||||
} from '../platforms/douyin-mashida/order-mapper.js'
|
||||
import {
|
||||
consumeKuaishouIndustryVoucher,
|
||||
isKuaishouIndustryVoucherSendCallbackSuccess,
|
||||
normalizeVoucherStatus,
|
||||
} from '../platforms/kuaishou-industry/voucher-service.js'
|
||||
import { attachKuaishouIndustryVoucherToTask } from '../platforms/kuaishou-industry/voucher-binding-service.js'
|
||||
import { verifyCertificate } from '../redeem/certificate-service.js'
|
||||
import { logIntegration, logWarn } from '../../utils/logger.js'
|
||||
import {
|
||||
getVoucherConsumePolicy,
|
||||
resolveVoucherConsumePolicy,
|
||||
type VoucherConsumeChannel,
|
||||
type VoucherConsumeEvent,
|
||||
type VoucherConsumeProvider,
|
||||
} from './voucher-consume-policy-service.js'
|
||||
import type {
|
||||
KuaishouIndustryVoucherRow,
|
||||
OrderItemRow,
|
||||
OrderRow,
|
||||
TaskRow,
|
||||
} from '../../types/repository/rows.js'
|
||||
import { OPEN_91_PLATFORM, OPEN_91_PROVIDER } from '../open-91/config.js'
|
||||
import { FULFILLMENT_EXECUTOR_KEYS } from './executors/types.js'
|
||||
|
||||
export type VoucherConsumeScheduleResult = {
|
||||
ok: boolean
|
||||
voucherCount: number
|
||||
consumedCount: number
|
||||
alreadyConsumedCount: number
|
||||
failedCount: number
|
||||
voucherCodes: string[]
|
||||
errorMessage: string
|
||||
queuedCount: number
|
||||
jobIds: number[]
|
||||
dueAt: string
|
||||
status: 'queued' | 'already_scheduled' | 'skipped' | 'failed'
|
||||
}
|
||||
|
||||
export type VoucherConsumeJobRow = {
|
||||
id: number
|
||||
provider: VoucherConsumeProvider
|
||||
channel: VoucherConsumeChannel
|
||||
trigger_event: VoucherConsumeEvent
|
||||
target_key: string
|
||||
platform_order_id: string
|
||||
order_id: number | null
|
||||
order_item_id: number | null
|
||||
task_id: number | null
|
||||
work_order_id: number | null
|
||||
due_at: string
|
||||
status: 'pending' | 'processing' | 'succeeded' | 'failed' | 'cancelled'
|
||||
attempt_count: number
|
||||
max_attempts: number
|
||||
locked_until: string | null
|
||||
last_error: string
|
||||
result_json: string | Record<string, unknown>
|
||||
created_at: string
|
||||
updated_at: string
|
||||
finished_at: string | null
|
||||
}
|
||||
|
||||
type VoucherConsumeScheduleTarget = {
|
||||
provider: VoucherConsumeProvider
|
||||
channel: VoucherConsumeChannel
|
||||
event: VoucherConsumeEvent
|
||||
targetKey: string
|
||||
platformOrderId: string
|
||||
orderId: number | null
|
||||
orderItemId: number | null
|
||||
taskId: number | null
|
||||
workOrderId: number | null
|
||||
voucherCodes: string[]
|
||||
voucherCount: number
|
||||
}
|
||||
|
||||
type VoucherOrderItemTargetRow = {
|
||||
id: number
|
||||
order_id: number
|
||||
provider: string
|
||||
platform: string
|
||||
platform_order_id: string
|
||||
fulfillment_channel: string
|
||||
}
|
||||
|
||||
const RETRY_DELAYS_SECONDS = [10, 30, 60, 300, 600, 900, 1800]
|
||||
const PROCESSING_LOCK_SECONDS = 120
|
||||
|
||||
export async function scheduleVoucherConsumeForTask(
|
||||
task: TaskRow,
|
||||
event: VoucherConsumeEvent,
|
||||
): Promise<VoucherConsumeScheduleResult> {
|
||||
const order = await getOrderById(task.order_id)
|
||||
if (!order) {
|
||||
return buildScheduleResult('skipped', { errorMessage: '订单不存在,无法安排核销' })
|
||||
}
|
||||
|
||||
const provider = resolveVoucherProvider(order)
|
||||
if (!provider) {
|
||||
return buildScheduleResult('skipped')
|
||||
}
|
||||
|
||||
const orderItem = await getOrderItemById(task.order_item_id)
|
||||
const channel = resolveTaskVoucherChannel(task, orderItem)
|
||||
if (!channel) {
|
||||
return buildScheduleResult('skipped')
|
||||
}
|
||||
|
||||
return scheduleVoucherConsumeTarget({
|
||||
provider,
|
||||
channel,
|
||||
event,
|
||||
targetKey: buildVoucherConsumeTargetKey(task.order_item_id, `task:${task.id}`),
|
||||
platformOrderId: String(order.platform_order_id || task.platform_order_id || '').trim(),
|
||||
orderId: Number(order.id),
|
||||
orderItemId: Number(task.order_item_id || 0) || null,
|
||||
taskId: Number(task.id),
|
||||
workOrderId: null,
|
||||
})
|
||||
}
|
||||
|
||||
export async function scheduleVoucherConsumeForWorkOrder(
|
||||
workOrder: WorkOrderRow,
|
||||
event: Extract<VoucherConsumeEvent, 'work_order_published' | 'work_order_assigned'>,
|
||||
): Promise<VoucherConsumeScheduleResult> {
|
||||
const order = workOrder.order_id ? await getOrderById(workOrder.order_id) : null
|
||||
if (!order) {
|
||||
return buildScheduleResult('skipped', { errorMessage: '订单不存在,无法安排核销' })
|
||||
}
|
||||
|
||||
const provider = resolveVoucherProvider(order)
|
||||
if (!provider) {
|
||||
return buildScheduleResult('skipped')
|
||||
}
|
||||
|
||||
return scheduleVoucherConsumeTarget({
|
||||
provider,
|
||||
channel: 'work_order',
|
||||
event,
|
||||
targetKey: buildVoucherConsumeTargetKey(workOrder.order_item_id, `work_order:${workOrder.id}`),
|
||||
platformOrderId: String(order.platform_order_id || workOrder.platform_order_id || '').trim(),
|
||||
orderId: Number(order.id),
|
||||
orderItemId: Number(workOrder.order_item_id || 0) || null,
|
||||
// work_orders.task_id is vestigial (migration 091); never use it as a join key.
|
||||
taskId: null,
|
||||
workOrderId: Number(workOrder.id),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule after an order item has a fulfillment channel.
|
||||
*
|
||||
* The platform voucher may not exist yet when this runs. The queued job resolves
|
||||
* the voucher again at execution time and retries while the incoming voucher is
|
||||
* still being created or synchronized.
|
||||
*/
|
||||
export async function scheduleVoucherConsumeForOrderItems(
|
||||
orderItemIds: number[],
|
||||
event: VoucherConsumeEvent,
|
||||
): Promise<{ targetCount: number; queuedCount: number; jobIds: number[] }> {
|
||||
const ids = [...new Set(orderItemIds.map(Number).filter((id) => Number.isInteger(id) && id > 0))]
|
||||
if (ids.length === 0) {
|
||||
return { targetCount: 0, queuedCount: 0, jobIds: [] }
|
||||
}
|
||||
|
||||
const rows = await query<VoucherOrderItemTargetRow>(
|
||||
`
|
||||
SELECT
|
||||
oi.id,
|
||||
oi.order_id,
|
||||
oi.fulfillment_channel,
|
||||
o.provider,
|
||||
o.platform,
|
||||
o.platform_order_id
|
||||
FROM order_items oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
WHERE oi.id = ANY($1::bigint[])
|
||||
AND oi.fulfillment_status = 'assigned'
|
||||
`,
|
||||
[ids],
|
||||
)
|
||||
const policy = getVoucherConsumePolicy()
|
||||
const targets: Array<{
|
||||
provider: VoucherConsumeProvider
|
||||
channel: VoucherConsumeChannel
|
||||
trigger_event: VoucherConsumeEvent
|
||||
target_key: string
|
||||
platform_order_id: string
|
||||
order_id: number
|
||||
order_item_id: number
|
||||
due_at: string
|
||||
}> = []
|
||||
|
||||
for (const row of rows.rows) {
|
||||
const provider = resolveVoucherProviderFromFields(row.provider, row.platform)
|
||||
const channel = normalizeVoucherChannel(row.fulfillment_channel)
|
||||
if (!provider || !channel) continue
|
||||
|
||||
const resolved = resolveVoucherConsumePolicy({
|
||||
provider,
|
||||
channel,
|
||||
event,
|
||||
policy,
|
||||
})
|
||||
if (!resolved.enabled) continue
|
||||
|
||||
targets.push({
|
||||
provider,
|
||||
channel,
|
||||
trigger_event: event,
|
||||
target_key: buildVoucherConsumeTargetKey(row.id, `order_item:${row.id}`),
|
||||
platform_order_id: String(row.platform_order_id || '').trim(),
|
||||
order_id: Number(row.order_id),
|
||||
order_item_id: Number(row.id),
|
||||
due_at: new Date(Date.now() + resolved.delaySeconds * 1000).toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
if (targets.length === 0) {
|
||||
return { targetCount: 0, queuedCount: 0, jobIds: [] }
|
||||
}
|
||||
|
||||
const inserted = await query<{ id: number }>(
|
||||
`
|
||||
INSERT INTO voucher_consume_jobs (
|
||||
provider,
|
||||
channel,
|
||||
trigger_event,
|
||||
target_key,
|
||||
platform_order_id,
|
||||
order_id,
|
||||
order_item_id,
|
||||
due_at,
|
||||
status,
|
||||
attempt_count,
|
||||
max_attempts,
|
||||
updated_at
|
||||
)
|
||||
SELECT
|
||||
target.provider,
|
||||
target.channel,
|
||||
target.trigger_event,
|
||||
target.target_key,
|
||||
target.platform_order_id,
|
||||
target.order_id,
|
||||
target.order_item_id,
|
||||
target.due_at,
|
||||
'pending',
|
||||
0,
|
||||
8,
|
||||
NOW()
|
||||
FROM jsonb_to_recordset($1::jsonb) AS target(
|
||||
provider text,
|
||||
channel text,
|
||||
trigger_event text,
|
||||
target_key text,
|
||||
platform_order_id text,
|
||||
order_id bigint,
|
||||
order_item_id bigint,
|
||||
due_at timestamptz
|
||||
)
|
||||
ON CONFLICT (provider, target_key)
|
||||
WHERE status IN ('pending', 'processing', 'succeeded')
|
||||
DO NOTHING
|
||||
RETURNING id
|
||||
`,
|
||||
[JSON.stringify(targets)],
|
||||
)
|
||||
|
||||
logIntegration('[voucher-consume/queue]', '订单渠道确定后批量安排核销', {
|
||||
event,
|
||||
targetCount: targets.length,
|
||||
queuedCount: inserted.rowCount || 0,
|
||||
jobIds: inserted.rows.map((row) => Number(row.id)),
|
||||
})
|
||||
|
||||
return {
|
||||
targetCount: targets.length,
|
||||
queuedCount: inserted.rowCount || 0,
|
||||
jobIds: inserted.rows.map((row) => Number(row.id)),
|
||||
}
|
||||
}
|
||||
|
||||
async function scheduleVoucherConsumeTarget(
|
||||
target: Omit<VoucherConsumeScheduleTarget, 'voucherCodes' | 'voucherCount'>,
|
||||
): Promise<VoucherConsumeScheduleResult> {
|
||||
const policy = resolveVoucherConsumePolicy({
|
||||
provider: target.provider,
|
||||
channel: target.channel,
|
||||
event: target.event,
|
||||
policy: getVoucherConsumePolicy(),
|
||||
})
|
||||
if (!policy.enabled) {
|
||||
return buildScheduleResult('skipped')
|
||||
}
|
||||
|
||||
const candidates = await listVoucherCandidatesForTarget(target)
|
||||
const dueAt = new Date(Date.now() + policy.delaySeconds * 1000).toISOString()
|
||||
const inserted = await query<VoucherConsumeJobRow>(
|
||||
`
|
||||
INSERT INTO voucher_consume_jobs (
|
||||
provider,
|
||||
channel,
|
||||
trigger_event,
|
||||
target_key,
|
||||
platform_order_id,
|
||||
order_id,
|
||||
order_item_id,
|
||||
task_id,
|
||||
work_order_id,
|
||||
due_at,
|
||||
status,
|
||||
attempt_count,
|
||||
max_attempts,
|
||||
updated_at
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10,
|
||||
'pending', 0, 8, NOW()
|
||||
)
|
||||
ON CONFLICT (provider, target_key)
|
||||
WHERE status IN ('pending', 'processing', 'succeeded')
|
||||
DO NOTHING
|
||||
RETURNING *
|
||||
`,
|
||||
[
|
||||
target.provider,
|
||||
target.channel,
|
||||
target.event,
|
||||
target.targetKey,
|
||||
target.platformOrderId,
|
||||
target.orderId,
|
||||
target.orderItemId,
|
||||
target.taskId,
|
||||
target.workOrderId,
|
||||
dueAt,
|
||||
],
|
||||
)
|
||||
|
||||
const job = inserted.rows[0]
|
||||
if (!job) {
|
||||
return buildScheduleResult('already_scheduled', {
|
||||
voucherCount: candidates.voucherCount,
|
||||
voucherCodes: candidates.voucherCodes,
|
||||
})
|
||||
}
|
||||
|
||||
logIntegration('[voucher-consume/queue]', '核销任务已安排', {
|
||||
jobId: job.id,
|
||||
provider: target.provider,
|
||||
channel: target.channel,
|
||||
event: target.event,
|
||||
dueAt,
|
||||
orderId: target.orderId,
|
||||
orderItemId: target.orderItemId,
|
||||
workOrderId: target.workOrderId,
|
||||
voucherCount: candidates.voucherCount,
|
||||
})
|
||||
|
||||
return buildScheduleResult('queued', {
|
||||
queuedCount: 1,
|
||||
jobIds: [Number(job.id)],
|
||||
dueAt,
|
||||
voucherCount: candidates.voucherCount,
|
||||
voucherCodes: candidates.voucherCodes,
|
||||
})
|
||||
}
|
||||
|
||||
export async function runVoucherConsumeDispatchJob(
|
||||
jobConfig: Record<string, unknown> = {},
|
||||
): Promise<Record<string, unknown>> {
|
||||
const batchSize = clampInteger(jobConfig.batchSize, 20, 1, 200)
|
||||
const jobs = await claimDueVoucherConsumeJobs(batchSize)
|
||||
let okCount = 0
|
||||
let failedCount = 0
|
||||
let retryCount = 0
|
||||
let cancelledCount = 0
|
||||
|
||||
for (const job of jobs) {
|
||||
try {
|
||||
const policy = resolveVoucherConsumePolicy({
|
||||
provider: job.provider,
|
||||
channel: job.channel,
|
||||
event: job.trigger_event,
|
||||
policy: getVoucherConsumePolicy(),
|
||||
})
|
||||
if (!policy.enabled) {
|
||||
await finishVoucherConsumeJob(job.id, 'cancelled', {
|
||||
reason: 'policy_disabled_or_changed',
|
||||
})
|
||||
cancelledCount += 1
|
||||
continue
|
||||
}
|
||||
|
||||
const result = await executeVoucherConsumeJob(job)
|
||||
await finishVoucherConsumeJob(job.id, 'succeeded', result)
|
||||
okCount += 1
|
||||
} catch (error) {
|
||||
const retry = Number(job.attempt_count || 0) < Number(job.max_attempts || 8)
|
||||
await failVoucherConsumeJob(job, error, retry)
|
||||
if (retry) {
|
||||
retryCount += 1
|
||||
} else {
|
||||
failedCount += 1
|
||||
}
|
||||
logWarn('[voucher-consume/dispatch]', '核销任务执行失败', {
|
||||
jobId: job.id,
|
||||
provider: job.provider,
|
||||
channel: job.channel,
|
||||
event: job.trigger_event,
|
||||
attemptCount: job.attempt_count,
|
||||
willRetry: retry,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: failedCount > 0 ? 'warning' : 'ok',
|
||||
message:
|
||||
`核销任务:领取 ${jobs.length},成功 ${okCount},重试 ${retryCount},` +
|
||||
`失败 ${failedCount},取消 ${cancelledCount}`,
|
||||
checkedCount: jobs.length,
|
||||
okCount,
|
||||
failedCount,
|
||||
retryCount,
|
||||
cancelledCount,
|
||||
}
|
||||
}
|
||||
|
||||
export async function listVoucherConsumeJobs(
|
||||
input: {
|
||||
status?: string
|
||||
page?: number
|
||||
pageSize?: number
|
||||
} = {},
|
||||
) {
|
||||
const page = clampInteger(input.page, 1, 1, Number.MAX_SAFE_INTEGER)
|
||||
const pageSize = clampInteger(input.pageSize, 20, 1, 100)
|
||||
const status = String(input.status || '').trim()
|
||||
const params: unknown[] = []
|
||||
const where: string[] = []
|
||||
|
||||
if (status) {
|
||||
params.push(status)
|
||||
where.push(`status = $${params.length}`)
|
||||
}
|
||||
|
||||
const whereSql = where.length > 0 ? `WHERE ${where.join(' AND ')}` : ''
|
||||
const countResult = await query<{ total: string | number }>(
|
||||
`SELECT COUNT(*) AS total FROM voucher_consume_jobs ${whereSql}`,
|
||||
params,
|
||||
)
|
||||
const listParams = [...params, pageSize, (page - 1) * pageSize]
|
||||
const result = await query<VoucherConsumeJobRow>(
|
||||
`
|
||||
SELECT *
|
||||
FROM voucher_consume_jobs
|
||||
${whereSql}
|
||||
ORDER BY updated_at DESC, id DESC
|
||||
LIMIT $${params.length + 1}
|
||||
OFFSET $${params.length + 2}
|
||||
`,
|
||||
listParams,
|
||||
)
|
||||
|
||||
return {
|
||||
items: result.rows,
|
||||
total: Number(countResult.rows[0]?.total || 0) || 0,
|
||||
page,
|
||||
pageSize,
|
||||
}
|
||||
}
|
||||
|
||||
export async function retryVoucherConsumeJob(jobId: unknown): Promise<VoucherConsumeJobRow | null> {
|
||||
const id = Number(jobId)
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const result = await query<VoucherConsumeJobRow>(
|
||||
`
|
||||
UPDATE voucher_consume_jobs job
|
||||
SET
|
||||
status = 'pending',
|
||||
attempt_count = 0,
|
||||
locked_until = NULL,
|
||||
last_error = '',
|
||||
result_json = '{}'::jsonb,
|
||||
due_at = NOW(),
|
||||
updated_at = NOW(),
|
||||
finished_at = NULL
|
||||
WHERE job.id = $1
|
||||
AND job.status IN ('failed', 'cancelled')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM voucher_consume_jobs active
|
||||
WHERE active.provider = job.provider
|
||||
AND active.target_key = job.target_key
|
||||
AND active.id <> job.id
|
||||
AND active.status IN ('pending', 'processing', 'succeeded')
|
||||
)
|
||||
RETURNING job.*
|
||||
`,
|
||||
[id],
|
||||
)
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
async function executeVoucherConsumeJob(job: VoucherConsumeJobRow) {
|
||||
if (job.provider === 'kuaishou_industry') {
|
||||
return executeKuaishouConsumeJob(job)
|
||||
}
|
||||
if (job.provider === DOUYIN_MASHIDA_PROVIDER) {
|
||||
return executeDouyinConsumeJob(job)
|
||||
}
|
||||
throw new Error(`不支持的核销平台:${job.provider}`)
|
||||
}
|
||||
|
||||
async function executeKuaishouConsumeJob(job: VoucherConsumeJobRow) {
|
||||
const vouchers = await resolveKuaishouVouchersForJob(job)
|
||||
if (vouchers.length === 0) {
|
||||
throw new Error('未找到待核销的快手电子凭证')
|
||||
}
|
||||
|
||||
const task = job.task_id ? await getTaskById(job.task_id) : null
|
||||
let consumedCount = 0
|
||||
let alreadyConsumedCount = 0
|
||||
let skippedCount = 0
|
||||
|
||||
for (const voucher of vouchers) {
|
||||
const status = normalizeVoucherStatus(voucher.status)
|
||||
if (status === 'CONSUMED') {
|
||||
alreadyConsumedCount += 1
|
||||
continue
|
||||
}
|
||||
if (status === 'DESTROYED') {
|
||||
skippedCount += 1
|
||||
continue
|
||||
}
|
||||
if (!isKuaishouIndustryVoucherSendCallbackSuccess(voucher)) {
|
||||
throw new Error(`电子凭证 ${voucher.voucher_code} 尚未完成发码回调`)
|
||||
}
|
||||
|
||||
const result = await consumeKuaishouIndustryVoucher(voucher, {
|
||||
source: `delayed_${job.trigger_event}`,
|
||||
consumeTime: Date.now(),
|
||||
...(task ? { task } : {}),
|
||||
})
|
||||
if (!result.ok || !result.voucher) {
|
||||
throw new Error(result.errorMessage || `电子凭证 ${voucher.voucher_code} 核销失败`)
|
||||
}
|
||||
|
||||
consumedCount += 1
|
||||
if (task) {
|
||||
await attachKuaishouIndustryVoucherToTask(task, result.voucher, {
|
||||
source: `delayed_${job.trigger_event}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
provider: job.provider,
|
||||
voucherCount: vouchers.length,
|
||||
consumedCount,
|
||||
alreadyConsumedCount,
|
||||
skippedCount,
|
||||
}
|
||||
}
|
||||
|
||||
async function executeDouyinConsumeJob(job: VoucherConsumeJobRow) {
|
||||
const certificates = await resolveDouyinCertificatesForJob(job)
|
||||
if (certificates.length === 0) {
|
||||
throw new Error('未找到待核销的抖音券')
|
||||
}
|
||||
|
||||
let consumedCount = 0
|
||||
let alreadyConsumedCount = 0
|
||||
let skippedCount = 0
|
||||
|
||||
for (const certificate of certificates) {
|
||||
if (certificate.status === 'verified') {
|
||||
alreadyConsumedCount += 1
|
||||
continue
|
||||
}
|
||||
if (certificate.status === 'invalid') {
|
||||
skippedCount += 1
|
||||
continue
|
||||
}
|
||||
|
||||
await verifyCertificate({ certNo: certificate.cert_no })
|
||||
consumedCount += 1
|
||||
}
|
||||
|
||||
return {
|
||||
provider: job.provider,
|
||||
voucherCount: certificates.length,
|
||||
consumedCount,
|
||||
alreadyConsumedCount,
|
||||
skippedCount,
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveKuaishouVouchersForJob(
|
||||
job: VoucherConsumeJobRow,
|
||||
): Promise<KuaishouIndustryVoucherRow[]> {
|
||||
const oid = String(job.platform_order_id || '').trim()
|
||||
if (!oid) return []
|
||||
const vouchers = await listKuaishouIndustryVouchersByOid(oid)
|
||||
if (vouchers.length === 0) return []
|
||||
|
||||
if (job.task_id) {
|
||||
const task = await getTaskById(job.task_id)
|
||||
if (task) {
|
||||
return vouchers.filter(
|
||||
(voucher) =>
|
||||
Number(voucher.task_id || 0) === Number(task.id) ||
|
||||
Number(voucher.unit_index || 0) === Number(task.unit_index || 0),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const orderId = Number(job.order_id || 0)
|
||||
const orderItemId = Number(job.order_item_id || 0)
|
||||
if (!orderId) return vouchers
|
||||
|
||||
const orderItems = await listOrderItemsByOrderId(orderId)
|
||||
if (orderItems.length <= 1 || !orderItemId) {
|
||||
return vouchers
|
||||
}
|
||||
|
||||
const tasks = await listTasksByOrderId(orderId)
|
||||
const itemTaskIds = new Set(
|
||||
tasks
|
||||
.filter((task) => Number(task.order_item_id) === orderItemId)
|
||||
.map((task) => Number(task.id)),
|
||||
)
|
||||
const byTask = vouchers.filter((voucher) => itemTaskIds.has(Number(voucher.task_id || 0)))
|
||||
if (byTask.length > 0) {
|
||||
return byTask
|
||||
}
|
||||
|
||||
const range = resolveOrderItemUnitRange(orderItems, orderItemId)
|
||||
if (!range) return []
|
||||
return vouchers.filter((voucher) => {
|
||||
const unitIndex = Number(voucher.unit_index || 0)
|
||||
return unitIndex >= range.start && unitIndex <= range.end
|
||||
})
|
||||
}
|
||||
|
||||
async function resolveDouyinCertificatesForJob(
|
||||
job: VoucherConsumeJobRow,
|
||||
): Promise<RedeemCertificateRow[]> {
|
||||
const orderId = Number(job.order_id || 0)
|
||||
const orderItemId = Number(job.order_item_id || 0)
|
||||
const certificates = orderId ? await listRedeemCertificatesByLocalOrder(orderId) : []
|
||||
const filtered = orderItemId
|
||||
? certificates.filter(
|
||||
(certificate) =>
|
||||
!certificate.order_item_id || Number(certificate.order_item_id) === orderItemId,
|
||||
)
|
||||
: certificates
|
||||
if (filtered.length > 0) {
|
||||
return filtered
|
||||
}
|
||||
|
||||
const receiptOrderId = String(job.platform_order_id || '').trim()
|
||||
if (!receiptOrderId) return []
|
||||
const fallback = await findRedeemCertificateByOrder({
|
||||
provider: DOUYIN_MASHIDA_PROVIDER,
|
||||
orderId: receiptOrderId,
|
||||
})
|
||||
return fallback ? [fallback] : []
|
||||
}
|
||||
|
||||
async function listVoucherCandidatesForTarget(
|
||||
target: Pick<
|
||||
VoucherConsumeScheduleTarget,
|
||||
'provider' | 'platformOrderId' | 'orderId' | 'orderItemId' | 'taskId'
|
||||
>,
|
||||
): Promise<{ voucherCount: number; voucherCodes: string[] }> {
|
||||
if (target.provider === 'kuaishou_industry') {
|
||||
const vouchers = target.platformOrderId
|
||||
? await listKuaishouIndustryVouchersByOid(target.platformOrderId)
|
||||
: []
|
||||
return {
|
||||
voucherCount: vouchers.length,
|
||||
voucherCodes: vouchers.map((voucher) => String(voucher.voucher_code || '')).filter(Boolean),
|
||||
}
|
||||
}
|
||||
|
||||
const certificates = await resolveDouyinCertificatesForJob({
|
||||
...buildSyntheticJob(target),
|
||||
})
|
||||
return {
|
||||
voucherCount: certificates.length,
|
||||
voucherCodes: certificates.map((certificate) => certificate.cert_no),
|
||||
}
|
||||
}
|
||||
|
||||
function buildSyntheticJob(
|
||||
target: Pick<
|
||||
VoucherConsumeScheduleTarget,
|
||||
'provider' | 'platformOrderId' | 'orderId' | 'orderItemId' | 'taskId'
|
||||
>,
|
||||
): VoucherConsumeJobRow {
|
||||
return {
|
||||
id: 0,
|
||||
provider: target.provider,
|
||||
channel: 'affiliate_dash',
|
||||
trigger_event: 'link_generated',
|
||||
target_key: '',
|
||||
platform_order_id: target.platformOrderId,
|
||||
order_id: target.orderId,
|
||||
order_item_id: target.orderItemId,
|
||||
task_id: target.taskId,
|
||||
work_order_id: null,
|
||||
due_at: '',
|
||||
status: 'pending',
|
||||
attempt_count: 0,
|
||||
max_attempts: 8,
|
||||
locked_until: null,
|
||||
last_error: '',
|
||||
result_json: {},
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
finished_at: null,
|
||||
}
|
||||
}
|
||||
|
||||
async function claimDueVoucherConsumeJobs(limit: number): Promise<VoucherConsumeJobRow[]> {
|
||||
const result = await query<VoucherConsumeJobRow>(
|
||||
`
|
||||
WITH due AS (
|
||||
SELECT id
|
||||
FROM voucher_consume_jobs
|
||||
WHERE attempt_count < max_attempts
|
||||
AND (
|
||||
(status = 'pending' AND due_at <= NOW())
|
||||
OR (status = 'processing' AND locked_until < NOW())
|
||||
)
|
||||
ORDER BY due_at ASC, id ASC
|
||||
LIMIT $1
|
||||
FOR UPDATE SKIP LOCKED
|
||||
)
|
||||
UPDATE voucher_consume_jobs job
|
||||
SET
|
||||
status = 'processing',
|
||||
attempt_count = job.attempt_count + 1,
|
||||
locked_until = NOW() + ($2 * INTERVAL '1 second'),
|
||||
updated_at = NOW()
|
||||
FROM due
|
||||
WHERE job.id = due.id
|
||||
RETURNING job.*
|
||||
`,
|
||||
[limit, PROCESSING_LOCK_SECONDS],
|
||||
)
|
||||
return result.rows
|
||||
}
|
||||
|
||||
async function finishVoucherConsumeJob(
|
||||
jobId: number,
|
||||
status: 'succeeded' | 'cancelled',
|
||||
result: Record<string, unknown>,
|
||||
) {
|
||||
await query(
|
||||
`
|
||||
UPDATE voucher_consume_jobs
|
||||
SET
|
||||
status = $1,
|
||||
locked_until = NULL,
|
||||
last_error = '',
|
||||
result_json = $2::jsonb,
|
||||
updated_at = NOW(),
|
||||
finished_at = NOW()
|
||||
WHERE id = $3
|
||||
`,
|
||||
[status, JSON.stringify(result), jobId],
|
||||
)
|
||||
}
|
||||
|
||||
async function failVoucherConsumeJob(job: VoucherConsumeJobRow, error: unknown, retry: boolean) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
if (!retry) {
|
||||
await query(
|
||||
`
|
||||
UPDATE voucher_consume_jobs
|
||||
SET
|
||||
status = 'failed',
|
||||
locked_until = NULL,
|
||||
last_error = $1,
|
||||
result_json = '{}'::jsonb,
|
||||
updated_at = NOW(),
|
||||
finished_at = NOW()
|
||||
WHERE id = $2
|
||||
`,
|
||||
[message, job.id],
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const attemptIndex = Math.max(0, Number(job.attempt_count || 1) - 1)
|
||||
const retryDelaySeconds =
|
||||
RETRY_DELAYS_SECONDS[Math.min(attemptIndex, RETRY_DELAYS_SECONDS.length - 1)] || 60
|
||||
await query(
|
||||
`
|
||||
UPDATE voucher_consume_jobs
|
||||
SET
|
||||
status = 'pending',
|
||||
due_at = NOW() + ($1 * INTERVAL '1 second'),
|
||||
locked_until = NULL,
|
||||
last_error = $2,
|
||||
updated_at = NOW()
|
||||
WHERE id = $3
|
||||
`,
|
||||
[retryDelaySeconds, message, job.id],
|
||||
)
|
||||
}
|
||||
|
||||
function resolveVoucherProvider(order: OrderRow): VoucherConsumeProvider | null {
|
||||
return resolveVoucherProviderFromFields(order.provider, order.platform)
|
||||
}
|
||||
|
||||
function resolveVoucherProviderFromFields(
|
||||
orderProvider: unknown,
|
||||
orderPlatform: unknown,
|
||||
): VoucherConsumeProvider | null {
|
||||
if (isDouyinMashidaProvider(orderProvider)) {
|
||||
return 'douyin_mashida'
|
||||
}
|
||||
const provider = String(orderProvider || '').trim()
|
||||
const platform = String(orderPlatform || '').trim()
|
||||
if (
|
||||
[OPEN_91_PROVIDER, 'open_91', '91'].includes(provider) ||
|
||||
[OPEN_91_PLATFORM, 'kuaishou'].includes(platform)
|
||||
) {
|
||||
return 'kuaishou_industry'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function normalizeVoucherChannel(value: unknown): VoucherConsumeChannel | null {
|
||||
const channel = String(value || '').trim()
|
||||
if (
|
||||
channel === 'affiliate_dash' ||
|
||||
channel === 'work_order' ||
|
||||
channel === 'manual' ||
|
||||
channel === 'third_party_voucher' ||
|
||||
channel === 'on_account'
|
||||
) {
|
||||
return channel
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function resolveTaskVoucherChannel(
|
||||
task: Pick<TaskRow, 'executor_key'>,
|
||||
orderItem: OrderItemRow | null,
|
||||
): VoucherConsumeChannel | null {
|
||||
const executorKey = String(task.executor_key || '').trim()
|
||||
if (executorKey === FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH) return 'affiliate_dash'
|
||||
if (
|
||||
executorKey === FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH ||
|
||||
executorKey === FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD ||
|
||||
executorKey === FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI ||
|
||||
executorKey === FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_INDUSTRY
|
||||
) {
|
||||
return 'manual'
|
||||
}
|
||||
|
||||
return normalizeVoucherChannel(orderItem?.fulfillment_channel)
|
||||
}
|
||||
|
||||
function buildVoucherConsumeTargetKey(orderItemId: unknown, fallback: string): string {
|
||||
const normalizedItemId = Number(orderItemId || 0)
|
||||
return normalizedItemId > 0 ? `order_item:${normalizedItemId}` : fallback
|
||||
}
|
||||
|
||||
function resolveOrderItemUnitRange(
|
||||
orderItems: OrderItemRow[],
|
||||
orderItemId: number,
|
||||
): { start: number; end: number } | null {
|
||||
let cursor = 0
|
||||
for (const item of orderItems) {
|
||||
const quantity = Math.max(1, Number(item.quantity || 1))
|
||||
const start = cursor + 1
|
||||
const end = cursor + quantity
|
||||
if (Number(item.id) === orderItemId) {
|
||||
return { start, end }
|
||||
}
|
||||
cursor = end
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function buildScheduleResult(
|
||||
status: VoucherConsumeScheduleResult['status'],
|
||||
input: Partial<VoucherConsumeScheduleResult> = {},
|
||||
): VoucherConsumeScheduleResult {
|
||||
return {
|
||||
ok: status !== 'failed',
|
||||
voucherCount: Number(input.voucherCount || 0),
|
||||
consumedCount: 0,
|
||||
alreadyConsumedCount: 0,
|
||||
failedCount: 0,
|
||||
voucherCodes: input.voucherCodes || [],
|
||||
errorMessage: String(input.errorMessage || ''),
|
||||
queuedCount: Number(input.queuedCount || 0),
|
||||
jobIds: input.jobIds || [],
|
||||
dueAt: String(input.dueAt || ''),
|
||||
status,
|
||||
}
|
||||
}
|
||||
|
||||
function clampInteger(value: unknown, fallback: number, min: number, max: number): number {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isInteger(parsed)) return fallback
|
||||
return Math.min(max, Math.max(min, parsed))
|
||||
}
|
||||
@@ -1,14 +1,10 @@
|
||||
/**
|
||||
* 91 卡券交付前核销:
|
||||
* 在查询接口即将返回 orderStatus=20 + cards 前,同步核销行业电子凭证,
|
||||
* 把「核销时间」提前到链接交付时刻,降低「拿链即退」误退窗口。
|
||||
*
|
||||
* 语义:核销 = 领取链接已交付(售出闭环),≠ 游戏内道具已到账。
|
||||
* 91 卡券交付时安排延迟核销。
|
||||
*
|
||||
* 查询接口仍按任务 ready 状态返回 cards,但平台核销不再同步阻塞交付;
|
||||
* 这里投递 link_generated 事件,后续由持久化定时任务按配置延迟执行。
|
||||
*/
|
||||
import { attachKuaishouIndustryVoucherToTask } from '../platforms/kuaishou-industry/voucher-binding-service.js'
|
||||
import { consumeKuaishouIndustryVoucher } from '../platforms/kuaishou-industry/voucher-service.js'
|
||||
import { listKuaishouIndustryVouchersByOid } from '../../repositories/kuaishou-industry-voucher-repo.js'
|
||||
import { scheduleVoucherConsumeForTask } from '../fulfillment/voucher-consume-queue-service.js'
|
||||
import { logIntegration, logWarn } from '../../utils/logger.js'
|
||||
import type { KuaishouIndustryVoucherRow, OrderRow, TaskRow } from '../../types/repository/rows.js'
|
||||
|
||||
@@ -55,18 +51,7 @@ export function resolveIndustryVoucherForTask(
|
||||
return null
|
||||
}
|
||||
|
||||
function isVoucherConsumed(voucher: Pick<KuaishouIndustryVoucherRow, 'status'>): boolean {
|
||||
return (
|
||||
String(voucher.status || '')
|
||||
.trim()
|
||||
.toUpperCase() === 'CONSUMED'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 对即将交付给 91 的 ready 任务同步核销行业凭证(幂等)。
|
||||
* 失败时调用方应返回 orderStatus=10,避免「未核销却已发链」。
|
||||
*/
|
||||
/** 对即将交付给 91 的 ready 任务投递延迟核销任务。 */
|
||||
export async function consumeIndustryVouchersBeforeOpen91Deliver(options: {
|
||||
order: OrderRow
|
||||
tasks: TaskRow[]
|
||||
@@ -95,59 +80,29 @@ export async function consumeIndustryVouchersBeforeOpen91Deliver(options: {
|
||||
}
|
||||
|
||||
const oid = String(options.order.platform_order_id || '').trim()
|
||||
const vouchers = oid ? await listKuaishouIndustryVouchersByOid(oid) : []
|
||||
const consumedTaskIds: number[] = []
|
||||
const alreadyConsumedTaskIds: number[] = []
|
||||
const failed: Array<{ taskId: number; errorMessage: string }> = []
|
||||
|
||||
for (const task of readyTasks) {
|
||||
const taskId = Number(task.id || 0)
|
||||
const voucher = resolveIndustryVoucherForTask(task, vouchers)
|
||||
|
||||
if (!voucher) {
|
||||
try {
|
||||
await scheduleVoucherConsumeForTask(task, 'link_generated')
|
||||
} catch (error) {
|
||||
failed.push({
|
||||
taskId,
|
||||
errorMessage: '交付前核销失败:未找到对应电子凭证',
|
||||
errorMessage: error instanceof Error ? error.message : '安排自动核销失败',
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const alreadyConsumed = isVoucherConsumed(voucher)
|
||||
const result = await consumeKuaishouIndustryVoucher(voucher, {
|
||||
source,
|
||||
task,
|
||||
token: String(voucher.token || '').trim(),
|
||||
consumeTime: Date.now(),
|
||||
})
|
||||
|
||||
if (!result.ok || !result.voucher) {
|
||||
failed.push({
|
||||
taskId,
|
||||
errorMessage: result.errorMessage || '电子凭证核销回调失败',
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
await attachKuaishouIndustryVoucherToTask(task, result.voucher, {
|
||||
source,
|
||||
})
|
||||
|
||||
if (alreadyConsumed) {
|
||||
alreadyConsumedTaskIds.push(taskId)
|
||||
} else {
|
||||
consumedTaskIds.push(taskId)
|
||||
}
|
||||
}
|
||||
|
||||
if (failed.length > 0) {
|
||||
const errorMessage = failed[0]?.errorMessage || '电子凭证核销失败,暂缓交付领取链接'
|
||||
logWarn('[open-91/query]', '91交付前核销失败,暂不返回 cards', {
|
||||
const errorMessage = failed[0]?.errorMessage || '安排自动核销失败'
|
||||
logWarn('[open-91/query]', '91交付时安排自动核销失败', {
|
||||
requestId: options.requestId || '',
|
||||
orderId: options.order.id,
|
||||
orderNo: oid,
|
||||
failedTaskIds: failed.map((item) => item.taskId),
|
||||
failed,
|
||||
consumedTaskIds,
|
||||
alreadyConsumedTaskIds,
|
||||
errorMessage,
|
||||
})
|
||||
@@ -156,24 +111,24 @@ export async function consumeIndustryVouchersBeforeOpen91Deliver(options: {
|
||||
ok: false,
|
||||
errorMessage,
|
||||
failedTaskIds: failed.map((item) => item.taskId),
|
||||
consumedTaskIds,
|
||||
consumedTaskIds: [],
|
||||
alreadyConsumedTaskIds,
|
||||
}
|
||||
}
|
||||
|
||||
logIntegration('[open-91/query]', '91交付前核销完成', {
|
||||
logIntegration('[open-91/query]', '91交付时核销任务已投递', {
|
||||
requestId: options.requestId || '',
|
||||
orderId: options.order.id,
|
||||
orderNo: oid,
|
||||
readyTaskCount: readyTasks.length,
|
||||
consumedTaskIds,
|
||||
taskCount: readyTasks.length,
|
||||
alreadyConsumedTaskIds,
|
||||
source,
|
||||
})
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
consumedTaskIds,
|
||||
consumedTaskIds: [],
|
||||
alreadyConsumedTaskIds,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,6 +197,88 @@ test('syncDeliveryTasksForOrder 已有部分任务时按订单项补建缺失任
|
||||
assert.deepEqual(taskEvents, [{ taskId: 202, eventType: 'fulfillment_task_planned' }])
|
||||
})
|
||||
|
||||
test('syncDeliveryTasksForOrder 关闭任务不阻塞新的履约尝试', async () => {
|
||||
const closedTask = stubCreatedTask(205, {
|
||||
orderId: 5,
|
||||
orderItemId: 25,
|
||||
unitIndex: 1,
|
||||
executorKey: 'affiliate_dash',
|
||||
taskStatus: 'closed',
|
||||
})
|
||||
const createdInputs: CreatedTaskInput[] = []
|
||||
|
||||
await syncDeliveryTasksForOrderWithDeps(
|
||||
stubOrder({ id: 5, platform_order_id: 'retry-closed-order' }),
|
||||
[
|
||||
stubOrderItem({
|
||||
id: 25,
|
||||
order_id: 5,
|
||||
sku_code: 'SKU-retry',
|
||||
item_snapshot_json: buildAffiliateSnapshot('AFF-RETRY', '商品 RETRY'),
|
||||
}),
|
||||
],
|
||||
{
|
||||
loadActiveCarrierEvidence: async () => new Map(),
|
||||
listTasksByOrderId: async () => [closedTask],
|
||||
getFulfillmentProfileByKey: async () => buildAffiliateProfile(),
|
||||
createTask: async (input) => {
|
||||
createdInputs.push(input as CreatedTaskInput)
|
||||
return stubCreatedTask(206, input as CreatedTaskInput)
|
||||
},
|
||||
createTaskEvent: async () => null,
|
||||
logInfo: () => null,
|
||||
logWarn: () => null,
|
||||
allowInactiveTaskRetry: true,
|
||||
nowIso: () => '2026-05-29T09:05:46.658Z',
|
||||
randomId: () => 'DT-retry-closed',
|
||||
},
|
||||
)
|
||||
|
||||
assert.deepEqual(
|
||||
createdInputs.map((input) => [input.orderItemId, input.unitIndex]),
|
||||
[[25, 1]],
|
||||
)
|
||||
})
|
||||
|
||||
test('syncDeliveryTasksForOrder 默认不会因历史关闭任务自动补建', async () => {
|
||||
const closedTask = stubCreatedTask(208, {
|
||||
orderId: 6,
|
||||
orderItemId: 26,
|
||||
unitIndex: 1,
|
||||
executorKey: 'affiliate_dash',
|
||||
taskStatus: 'closed',
|
||||
})
|
||||
const createdInputs: CreatedTaskInput[] = []
|
||||
|
||||
await syncDeliveryTasksForOrderWithDeps(
|
||||
stubOrder({ id: 6, platform_order_id: 'closed-order-no-retry' }),
|
||||
[
|
||||
stubOrderItem({
|
||||
id: 26,
|
||||
order_id: 6,
|
||||
sku_code: 'SKU-closed-no-retry',
|
||||
item_snapshot_json: buildAffiliateSnapshot('AFF-CLOSED', '商品 CLOSED'),
|
||||
}),
|
||||
],
|
||||
{
|
||||
loadActiveCarrierEvidence: async () => new Map(),
|
||||
listTasksByOrderId: async () => [closedTask],
|
||||
getFulfillmentProfileByKey: async () => buildAffiliateProfile(),
|
||||
createTask: async (input) => {
|
||||
createdInputs.push(input as CreatedTaskInput)
|
||||
return stubCreatedTask(209, input as CreatedTaskInput)
|
||||
},
|
||||
createTaskEvent: async () => null,
|
||||
logInfo: () => null,
|
||||
logWarn: () => null,
|
||||
nowIso: () => '2026-05-29T09:05:46.658Z',
|
||||
randomId: () => 'DT-closed-no-retry',
|
||||
},
|
||||
)
|
||||
|
||||
assert.equal(createdInputs.length, 0)
|
||||
})
|
||||
|
||||
test('syncDeliveryTasksForOrder 数量增加时只补建缺失单元', async () => {
|
||||
const existingTask = stubCreatedTask(301, {
|
||||
orderId: 4,
|
||||
|
||||
@@ -22,7 +22,10 @@ import {
|
||||
findConflictingCarrier,
|
||||
loadActiveCarrierEvidence,
|
||||
} from '../fulfillment/carrier-evidence-service.js'
|
||||
import type { FulfillmentChannel } from '../fulfillment/channel-types.js'
|
||||
import {
|
||||
isInactiveCarrierTaskStatus,
|
||||
type FulfillmentChannel,
|
||||
} from '../fulfillment/channel-types.js'
|
||||
|
||||
type ClaimTokenLike = {
|
||||
token: string
|
||||
@@ -52,13 +55,15 @@ type DeliveryTaskDeps = {
|
||||
createTaskEvent?: typeof createTaskEvent
|
||||
logInfo?: typeof logInfo
|
||||
logWarn?: typeof logWarn
|
||||
allowInactiveTaskRetry?: boolean
|
||||
}
|
||||
|
||||
export async function syncDeliveryTasksForOrder(
|
||||
order: OrderRow,
|
||||
orderItems: OrderItemRow[],
|
||||
options: { allowInactiveTaskRetry?: boolean } = {},
|
||||
): Promise<TaskRow[]> {
|
||||
return syncDeliveryTasksForOrderWithDeps(order, orderItems)
|
||||
return syncDeliveryTasksForOrderWithDeps(order, orderItems, options)
|
||||
}
|
||||
|
||||
export async function syncDeliveryTasksForOrderWithDeps(
|
||||
@@ -79,6 +84,7 @@ export async function syncDeliveryTasksForOrderWithDeps(
|
||||
createTaskEvent: appendTaskEvent = createTaskEvent,
|
||||
logInfo: writeInfoLog = logInfo,
|
||||
logWarn: writeWarnLog = logWarn,
|
||||
allowInactiveTaskRetry = false,
|
||||
} = deps
|
||||
|
||||
const runtimeDeps: FulfillmentPrepareDeps = {
|
||||
@@ -90,7 +96,12 @@ export async function syncDeliveryTasksForOrderWithDeps(
|
||||
|
||||
const existingTasks = await listTasks(order.id)
|
||||
const tasks: DeliveryTaskRow[] = [...existingTasks]
|
||||
const existingTaskKeys = new Set(existingTasks.map(buildTaskUnitKey))
|
||||
const existingTaskKeys = new Set(
|
||||
(allowInactiveTaskRetry
|
||||
? existingTasks.filter((task) => !isInactiveCarrierTaskStatus(task.task_status))
|
||||
: existingTasks
|
||||
).map(buildTaskUnitKey),
|
||||
)
|
||||
const carrierEvidence = await loadCarrierEvidence(orderItems.map((item) => Number(item.id)))
|
||||
|
||||
writeInfoLog('[delivery-task-service]', '开始核对订单履约任务完整性', {
|
||||
@@ -205,6 +216,9 @@ export async function syncDeliveryTasksForOrderWithDeps(
|
||||
task =
|
||||
concurrentTasks.find(
|
||||
(candidate) =>
|
||||
(allowInactiveTaskRetry
|
||||
? !isInactiveCarrierTaskStatus(candidate.task_status)
|
||||
: true) &&
|
||||
buildTaskUnitKey(candidate) === buildOrderItemUnitKey(item.id, unitIndex),
|
||||
) || null
|
||||
if (!task) {
|
||||
|
||||
@@ -124,6 +124,43 @@ export async function cancelDouyinMashidaCouponVerify(
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 同意用户售后(`afterSale.operate`,type=201)。
|
||||
* update_time 必须使用 refund_RefundCreated 消息中的原值,平台用它做版本校验。
|
||||
*/
|
||||
export async function approveDouyinMashidaAfterSale(
|
||||
input: {
|
||||
afterSaleId: string
|
||||
updateTime: string
|
||||
reason?: string | undefined
|
||||
remark?: string | undefined
|
||||
} & DouyinMashidaCallOptions,
|
||||
): Promise<JsonObject> {
|
||||
const afterSaleId = String(input.afterSaleId || '').trim()
|
||||
const updateTime = String(input.updateTime || '').trim()
|
||||
if (!afterSaleId || !updateTime) {
|
||||
throw new Error('抖音售后审核缺少 aftersale_id 或 update_time')
|
||||
}
|
||||
const response = await douyinMashidaGatewayRequest({
|
||||
method: 'afterSale.operate',
|
||||
params: {
|
||||
type: 201,
|
||||
items: JSON.stringify([
|
||||
{
|
||||
repair_type: 1,
|
||||
aftersale_id: afterSaleId,
|
||||
reason: String(input.reason || '').trim(),
|
||||
remark: String(input.remark || '').trim(),
|
||||
update_time: updateTime,
|
||||
},
|
||||
]),
|
||||
},
|
||||
config: input.config,
|
||||
timeoutMs: input.timeoutMs,
|
||||
})
|
||||
return isPlainObject(response.data) ? response.data : { data: response.data }
|
||||
}
|
||||
|
||||
/** 订单消息(把「去使用链接」发给买家)。 */
|
||||
export async function sendDouyinMashidaOrderMessage(
|
||||
input: {
|
||||
|
||||
@@ -61,7 +61,7 @@ test('data 解析:坏 JSON 不抛错,返回空对象', () => {
|
||||
assert.deepEqual(parseMessageData(''), {})
|
||||
})
|
||||
|
||||
test('幂等键:有订单号时按 topic:pub_time:p_id:s_ids 派生', () => {
|
||||
test('幂等键:有订单号时按 topic:pub_time:p_id:s_ids:aftersale_id 派生', () => {
|
||||
const eventId = buildDouyinMashidaMessageEventId({
|
||||
topic: 'trade_TradePaid',
|
||||
pubTime: '1780000000',
|
||||
@@ -76,6 +76,23 @@ test('幂等键:有订单号时按 topic:pub_time:p_id:s_ids 派生', () => {
|
||||
)
|
||||
})
|
||||
|
||||
test('幂等键:同一订单同一秒的两笔售后不会互相去重', () => {
|
||||
const first = buildDouyinMashidaMessageEventId({
|
||||
topic: 'refund_RefundCreated',
|
||||
pubTime: '1780000000',
|
||||
data: '{}',
|
||||
message: { p_id: '4837328765348567000', s_id: '4837328765348567001', aftersale_id: '1' },
|
||||
})
|
||||
const second = buildDouyinMashidaMessageEventId({
|
||||
topic: 'refund_RefundCreated',
|
||||
pubTime: '1780000000',
|
||||
data: '{}',
|
||||
message: { p_id: '4837328765348567000', s_id: '4837328765348567001', aftersale_id: '2' },
|
||||
})
|
||||
|
||||
assert.notEqual(first, second)
|
||||
})
|
||||
|
||||
test('幂等键:拿不到订单号时退回 data 摘要(同一内容稳定、内容变则键变)', () => {
|
||||
const base = { topic: 'refund_RefundSuccess', pubTime: '1780000000' }
|
||||
const first = buildDouyinMashidaMessageEventId({ ...base, data: '{"a":1}', message: {} })
|
||||
|
||||
@@ -2,6 +2,7 @@ import { isPlainObject, type JsonObject } from '../../../types/json.js'
|
||||
|
||||
import { insertWebhookEventOnce } from '../../../repositories/webhook-event-repo.js'
|
||||
import { findLatestOrderByPlatformOrderId } from '../../../repositories/order-repo.js'
|
||||
import { query } from '../../../db/client.js'
|
||||
import { upsertOrderFromSource } from '../../order/order-service.js'
|
||||
import {
|
||||
ensureCertificateForOrderItem,
|
||||
@@ -26,6 +27,11 @@ import {
|
||||
} from './source-config-service.js'
|
||||
import { verifyMessageSignature } from './sign.js'
|
||||
import type { OrderItemRow, OrderRow } from '../../../types/repository/rows.js'
|
||||
import {
|
||||
bindPlatformAfterSalesToLocalOrder,
|
||||
markPlatformAfterSaleState,
|
||||
upsertPlatformAfterSale,
|
||||
} from '../platform-after-sales-service.js'
|
||||
|
||||
/**
|
||||
* 抖音码速达消息推送入口(验签 → 幂等 → 分派)。
|
||||
@@ -113,14 +119,96 @@ export async function handleDouyinMashidaMessage(input: {
|
||||
return handlePaidMessage({ message, topic, eventId, config })
|
||||
}
|
||||
|
||||
if (topic === 'refund_RefundCreated') {
|
||||
return handleAfterSaleCreated({ message, topic, eventId, pubTime })
|
||||
}
|
||||
|
||||
if (topic === 'refund_RefundClosed') {
|
||||
return handleAfterSaleClosed({ message, topic, eventId, pubTime })
|
||||
}
|
||||
|
||||
if (topic === 'trade_TradeCanceled' || topic === 'refund_RefundSuccess') {
|
||||
return handleInvalidateMessage({ message, topic, eventId })
|
||||
return handleInvalidateMessage({ message, topic, eventId, pubTime })
|
||||
}
|
||||
|
||||
logIntegration('[douyin-mashida/notify]', 'topic 暂无处理逻辑', { topic, eventId })
|
||||
return { ok: true, duplicate: false, topic, action: 'noop', eventId }
|
||||
}
|
||||
|
||||
async function handleAfterSaleCreated(input: {
|
||||
message: JsonObject
|
||||
topic: string
|
||||
eventId: string
|
||||
pubTime: string
|
||||
}): Promise<DouyinMashidaMessageResult> {
|
||||
const afterSaleId = String(input.message.aftersale_id || '').trim()
|
||||
const parentOrderId = resolveDouyinMashidaParentOrderId(input.message)
|
||||
if (!afterSaleId || !parentOrderId) {
|
||||
throw createHttpError('抖音售后消息缺少 aftersale_id 或 p_id', {
|
||||
statusCode: 400,
|
||||
errorCode: 'douyin_mashida_after_sale_id_missing',
|
||||
context: { topic: input.topic, eventId: input.eventId },
|
||||
})
|
||||
}
|
||||
const order = await findLatestOrderByPlatformOrderId({
|
||||
provider: DOUYIN_MASHIDA_PROVIDER,
|
||||
platform: DOUYIN_MASHIDA_PLATFORM,
|
||||
platformOrderId: parentOrderId,
|
||||
})
|
||||
const orderItems = order
|
||||
? await queryOrderItemsForAfterSale(order.id, String(input.message.s_id || '').trim())
|
||||
: []
|
||||
const afterSale = await upsertPlatformAfterSale({
|
||||
provider: DOUYIN_MASHIDA_PROVIDER,
|
||||
platformOrderId: parentOrderId,
|
||||
subOrderId: String(input.message.s_id || ''),
|
||||
externalAfterSaleId: afterSaleId,
|
||||
localOrderId: order?.id || null,
|
||||
localOrderItemId: orderItems.length === 1 ? orderItems[0]?.id || null : null,
|
||||
amountFen: Number(input.message.refund_amount || 0),
|
||||
afterSaleStatus: numberOrNull(input.message.aftersale_status),
|
||||
afterSaleType: numberOrNull(input.message.aftersale_type),
|
||||
refundStatus: numberOrNull(input.message.refund_status),
|
||||
platformUpdateTime: String(input.message.update_time || ''),
|
||||
state: 'open',
|
||||
raw: input.message,
|
||||
appliedAt: numberToIso(input.message.apply_time),
|
||||
eventAt: numberToIso(input.pubTime),
|
||||
})
|
||||
return {
|
||||
ok: true,
|
||||
duplicate: false,
|
||||
topic: input.topic,
|
||||
action: afterSale ? 'after-sale-opened' : 'after-sale-ignored',
|
||||
eventId: input.eventId,
|
||||
orderId: order?.id || null,
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAfterSaleClosed(input: {
|
||||
message: JsonObject
|
||||
topic: string
|
||||
eventId: string
|
||||
pubTime: string
|
||||
}): Promise<DouyinMashidaMessageResult> {
|
||||
const afterSaleId = String(input.message.aftersale_id || '').trim()
|
||||
if (afterSaleId) {
|
||||
await markPlatformAfterSaleState({
|
||||
provider: DOUYIN_MASHIDA_PROVIDER,
|
||||
externalAfterSaleId: afterSaleId,
|
||||
state: 'closed',
|
||||
eventAt: numberToIso(input.pubTime),
|
||||
})
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
duplicate: false,
|
||||
topic: input.topic,
|
||||
action: afterSaleId ? 'after-sale-closed' : 'after-sale-close-id-missing',
|
||||
eventId: input.eventId,
|
||||
}
|
||||
}
|
||||
|
||||
/** `trade_TradePaid`:拉订单详情 → 建单 → 生成券码并同步给平台。 */
|
||||
async function handlePaidMessage(input: {
|
||||
message: JsonObject
|
||||
@@ -171,6 +259,14 @@ async function handlePaidMessage(input: {
|
||||
}
|
||||
}
|
||||
|
||||
const orderItems = result.orderItems || []
|
||||
await bindPlatformAfterSalesToLocalOrder({
|
||||
provider: DOUYIN_MASHIDA_PROVIDER,
|
||||
platformOrderId: parentOrderId,
|
||||
localOrderId: order.id,
|
||||
localOrderItemId: orderItems.length === 1 ? orderItems[0]?.id || null : null,
|
||||
})
|
||||
|
||||
// 只有真正付款的单才发券(详情可能显示已取消/退款)
|
||||
if (String(order.pay_status || '') !== 'paid') {
|
||||
logIntegration('[douyin-mashida/notify]', '订单非已支付状态,跳过发券', {
|
||||
@@ -315,6 +411,7 @@ async function handleInvalidateMessage(input: {
|
||||
message: JsonObject
|
||||
topic: string
|
||||
eventId: string
|
||||
pubTime: string
|
||||
}): Promise<DouyinMashidaMessageResult> {
|
||||
const parentOrderId = resolveDouyinMashidaParentOrderId(input.message)
|
||||
if (!parentOrderId) {
|
||||
@@ -364,6 +461,15 @@ async function handleInvalidateMessage(input: {
|
||||
localOrderId: order.id,
|
||||
reason: input.topic,
|
||||
})
|
||||
const afterSaleId = String(input.message.refund_id || input.message.aftersale_id || '').trim()
|
||||
if (afterSaleId) {
|
||||
await markPlatformAfterSaleState({
|
||||
provider: DOUYIN_MASHIDA_PROVIDER,
|
||||
externalAfterSaleId: afterSaleId,
|
||||
state: input.topic === 'refund_RefundSuccess' ? 'success' : 'closed',
|
||||
eventAt: numberToIso(input.message.success_time || input.pubTime),
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
@@ -376,6 +482,28 @@ async function handleInvalidateMessage(input: {
|
||||
}
|
||||
}
|
||||
|
||||
async function queryOrderItemsForAfterSale(orderId: number, subOrderId: string) {
|
||||
const result = await query<{ id: number; sku_code: string }>(
|
||||
`SELECT id, sku_code FROM order_items WHERE order_id = $1 ORDER BY id`,
|
||||
[orderId],
|
||||
)
|
||||
if (subOrderId) {
|
||||
const matched = result.rows.find((row) => String(row.sku_code || '').trim() === subOrderId)
|
||||
if (matched) return [matched]
|
||||
}
|
||||
return result.rows.length === 1 ? result.rows : []
|
||||
}
|
||||
|
||||
function numberOrNull(value: unknown) {
|
||||
const parsed = Number(value)
|
||||
return Number.isInteger(parsed) ? parsed : null
|
||||
}
|
||||
|
||||
function numberToIso(value: unknown) {
|
||||
const seconds = Number(value)
|
||||
return Number.isFinite(seconds) && seconds > 0 ? new Date(seconds * 1000).toISOString() : null
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 POST body 取 `data` 原文(必须与平台签名时使用的字符串完全一致)。
|
||||
* 兼容两种发法:body 直接是 JSON 字符串;或 `data=<json>` 的 urlencoded 表单。
|
||||
@@ -474,9 +602,12 @@ export function buildDouyinMashidaMessageEventId(input: {
|
||||
const subIds = Array.isArray(input.message.s_ids)
|
||||
? input.message.s_ids.map((id) => String(id || '').trim()).filter(Boolean)
|
||||
: []
|
||||
const afterSaleId = String(input.message.aftersale_id || input.message.refund_id || '').trim()
|
||||
|
||||
if (parentOrderId) {
|
||||
return [input.topic, input.pubTime, parentOrderId, subIds.join(',')].join(':')
|
||||
return [input.topic, input.pubTime, parentOrderId, subIds.join(','), afterSaleId]
|
||||
.filter(Boolean)
|
||||
.join(':')
|
||||
}
|
||||
|
||||
const digest = createHash('sha256')
|
||||
|
||||
@@ -30,7 +30,7 @@ const DOUYIN_MASHIDA_CONFIG_KEY = APP_CONFIG_KEYS.douyinMashida
|
||||
|
||||
export const DOUYIN_MASHIDA_DEFAULT_BASE_URL = 'https://dy-openplatform-api.wanmahui.com'
|
||||
|
||||
/** 最小接入只处理这几条:付款进单、取消/退款作废券码。 */
|
||||
/** 付款进单、售后自动发现、取消/退款作废券码。 */
|
||||
/** 兑换成功后 order.message 的默认正文(可在后台覆盖)。 */
|
||||
export const DEFAULT_AFTER_VERIFY_MESSAGE = '您的订单已兑换成功'
|
||||
|
||||
@@ -40,6 +40,8 @@ export const DEFAULT_CLAIM_MESSAGE_TEMPLATE = '您的订单已发货,请点击
|
||||
export const DOUYIN_MASHIDA_DEFAULT_HANDLED_TOPICS = [
|
||||
'trade_TradePaid',
|
||||
'trade_TradeCanceled',
|
||||
'refund_RefundCreated',
|
||||
'refund_RefundClosed',
|
||||
'refund_RefundSuccess',
|
||||
] as const
|
||||
|
||||
@@ -165,7 +167,13 @@ function normalizeHandledTopics(value: unknown): string[] {
|
||||
}
|
||||
|
||||
const topics = [...new Set(value.map((item) => String(item || '').trim()).filter(Boolean))]
|
||||
return topics.length > 0 ? topics : [...DOUYIN_MASHIDA_DEFAULT_HANDLED_TOPICS]
|
||||
return [
|
||||
...new Set([
|
||||
...(topics.length > 0 ? topics : DOUYIN_MASHIDA_DEFAULT_HANDLED_TOPICS),
|
||||
'refund_RefundCreated',
|
||||
'refund_RefundClosed',
|
||||
]),
|
||||
]
|
||||
}
|
||||
|
||||
function normalizePositiveInteger(value: unknown, fallback: number) {
|
||||
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
resolveKuaishouIndustryVoucherValidity,
|
||||
} from './voucher-service.js'
|
||||
import { bindKuaishouIndustryVouchersToOrderTasks } from './voucher-binding-service.js'
|
||||
import { autoConsumeKuaishouIndustryVouchersForOrder } from './voucher-auto-consume-service.js'
|
||||
import { parseAmountToFen } from '../../../utils/money.js'
|
||||
import {
|
||||
publishAdminOrderRealtimeChange,
|
||||
@@ -518,32 +517,6 @@ async function syncOpen91OrderAfterSendCallbackSuccess(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const autoConsumeResult = await autoConsumeKuaishouIndustryVouchersForOrder(normalizedOid, {
|
||||
source: `${source}_auto_consume`,
|
||||
})
|
||||
if (autoConsumeResult.consumedCount > 0 || autoConsumeResult.failedCount > 0) {
|
||||
logIntegration('[kuaishou-industry/send-code]', '发码回调成功后自动推进电子凭证核销完成', {
|
||||
source,
|
||||
oid: normalizedOid,
|
||||
orderId: order.id,
|
||||
consumedCount: autoConsumeResult.consumedCount,
|
||||
alreadyConsumedCount: autoConsumeResult.alreadyConsumedCount,
|
||||
skippedCount: autoConsumeResult.skippedCount,
|
||||
failedCount: autoConsumeResult.failedCount,
|
||||
skippedReasons: autoConsumeResult.skippedReasons,
|
||||
errors: autoConsumeResult.errors,
|
||||
})
|
||||
}
|
||||
} catch (consumeError) {
|
||||
logWarn('[kuaishou-industry/send-code]', '发码回调成功后自动推进电子凭证核销异常', {
|
||||
source,
|
||||
oid: normalizedOid,
|
||||
orderId: order.id,
|
||||
error: consumeError instanceof Error ? consumeError.message : String(consumeError),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function isOpen91OrderAwaitingFulfillmentConfig(error: unknown) {
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
import { query } from '../../db/client.js'
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
import type { JsonObject } from '../../types/json.js'
|
||||
|
||||
export type PlatformAfterSaleState = 'open' | 'agreed' | 'refused' | 'closed' | 'success' | 'failed'
|
||||
|
||||
export type PlatformAfterSaleInput = {
|
||||
provider: string
|
||||
platformOrderId: string
|
||||
subOrderId?: string
|
||||
externalAfterSaleId: string
|
||||
localOrderId?: number | null
|
||||
localOrderItemId?: number | null
|
||||
amountFen?: number
|
||||
afterSaleStatus?: number | null
|
||||
afterSaleType?: number | null
|
||||
refundStatus?: number | null
|
||||
platformUpdateTime?: string
|
||||
state?: PlatformAfterSaleState
|
||||
raw?: JsonObject
|
||||
appliedAt?: string | null
|
||||
eventAt?: string | null
|
||||
}
|
||||
|
||||
export type PlatformAfterSale = {
|
||||
id: number
|
||||
provider: string
|
||||
platformOrderId: string
|
||||
subOrderId: string
|
||||
externalAfterSaleId: string
|
||||
localOrderId: number | null
|
||||
localOrderItemId: number | null
|
||||
amountFen: number
|
||||
afterSaleStatus: number | null
|
||||
afterSaleType: number | null
|
||||
refundStatus: number | null
|
||||
platformUpdateTime: string
|
||||
state: PlatformAfterSaleState
|
||||
raw: JsonObject
|
||||
appliedAt: string | null
|
||||
lastEventAt: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
type PlatformAfterSaleRow = {
|
||||
id: number
|
||||
provider: string
|
||||
platform_order_id: string
|
||||
sub_order_id: string
|
||||
external_after_sale_id: string
|
||||
local_order_id: number | null
|
||||
local_order_item_id: number | null
|
||||
amount_fen: number | string
|
||||
after_sale_status: number | null
|
||||
after_sale_type: number | null
|
||||
refund_status: number | null
|
||||
platform_update_time: string
|
||||
state: PlatformAfterSaleState
|
||||
raw_json: unknown
|
||||
applied_at: string | null
|
||||
last_event_at: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export async function upsertPlatformAfterSale(
|
||||
input: PlatformAfterSaleInput,
|
||||
): Promise<PlatformAfterSale | null> {
|
||||
const provider = normalizeText(input.provider, 80)
|
||||
const platformOrderId = normalizeText(input.platformOrderId, 120)
|
||||
const externalAfterSaleId = normalizeText(input.externalAfterSaleId, 120)
|
||||
if (!provider || !platformOrderId || !externalAfterSaleId) return null
|
||||
const now = nowIso()
|
||||
const result = await query<PlatformAfterSaleRow>(
|
||||
`
|
||||
INSERT INTO platform_after_sales (
|
||||
provider, platform_order_id, sub_order_id, external_after_sale_id,
|
||||
local_order_id, local_order_item_id, amount_fen,
|
||||
after_sale_status, after_sale_type, refund_status, platform_update_time,
|
||||
state, raw_json, applied_at, last_event_at, created_at, updated_at
|
||||
) VALUES (
|
||||
$1, $2, $3, $4,
|
||||
$5, $6, $7,
|
||||
$8, $9, $10, $11,
|
||||
$12, $13::jsonb, $14, $15, $16, $16
|
||||
)
|
||||
ON CONFLICT (provider, external_after_sale_id)
|
||||
DO UPDATE SET
|
||||
platform_order_id = EXCLUDED.platform_order_id,
|
||||
sub_order_id = EXCLUDED.sub_order_id,
|
||||
local_order_id = COALESCE(EXCLUDED.local_order_id, platform_after_sales.local_order_id),
|
||||
local_order_item_id = COALESCE(
|
||||
EXCLUDED.local_order_item_id,
|
||||
platform_after_sales.local_order_item_id
|
||||
),
|
||||
amount_fen = CASE
|
||||
WHEN EXCLUDED.amount_fen > 0 THEN EXCLUDED.amount_fen
|
||||
ELSE platform_after_sales.amount_fen
|
||||
END,
|
||||
after_sale_status = COALESCE(
|
||||
EXCLUDED.after_sale_status,
|
||||
platform_after_sales.after_sale_status
|
||||
),
|
||||
after_sale_type = COALESCE(EXCLUDED.after_sale_type, platform_after_sales.after_sale_type),
|
||||
refund_status = COALESCE(EXCLUDED.refund_status, platform_after_sales.refund_status),
|
||||
platform_update_time = CASE
|
||||
WHEN EXCLUDED.platform_update_time <> '' THEN EXCLUDED.platform_update_time
|
||||
ELSE platform_after_sales.platform_update_time
|
||||
END,
|
||||
state = CASE
|
||||
WHEN platform_after_sales.state IN ('agreed', 'success', 'closed', 'refused')
|
||||
AND EXCLUDED.state = 'open'
|
||||
THEN platform_after_sales.state
|
||||
ELSE EXCLUDED.state
|
||||
END,
|
||||
raw_json = EXCLUDED.raw_json,
|
||||
applied_at = COALESCE(EXCLUDED.applied_at, platform_after_sales.applied_at),
|
||||
last_event_at = COALESCE(EXCLUDED.last_event_at, platform_after_sales.last_event_at),
|
||||
updated_at = EXCLUDED.updated_at
|
||||
RETURNING *
|
||||
`,
|
||||
[
|
||||
provider,
|
||||
platformOrderId,
|
||||
normalizeText(input.subOrderId, 120),
|
||||
externalAfterSaleId,
|
||||
normalizeOptionalId(input.localOrderId),
|
||||
normalizeOptionalId(input.localOrderItemId),
|
||||
normalizeAmountFen(input.amountFen),
|
||||
normalizeOptionalInteger(input.afterSaleStatus),
|
||||
normalizeOptionalInteger(input.afterSaleType),
|
||||
normalizeOptionalInteger(input.refundStatus),
|
||||
normalizeText(input.platformUpdateTime, 80),
|
||||
input.state || 'open',
|
||||
JSON.stringify(input.raw || {}),
|
||||
input.appliedAt || null,
|
||||
input.eventAt || now,
|
||||
now,
|
||||
],
|
||||
)
|
||||
return result.rows[0] ? mapPlatformAfterSale(result.rows[0]) : null
|
||||
}
|
||||
|
||||
export async function findOpenPlatformAfterSale(input: {
|
||||
provider: string
|
||||
localOrderId: number
|
||||
localOrderItemId?: number | null
|
||||
}): Promise<PlatformAfterSale | null> {
|
||||
const params: unknown[] = [input.provider, input.localOrderId]
|
||||
let itemFilter = ''
|
||||
if (input.localOrderItemId) {
|
||||
params.push(input.localOrderItemId)
|
||||
itemFilter = `AND local_order_item_id = $${params.length}`
|
||||
}
|
||||
const result = await query<PlatformAfterSaleRow>(
|
||||
`
|
||||
SELECT *
|
||||
FROM platform_after_sales
|
||||
WHERE provider = $1
|
||||
AND local_order_id = $2
|
||||
AND state = 'open'
|
||||
${itemFilter}
|
||||
ORDER BY last_event_at DESC NULLS LAST, id DESC
|
||||
LIMIT 1
|
||||
`,
|
||||
params,
|
||||
)
|
||||
return result.rows[0] ? mapPlatformAfterSale(result.rows[0]) : null
|
||||
}
|
||||
|
||||
export async function findPlatformAfterSaleByExternalId(input: {
|
||||
provider: string
|
||||
externalAfterSaleId: string
|
||||
}): Promise<PlatformAfterSale | null> {
|
||||
const result = await query<PlatformAfterSaleRow>(
|
||||
`
|
||||
SELECT *
|
||||
FROM platform_after_sales
|
||||
WHERE provider = $1 AND external_after_sale_id = $2
|
||||
LIMIT 1
|
||||
`,
|
||||
[input.provider, input.externalAfterSaleId],
|
||||
)
|
||||
return result.rows[0] ? mapPlatformAfterSale(result.rows[0]) : null
|
||||
}
|
||||
|
||||
export async function bindPlatformAfterSalesToLocalOrder(input: {
|
||||
provider: string
|
||||
platformOrderId: string
|
||||
localOrderId: number
|
||||
localOrderItemId?: number | null
|
||||
}): Promise<number> {
|
||||
const result = await query(
|
||||
`
|
||||
UPDATE platform_after_sales
|
||||
SET local_order_id = $3,
|
||||
local_order_item_id = COALESCE(local_order_item_id, $4),
|
||||
updated_at = $5
|
||||
WHERE provider = $1
|
||||
AND platform_order_id = $2
|
||||
AND local_order_id IS NULL
|
||||
`,
|
||||
[
|
||||
input.provider,
|
||||
input.platformOrderId,
|
||||
input.localOrderId,
|
||||
normalizeOptionalId(input.localOrderItemId),
|
||||
nowIso(),
|
||||
],
|
||||
)
|
||||
return result.rowCount || 0
|
||||
}
|
||||
|
||||
export async function markPlatformAfterSaleState(input: {
|
||||
provider: string
|
||||
externalAfterSaleId: string
|
||||
state: PlatformAfterSaleState
|
||||
eventAt?: string | null
|
||||
}): Promise<void> {
|
||||
await query(
|
||||
`
|
||||
UPDATE platform_after_sales
|
||||
SET state = CASE
|
||||
WHEN state = 'success' THEN state
|
||||
WHEN state IN ('agreed', 'closed', 'refused') AND $3 = 'open' THEN state
|
||||
ELSE $3
|
||||
END,
|
||||
last_event_at = $4,
|
||||
updated_at = $4
|
||||
WHERE provider = $1 AND external_after_sale_id = $2
|
||||
`,
|
||||
[input.provider, input.externalAfterSaleId, input.state, input.eventAt || nowIso()],
|
||||
)
|
||||
}
|
||||
|
||||
function mapPlatformAfterSale(row: PlatformAfterSaleRow): PlatformAfterSale {
|
||||
return {
|
||||
id: Number(row.id),
|
||||
provider: row.provider,
|
||||
platformOrderId: row.platform_order_id,
|
||||
subOrderId: row.sub_order_id || '',
|
||||
externalAfterSaleId: row.external_after_sale_id,
|
||||
localOrderId: row.local_order_id ? Number(row.local_order_id) : null,
|
||||
localOrderItemId: row.local_order_item_id ? Number(row.local_order_item_id) : null,
|
||||
amountFen: Number(row.amount_fen || 0),
|
||||
afterSaleStatus: row.after_sale_status,
|
||||
afterSaleType: row.after_sale_type,
|
||||
refundStatus: row.refund_status,
|
||||
platformUpdateTime: row.platform_update_time || '',
|
||||
state: row.state,
|
||||
raw:
|
||||
row.raw_json && typeof row.raw_json === 'object' && !Array.isArray(row.raw_json)
|
||||
? (row.raw_json as JsonObject)
|
||||
: {},
|
||||
appliedAt: row.applied_at,
|
||||
lastEventAt: row.last_event_at,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeText(value: unknown, maxLength: number) {
|
||||
return String(value ?? '')
|
||||
.trim()
|
||||
.slice(0, maxLength)
|
||||
}
|
||||
|
||||
function normalizeOptionalId(value: unknown): number | null {
|
||||
const parsed = Number(value)
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : null
|
||||
}
|
||||
|
||||
function normalizeAmountFen(value: unknown): number {
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : 0
|
||||
}
|
||||
|
||||
function normalizeOptionalInteger(value: unknown): number | null {
|
||||
if (value === null || value === undefined || value === '') return null
|
||||
const parsed = Number(value)
|
||||
return Number.isInteger(parsed) ? parsed : null
|
||||
}
|
||||
@@ -45,6 +45,7 @@ test('normalizeScheduledJobsConfig ensures default jobs exist', () => {
|
||||
assert.equal(ids.includes('cost-finance-sync'), false)
|
||||
assert.ok(ids.includes('channel-assign'))
|
||||
assert.ok(ids.includes('ledger-post'))
|
||||
assert.ok(ids.includes('voucher-consume-dispatch'))
|
||||
})
|
||||
|
||||
test('旧的 cost_finance_sync 任务已彻底移除,不再出现在配置里', () => {
|
||||
|
||||
@@ -19,6 +19,7 @@ const COST_SNAPSHOT_JOB_ID = 'cost-snapshot'
|
||||
const SALES_FACTS_JOB_ID = 'sales-facts'
|
||||
const LEDGER_POST_JOB_ID = 'ledger-post'
|
||||
const ORDER_SERVICE_AUTO_PUBLISH_JOB_ID = 'order-service-auto-publish'
|
||||
const VOUCHER_CONSUME_DISPATCH_JOB_ID = 'voucher-consume-dispatch'
|
||||
|
||||
export function getScheduledJobsFilePath() {
|
||||
return SCHEDULED_JOBS_FILE_PATH
|
||||
@@ -93,6 +94,7 @@ export function normalizeScheduledJobsConfig(rawValue: unknown) {
|
||||
const hasOrderServiceAutoPublish = jobs.some(
|
||||
(item) => item.id === ORDER_SERVICE_AUTO_PUBLISH_JOB_ID,
|
||||
)
|
||||
const hasVoucherConsumeDispatch = jobs.some((item) => item.id === VOUCHER_CONSUME_DISPATCH_JOB_ID)
|
||||
|
||||
if (!hasWorkOrderTimeout) {
|
||||
jobs.push(createDefaultWorkOrderTimeoutJob())
|
||||
@@ -116,6 +118,7 @@ export function normalizeScheduledJobsConfig(rawValue: unknown) {
|
||||
if (!hasSalesFacts) jobs.push(createDefaultSalesFactsJob())
|
||||
if (!hasLedgerPost) jobs.push(createDefaultLedgerPostJob())
|
||||
if (!hasOrderServiceAutoPublish) jobs.push(createDefaultOrderServiceAutoPublishJob())
|
||||
if (!hasVoucherConsumeDispatch) jobs.push(createDefaultVoucherConsumeDispatchJob())
|
||||
|
||||
return {
|
||||
enabled: typeof source.enabled === 'boolean' ? source.enabled : true,
|
||||
@@ -166,6 +169,9 @@ function normalizeScheduledJob(rawValue: unknown) {
|
||||
if (type === 'order_service_auto_publish') {
|
||||
return normalizeOrderServiceAutoPublishJob(rawValue)
|
||||
}
|
||||
if (type === 'voucher_consume_dispatch') {
|
||||
return normalizeVoucherConsumeDispatchJob(rawValue)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -237,6 +243,7 @@ function createDefaultScheduledJobsConfig() {
|
||||
createDefaultSalesFactsJob(),
|
||||
createDefaultLedgerPostJob(),
|
||||
createDefaultOrderServiceAutoPublishJob(),
|
||||
createDefaultVoucherConsumeDispatchJob(),
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -322,6 +329,19 @@ function normalizeOrderServiceAutoPublishJob(rawValue: JsonObject) {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeVoucherConsumeDispatchJob(rawValue: JsonObject) {
|
||||
const config = isPlainObject(rawValue.config) ? rawValue.config : {}
|
||||
return {
|
||||
id: VOUCHER_CONSUME_DISPATCH_JOB_ID,
|
||||
type: 'voucher_consume_dispatch',
|
||||
enabled: rawValue.enabled !== false,
|
||||
intervalSeconds: normalizeRangeInteger(rawValue.intervalSeconds, 5, 1, 60),
|
||||
config: {
|
||||
batchSize: normalizeRangeInteger(config.batchSize, 20, 1, 200),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeLedgerPostJob(rawValue: JsonObject) {
|
||||
const config = isPlainObject(rawValue.config) ? rawValue.config : {}
|
||||
return {
|
||||
@@ -385,6 +405,16 @@ function createDefaultOrderServiceAutoPublishJob() {
|
||||
}
|
||||
}
|
||||
|
||||
function createDefaultVoucherConsumeDispatchJob() {
|
||||
return {
|
||||
id: VOUCHER_CONSUME_DISPATCH_JOB_ID,
|
||||
type: 'voucher_consume_dispatch',
|
||||
enabled: true,
|
||||
intervalSeconds: 5,
|
||||
config: { batchSize: 20 },
|
||||
}
|
||||
}
|
||||
|
||||
function createDefaultSalesFactsJob() {
|
||||
return {
|
||||
id: SALES_FACTS_JOB_ID,
|
||||
|
||||
@@ -86,7 +86,15 @@ export async function runChannelAssignJob(job: JsonObject = {}) {
|
||||
export async function runCarrierDispatchJob(job: JsonObject = {}) {
|
||||
const config = jobConfig(job)
|
||||
const limit = clampInt(config.limit, 200, 1, 2000)
|
||||
const reconciled = await reconcilePendingCarriers({ limit: Math.min(limit, 200) })
|
||||
const reconcileCursor =
|
||||
config.carrierReconcileAfterId != null
|
||||
? Math.max(0, Math.trunc(Number(config.carrierReconcileAfterId)))
|
||||
: await readWatermarkItemId('carrier_reconcile')
|
||||
const reconciled = await reconcilePendingCarriers({
|
||||
limit: Math.min(limit, 200),
|
||||
afterId: reconcileCursor,
|
||||
})
|
||||
await touchWatermark('carrier_reconcile', { lastItemId: reconciled.nextCursor })
|
||||
const repairedAssigned = await reconcilePendingCarriers({
|
||||
limit: Math.min(limit, 200),
|
||||
includeAssignedMissingCarrier: true,
|
||||
@@ -335,7 +343,8 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) {
|
||||
message:
|
||||
`人工载体挂载 ${attached},缺配置 ${missingManual.rows.length},` +
|
||||
`缺履约载体 ${missingCarrier.rows.length},多载体冲突 ${conflictingCarriers.rows.length},异常收口 ${resolved},` +
|
||||
`自动转人工 ${fallback.switched},链路补载体 ${reconciled.workOrdersCreated + reconciled.affiliateTasksCreated + repairedAssigned.workOrdersCreated + repairedAssigned.affiliateTasksCreated},` +
|
||||
`自动转人工 ${fallback.switched},本轮补载体扫描 ${reconciled.scanned}、创建 ${reconciled.workOrdersCreated + reconciled.affiliateTasksCreated}、失败 ${reconciled.failed},` +
|
||||
`已分配补载体 ${repairedAssigned.workOrdersCreated + repairedAssigned.affiliateTasksCreated},` +
|
||||
`兜底 ${reconciled.fallbacksSelected + repairedAssigned.fallbacksSelected}`,
|
||||
attached,
|
||||
missingManual: missingManual.rows.length,
|
||||
@@ -541,6 +550,7 @@ const PIPELINE_RUNNERS: Record<
|
||||
(job?: JsonObject) => Promise<Record<string, unknown>>
|
||||
> = {
|
||||
channel_assign: runChannelAssignJob,
|
||||
carrier_reconcile: runCarrierDispatchJob,
|
||||
carrier_dispatch: runCarrierDispatchJob,
|
||||
product_normalize: runProductNormalizeJob,
|
||||
cost_snapshot: runCostSnapshotJob,
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { WatermarkPipeline } from '../watermark-store.js'
|
||||
|
||||
export const FINANCE_PIPELINES = [
|
||||
{ name: 'channel_assign', label: '渠道判定', jobId: 'channel-assign' },
|
||||
{ name: 'carrier_reconcile', label: '载体补建', jobId: 'carrier-dispatch' },
|
||||
{ name: 'carrier_dispatch', label: '履约载体', jobId: 'carrier-dispatch' },
|
||||
{ name: 'product_normalize', label: '商品归一', jobId: 'product-normalize' },
|
||||
{ name: 'cost_snapshot', label: '成本快照', jobId: 'cost-snapshot' },
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
runProductNormalizeJob,
|
||||
runSalesFactsJob,
|
||||
} from './finance-pipeline-jobs.js'
|
||||
import { runVoucherConsumeDispatchJob } from '../fulfillment/voucher-consume-queue-service.js'
|
||||
|
||||
const timers = new Map<string, NodeJS.Timeout>()
|
||||
const jobStates = new Map<string, JsonObject>()
|
||||
@@ -88,7 +89,7 @@ function rescheduleJob(job: JsonObject) {
|
||||
if (!jobId) return
|
||||
const latestJob = getScheduledJobById(jobId)
|
||||
if (!latestJob || latestJob.enabled !== true) return
|
||||
const intervalMs = Math.max(10, Number(latestJob.intervalSeconds || 20)) * 1000
|
||||
const intervalMs = Math.max(1, Number(latestJob.intervalSeconds || 20)) * 1000
|
||||
scheduleJob(latestJob, intervalMs)
|
||||
}
|
||||
|
||||
@@ -234,6 +235,11 @@ function dispatchJob(job: JsonObject) {
|
||||
if (job.type === 'order_service_auto_publish') {
|
||||
return runOrderServiceAutoPublishJobs(job)
|
||||
}
|
||||
if (job.type === 'voucher_consume_dispatch') {
|
||||
return runVoucherConsumeDispatchJob(
|
||||
job.config && typeof job.config === 'object' ? (job.config as Record<string, unknown>) : {},
|
||||
)
|
||||
}
|
||||
|
||||
throw createHttpError(`不支持的定时任务类型:${job.type}`, {
|
||||
statusCode: 400,
|
||||
|
||||
@@ -9,6 +9,7 @@ import { nowIso } from '../utils/time.js'
|
||||
|
||||
export type WatermarkPipeline =
|
||||
| 'channel_assign'
|
||||
| 'carrier_reconcile'
|
||||
| 'carrier_dispatch'
|
||||
| 'product_normalize'
|
||||
| 'cost_snapshot'
|
||||
|
||||
@@ -153,6 +153,8 @@ export async function createAdminMockWorkOrder(payload: JsonObject = {}) {
|
||||
? JSON.stringify({
|
||||
profileId: serviceProfile.profileId,
|
||||
categoryId: serviceProfile.categoryId,
|
||||
sourceCategoryId: serviceProfile.sourceCategoryId,
|
||||
inherited: serviceProfile.inherited,
|
||||
versionId: serviceProfile.versionId,
|
||||
versionNo: serviceProfile.versionNo,
|
||||
config: serviceProfile.config,
|
||||
|
||||
@@ -13,7 +13,10 @@ import {
|
||||
publishWorkerWalletRealtimeChange,
|
||||
publishWorkOrderRealtimeChange,
|
||||
} from '../realtime/realtime-event-service.js'
|
||||
import { consumeIndustryVouchersBeforeWorkOrderAssign } from './publish-voucher-service.js'
|
||||
import {
|
||||
scheduleVoucherConsumeForWorkOrder,
|
||||
type VoucherConsumeScheduleResult,
|
||||
} from '../fulfillment/voucher-consume-queue-service.js'
|
||||
import {
|
||||
mapWorkOrderAdmin,
|
||||
normalizeOptionalId,
|
||||
@@ -65,8 +68,7 @@ export async function assignAdminWorkOrderToWorker(
|
||||
})
|
||||
}
|
||||
|
||||
// 已有拼单份额的订单不能再按整单指派;放在核销电子凭证之前,
|
||||
// 避免“凭证已核销但指派失败”的副作用。
|
||||
// 已有拼单份额的订单不能再按整单指派。
|
||||
const activeShares = (await listWorkOrderShares(workOrder.id)).filter(
|
||||
(share) => share.status !== 'cancelled',
|
||||
)
|
||||
@@ -77,26 +79,6 @@ export async function assignAdminWorkOrderToWorker(
|
||||
})
|
||||
}
|
||||
|
||||
const voucherConsume = await consumeIndustryVouchersBeforeWorkOrderAssign(workOrder)
|
||||
if (!voucherConsume.ok) {
|
||||
const failedAt = nowIso()
|
||||
await createWorkOrderEvent({
|
||||
workOrderId: workOrder.id,
|
||||
actorType: 'admin',
|
||||
actorId: actorName,
|
||||
eventType: 'assign_voucher_consume_failed',
|
||||
fromStatus: workOrder.status,
|
||||
toStatus: workOrder.status,
|
||||
payloadJson: JSON.stringify({ workerId: worker.id, voucherConsume }),
|
||||
now: failedAt,
|
||||
})
|
||||
throw createHttpError(voucherConsume.errorMessage || '电子凭证核销失败,订单未指派', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_assign_voucher_consume_failed',
|
||||
context: voucherConsume,
|
||||
})
|
||||
}
|
||||
|
||||
const now = nowIso()
|
||||
const { order: updated, failureReason } = await assignWorkOrderToWorker({
|
||||
workOrderId: workOrder.id,
|
||||
@@ -108,38 +90,70 @@ export async function assignAdminWorkOrderToWorker(
|
||||
actorName,
|
||||
})
|
||||
if (failureReason === 'work_order_not_assignable') {
|
||||
throw createHttpError(
|
||||
resolveAssignFailureMessage(voucherConsume.voucherCount, '订单状态已变化,无法指派'),
|
||||
{ statusCode: 409, errorCode: 'work_order_not_assignable' },
|
||||
)
|
||||
throw createHttpError('订单状态已变化,无法指派', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_not_assignable',
|
||||
})
|
||||
}
|
||||
if (failureReason === 'worker_active_order_limit') {
|
||||
throw createHttpError(
|
||||
resolveAssignFailureMessage(voucherConsume.voucherCount, '该打手已达到最大同时接单量'),
|
||||
{ statusCode: 409, errorCode: 'worker_active_order_limit' },
|
||||
)
|
||||
throw createHttpError('该打手已达到最大同时接单量', {
|
||||
statusCode: 409,
|
||||
errorCode: 'worker_active_order_limit',
|
||||
})
|
||||
}
|
||||
if (failureReason === 'worker_deposit_insufficient') {
|
||||
throw createHttpError(
|
||||
resolveAssignFailureMessage(voucherConsume.voucherCount, '该打手余额不足,无法冻结所需押金'),
|
||||
{ statusCode: 409, errorCode: 'worker_deposit_insufficient' },
|
||||
)
|
||||
throw createHttpError('该打手余额不足,无法冻结所需押金', {
|
||||
statusCode: 409,
|
||||
errorCode: 'worker_deposit_insufficient',
|
||||
})
|
||||
}
|
||||
if (failureReason === 'work_order_has_shares') {
|
||||
throw createHttpError(
|
||||
resolveAssignFailureMessage(
|
||||
voucherConsume.voucherCount,
|
||||
'该订单已有拼单份额,不能按整单指派;请使用拼单管理',
|
||||
),
|
||||
{ statusCode: 409, errorCode: 'work_order_has_shares' },
|
||||
)
|
||||
throw createHttpError('该订单已有拼单份额,不能按整单指派;请使用拼单管理', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_has_shares',
|
||||
})
|
||||
}
|
||||
if (!updated) {
|
||||
throw createHttpError(
|
||||
resolveAssignFailureMessage(voucherConsume.voucherCount, '指派失败,订单状态可能已变化'),
|
||||
{ statusCode: 409, errorCode: 'work_order_assign_conflict' },
|
||||
)
|
||||
throw createHttpError('指派失败,订单状态可能已变化', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_assign_conflict',
|
||||
})
|
||||
}
|
||||
|
||||
let voucherConsume: VoucherConsumeScheduleResult
|
||||
try {
|
||||
voucherConsume = await scheduleVoucherConsumeForWorkOrder(updated, 'work_order_assigned')
|
||||
} catch (error) {
|
||||
voucherConsume = {
|
||||
ok: false,
|
||||
voucherCount: 0,
|
||||
consumedCount: 0,
|
||||
alreadyConsumedCount: 0,
|
||||
failedCount: 1,
|
||||
voucherCodes: [],
|
||||
errorMessage: error instanceof Error ? error.message : '安排自动核销失败',
|
||||
queuedCount: 0,
|
||||
jobIds: [],
|
||||
dueAt: '',
|
||||
status: 'failed',
|
||||
}
|
||||
}
|
||||
await createWorkOrderEvent({
|
||||
workOrderId: workOrder.id,
|
||||
actorType: 'admin',
|
||||
actorId: actorName,
|
||||
eventType:
|
||||
voucherConsume.status === 'queued'
|
||||
? 'assign_voucher_consume_scheduled'
|
||||
: voucherConsume.status === 'failed'
|
||||
? 'assign_voucher_consume_schedule_failed'
|
||||
: 'assign_voucher_consume_schedule_skipped',
|
||||
fromStatus: workOrder.status,
|
||||
toStatus: updated.status,
|
||||
payloadJson: JSON.stringify({ workerId: worker.id, voucherConsume }),
|
||||
now,
|
||||
}).catch(() => undefined)
|
||||
|
||||
publishWorkOrderRealtimeChange({
|
||||
workOrderId: Number(workOrder.id),
|
||||
workerIds: [Number(worker.id)],
|
||||
@@ -149,10 +163,6 @@ export async function assignAdminWorkOrderToWorker(
|
||||
return { order: mapWorkOrderAdmin(updated), voucherConsume }
|
||||
}
|
||||
|
||||
function resolveAssignFailureMessage(voucherCount: number, fallback: string) {
|
||||
return voucherCount > 0 ? `电子凭证已核销,但${fallback},请重试指派` : fallback
|
||||
}
|
||||
|
||||
function resolveAssignedDeadlineAt(timeoutMinutes: number, now: string): string | null {
|
||||
if (timeoutMinutes <= 0) return null
|
||||
return new Date(new Date(now).getTime() + timeoutMinutes * 60 * 1000).toISOString()
|
||||
|
||||
@@ -22,9 +22,9 @@ import { resolveAdminNotificationEntity } from '../admin/admin-notification-serv
|
||||
import { enqueueCustomerOrderNotification } from '../notification/customer-order-notification-service.js'
|
||||
import { publishWorkOrderRealtimeChange } from '../realtime/realtime-event-service.js'
|
||||
import {
|
||||
consumeIndustryVouchersBeforeWorkOrderPublish,
|
||||
type WorkOrderVoucherConsumeResult,
|
||||
} from './publish-voucher-service.js'
|
||||
scheduleVoucherConsumeForWorkOrder,
|
||||
type VoucherConsumeScheduleResult,
|
||||
} from '../fulfillment/voucher-consume-queue-service.js'
|
||||
import {
|
||||
mapWorkOrderAdmin,
|
||||
mapWorkOrderShare,
|
||||
@@ -347,7 +347,7 @@ export async function reopenAdminCancelledWorkOrder(workOrderId: number | string
|
||||
export async function publishAdminWorkOrder(
|
||||
workOrderId: number | string,
|
||||
actorName = '',
|
||||
options: { requireVoucherConsume?: boolean } = {},
|
||||
_options: { requireVoucherConsume?: boolean } = {},
|
||||
) {
|
||||
const workOrder = await getRequiredWorkOrder(workOrderId)
|
||||
if (workOrder.status !== WORK_ORDER_STATUS.UNASSIGNED) {
|
||||
@@ -364,39 +364,6 @@ export async function publishAdminWorkOrder(
|
||||
}
|
||||
|
||||
const now = nowIso()
|
||||
let voucherConsume: WorkOrderVoucherConsumeResult
|
||||
try {
|
||||
voucherConsume = await consumeIndustryVouchersBeforeWorkOrderPublish(workOrder)
|
||||
} catch (error) {
|
||||
voucherConsume = {
|
||||
ok: false,
|
||||
voucherCount: 0,
|
||||
consumedCount: 0,
|
||||
alreadyConsumedCount: 0,
|
||||
failedCount: 1,
|
||||
voucherCodes: [],
|
||||
errorMessage: error instanceof Error ? error.message : '电子凭证核销失败',
|
||||
}
|
||||
}
|
||||
if (!voucherConsume.ok) {
|
||||
await createWorkOrderEvent({
|
||||
workOrderId: workOrder.id,
|
||||
actorType: 'system',
|
||||
actorId: 'kuaishou_send_code',
|
||||
eventType: 'publish_voucher_consume_failed',
|
||||
fromStatus: workOrder.status,
|
||||
toStatus: workOrder.status,
|
||||
payloadJson: JSON.stringify(voucherConsume),
|
||||
now: nowIso(),
|
||||
})
|
||||
if (options.requireVoucherConsume === true) {
|
||||
throw createHttpError(voucherConsume.errorMessage || '电子凭证核销失败,订单未发布', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_publish_voucher_consume_failed',
|
||||
context: { voucherConsume },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const published = await publishUnassignedWorkOrder({
|
||||
workOrderId: workOrder.id,
|
||||
@@ -411,6 +378,39 @@ export async function publishAdminWorkOrder(
|
||||
}
|
||||
|
||||
const currentOrder = await getRequiredWorkOrder(workOrder.id)
|
||||
let voucherConsume: VoucherConsumeScheduleResult
|
||||
try {
|
||||
voucherConsume = await scheduleVoucherConsumeForWorkOrder(currentOrder, 'work_order_published')
|
||||
} catch (error) {
|
||||
voucherConsume = {
|
||||
ok: false,
|
||||
voucherCount: 0,
|
||||
consumedCount: 0,
|
||||
alreadyConsumedCount: 0,
|
||||
failedCount: 1,
|
||||
voucherCodes: [],
|
||||
errorMessage: error instanceof Error ? error.message : '安排自动核销失败',
|
||||
queuedCount: 0,
|
||||
jobIds: [],
|
||||
dueAt: '',
|
||||
status: 'failed',
|
||||
}
|
||||
}
|
||||
await createWorkOrderEvent({
|
||||
workOrderId: currentOrder.id,
|
||||
actorType: 'system',
|
||||
actorId: 'voucher_consume_policy',
|
||||
eventType:
|
||||
voucherConsume.status === 'queued'
|
||||
? 'publish_voucher_consume_scheduled'
|
||||
: voucherConsume.status === 'failed'
|
||||
? 'publish_voucher_consume_schedule_failed'
|
||||
: 'publish_voucher_consume_schedule_skipped',
|
||||
fromStatus: currentOrder.status,
|
||||
toStatus: currentOrder.status,
|
||||
payloadJson: JSON.stringify(voucherConsume),
|
||||
now: nowIso(),
|
||||
}).catch(() => undefined)
|
||||
publishWorkOrderRealtimeChange({ workOrderId: Number(workOrder.id), hallChanged: true })
|
||||
await enqueueCustomerOrderNotification({
|
||||
orderId: currentOrder.order_id,
|
||||
|
||||
@@ -47,9 +47,9 @@ import { assignAdminWorkOrderToWorker } from './admin-work-order-assignment-serv
|
||||
import { unassignAdminWorkOrder } from './admin-work-order-cancellation-service.js'
|
||||
import { mapWorkOrderAdmin } from './mappers.js'
|
||||
import {
|
||||
consumeIndustryVouchersBeforeWorkOrderPublish,
|
||||
type WorkOrderVoucherConsumeResult,
|
||||
} from './publish-voucher-service.js'
|
||||
scheduleVoucherConsumeForWorkOrder,
|
||||
type VoucherConsumeScheduleResult,
|
||||
} from '../fulfillment/voucher-consume-queue-service.js'
|
||||
import {
|
||||
type ConsignmentMatchOption,
|
||||
type ConsignmentMatchResult,
|
||||
@@ -383,7 +383,7 @@ export async function fulfillWorkOrderWithConsignment(input: {
|
||||
}): Promise<{
|
||||
order: ReturnType<typeof mapWorkOrderAdmin>
|
||||
inventory: ReturnType<typeof mapConsignmentInventory>
|
||||
voucherConsume: WorkOrderVoucherConsumeResult
|
||||
voucherConsume: VoucherConsumeScheduleResult
|
||||
}> {
|
||||
const workOrder = await getWorkOrderById(input.workOrderId)
|
||||
if (!workOrder) {
|
||||
@@ -576,13 +576,10 @@ export async function fulfillWorkOrderWithConsignment(input: {
|
||||
return { updatedOrder: wo, updatedInventory: inv }
|
||||
})
|
||||
|
||||
// 顺带核销快手电子凭证。
|
||||
// 核销要走快手外部接口、无法和上面的数据库事务合并回滚,因此这里只能做两件事:
|
||||
// 1) 失败时不吞错:写事件留痕,并把 voucherConsume.ok=false 交给前端提示运营人工核销;
|
||||
// 2) 绝不因为"凭证没核销"就谎报成功(此前前端无条件弹成功提示,属于假成功)。
|
||||
let voucherConsume: WorkOrderVoucherConsumeResult
|
||||
// 行内发货完成后安排延迟核销,外部平台调用不阻塞当前履约事务。
|
||||
let voucherConsume: VoucherConsumeScheduleResult
|
||||
try {
|
||||
voucherConsume = await consumeIndustryVouchersBeforeWorkOrderPublish(updatedOrder)
|
||||
voucherConsume = await scheduleVoucherConsumeForWorkOrder(updatedOrder, 'work_order_assigned')
|
||||
} catch (error) {
|
||||
voucherConsume = {
|
||||
ok: false,
|
||||
@@ -591,7 +588,11 @@ export async function fulfillWorkOrderWithConsignment(input: {
|
||||
alreadyConsumedCount: 0,
|
||||
failedCount: 1,
|
||||
voucherCodes: [],
|
||||
errorMessage: error instanceof Error ? error.message : '电子凭证核销失败',
|
||||
errorMessage: error instanceof Error ? error.message : '安排自动核销失败',
|
||||
queuedCount: 0,
|
||||
jobIds: [],
|
||||
dueAt: '',
|
||||
status: 'failed',
|
||||
}
|
||||
}
|
||||
if (!voucherConsume.ok) {
|
||||
@@ -599,8 +600,13 @@ export async function fulfillWorkOrderWithConsignment(input: {
|
||||
await createWorkOrderEvent({
|
||||
workOrderId: Number(updatedOrder.id),
|
||||
actorType: 'system',
|
||||
actorId: 'kuaishou_send_code',
|
||||
eventType: 'consignment_fulfill_voucher_consume_failed',
|
||||
actorId: 'voucher_consume_policy',
|
||||
eventType:
|
||||
voucherConsume.status === 'queued'
|
||||
? 'consignment_fulfill_voucher_consume_scheduled'
|
||||
: voucherConsume.status === 'failed'
|
||||
? 'consignment_fulfill_voucher_consume_schedule_failed'
|
||||
: 'consignment_fulfill_voucher_consume_schedule_skipped',
|
||||
fromStatus: String(updatedOrder.status),
|
||||
toStatus: String(updatedOrder.status),
|
||||
payloadJson: JSON.stringify(voucherConsume),
|
||||
@@ -668,7 +674,7 @@ export async function assignWorkOrderWithConsignmentWorker(input: {
|
||||
}): Promise<{
|
||||
order: ReturnType<typeof mapWorkOrderAdmin>
|
||||
inventory: ReturnType<typeof mapConsignmentInventory>
|
||||
voucherConsume: WorkOrderVoucherConsumeResult
|
||||
voucherConsume: VoucherConsumeScheduleResult
|
||||
}> {
|
||||
const workOrder = await getWorkOrderById(input.workOrderId)
|
||||
if (!workOrder) {
|
||||
|
||||
@@ -87,9 +87,9 @@ test('降价提示:未到时长不提示,到点后按比例给出建议售
|
||||
assert.equal(suggestion?.dropHours, 24)
|
||||
})
|
||||
|
||||
test('迁移 129 必须包含寄卖配置、库存数量/售价与分配表', async () => {
|
||||
test('迁移 135 必须包含寄卖配置、库存数量/售价与分配表', async () => {
|
||||
const source = await readFile(
|
||||
new URL('../../db/migrations/129_consignment_matching.sql', import.meta.url),
|
||||
new URL('../../db/migrations/135_consignment_matching.sql', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
for (const fragment of [
|
||||
@@ -101,6 +101,6 @@ test('迁移 129 必须包含寄卖配置、库存数量/售价与分配表', as
|
||||
'internal_match_deadline_at',
|
||||
'consignment_inventory_id',
|
||||
]) {
|
||||
assert.ok(source.includes(fragment), `迁移 129 缺少 ${fragment}`)
|
||||
assert.ok(source.includes(fragment), `迁移 135 缺少 ${fragment}`)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -34,7 +34,7 @@ function option(label: string, value: string) {
|
||||
return { label, value, enabled: true }
|
||||
}
|
||||
|
||||
/** 苏尔南手枪种子配置的五个字段(与迁移 131 一致)。 */
|
||||
/** 苏尔南手枪种子配置的五个字段(与迁移 137 一致)。 */
|
||||
function surnanSpecs(): ConsignmentSpecField[] {
|
||||
return [
|
||||
{
|
||||
@@ -423,9 +423,9 @@ test('动态规格:旧列仍由兼容 key 同步', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('迁移 130 必须包含 JSON、匹配 key、文本与改价上限', async () => {
|
||||
test('迁移 136 必须包含 JSON、匹配 key、文本与改价上限', async () => {
|
||||
const sql = await readFile(
|
||||
new URL('../../db/migrations/130_consignment_dynamic_specs.sql', import.meta.url),
|
||||
new URL('../../db/migrations/136_consignment_dynamic_specs.sql', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
for (const fragment of [
|
||||
@@ -435,14 +435,14 @@ test('迁移 130 必须包含 JSON、匹配 key、文本与改价上限', async
|
||||
'price_cap_fen',
|
||||
'idx_consignment_inventories_spec_key',
|
||||
]) {
|
||||
assert.ok(sql.includes(fragment), `迁移 130 缺少 ${fragment}`)
|
||||
assert.ok(sql.includes(fragment), `迁移 136 缺少 ${fragment}`)
|
||||
}
|
||||
assert.ok(parseConsignmentSpecValues({ system: '苹果', diamondLevel: 3 }).system === '苹果')
|
||||
})
|
||||
|
||||
test('迁移 131 必须为苏尔南手枪写入已发布的五个单选字段配置', async () => {
|
||||
test('迁移 137 必须为苏尔南手枪写入已发布的五个单选字段配置', async () => {
|
||||
const sql = await readFile(
|
||||
new URL('../../db/migrations/131_consignment_surnan_default_profile.sql', import.meta.url),
|
||||
new URL('../../db/migrations/137_consignment_surnan_default_profile.sql', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
for (const fragment of [
|
||||
@@ -452,6 +452,6 @@ test('迁移 131 必须为苏尔南手枪写入已发布的五个单选字段配
|
||||
"'published'",
|
||||
'"key": "brillianceCount"',
|
||||
]) {
|
||||
assert.ok(sql.includes(fragment), `迁移 131 缺少 ${fragment}`)
|
||||
assert.ok(sql.includes(fragment), `迁移 137 缺少 ${fragment}`)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -5,7 +5,10 @@ import {
|
||||
isOrderServiceMaterialRequired,
|
||||
normalizeProfileConfig,
|
||||
} from './order-service-profile-service.js'
|
||||
import { backfillPendingOrderServiceSnapshotsForCategory } from '../../repositories/worker-platform/order-service-profile-repo.js'
|
||||
import {
|
||||
backfillPendingOrderServiceSnapshotsForCategory,
|
||||
getPublishedOrderServiceProfileByCategoryWithInheritance,
|
||||
} from '../../repositories/worker-platform/order-service-profile-repo.js'
|
||||
|
||||
function config(patch: Record<string, unknown> = {}) {
|
||||
return normalizeProfileConfig({
|
||||
@@ -181,4 +184,40 @@ test('发布配置:待提交且无有效拼单份额的工单可更新到最
|
||||
assert.equal(count, 2)
|
||||
assert.doesNotMatch(capturedSql, /service_profile_snapshot_json IS NULL/)
|
||||
assert.match(capturedSql, /share\.status <> 'cancelled'/)
|
||||
assert.match(capturedSql, /WITH affected_categories/)
|
||||
assert.match(capturedSql, /child_profile\.status <> 'archived'/)
|
||||
assert.match(capturedSql, /wo\.category_id IN/)
|
||||
assert.match(capturedSql, /'sourceCategoryId', p\.category_id/)
|
||||
assert.match(capturedSql, /'inherited', wo\.category_id <> p\.category_id/)
|
||||
})
|
||||
|
||||
test('订单服务配置:二级分类优先,未发布时沿分类链继承父级', async () => {
|
||||
let capturedSql = ''
|
||||
const client = {
|
||||
query: async (sql: string) => {
|
||||
capturedSql = sql
|
||||
return {
|
||||
rows: [
|
||||
{
|
||||
profile_id: 9,
|
||||
category_id: 3,
|
||||
source_category_id: 3,
|
||||
version_id: 19,
|
||||
version_no: 4,
|
||||
config_json: { fields: [] },
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
} as unknown as Parameters<typeof getPublishedOrderServiceProfileByCategoryWithInheritance>[1]
|
||||
|
||||
const resolved = await getPublishedOrderServiceProfileByCategoryWithInheritance(81, client)
|
||||
|
||||
assert.equal(resolved?.profile_id, 9)
|
||||
assert.equal(resolved?.category_id, 3)
|
||||
assert.equal(resolved?.source_category_id, 3)
|
||||
assert.equal(resolved?.version_no, 4)
|
||||
assert.match(capturedSql, /WITH RECURSIVE category_chain/)
|
||||
assert.match(capturedSql, /ORDER BY chain\.depth ASC/)
|
||||
assert.match(capturedSql, /p\.status <> 'archived'/)
|
||||
})
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
backfillPendingOrderServiceSnapshotsForCategory,
|
||||
createOrderServiceProfileVersion,
|
||||
getOrderServiceProfileVersion,
|
||||
getPublishedOrderServiceProfileByCategory,
|
||||
getPublishedOrderServiceProfileByCategoryWithInheritance,
|
||||
listOrderServiceProfiles,
|
||||
setOrderServiceProfileCurrentVersion,
|
||||
upsertOrderServiceProfile,
|
||||
@@ -83,6 +83,8 @@ export type OrderServiceProfileConfig = {
|
||||
export type OrderServiceProfileSnapshot = {
|
||||
profileId: number
|
||||
categoryId: number
|
||||
sourceCategoryId: number
|
||||
inherited: boolean
|
||||
versionId: number
|
||||
versionNo: number
|
||||
config: OrderServiceProfileConfig
|
||||
@@ -307,11 +309,14 @@ export async function getPublishedOrderServiceProfileSnapshotByCategory(
|
||||
categoryId: number | string,
|
||||
client?: Pick<PoolClient, 'query'>,
|
||||
): Promise<OrderServiceProfileSnapshot | null> {
|
||||
const row = await getPublishedOrderServiceProfileByCategory(categoryId, client)
|
||||
const row = await getPublishedOrderServiceProfileByCategoryWithInheritance(categoryId, client)
|
||||
if (!row) return null
|
||||
const targetCategoryId = Number(categoryId)
|
||||
return {
|
||||
profileId: Number(row.profile_id),
|
||||
categoryId: Number(row.category_id),
|
||||
categoryId: targetCategoryId,
|
||||
sourceCategoryId: Number(row.source_category_id),
|
||||
inherited: Number(row.source_category_id) !== targetCategoryId,
|
||||
versionId: Number(row.version_id),
|
||||
versionNo: Number(row.version_no),
|
||||
config: normalizeProfileConfig(row.config_json),
|
||||
@@ -329,6 +334,8 @@ export async function getOrderServiceProfileSnapshotByVersion(input: {
|
||||
return {
|
||||
profileId: Number(input.profileId),
|
||||
categoryId: Number(input.categoryId),
|
||||
sourceCategoryId: Number(input.categoryId),
|
||||
inherited: false,
|
||||
versionId: Number(version.id),
|
||||
versionNo: Number(version.version_no),
|
||||
config: normalizeProfileConfig(version.config_json),
|
||||
|
||||
@@ -564,6 +564,13 @@ async function resolveProfileConfig(
|
||||
})
|
||||
if (snapshotFromVersion) return snapshotFromVersion.config
|
||||
}
|
||||
if (order.category_id) {
|
||||
const published = await getPublishedOrderServiceProfileSnapshotByCategory(
|
||||
Number(order.category_id),
|
||||
client,
|
||||
)
|
||||
if (published) return published.config
|
||||
}
|
||||
const legacy = safeParseJson(order.requirement_json)
|
||||
if (Array.isArray(legacy.fields)) {
|
||||
return {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { query } from '../../db/client.js'
|
||||
* - 订单不存在(91 未到/缺失):用快手数据兜底建单 + 建工单 + 凭证回填
|
||||
*
|
||||
* 幂等依据:orders 唯一键 provider+platform+shop_id+platform_order_id
|
||||
* (由 91 侧查重逻辑保证同 oid 单条记录)、work_orders.order_item_id 唯一索引、
|
||||
* (由 91 侧查重逻辑保证同 oid 单条记录)、work_orders 未取消工单对 order_item_id 的唯一索引、
|
||||
* listWorkOrdersByPlatformOrderId 查重。
|
||||
*
|
||||
* 本服务只写 orders / order_items / work_orders,不触碰
|
||||
|
||||
@@ -125,6 +125,7 @@ export async function syncWorkerOrdersForSourceOrder(
|
||||
sourceMetadata?: JsonObject
|
||||
skipChannelAssign?: boolean
|
||||
preferredWorkRuleKey?: string
|
||||
retryCancelledWorkOrder?: boolean
|
||||
} = {},
|
||||
) {
|
||||
const [rules, ruleMappings] = await Promise.all([
|
||||
@@ -252,7 +253,10 @@ export async function syncWorkerOrdersForSourceOrder(
|
||||
|
||||
// 已有工单:跳过匹配日志,避免 PDD 定时同步对旧单反复刷「命中」日志。
|
||||
const existingWorkOrder = await getWorkOrderByOrderItemId(item.id)
|
||||
if (existingWorkOrder) {
|
||||
if (
|
||||
existingWorkOrder &&
|
||||
!(options.retryCancelledWorkOrder === true && existingWorkOrder.status === 'cancelled')
|
||||
) {
|
||||
skipped.push({ orderItemId: Number(item.id), reason: 'already_exists' })
|
||||
decisions.push(
|
||||
buildWorkerConsumerDecision(item, {
|
||||
@@ -425,6 +429,8 @@ export async function syncWorkerOrdersForSourceOrder(
|
||||
? JSON.stringify({
|
||||
profileId: serviceProfile.profileId,
|
||||
categoryId: serviceProfile.categoryId,
|
||||
sourceCategoryId: serviceProfile.sourceCategoryId,
|
||||
inherited: serviceProfile.inherited,
|
||||
versionId: serviceProfile.versionId,
|
||||
versionNo: serviceProfile.versionNo,
|
||||
config: serviceProfile.config,
|
||||
|
||||
@@ -2554,14 +2554,15 @@ test('履约与指派两条路径都要过服务端规格复检(不只依赖
|
||||
assert.ok(src.includes('consignment_match_note_required'))
|
||||
})
|
||||
|
||||
test('内部履约的电子凭证核销失败要写事件留痕(不能静默假成功)', async () => {
|
||||
test('内部履约要投递延迟核销任务并写事件留痕', async () => {
|
||||
const readFile = await import('node:fs/promises')
|
||||
const src = await readFile.readFile(
|
||||
new URL('./consignment-fulfillment-service.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
assert.ok(src.includes('consignment_fulfill_voucher_consume_failed'))
|
||||
assert.ok(src.includes('consignment_fulfill_voucher_consume_scheduled'))
|
||||
assert.ok(src.includes('scheduleVoucherConsumeForWorkOrder'))
|
||||
assert.ok(src.includes('if (!voucherConsume.ok)'))
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user