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

This commit is contained in:
yml2213
2026-11-05 16:14:56 +08:00
parent 548070c2ad
commit 4e83dbb84c
43 changed files with 1582 additions and 334 deletions
@@ -1,7 +1,7 @@
-- Persistent delayed voucher consumption jobs.
-- 延迟核销任务。
--
-- Business events only enqueue a job. A scheduler worker performs the external
-- platform call later, so a restart cannot lose a delayed consumption.
-- 业务事件只负责投递任务,真正的外部平台核销由定时任务稍后执行;
-- 任务持久化到数据库,服务重启也不会丢失延迟核销。
CREATE TABLE IF NOT EXISTS voucher_consume_jobs (
id BIGSERIAL PRIMARY KEY,
@@ -29,8 +29,8 @@ CREATE TABLE IF NOT EXISTS voucher_consume_jobs (
finished_at TIMESTAMPTZ
);
-- Keep one active/successful target per provider. Failed jobs may be replaced
-- by a later business event, while succeeded jobs suppress duplicate events.
-- 同一平台、同一个核销目标只保留一个活跃或已成功任务。
-- 失败任务允许后续业务事件重新投递;成功任务阻止重复投递。
CREATE UNIQUE INDEX IF NOT EXISTS uq_voucher_consume_jobs_active_target
ON voucher_consume_jobs(provider, target_key)
WHERE status IN ('pending', 'processing', 'succeeded');
@@ -45,6 +45,6 @@ CREATE INDEX IF NOT EXISTS idx_voucher_consume_jobs_updated
ON voucher_consume_jobs(updated_at DESC, id DESC);
COMMENT ON TABLE voucher_consume_jobs IS
'Delayed voucher verification jobs; events enqueue, scheduler executes';
'延迟核销任务:业务事件负责投递,定时任务负责执行';
COMMENT ON COLUMN voucher_consume_jobs.target_key IS
'Stable dedupe target: order-item when available, otherwise task/order';
'稳定去重目标:优先订单项,没有订单项时使用任务或订单';
@@ -0,0 +1,18 @@
-- 允许待履约订单项保留已经选择的履约渠道。
--
-- pending + 渠道:渠道已确定,但载体、商品或成本配置尚未完成;
-- assigned:真实履约载体已经创建完成。
ALTER TABLE order_items
DROP CONSTRAINT IF EXISTS chk_order_items_fulfillment_channel_status;
ALTER TABLE order_items
ADD CONSTRAINT chk_order_items_fulfillment_channel_status
CHECK (
fulfillment_status = 'pending'
OR (fulfillment_status = 'assigned' AND fulfillment_channel IS NOT NULL)
OR fulfillment_status = 'archived'
);
COMMENT ON CONSTRAINT chk_order_items_fulfillment_channel_status ON order_items IS
'pending 允许保留已选择渠道并等待配置完成;assigned 必须存在履约渠道';
@@ -0,0 +1,93 @@
-- 132_on_account_and_manual_channels.sql
--
-- 新增“统一结算”履约渠道,并支持一个内部 SKU 配置多个人工履约渠道。
-- 客服确认履约时选择本次实际渠道;统一结算只生成虚拟账单,不扣积分或备用金。
-- 1. 订单项履约渠道增加 on_account。
ALTER TABLE order_items
DROP CONSTRAINT IF EXISTS chk_order_items_fulfillment_channel;
ALTER TABLE order_items
ADD CONSTRAINT chk_order_items_fulfillment_channel
CHECK (
fulfillment_channel IS NULL
OR fulfillment_channel IN (
'work_order', 'affiliate_dash', 'third_party_voucher', 'manual', 'on_account'
)
);
-- 2. 内部 SKU 渠道配置增加 on_account。
ALTER TABLE internal_sku_channels
DROP CONSTRAINT IF EXISTS internal_sku_channels_channel_check;
ALTER TABLE internal_sku_channels
ADD CONSTRAINT internal_sku_channels_channel_check
CHECK (channel IN ('work_order', 'affiliate_dash', 'third_party_voucher', 'manual', 'on_account'));
-- 3. 自动核销任务允许记录 on_account 渠道。
ALTER TABLE voucher_consume_jobs
DROP CONSTRAINT IF EXISTS voucher_consume_jobs_channel_check;
ALTER TABLE voucher_consume_jobs
ADD CONSTRAINT voucher_consume_jobs_channel_check
CHECK (channel IN ('affiliate_dash', 'work_order', 'manual', 'third_party_voucher', 'on_account'));
-- 4. 人工 SKU 配置支持多个渠道明细,旧单渠道字段继续保留用于兼容。
ALTER TABLE manual_fulfillment_configs
ADD COLUMN IF NOT EXISTS channels_json JSONB NOT NULL DEFAULT '[]'::jsonb;
ALTER TABLE manual_fulfillment_configs
DROP CONSTRAINT IF EXISTS chk_manual_fulfillment_configs_platform;
ALTER TABLE manual_fulfillment_configs
ADD CONSTRAINT chk_manual_fulfillment_configs_platform
CHECK (platform_key IN ('nanhang', 'lewan', 'rock', 'fund_cash', 'on_account'));
UPDATE manual_fulfillment_configs
SET channels_json = jsonb_build_array(
jsonb_build_object(
'platformKey', platform_key,
'creditQuantity', credit_quantity,
'costMode', cost_mode,
'enabled', enabled
)
)
WHERE channels_json = '[]'::jsonb;
COMMENT ON COLUMN manual_fulfillment_configs.channels_json IS
'人工履约渠道明细:platformKey / creditQuantity / costMode / enabled;旧 platform_key 字段保留兼容';
-- 5. 统一结算虚拟账单。后续统一对账以该表为待办真源。
CREATE TABLE IF NOT EXISTS on_account_bills (
id BIGSERIAL PRIMARY KEY,
bill_no TEXT NOT NULL UNIQUE,
order_id BIGINT NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
order_item_id BIGINT NOT NULL REFERENCES order_items(id) ON DELETE CASCADE,
sku_key TEXT NOT NULL DEFAULT '',
canonical_product_id BIGINT REFERENCES product_catalog(id) ON DELETE SET NULL,
platform_order_id TEXT NOT NULL DEFAULT '',
amount_fen BIGINT NOT NULL CHECK (amount_fen >= 0),
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'reconciled', 'void')),
payee_name TEXT NOT NULL DEFAULT '',
note TEXT NOT NULL DEFAULT '',
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_by_admin_user_id BIGINT REFERENCES admin_users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
reconciled_at TIMESTAMPTZ
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_on_account_bills_order_item
ON on_account_bills(order_item_id);
CREATE INDEX IF NOT EXISTS idx_on_account_bills_status_created
ON on_account_bills(status, created_at DESC, id DESC);
CREATE INDEX IF NOT EXISTS idx_on_account_bills_order
ON on_account_bills(order_id, order_item_id);
COMMENT ON TABLE on_account_bills IS
'统一结算虚拟账单:确认履约时生成,不扣客服积分或备用金,后续统一对账';
COMMENT ON COLUMN on_account_bills.status IS
'pending 待对账 / reconciled 已对账 / void 已作废';
@@ -0,0 +1,21 @@
-- 133_manual_credit_quantity_decimal.sql
--
-- 人工履约的现金金额支持最多 2 位小数;积分渠道仍只接受整数。
-- 成本金额仍按整数分保存,避免把金额精度扩散到财务快照和总账。
ALTER TABLE manual_fulfillment_configs
ALTER COLUMN credit_quantity TYPE NUMERIC(18, 2)
USING ROUND(credit_quantity::numeric, 2);
COMMENT ON COLUMN manual_fulfillment_configs.credit_quantity IS
'每单消耗量;积分渠道必须为整数,备用金现金/统一结算为元且最多 2 位小数;0 表示成本不固定';
ALTER TABLE manual_fulfillment_configs
DROP CONSTRAINT IF EXISTS chk_manual_fulfillment_configs_integer_credit;
ALTER TABLE manual_fulfillment_configs
ADD CONSTRAINT chk_manual_fulfillment_configs_integer_credit
CHECK (
platform_key IN ('fund_cash', 'on_account')
OR credit_quantity = TRUNC(credit_quantity)
);
@@ -8,9 +8,9 @@
import { Router } from 'express'
import {
assignChannelByInternalSku,
assignChannelBySku,
assignChannelToOrderItems,
assignPendingChannelGroup,
deletePendingChannelGroup,
deleteChannelRule,
deleteChannelRules,
@@ -150,17 +150,18 @@ router.post(
createJsonHandler(
(req) =>
deletePendingChannelGroup({
groupKey: String(bodyOf(req).groupKey || ''),
internalSku: String(bodyOf(req).internalSku || ''),
adminUserId: req.adminSession?.userId ?? null,
}),
{
successMessage: '待分类记录已删除',
errorMessage: '删除待分类记录失败',
successMessage: '待分类记录已忽略归档',
errorMessage: '归档待分类记录失败',
scope: '[admin/fulfillment/pending/delete]',
audit: (req, data) => ({
action: 'fulfillment.delete_pending_group',
targetType: 'internal_sku',
targetId: String(bodyOf(req).internalSku || ''),
action: 'fulfillment.archive_pending_group',
targetType: 'pending_group',
targetId: String(bodyOf(req).groupKey || bodyOf(req).internalSku || ''),
data: data as Record<string, unknown>,
}),
},
@@ -307,13 +308,14 @@ router.post(
const adminUserId = req.adminSession?.userId ?? null
const note = String(body.note || '')
if (body.internalSku) {
return assignChannelByInternalSku({
internalSku: String(body.internalSku),
if (body.groupKey || body.internalSku) {
return assignPendingChannelGroup({
groupKey: String(body.groupKey || ''),
internalSku: String(body.internalSku || ''),
mappedInternalSku: String(body.mappedInternalSku || ''),
channel,
adminUserId,
note,
createRule: body.createRule !== false,
})
}
@@ -341,8 +343,14 @@ router.post(
scope: '[admin/fulfillment/assign]',
audit: (req, data) => ({
action: 'fulfillment.assign_channel',
targetType: 'order_item',
targetId: String(bodyOf(req).skuCode || bodyOf(req).orderItemId || ''),
targetType: bodyOf(req).groupKey ? 'pending_group' : 'order_item',
targetId: String(
bodyOf(req).groupKey ||
bodyOf(req).internalSku ||
bodyOf(req).skuCode ||
bodyOf(req).orderItemId ||
'',
),
data: { payload: bodyOf(req), result: data as Record<string, unknown> },
}),
},
@@ -144,6 +144,7 @@ const ORDER_ITEM_FULFILLMENT_CHANNELS = new Set<AdminOrderItemFulfillmentMode>([
'third_party_voucher',
'legacy_platform',
'manual',
'on_account',
'pending',
'archived',
])
@@ -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,
}
}
@@ -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]
@@ -21,6 +21,17 @@ test('人工成本:固定成本按配置积分 × 平台换算率', () => {
)
})
test('人工成本:备用金和统一结算金额支持两位小数', () => {
assert.deepEqual(
resolveManualFulfillmentCost({
creditQuantity: 12.34,
unitPriceFen: 100,
providedCostFen: undefined,
}),
{ costFen: 1234, costMode: 'fixed' },
)
})
test('人工成本:成本不固定必须手填正数金额', () => {
assert.deepEqual(
resolveManualFulfillmentCost({
@@ -17,6 +17,11 @@ import {
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'
/**
* 人工履约待办与「确认履约」。
@@ -37,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 = {
@@ -55,6 +146,7 @@ type PendingRow = {
credit_quantity: string
cost_mode: string
credit_unit_price_fen: string
config_channels_json: unknown
fulfilled_at: string | null
}
@@ -67,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
@@ -76,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
@@ -91,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,
@@ -173,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
@@ -187,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) },
}
}
@@ -204,6 +300,7 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm
'请选择订单项',
)
const now = nowIso()
const platformUnitPrices = await loadManualPlatformUnitPrices()
// 钱与成本必须同一事务:先锁订单项、复查未履约,再扣款、最后写快照。
// 任何一步失败(余额不足、成本版本缺失、重复快照)都整体回滚,不会留下「扣了钱没记成本」。
@@ -223,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,
@@ -284,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 }>(
@@ -306,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)
@@ -342,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')
@@ -457,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,
@@ -469,7 +632,7 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm
WHERE id = $1
AND fulfillment_status <> 'archived'
`,
[orderItemId, now],
[orderItemId, now, finalChannel],
)
// 人工缺成本异常立即收口,不等下一轮定时任务。
@@ -508,6 +671,7 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm
fundExpenseId,
creditEntryId,
balanceAfter,
onAccountBillId,
}
})
@@ -754,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 ||
@@ -779,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),
@@ -795,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,
}
@@ -182,6 +182,10 @@ export async function ensureCarrierForOrderItem(input: {
return { ready: true, created: false, reason: 'carrier_not_required', message: '' }
}
if (channel === 'on_account') {
return { ready: true, created: false, reason: 'carrier_not_required', message: '' }
}
if (channel === 'work_order') {
if (await hasWorkOrder(Number(item.id))) {
return { ready: true, created: false, reason: 'work_order_exists', message: '' }
@@ -213,7 +213,7 @@ export function resolveChannelDecision(item: ChannelCandidateItem): ChannelDecis
pendingReason: '',
}
}
if (plan.primary.channel === 'third_party_voucher') {
if (plan.primary.channel === 'third_party_voucher' || plan.primary.channel === 'on_account') {
return {
channel: plan.primary.channel,
source: 'auto_mapping',
@@ -419,13 +419,14 @@ export async function assignPendingOrderItemChannels(
AS input(id, channel, source)
WHERE oi.id = input.id
AND oi.fulfillment_status = 'pending'
AND oi.fulfillment_locked = FALSE
AND (oi.fulfillment_locked = FALSE OR $4::boolean)
RETURNING oi.id
`,
[
assignments.map((row) => row.id),
assignments.map((row) => row.channel),
assignments.map((row) => row.source),
options.includeLocked === true,
],
)
const assignedItemIds = updated.rows
@@ -456,11 +457,18 @@ export async function assignOrderItemsAfterCarrierReady(input: {
source: FulfillmentSource
lock: boolean
note?: string | undefined
}): Promise<{ updated: number; carrierFailed: number }> {
canonicalProductId?: number | null
}): Promise<{
updated: number
carrierFailed: number
carrierFailures: Array<{ orderItemId: number; reason: string; message: string }>
}> {
const ids = [
...new Set(input.orderItemIds.map(Number).filter((id) => Number.isInteger(id) && id > 0)),
]
if (ids.length === 0) return { updated: 0, carrierFailed: 0 }
if (ids.length === 0) {
return { updated: 0, carrierFailed: 0, carrierFailures: [] }
}
const items = (
await query<OrderItemRow>(`SELECT * FROM order_items WHERE id = ANY($1::bigint[])`, [ids])
@@ -473,6 +481,7 @@ export async function assignOrderItemsAfterCarrierReady(input: {
)
const assignedIds: number[] = []
let carrierFailed = 0
const carrierFailures: Array<{ orderItemId: number; reason: string; message: string }> = []
for (const item of items) {
if (item.fulfillment_status === 'archived') continue
@@ -515,6 +524,11 @@ export async function assignOrderItemsAfterCarrierReady(input: {
if (!carrier.ready) {
carrierFailed += 1
carrierFailures.push({
orderItemId: Number(item.id),
reason: carrier.reason,
message: carrier.message,
})
if (item.fulfillment_status === 'assigned') {
// 运营切换渠道失败时恢复原渠道,宁可保留旧状态,也不制造渠道与载体不一致。
await query(
@@ -542,12 +556,13 @@ export async function assignOrderItemsAfterCarrierReady(input: {
} else {
await query(
`UPDATE order_items
SET fulfillment_channel = NULL,
SET fulfillment_channel = $3,
fulfillment_status = 'pending',
fulfillment_source = '',
fulfillment_source = 'operator_assigned',
fulfillment_locked = TRUE,
fulfillment_pending_reason = 'no_carrier',
fulfillment_pending_detail = $2::jsonb,
canonical_product_id = COALESCE($4, canonical_product_id),
updated_at = NOW()
WHERE id = $1 AND fulfillment_status <> 'archived'`,
[
@@ -558,6 +573,8 @@ export async function assignOrderItemsAfterCarrierReady(input: {
message: carrier.message,
carrierIssue: carrier.reason,
}),
input.channel,
input.canonicalProductId || null,
],
)
}
@@ -570,12 +587,20 @@ export async function assignOrderItemsAfterCarrierReady(input: {
fulfillment_status = 'assigned',
fulfillment_source = $3,
fulfillment_locked = $4,
canonical_product_id = COALESCE($6, canonical_product_id),
fulfillment_reason = COALESCE(NULLIF($5, ''), fulfillment_reason),
fulfillment_pending_reason = NULL,
fulfillment_pending_detail = NULL,
updated_at = NOW()
WHERE id = $1 AND fulfillment_status <> 'archived'`,
[item.id, input.channel, input.source, input.lock, String(input.note || '').slice(0, 200)],
[
item.id,
input.channel,
input.source,
input.lock,
String(input.note || '').slice(0, 200),
input.canonicalProductId || null,
],
)
if ((updated.rowCount || 0) > 0) assignedIds.push(Number(item.id))
}
@@ -611,7 +636,11 @@ export async function assignOrderItemsAfterCarrierReady(input: {
[ids, input.channel, input.source, input.lock],
)
await syncAssignedChannelSnapshots(ids, input.channel, input.source)
return { updated: Number(final.rows[0]?.count || assignedIds.length), carrierFailed }
return {
updated: Number(final.rows[0]?.count || assignedIds.length),
carrierFailed,
carrierFailures,
}
}
async function syncAssignedChannelSnapshots(
@@ -646,7 +675,9 @@ async function syncAssignedChannelSnapshots(
reason:
channel === 'work_order'
? '内部 SKU 渠道配置指定接单平台'
: '内部 SKU 渠道配置指定第三方卡券',
: channel === 'on_account'
? '内部 SKU 渠道配置指定统一结算'
: '内部 SKU 渠道配置指定第三方卡券',
matchedRule: null,
candidates: [],
skipped: [],
@@ -963,7 +994,9 @@ export async function unlockChannel(orderItemIds: number[]): Promise<{ updated:
}
export type PendingGroupRow = {
internal_sku: string
group_key: string
scope_type: 'internal_sku' | 'source_sku'
internal_sku: string | null
sku_name: string
providers: string[]
shop_ids: string[]
@@ -980,7 +1013,7 @@ export type PendingGroupRow = {
}
type PendingShopNameRow = {
internal_sku: string
group_key: string
provider: string
shop_id: string
shop_name: string
@@ -989,6 +1022,20 @@ type PendingShopNameRow = {
type PendingGroupDbRow = Omit<PendingGroupRow, 'shops'>
const PENDING_GROUP_KEY_SQL = `
CASE
WHEN COALESCE(BTRIM(${INTERNAL_SKU_SQL}), '') <> ''
THEN 'sku:' || encode(convert_to(${INTERNAL_SKU_SQL}, 'UTF8'), 'hex')
ELSE 'source:' || encode(
convert_to(
CONCAT_WS(CHR(31), o.provider, o.shop_id, oi.sku_code),
'UTF8'
),
'hex'
)
END
`
/** 待分类队列:按全局内部 SKU 聚合(跨平台、跨店铺合并为一行)。 */
export async function listPendingChannelGroups(input: {
page?: number
@@ -1010,13 +1057,16 @@ export async function listPendingChannelGroups(input: {
const total = await query<{ count: number }>(
`
SELECT COUNT(*)::int AS count FROM (
SELECT 1
WITH pending_items AS (
SELECT
${INTERNAL_SKU_SQL} AS internal_sku,
${PENDING_GROUP_KEY_SQL} AS group_key
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
WHERE ${where}
GROUP BY ${INTERNAL_SKU_SQL}
) grouped
)
SELECT COUNT(DISTINCT group_key)::int AS count
FROM pending_items
`,
params,
)
@@ -1024,86 +1074,116 @@ export async function listPendingChannelGroups(input: {
params.push(pageSize, (page - 1) * pageSize)
const rows = await query<PendingGroupDbRow>(
`
WITH pending_items AS (
SELECT
oi.id,
oi.order_id,
oi.sku_code,
oi.sku_name,
oi.fulfillment_locked,
oi.fulfillment_pending_reason,
oi.fulfillment_pending_detail,
oi.revenue_amount_fen,
oi.created_at,
o.provider,
o.shop_id,
${INTERNAL_SKU_SQL} AS internal_sku,
${PENDING_GROUP_KEY_SQL} AS group_key
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
WHERE ${where}
)
SELECT
${INTERNAL_SKU_SQL} AS internal_sku,
MIN(oi.sku_name) AS sku_name,
array_agg(DISTINCT o.provider) AS providers,
array_agg(DISTINCT o.shop_id) AS shop_ids,
array_agg(DISTINCT oi.sku_code) AS sku_codes,
array_remove(array_agg(DISTINCT oi.fulfillment_pending_reason), NULL) AS pending_reasons,
pi.group_key,
CASE WHEN pi.internal_sku <> '' THEN 'internal_sku' ELSE 'source_sku' END AS scope_type,
pi.internal_sku,
MIN(pi.sku_name) AS sku_name,
array_agg(DISTINCT pi.provider) AS providers,
array_agg(DISTINCT pi.shop_id) AS shop_ids,
array_agg(DISTINCT pi.sku_code) AS sku_codes,
array_remove(array_agg(DISTINCT pi.fulfillment_pending_reason), NULL) AS pending_reasons,
array_remove(
array_agg(
DISTINCT COALESCE(
oi.fulfillment_pending_detail->>'carrierIssue',
oi.fulfillment_pending_detail->>'reason'
pi.fulfillment_pending_detail->>'carrierIssue',
pi.fulfillment_pending_detail->>'reason'
)
),
NULL
) AS pending_subreasons,
COALESCE(cfg.channels, ARRAY[]::text[]) AS configured_channels,
COUNT(*) FILTER (WHERE oi.fulfillment_locked)::int AS locked_item_count,
COUNT(*) FILTER (WHERE pi.fulfillment_locked)::int AS locked_item_count,
COUNT(*)::int AS item_count,
COALESCE(SUM(oi.revenue_amount_fen), 0)::bigint AS revenue_fen,
MIN(oi.created_at) AS first_seen_at,
MAX(oi.created_at) AS last_seen_at
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
COALESCE(SUM(pi.revenue_amount_fen), 0)::bigint AS revenue_fen,
MIN(pi.created_at) AS first_seen_at,
MAX(pi.created_at) AS last_seen_at
FROM pending_items pi
LEFT JOIN LATERAL (
SELECT array_agg(DISTINCT channel.channel ORDER BY channel.channel) AS channels
FROM internal_sku_channels channel
WHERE channel.enabled = TRUE
AND canonicalize_internal_sku(channel.internal_sku) =
${INTERNAL_SKU_SQL}
pi.internal_sku
) cfg ON TRUE
WHERE ${where}
GROUP BY ${INTERNAL_SKU_SQL}, cfg.channels
GROUP BY pi.group_key, pi.internal_sku, cfg.channels
ORDER BY item_count DESC, internal_sku
LIMIT $${params.length - 1} OFFSET $${params.length}
`,
params,
)
const internalSkus = rows.rows.map((row) => row.internal_sku)
const groupKeys = rows.rows.map((row) => row.group_key)
const shopsBySku = new Map<string, Array<{ id: string; name: string }>>()
if (internalSkus.length > 0) {
if (groupKeys.length > 0) {
const shopRows = await query<PendingShopNameRow>(
`
WITH pending_items AS (
SELECT
oi.id,
o.provider,
o.shop_id,
o.shop_name,
oi.sku_code,
${INTERNAL_SKU_SQL} AS internal_sku,
${PENDING_GROUP_KEY_SQL} AS group_key
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
WHERE oi.fulfillment_status = 'pending'
)
SELECT
${INTERNAL_SKU_SQL} AS internal_sku,
o.provider,
o.shop_id,
COALESCE(NULLIF(BTRIM(o.shop_name), ''), o.shop_id) AS shop_name,
group_key,
provider,
shop_id,
COALESCE(NULLIF(BTRIM(shop_name), ''), shop_id) AS shop_name,
COUNT(*)::int AS usage_count
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
WHERE oi.fulfillment_status = 'pending'
AND ${INTERNAL_SKU_SQL} = ANY($1::text[])
AND BTRIM(o.shop_id) <> ''
GROUP BY ${INTERNAL_SKU_SQL}, o.provider, o.shop_id, o.shop_name
FROM pending_items
WHERE group_key = ANY($1::text[])
AND BTRIM(shop_id) <> ''
GROUP BY group_key, provider, shop_id, shop_name
ORDER BY
internal_sku,
group_key,
provider,
shop_id,
CASE
WHEN BTRIM(o.shop_name) = '' OR BTRIM(o.shop_name) = o.shop_id THEN 3
WHEN BTRIM(o.shop_name) = '91卡券' OR BTRIM(o.shop_name) LIKE '快手店铺-%' THEN 2
WHEN BTRIM(o.shop_name) ~ '[一-龥]' THEN 0
WHEN BTRIM(shop_name) = '' OR BTRIM(shop_name) = shop_id THEN 3
WHEN BTRIM(shop_name) = '91卡券' OR BTRIM(shop_name) LIKE '快手店铺-%' THEN 2
WHEN BTRIM(shop_name) ~ '[一-龥]' THEN 0
ELSE 1
END,
usage_count DESC,
shop_name
`,
[internalSkus],
[groupKeys],
)
const seen = new Set<string>()
for (const row of shopRows.rows) {
const shopKey = `${row.internal_sku}\u0000${row.shop_id}`
const shopKey = `${row.group_key}\u0000${row.shop_id}`
if (seen.has(shopKey)) continue
seen.add(shopKey)
const shops = shopsBySku.get(row.internal_sku) || []
const shops = shopsBySku.get(row.group_key) || []
shops.push({ id: row.shop_id, name: row.shop_name || row.shop_id })
shopsBySku.set(row.internal_sku, shops)
shopsBySku.set(row.group_key, shops)
}
}
@@ -1111,7 +1191,7 @@ export async function listPendingChannelGroups(input: {
items: rows.rows.map((row) => ({
...row,
shops:
shopsBySku.get(row.internal_sku) ||
shopsBySku.get(row.group_key) ||
row.shop_ids.map((shopId) => ({ id: shopId, name: shopId })),
})),
total: total.rows[0]?.count || 0,
@@ -1225,23 +1305,67 @@ export type DeletePendingChannelGroupResult = {
skippedClosedDays: number
}
type PendingGroupScope =
| { type: 'internal_sku'; internalSku: string }
| { type: 'source_sku'; provider: string; shopId: string; skuCode: string }
function resolvePendingGroupScopeInput(input: {
groupKey?: string
internalSku?: string
}): PendingGroupScope | null {
const groupKey = String(input.groupKey || '').trim()
if (groupKey) {
const match = /^(sku|source):([0-9a-f]+)$/i.exec(groupKey)
if (!match) return null
const scopeType = String(match[1] || '').toLowerCase()
const encoded = String(match[2] || '')
if (!encoded || encoded.length % 2 !== 0) return null
const decoded = Buffer.from(encoded, 'hex').toString('utf8')
if (scopeType === 'sku') {
const internalSku = canonicalizeInternalSku(decoded)
return internalSku ? { type: 'internal_sku', internalSku } : null
}
const parts = decoded.split(String.fromCharCode(31))
if (parts.length !== 3 || !parts[0]) return null
return {
type: 'source_sku',
provider: parts[0],
shopId: parts[1] || '',
skuCode: parts[2] || '',
}
}
const internalSku = canonicalizeInternalSku(input.internalSku)
return internalSku ? { type: 'internal_sku', internalSku } : null
}
/**
* 删除待分类 SKU:只归档订单项并清理其成本/异常,不删除原始订单。
* 已关账月份拒绝操作,避免已固化财务口径与底层数据不一致。
* 已关账月份只跳过事实重算,不再阻止历史待办归档。
*/
export async function deletePendingChannelGroup(input: {
internalSku: string
groupKey?: string
internalSku?: string
adminUserId: number | null
}): Promise<DeletePendingChannelGroupResult> {
const internalSku = canonicalizeInternalSku(input.internalSku)
if (!internalSku || !containsCjk(internalSku)) {
throw createHttpError('必须使用包含中文的内部 SKU', {
const scope = resolvePendingGroupScopeInput(input)
if (!scope) {
throw createHttpError('缺少待分类队列分组标识', {
statusCode: 400,
errorCode: 'internal_sku_required',
errorCode: 'pending_group_key_required',
})
}
const result = await withTransaction(async (client) => {
const itemParams: unknown[] = []
const itemWhere = ["oi.fulfillment_status = 'pending'"]
if (scope.type === 'internal_sku') {
itemParams.push(scope.internalSku)
itemWhere.push(`${INTERNAL_SKU_SQL} = $${itemParams.length}`)
} else {
itemParams.push(scope.provider, scope.shopId, scope.skuCode)
itemWhere.push(`o.provider = $1 AND o.shop_id = $2 AND oi.sku_code = $3`)
}
const items = await client.query<{ id: number; order_id: number; basis_date: string }>(
`
SELECT
@@ -1251,12 +1375,11 @@ export async function deletePendingChannelGroup(input: {
AS basis_date
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
WHERE oi.fulfillment_status = 'pending'
AND ${INTERNAL_SKU_SQL} = $1
WHERE ${itemWhere.join(' AND ')}
ORDER BY oi.id
FOR UPDATE OF oi
`,
[internalSku],
itemParams,
)
const itemIds = items.rows.map((row) => Number(row.id))
if (itemIds.length === 0) {
@@ -1269,29 +1392,6 @@ export async function deletePendingChannelGroup(input: {
}
}
const periodKeys = [
...new Set(items.rows.map((row) => String(row.basis_date || '').slice(0, 7)).filter(Boolean)),
]
const closedPeriods = await client.query<{ period_key: string }>(
`
SELECT period_key
FROM finance_periods
WHERE status = 'closed'
AND period_key = ANY($1::text[])
ORDER BY period_key
`,
[periodKeys],
)
if (closedPeriods.rows.length > 0) {
throw createHttpError(
`包含已关账期间(${closedPeriods.rows.map((row) => row.period_key).join('、')}),不能删除`,
{
statusCode: 409,
errorCode: 'finance_period_closed',
},
)
}
const snapshots = await client.query(
`DELETE FROM cost_snapshots WHERE order_item_id = ANY($1::bigint[])`,
[itemIds],
@@ -1368,6 +1468,71 @@ export async function deletePendingChannelGroup(input: {
}
}
export async function assignPendingChannelGroup(input: {
groupKey?: string
internalSku?: string
mappedInternalSku?: string
channel: string
adminUserId: number | null
note?: string | undefined
}): Promise<{
updated: number
ruleCreated: boolean
carrierFailed: number
channelSaved: boolean
draftProductId: number | null
}> {
const scope = resolvePendingGroupScopeInput(input)
if (!scope) {
throw createHttpError('缺少待分类队列分组标识', {
statusCode: 400,
errorCode: 'pending_group_key_required',
})
}
if (scope.type !== 'internal_sku') {
const mappedInternalSku = canonicalizeInternalSku(input.mappedInternalSku)
if (!mappedInternalSku || !containsCjk(mappedInternalSku)) {
throw createHttpError('该来源 SKU 缺少内部 SKU,请先填写包含中文的内部 SKU', {
statusCode: 409,
errorCode: 'internal_sku_mapping_required',
})
}
const updated = await query(
`
UPDATE order_items oi
SET spec_json = COALESCE(oi.spec_json, '{}'::jsonb)
|| jsonb_build_object('skuNick', $4::text),
updated_at = NOW()
FROM orders o
WHERE o.id = oi.order_id
AND o.provider = $1
AND o.shop_id = $2
AND oi.sku_code = $3
AND oi.fulfillment_status = 'pending'
`,
[scope.provider, scope.shopId, scope.skuCode, mappedInternalSku],
)
if ((updated.rowCount || 0) === 0) {
throw createHttpError('待分类来源 SKU 已发生变化,请刷新后重试', {
statusCode: 409,
errorCode: 'pending_group_stale',
})
}
return assignChannelByInternalSku({
internalSku: mappedInternalSku,
channel: input.channel,
adminUserId: input.adminUserId,
note: input.note,
})
}
return assignChannelByInternalSku({
internalSku: scope.internalSku,
channel: input.channel,
adminUserId: input.adminUserId,
note: input.note,
})
}
/** 按内部 SKU 批量指定(同一中文 SKU 跨平台码一起分流)。 */
export async function assignChannelByInternalSku(input: {
internalSku: string
@@ -1375,7 +1540,14 @@ export async function assignChannelByInternalSku(input: {
adminUserId: number | null
note?: string | undefined
createRule?: boolean | undefined
}): Promise<{ updated: number; ruleCreated: boolean; carrierFailed: number }> {
}): Promise<{
updated: number
ruleCreated: boolean
carrierFailed: number
channelSaved: boolean
draftProductId: number | null
carrierFailures: Array<{ orderItemId: number; reason: string; message: string }>
}> {
if (!isFulfillmentChannel(input.channel)) {
throw createHttpError('渠道无效', { statusCode: 400, errorCode: 'invalid_channel' })
}
@@ -1408,6 +1580,16 @@ export async function assignChannelByInternalSku(input: {
[internalSku, input.channel],
)
const draftProductId = await ensureDraftProductForChannel(internalSku, input.channel).catch(
(error) => {
logWarn('[channel-assign]', '自动创建渠道草稿商品失败', {
internalSku,
channel: input.channel,
error: error instanceof Error ? error.message : String(error),
})
return null
},
)
const carrier = await resolveInternalSkuCarrierConfig(internalSku, input.channel)
await setInternalSkuPrimaryChannel({
internalSku,
@@ -1431,6 +1613,7 @@ export async function assignChannelByInternalSku(input: {
source: 'operator_assigned',
lock: true,
note: input.note,
canonicalProductId: draftProductId,
})
await query(
`UPDATE order_items
@@ -1439,7 +1622,12 @@ export async function assignChannelByInternalSku(input: {
[ids, input.adminUserId],
)
return { ...applied, ruleCreated }
return {
...applied,
ruleCreated,
channelSaved: true,
draftProductId,
}
}
/** 按平台 SKU 定位内部 SKU 后全局指定渠道。 */
@@ -1597,6 +1785,72 @@ export async function listChannelRulesPage(
}
}
async function ensureDraftProductForChannel(
internalSku: string,
channel: FulfillmentChannel,
): Promise<number | null> {
const key = canonicalizeInternalSku(internalSku)
if (!key) return null
const definition = {
affiliate_dash: { prefix: 'aff:', category: '自动发货', unit: '件' },
work_order: { prefix: 'tpl:', category: '接单', unit: '件' },
manual: { prefix: 'man:', category: '人工', unit: '件' },
on_account: { prefix: 'man:', category: '统一结算', unit: '件' },
third_party_voucher: { prefix: 'tpv:', category: '卡券', unit: '张' },
}[channel]
const canonicalSku = `${definition.prefix}${key}`
if (canonicalSku.length > 64) return null
const now = nowIso()
const result = await query<{ id: number; status: string }>(
`
INSERT INTO product_catalog (
canonical_sku,
canonical_name,
category,
unit_name,
status,
attributes_json,
created_at,
updated_at
) VALUES (
$1, $2, $3, $4, 'active',
$5::jsonb, $6, $6
)
ON CONFLICT (canonical_sku) DO UPDATE
SET updated_at = product_catalog.updated_at
RETURNING id, status
`,
[
canonicalSku,
key.slice(0, 120),
definition.category,
definition.unit,
JSON.stringify({
source: channel,
auto_created: true,
draft: true,
pending_cost_config: true,
}),
now,
],
)
const product = result.rows[0]
const productId = Number(product?.id || 0)
if (!productId || product?.status !== 'active') return null
await query(
`
INSERT INTO product_channel_capabilities (
product_id, channel, priority, is_fallback, enabled, source
) VALUES ($1, $2, 10, FALSE, TRUE, 'auto')
ON CONFLICT (product_id, channel) DO NOTHING
`,
[productId, channel],
)
return productId
}
async function resolveInternalSkuCarrierConfig(
internalSku: string,
channel: string,
@@ -1625,7 +1879,13 @@ async function resolveInternalSkuCarrierConfig(
}
const prefixes =
channel === 'affiliate_dash' ? ['aff:'] : channel === 'work_order' ? ['tpl:', 'wo:'] : ['tpv:']
channel === 'affiliate_dash'
? ['aff:']
: channel === 'work_order'
? ['tpl:', 'wo:']
: channel === 'on_account'
? ['man:']
: ['tpv:']
const product = await query<{ id: number; canonical_sku: string }>(
`SELECT id, canonical_sku
FROM product_catalog
@@ -1663,7 +1923,9 @@ async function resolveInternalSkuCarrierConfig(
? { affiliateSku: carrierKey }
: channel === 'work_order'
? { workRuleKey: carrierKey }
: { voucherSku: carrierKey },
: channel === 'on_account'
? { settlementSku: carrierKey }
: { voucherSku: carrierKey },
}
}
@@ -12,6 +12,7 @@ export const FULFILLMENT_CHANNELS = [
'affiliate_dash',
'third_party_voucher',
'manual',
'on_account',
] as const
export type FulfillmentChannel = (typeof FULFILLMENT_CHANNELS)[number]
@@ -51,6 +52,7 @@ export const CHANNEL_LABELS: Record<FulfillmentChannel, string> = {
affiliate_dash: '自动发货',
third_party_voucher: '第三方卡券',
manual: '人工客服',
on_account: '统一结算',
}
export const MATCH_KEY_TYPES = ['sku_code', 'internal_sku', 'sku_name'] as const
@@ -64,13 +66,17 @@ export type RuleSource = (typeof RULE_SOURCES)[number]
/** 渠道是否需要履约载体(卡券渠道不需要)。 */
export function channelNeedsCarrier(channel: FulfillmentChannel | null | undefined): boolean {
if (!channel) return false
return channel !== 'third_party_voucher'
return channel !== 'third_party_voucher' && channel !== 'on_account'
}
/** 渠道是否参与成本核算。 */
export function channelHasCostModel(channel: FulfillmentChannel | null | undefined): boolean {
return (
channel === 'affiliate_dash' || channel === 'work_order' || channel === 'third_party_voucher'
channel === 'affiliate_dash' ||
channel === 'work_order' ||
channel === 'third_party_voucher' ||
channel === 'manual' ||
channel === 'on_account'
)
}
@@ -200,7 +200,9 @@ export function applyIntakeChannelPlan(
reason:
channel === 'work_order'
? '内部 SKU 渠道配置指定接单平台'
: '内部 SKU 渠道配置指定第三方卡券',
: channel === 'on_account'
? '内部 SKU 渠道配置指定统一结算'
: '内部 SKU 渠道配置指定第三方卡券',
configured: false,
})
}
@@ -57,6 +57,7 @@ export const INTERNAL_SKU_CHANNEL_ORDER: readonly FulfillmentChannel[] = [
'work_order',
'third_party_voucher',
'manual',
'on_account',
]
export function compareFulfillmentChannel(a: FulfillmentChannel, b: FulfillmentChannel): number {
@@ -10,6 +10,7 @@ export const VOUCHER_CONSUME_CHANNELS = [
'work_order',
'manual',
'third_party_voucher',
'on_account',
] as const
export type VoucherConsumeChannel = (typeof VOUCHER_CONSUME_CHANNELS)[number]
@@ -150,6 +151,11 @@ function createDefaultPlatformPolicy(): VoucherPlatformConsumePolicy {
mode: 'after_order_received',
delaySeconds: 5,
},
on_account: {
enabled: false,
mode: 'manual',
delaySeconds: 0,
},
},
}
}
@@ -189,6 +195,10 @@ function normalizePlatformPolicy(
fallback.channels.third_party_voucher,
['manual', 'after_order_received'],
),
on_account: normalizeChannelPolicy(rawChannels.on_account, fallback.channels.on_account, [
'manual',
'after_order_received',
]),
},
}
}
@@ -858,7 +858,8 @@ function normalizeVoucherChannel(value: unknown): VoucherConsumeChannel | null {
channel === 'affiliate_dash' ||
channel === 'work_order' ||
channel === 'manual' ||
channel === 'third_party_voucher'
channel === 'third_party_voucher' ||
channel === 'on_account'
) {
return channel
}
@@ -13,6 +13,7 @@ export type AdminOrderItemFulfillmentMode =
| 'affiliate_dash'
| 'legacy_platform'
| 'manual'
| 'on_account'
| 'pending'
export type AdminOrderFulfillmentMode = AdminOrderItemFulfillmentMode | 'mixed'