feat(fulfillment): 增加统一结算与多渠道人工履约
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -323,7 +323,7 @@ const CANDIDATE_SQL = `
|
||||
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], '保存人工履约配置失败')
|
||||
})
|
||||
@@ -776,7 +808,7 @@ async function attachCostVersionsForManualConfigs() {
|
||||
)
|
||||
SELECT
|
||||
$1, 'product', '', '', cfg.canonical_product_id, NULL,
|
||||
(cfg.credit_quantity * (fp.config_json->>'rechargeUnitPriceFen')::bigint), 0,
|
||||
ROUND(cfg.credit_quantity * (fp.config_json->>'rechargeUnitPriceFen')::bigint, 0)::bigint,
|
||||
'1970-01-01T00:00:00+08:00', 'manual', 'active',
|
||||
'人工履约配置:积分按平台换算率折算',
|
||||
NOW(), NOW()
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user