feat(fulfillment): 增加统一结算与多渠道人工履约
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
fetchAdminManualFulfillmentPending,
|
||||
fetchAdminPlatformCreditOwners,
|
||||
} from '@/services/admin'
|
||||
import type { ManualFulfillmentPendingItem } from '@/types/admin'
|
||||
import type { ManualConfiguredChannel, ManualFulfillmentPendingItem } from '@/types/admin'
|
||||
import { getAdminRole, getAdminUserId } from '@/utils/admin-auth'
|
||||
|
||||
type ConfirmFormValues = {
|
||||
@@ -67,6 +67,7 @@ export function ManualFulfillmentConfirmModal({
|
||||
const queryClient = useQueryClient()
|
||||
const [form] = Form.useForm<ConfirmFormValues>()
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null)
|
||||
const [selectedPlatformKey, setSelectedPlatformKey] = useState<string | null>(null)
|
||||
const [fundAccountId, setFundAccountId] = useState<number | null>(null)
|
||||
const [creditOwnerAdminUserId, setCreditOwnerAdminUserId] = useState<number | null>(null)
|
||||
const role = getAdminRole()
|
||||
@@ -91,30 +92,51 @@ export function ManualFulfillmentConfirmModal({
|
||||
items.find((item) => item.orderItemId === initialOrderItemId) ||
|
||||
items[0] ||
|
||||
null
|
||||
const configuredChannels = selected?.channels || []
|
||||
const selectedChannel: ManualConfiguredChannel | null =
|
||||
configuredChannels.find((channel) => channel.platformKey === selectedPlatformKey) ||
|
||||
configuredChannels[0] ||
|
||||
null
|
||||
|
||||
const fundAccountsQuery = useQuery({
|
||||
queryKey: ['admin-manual-confirm-fund-accounts'],
|
||||
queryFn: () => fetchAdminFundAccounts({ page: 1, pageSize: 100 }),
|
||||
enabled: open && privileged && Boolean(selected?.isFundCash),
|
||||
enabled: open && privileged && selectedChannel?.isFundCash === true,
|
||||
retry: false,
|
||||
})
|
||||
const creditOwnersQuery = useQuery({
|
||||
queryKey: ['admin-manual-confirm-credit-owners'],
|
||||
queryFn: () => fetchAdminPlatformCreditOwners(),
|
||||
enabled: open && privileged && Boolean(selected) && !selected?.isFundCash,
|
||||
enabled:
|
||||
open &&
|
||||
privileged &&
|
||||
Boolean(selectedChannel) &&
|
||||
selectedChannel?.isFundCash !== true &&
|
||||
selectedChannel?.isOnAccount !== true,
|
||||
retry: false,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setSelectedId(initialOrderItemId || null)
|
||||
setSelectedPlatformKey(null)
|
||||
form.resetFields()
|
||||
}, [open, initialOrderItemId, form])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !selected) return
|
||||
setSelectedPlatformKey((current) => {
|
||||
if (current && selected.channels.some((channel) => channel.platformKey === current)) {
|
||||
return current
|
||||
}
|
||||
return selected.channels[0]?.platformKey || selected.platformKey || null
|
||||
})
|
||||
}, [open, selected?.orderItemId, selected?.channels, selected?.platformKey])
|
||||
|
||||
// 代录时默认选本人账户;客服没有代录权限,不展示选择。
|
||||
useEffect(() => {
|
||||
if (!open || !privileged) return
|
||||
if (selected?.isFundCash) {
|
||||
if (selectedChannel?.isFundCash) {
|
||||
const accounts = (fundAccountsQuery.data?.data.items || []).filter(
|
||||
(item) => item.accountType === 'agent' && item.status === 'active',
|
||||
)
|
||||
@@ -122,6 +144,11 @@ export function ManualFulfillmentConfirmModal({
|
||||
setFundAccountId(own?.id ?? null)
|
||||
return
|
||||
}
|
||||
if (selectedChannel?.isOnAccount) {
|
||||
setCreditOwnerAdminUserId(null)
|
||||
setFundAccountId(null)
|
||||
return
|
||||
}
|
||||
const owners = creditOwnersQuery.data?.data.items || []
|
||||
const own = owners.find((item) => item.id === currentUserId)
|
||||
setCreditOwnerAdminUserId(own?.id ?? null)
|
||||
@@ -129,7 +156,8 @@ export function ManualFulfillmentConfirmModal({
|
||||
open,
|
||||
privileged,
|
||||
selected?.orderItemId,
|
||||
selected?.isFundCash,
|
||||
selectedChannel?.isFundCash,
|
||||
selectedChannel?.isOnAccount,
|
||||
currentUserId,
|
||||
fundAccountsQuery.data,
|
||||
creditOwnersQuery.data,
|
||||
@@ -139,18 +167,25 @@ export function ManualFulfillmentConfirmModal({
|
||||
mutationFn: (values: ConfirmFormValues) =>
|
||||
confirmAdminManualFulfillment({
|
||||
orderItemId: selected?.orderItemId || 0,
|
||||
platformKey: selectedChannel?.platformKey || selected?.platformKey || '',
|
||||
costFen: values.costYuan === undefined ? undefined : Math.round(values.costYuan * 100),
|
||||
payeeName: values.payeeName || '',
|
||||
note: values.note || '',
|
||||
...(privileged && selected?.isFundCash && fundAccountId ? { fundAccountId } : {}),
|
||||
...(privileged && selected && !selected.isFundCash && creditOwnerAdminUserId
|
||||
...(privileged && selectedChannel?.isFundCash && fundAccountId ? { fundAccountId } : {}),
|
||||
...(privileged &&
|
||||
selectedChannel &&
|
||||
!selectedChannel.isFundCash &&
|
||||
!selectedChannel.isOnAccount &&
|
||||
creditOwnerAdminUserId
|
||||
? { creditOwnerAdminUserId }
|
||||
: {}),
|
||||
}),
|
||||
onSuccess: (response) => {
|
||||
const result = response.data
|
||||
message.success(
|
||||
`履约已确认,扣 ${result.creditQuantity} 积分,成本 ¥${(result.costFen / 100).toFixed(2)}`,
|
||||
result.onAccountBillId
|
||||
? `履约已确认,已生成统一结算虚拟账单 ¥${(result.costFen / 100).toFixed(2)}`
|
||||
: `履约已确认,扣 ${result.creditQuantity} 积分,成本 ¥${(result.costFen / 100).toFixed(2)}`,
|
||||
)
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-manual-fulfillment-pending'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-order-detail'] })
|
||||
@@ -167,7 +202,8 @@ export function ManualFulfillmentConfirmModal({
|
||||
},
|
||||
})
|
||||
|
||||
const showCostInput = selected?.costFen === null
|
||||
const showCostInput = selectedChannel?.costFen === null
|
||||
const costRequired = showCostInput && selectedChannel?.isOnAccount !== true
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -178,9 +214,12 @@ export function ManualFulfillmentConfirmModal({
|
||||
icon: <CheckOutlined />,
|
||||
disabled:
|
||||
!selected ||
|
||||
!selectedChannel ||
|
||||
(privileged &&
|
||||
((selected.isFundCash && !fundAccountId) ||
|
||||
(!selected.isFundCash && !creditOwnerAdminUserId))),
|
||||
((selectedChannel.isFundCash && !fundAccountId) ||
|
||||
(!selectedChannel.isFundCash &&
|
||||
!selectedChannel.isOnAccount &&
|
||||
!creditOwnerAdminUserId))),
|
||||
}}
|
||||
cancelText="取消"
|
||||
confirmLoading={confirmMutation.isPending}
|
||||
@@ -227,12 +266,30 @@ export function ManualFulfillmentConfirmModal({
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text type="secondary">履约渠道:</Typography.Text>
|
||||
<Tag color={PLATFORM_TAG_COLORS[selected?.platformKey || ''] || 'default'}>
|
||||
{selected?.platformLabel || '-'}
|
||||
</Tag>
|
||||
{selected && !selected.isFundCash ? (
|
||||
<Typography.Text>消耗 {selected.creditQuantity} 积分</Typography.Text>
|
||||
<Typography.Text type="secondary">本次实际履约渠道:</Typography.Text>
|
||||
{selected && configuredChannels.length > 1 ? (
|
||||
<Select
|
||||
value={selectedChannel?.platformKey}
|
||||
onChange={(value) => {
|
||||
setSelectedPlatformKey(value)
|
||||
form.setFieldValue('costYuan', undefined)
|
||||
}}
|
||||
options={configuredChannels.map((channel) => ({
|
||||
value: channel.platformKey,
|
||||
label: channel.platformLabel,
|
||||
}))}
|
||||
style={{ width: '100%', marginTop: 6 }}
|
||||
/>
|
||||
) : (
|
||||
<Tag color={PLATFORM_TAG_COLORS[selectedChannel?.platformKey || ''] || 'default'}>
|
||||
{selectedChannel?.platformLabel || '-'}
|
||||
</Tag>
|
||||
)}
|
||||
{selectedChannel && !selectedChannel.isFundCash && !selectedChannel.isOnAccount ? (
|
||||
<Typography.Text>消耗 {selectedChannel.creditQuantity} 积分</Typography.Text>
|
||||
) : null}
|
||||
{selectedChannel?.isOnAccount ? (
|
||||
<Typography.Text>只生成虚拟账单,不扣积分或备用金</Typography.Text>
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
@@ -240,8 +297,8 @@ export function ManualFulfillmentConfirmModal({
|
||||
<Typography.Text>¥{((selected?.revenueFen || 0) / 100).toFixed(2)}</Typography.Text>
|
||||
</div>
|
||||
|
||||
{privileged && selected ? (
|
||||
selected.isFundCash ? (
|
||||
{privileged && selected && selectedChannel ? (
|
||||
selectedChannel.isFundCash ? (
|
||||
<div>
|
||||
<Typography.Text type="secondary">代录到客服备用金账户:</Typography.Text>
|
||||
<Select
|
||||
@@ -260,7 +317,7 @@ export function ManualFulfillmentConfirmModal({
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
) : selectedChannel.isOnAccount ? null : (
|
||||
<div>
|
||||
<Typography.Text type="secondary">代录到客服积分账户:</Typography.Text>
|
||||
<Select
|
||||
@@ -281,11 +338,19 @@ export function ManualFulfillmentConfirmModal({
|
||||
) : null}
|
||||
|
||||
{showCostInput ? (
|
||||
<Alert type="warning" showIcon message="该商品成本不固定,请填写本次实际成本" />
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message={
|
||||
selectedChannel?.isOnAccount
|
||||
? '统一结算金额可以先留空,后续统一对账时补录'
|
||||
: '该商品成本不固定,请填写本次实际成本'
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Statistic
|
||||
title="本次成本"
|
||||
value={(selected?.costFen || 0) / 100}
|
||||
value={(selectedChannel?.costFen || 0) / 100}
|
||||
precision={2}
|
||||
prefix="¥"
|
||||
/>
|
||||
@@ -295,11 +360,11 @@ export function ManualFulfillmentConfirmModal({
|
||||
{showCostInput ? (
|
||||
<Form.Item
|
||||
name="costYuan"
|
||||
label="实际成本(元)"
|
||||
rules={[{ required: true, message: '请填写实际成本' }]}
|
||||
label={selectedChannel?.isOnAccount ? '预计结算金额(元)' : '实际成本(元)'}
|
||||
rules={costRequired ? [{ required: true, message: '请填写实际成本' }] : []}
|
||||
>
|
||||
<InputNumber
|
||||
min={0.01}
|
||||
min={selectedChannel?.isOnAccount ? 0 : 0.01}
|
||||
precision={2}
|
||||
style={{ width: '100%' }}
|
||||
placeholder="例如 12.50"
|
||||
|
||||
@@ -68,6 +68,7 @@ const CHANNEL_OPTIONS = [
|
||||
{ value: 'affiliate_dash', label: '自动发货' },
|
||||
{ value: 'third_party_voucher', label: '第三方卡券' },
|
||||
{ value: 'manual', label: '人工客服' },
|
||||
{ value: 'on_account', label: '统一结算' },
|
||||
]
|
||||
|
||||
const CHANNEL_COLOR: Record<string, string> = {
|
||||
@@ -75,6 +76,7 @@ const CHANNEL_COLOR: Record<string, string> = {
|
||||
affiliate_dash: 'green',
|
||||
third_party_voucher: 'purple',
|
||||
manual: 'orange',
|
||||
on_account: 'gold',
|
||||
}
|
||||
|
||||
const CHANNEL_SOURCE_LABEL: Record<string, string> = {
|
||||
@@ -133,6 +135,7 @@ function PendingPanel() {
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE)
|
||||
const [selections, setSelections] = useState<Record<string, string>>({})
|
||||
const [internalSkuDrafts, setInternalSkuDrafts] = useState<Record<string, string>>({})
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['fulfillment-pending', keyword, page, pageSize],
|
||||
@@ -142,16 +145,26 @@ function PendingPanel() {
|
||||
const assignMutation = useMutation({
|
||||
mutationFn: (row: PendingChannelGroup) =>
|
||||
assignChannelByInternalSku({
|
||||
internalSku: row.internal_sku,
|
||||
groupKey: row.group_key,
|
||||
internalSku: row.internal_sku || '',
|
||||
mappedInternalSku: row.internal_sku
|
||||
? ''
|
||||
: String(internalSkuDrafts[rowKey(row)] || '').trim(),
|
||||
channel: selections[rowKey(row)] || '',
|
||||
}),
|
||||
onSuccess: (result) => {
|
||||
const productMessage = result.data.draftProductId ? '和标准商品' : ''
|
||||
if (result.data.carrierFailed > 0) {
|
||||
const firstFailure = result.data.carrierFailures?.[0]
|
||||
message.warning(
|
||||
`已指定 ${result.data.updated} 个订单项,${result.data.carrierFailed} 个因载体创建失败仍待处理`,
|
||||
`渠道${productMessage}已保存,${result.data.carrierFailed} 个订单项仍待补配置${
|
||||
firstFailure?.message ? `:${firstFailure.message}` : ''
|
||||
}`,
|
||||
)
|
||||
} else {
|
||||
message.success(`已指定 ${result.data.updated} 个订单项`)
|
||||
message.success(
|
||||
`已指定 ${result.data.updated} 个订单项${productMessage ? `并创建${productMessage.slice(1)}` : ''}`,
|
||||
)
|
||||
}
|
||||
void queryClient.invalidateQueries({ queryKey: ['fulfillment-pending'] })
|
||||
},
|
||||
@@ -183,7 +196,7 @@ function PendingPanel() {
|
||||
|
||||
const retryCarrierMutation = useMutation({
|
||||
mutationFn: (row: PendingChannelGroup) =>
|
||||
retryPendingCarriers({ internalSku: row.internal_sku }),
|
||||
retryPendingCarriers({ internalSku: row.internal_sku || '' }),
|
||||
onSuccess: (result) => {
|
||||
const data = result.data
|
||||
const detail = [
|
||||
@@ -205,11 +218,11 @@ function PendingPanel() {
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (row: PendingChannelGroup) =>
|
||||
deletePendingChannelGroup({ internalSku: row.internal_sku }),
|
||||
deletePendingChannelGroup({ groupKey: row.group_key }),
|
||||
onSuccess: (result) => {
|
||||
const data = result.data
|
||||
message.success(
|
||||
`已删除 ${data.archivedItems} 个待分类订单项,原始订单保留;财务事实已重算 ${data.refreshedDays} 天`,
|
||||
`已忽略归档 ${data.archivedItems} 个待分类订单项,原始订单保留;财务事实已重算 ${data.refreshedDays} 天`,
|
||||
)
|
||||
void queryClient.invalidateQueries({ queryKey: ['fulfillment-pending'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['fulfillment-exceptions'] })
|
||||
@@ -261,9 +274,23 @@ function PendingPanel() {
|
||||
title: '内部SKU',
|
||||
dataIndex: 'internal_sku',
|
||||
width: 220,
|
||||
render: (value: string, row: PendingChannelGroup) => (
|
||||
<Space direction="vertical" size={2}>
|
||||
<Typography.Text delete={!value}>{value || '未识别内部 SKU'}</Typography.Text>
|
||||
render: (value: string | null, row: PendingChannelGroup) => (
|
||||
<Space direction="vertical" size={4}>
|
||||
{value ? (
|
||||
<Typography.Text>{value}</Typography.Text>
|
||||
) : (
|
||||
<Input
|
||||
size="small"
|
||||
placeholder="填写内部 SKU"
|
||||
value={internalSkuDrafts[rowKey(row)] || ''}
|
||||
onChange={(event) =>
|
||||
setInternalSkuDrafts((current) => ({
|
||||
...current,
|
||||
[rowKey(row)]: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{row.configured_channels?.length ? (
|
||||
<Space size={2} wrap>
|
||||
{row.configured_channels.map((channel) => (
|
||||
@@ -345,7 +372,9 @@ function PendingPanel() {
|
||||
<Select
|
||||
size="small"
|
||||
style={{ width: 180 }}
|
||||
placeholder="选择渠道"
|
||||
placeholder={
|
||||
row.internal_sku || internalSkuDrafts[rowKey(row)] ? '选择渠道' : '先补内部 SKU'
|
||||
}
|
||||
options={CHANNEL_OPTIONS}
|
||||
value={selections[rowKey(row)]}
|
||||
onChange={(value) => setSelections((prev) => ({ ...prev, [rowKey(row)]: value }))}
|
||||
@@ -353,11 +382,14 @@ function PendingPanel() {
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
disabled={!selections[rowKey(row)]}
|
||||
disabled={
|
||||
(!row.internal_sku && !String(internalSkuDrafts[rowKey(row)] || '').trim()) ||
|
||||
!selections[rowKey(row)]
|
||||
}
|
||||
loading={assignMutation.isPending}
|
||||
onClick={() => assignMutation.mutate(row)}
|
||||
>
|
||||
指定
|
||||
保存渠道
|
||||
</Button>
|
||||
{row.configured_channels?.length ? (
|
||||
<Tooltip title="重新确认渠道并补建工单或自动发货任务">
|
||||
@@ -371,26 +403,26 @@ function PendingPanel() {
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
<Tooltip title="删除待分类记录,不删除原始订单">
|
||||
<Tooltip title="从待办中忽略并归档,不删除原始订单">
|
||||
<Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
loading={deleteMutation.isPending}
|
||||
onClick={() =>
|
||||
Modal.confirm({
|
||||
title: '删除待分类记录',
|
||||
title: '忽略并归档待分类记录',
|
||||
content: (
|
||||
<div>
|
||||
<p>
|
||||
将归档 <strong>{row.item_count}</strong> 个订单项,并清除已生成的成本快照、
|
||||
分类异常,重算对应销售事实与总账。
|
||||
将从待办中归档 <strong>{row.item_count}</strong> 个订单项,并清除已生成的
|
||||
派生成本快照、分类异常;可重建的财务事实会按开放期间重算。
|
||||
</p>
|
||||
<p style={{ marginBottom: 0 }}>
|
||||
原始订单和订单商品不会被删除;已关账期间禁止删除。
|
||||
原始订单和订单商品不会删除;已关账期间只跳过重算,不再阻止归档。
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
okText: '确认删除',
|
||||
okText: '确认归档',
|
||||
okButtonProps: { danger: true },
|
||||
cancelText: '取消',
|
||||
onOk: () => deleteMutation.mutateAsync(row),
|
||||
@@ -441,7 +473,7 @@ function PendingPanel() {
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: 12 }}
|
||||
message="删除只会归档对应订单项并清理财务成本记录,不会删除原始订单。配置错误优先修正模板/映射后重试匹配;确认无需履约或核算时,可直接删除。"
|
||||
message="指定渠道会先保存渠道和草稿标准商品;载体或成本未配置时会继续留在队列中。忽略归档只影响派生流程,不删除原始订单。"
|
||||
/>
|
||||
<PercentTable
|
||||
rowKey={(row) => rowKey(row)}
|
||||
@@ -1196,7 +1228,7 @@ function PipelinesPanel() {
|
||||
}
|
||||
|
||||
function rowKey(row: PendingChannelGroup) {
|
||||
return row.internal_sku
|
||||
return row.group_key
|
||||
}
|
||||
|
||||
function formatCarrierParams(row: ChannelRule) {
|
||||
|
||||
@@ -40,6 +40,7 @@ const FULFILLMENT_MODE_OPTIONS = [
|
||||
{ value: 'affiliate_dash', label: '自动发货' },
|
||||
{ value: 'third_party_voucher', label: '第三方卡券' },
|
||||
{ value: 'manual', label: '人工客服' },
|
||||
{ value: 'on_account', label: '统一结算' },
|
||||
{ value: 'unclassified', label: '未分类' },
|
||||
{ value: 'archived', label: '历史归档' },
|
||||
]
|
||||
@@ -49,6 +50,7 @@ const FULFILLMENT_MODE_LABEL: Record<string, string> = {
|
||||
affiliate_dash: '自动发货',
|
||||
third_party_voucher: '第三方卡券',
|
||||
manual: '人工客服',
|
||||
on_account: '统一结算',
|
||||
archived: '历史归档',
|
||||
unclassified: '未分类',
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
import {
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
MinusCircleOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
ScanOutlined,
|
||||
SyncOutlined,
|
||||
@@ -50,14 +52,21 @@ import {
|
||||
saveAdminManualConfig,
|
||||
scanAdminManualSkus,
|
||||
} from '@/services/admin'
|
||||
import type { ManualSkuItem } from '@/types/admin'
|
||||
import type { ManualPlatformKey, ManualSkuItem } from '@/types/admin'
|
||||
import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination'
|
||||
import {
|
||||
formatCreditQuantity,
|
||||
formatCreditUnitPriceLabel,
|
||||
formatCreditUnitPriceYuan,
|
||||
} from '@/utils/credit-unit-price'
|
||||
import dayjs from '@/lib/dayjs'
|
||||
import type { Dayjs } from 'dayjs'
|
||||
|
||||
type ConfigFormValues = {
|
||||
platformKey: string
|
||||
creditQuantity: number
|
||||
channels: Array<{
|
||||
platformKey: string
|
||||
creditQuantity: number
|
||||
}>
|
||||
note?: string
|
||||
}
|
||||
|
||||
@@ -66,13 +75,21 @@ const PLATFORM_TAG_COLORS: Record<string, string> = {
|
||||
lewan: 'purple',
|
||||
rock: 'cyan',
|
||||
fund_cash: 'orange',
|
||||
on_account: 'gold',
|
||||
}
|
||||
|
||||
/** 按换算率把「消耗单位」翻译成人话。 */
|
||||
function unitLabel(platformKey: string, creditUnitPriceFen: number) {
|
||||
if (platformKey === 'fund_cash') return '元'
|
||||
if (creditUnitPriceFen >= 100) return '积分(1积分=1元)'
|
||||
return '积分(1积分=1分)'
|
||||
function isCashChannel(platformKey: string) {
|
||||
return platformKey === 'fund_cash' || platformKey === 'on_account'
|
||||
}
|
||||
|
||||
function channelQuantityText(platformKey: string, creditQuantity: number) {
|
||||
const quantity = formatCreditQuantity(creditQuantity)
|
||||
return isCashChannel(platformKey) ? `¥${quantity}` : `${quantity} 积分`
|
||||
}
|
||||
|
||||
function platformOptionLabel(platformKey: string, label: string, creditUnitPriceFen: number) {
|
||||
if (isCashChannel(platformKey)) return `${label} · 按元填写`
|
||||
return `${label} · ${formatCreditUnitPriceLabel(creditUnitPriceFen)}`
|
||||
}
|
||||
|
||||
export function ManualSkuConfigPanel() {
|
||||
@@ -139,8 +156,10 @@ export function ManualSkuConfigPanel() {
|
||||
mutationFn: (values: ConfigFormValues) =>
|
||||
saveAdminManualConfig({
|
||||
skuKey: editing?.skuKey || '',
|
||||
platformKey: values.platformKey,
|
||||
creditQuantity: values.creditQuantity,
|
||||
channels: values.channels.map((channel) => ({
|
||||
platformKey: channel.platformKey,
|
||||
creditQuantity: Number(channel.creditQuantity || 0),
|
||||
})),
|
||||
note: values.note || '',
|
||||
}),
|
||||
onSuccess: (response) => {
|
||||
@@ -191,9 +210,21 @@ export function ManualSkuConfigPanel() {
|
||||
|
||||
function openConfig(item: ManualSkuItem) {
|
||||
setEditing(item)
|
||||
const configuredChannels = item.config?.channels?.length
|
||||
? item.config.channels
|
||||
: item.config
|
||||
? [
|
||||
{
|
||||
platformKey: item.config.platformKey,
|
||||
creditQuantity: item.config.creditQuantity,
|
||||
},
|
||||
]
|
||||
: [{ platformKey: '', creditQuantity: 0 }]
|
||||
form.setFieldsValue({
|
||||
platformKey: item.config?.platformKey || undefined,
|
||||
creditQuantity: item.config?.creditQuantity ?? undefined,
|
||||
channels: configuredChannels.map((channel) => ({
|
||||
platformKey: channel.platformKey,
|
||||
creditQuantity: channel.creditQuantity,
|
||||
})),
|
||||
note: item.config?.note || '',
|
||||
})
|
||||
setModalOpen(true)
|
||||
@@ -229,20 +260,37 @@ export function ManualSkuConfigPanel() {
|
||||
title: '履约渠道',
|
||||
key: 'platform',
|
||||
width: 150,
|
||||
render: (_, row) =>
|
||||
row.config ? (
|
||||
render: (_, row) => {
|
||||
if (!row.config) {
|
||||
return <Typography.Text type="secondary">未配置</Typography.Text>
|
||||
}
|
||||
const channels = row.config.channels?.length
|
||||
? row.config.channels
|
||||
: [
|
||||
{
|
||||
platformKey: row.config.platformKey,
|
||||
platformLabel: row.config.platformLabel,
|
||||
creditQuantity: row.config.creditQuantity,
|
||||
creditUnitPriceFen: row.config.creditUnitPriceFen,
|
||||
costFen: row.config.costFen,
|
||||
},
|
||||
]
|
||||
return (
|
||||
<Space size={4} direction="vertical">
|
||||
<Tag color={PLATFORM_TAG_COLORS[row.config.platformKey] || 'default'}>
|
||||
{row.config.platformLabel || row.config.platformKey}
|
||||
</Tag>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{row.config.creditQuantity}{' '}
|
||||
{unitLabel(row.config.platformKey, row.config.creditUnitPriceFen)}
|
||||
</Typography.Text>
|
||||
{channels.map((channel) => (
|
||||
<div key={`${row.id}-${channel.platformKey}`}>
|
||||
<Tag color={PLATFORM_TAG_COLORS[channel.platformKey] || 'default'}>
|
||||
{channel.platformLabel}
|
||||
</Tag>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{channelQuantityText(channel.platformKey, channel.creditQuantity)}
|
||||
{' · '}¥{((channel.costFen || 0) / 100).toFixed(2)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
))}
|
||||
</Space>
|
||||
) : (
|
||||
<Typography.Text type="secondary">未配置</Typography.Text>
|
||||
),
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '成本',
|
||||
@@ -251,7 +299,9 @@ export function ManualSkuConfigPanel() {
|
||||
align: 'right',
|
||||
render: (_, row) =>
|
||||
row.config ? (
|
||||
row.config.costMode === 'fixed' ? (
|
||||
(row.config.channels?.length || 0) > 1 ? (
|
||||
<Tag color="blue">按实际渠道</Tag>
|
||||
) : row.config.costMode === 'fixed' ? (
|
||||
<Typography.Text>¥{(row.config.costFen / 100).toFixed(2)}</Typography.Text>
|
||||
) : (
|
||||
<Tag color="orange">不固定·客服填</Tag>
|
||||
@@ -286,11 +336,10 @@ export function ManualSkuConfigPanel() {
|
||||
},
|
||||
]
|
||||
|
||||
const selectedPlatform = form.getFieldValue('platformKey')
|
||||
const selectedCredit = form.getFieldValue('creditQuantity')
|
||||
const previewUnitPrice = platformMap.get(selectedPlatform)?.creditUnitPriceFen ?? 0
|
||||
const previewCostFen =
|
||||
selectedPlatform && Number(selectedCredit) > 0 ? Number(selectedCredit) * previewUnitPrice : 0
|
||||
function previewChannelCost(platformKey: string, creditQuantity: number) {
|
||||
const unitPrice = platformMap.get(platformKey as ManualPlatformKey)?.creditUnitPriceFen ?? 0
|
||||
return Number(creditQuantity) > 0 ? Math.round(Number(creditQuantity) * unitPrice) : 0
|
||||
}
|
||||
|
||||
return (
|
||||
<Card bordered={false} title="人工 SKU 配置">
|
||||
@@ -426,38 +475,138 @@ export function ManualSkuConfigPanel() {
|
||||
showIcon
|
||||
style={{ marginBottom: 14 }}
|
||||
message={`商品名称:${editing?.displayName || '(无)'}(仅供识别)`}
|
||||
description="保存后自动同步标准商品、统一渠道规则和订单匹配;不需要再去其它页面重复设置。消耗积分填 0 表示成本不固定,客服会在确认履约时手填实际成本。"
|
||||
description="一个 SKU 可以配置多个渠道,客服确认履约时选择本次实际渠道。积分渠道填整数积分;备用金现金和统一结算填金额,最多两位小数。填 0 表示成本不固定,由客服确认时填写。"
|
||||
/>
|
||||
<Form<ConfigFormValues> form={form} layout="vertical">
|
||||
<Form.Item
|
||||
name="platformKey"
|
||||
label="履约渠道"
|
||||
rules={[{ required: true, message: '请选择履约渠道' }]}
|
||||
<Form.List
|
||||
name="channels"
|
||||
rules={[
|
||||
{
|
||||
validator: async (_, channels) => {
|
||||
if (!Array.isArray(channels) || channels.length === 0) {
|
||||
throw new Error('请至少配置一个履约渠道')
|
||||
}
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Select
|
||||
placeholder="请选择渠道"
|
||||
options={platforms.map((item) => ({
|
||||
value: item.key,
|
||||
label: `${item.label}(${unitLabel(item.key, item.creditUnitPriceFen)})`,
|
||||
}))}
|
||||
onChange={() => form.validateFields(['creditQuantity'])}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="creditQuantity"
|
||||
label="每单消耗积分"
|
||||
rules={[{ required: true, message: '请输入消耗积分' }]}
|
||||
extra="填 0 表示成本不固定,客服确认履约时手填实际成本。"
|
||||
>
|
||||
<InputNumber min={0} precision={0} style={{ width: '100%' }} placeholder="例如 10" />
|
||||
</Form.Item>
|
||||
<Form.Item label="折算成本">
|
||||
<Typography.Text strong>
|
||||
{previewCostFen > 0
|
||||
? `¥${(previewCostFen / 100).toFixed(2)}(${previewCostFen} 分)`
|
||||
: '成本不固定,由客服手填'}
|
||||
</Typography.Text>
|
||||
</Form.Item>
|
||||
{(fields, { add, remove }, { errors }) => (
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.key} className="manual-channel-config-item">
|
||||
<div className="manual-channel-config-head">
|
||||
<Typography.Text strong>履约渠道 {index + 1}</Typography.Text>
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
title="移除此渠道"
|
||||
aria-label={`移除履约渠道 ${index + 1}`}
|
||||
icon={<MinusCircleOutlined />}
|
||||
disabled={fields.length <= 1}
|
||||
onClick={() => remove(field.name)}
|
||||
/>
|
||||
</div>
|
||||
<Form.Item
|
||||
name={[field.name, 'platformKey']}
|
||||
label="履约渠道"
|
||||
rules={[{ required: true, message: '请选择履约渠道' }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="请选择履约渠道"
|
||||
options={platforms.map((item) => ({
|
||||
value: item.key,
|
||||
label: platformOptionLabel(item.key, item.label, item.creditUnitPriceFen),
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item noStyle shouldUpdate>
|
||||
{() => {
|
||||
const platformKey = String(
|
||||
form.getFieldValue(['channels', field.name, 'platformKey']) || '',
|
||||
)
|
||||
const creditQuantity = Number(
|
||||
form.getFieldValue(['channels', field.name, 'creditQuantity']) || 0,
|
||||
)
|
||||
const cashChannel = isCashChannel(platformKey)
|
||||
const unitPriceFen =
|
||||
platformMap.get(platformKey as ManualPlatformKey)?.creditUnitPriceFen ?? 0
|
||||
const costFen = previewChannelCost(platformKey, creditQuantity)
|
||||
return (
|
||||
<Row gutter={12}>
|
||||
<Col xs={24} sm={12}>
|
||||
<Form.Item
|
||||
name={[field.name, 'creditQuantity']}
|
||||
label={
|
||||
!platformKey
|
||||
? '消耗 / 金额'
|
||||
: cashChannel
|
||||
? '每单金额'
|
||||
: '每单消耗积分'
|
||||
}
|
||||
rules={[
|
||||
{ required: true, message: '请输入消耗或金额' },
|
||||
{
|
||||
validator: async (_, value) => {
|
||||
const quantity = Number(value)
|
||||
if (!Number.isFinite(quantity) || quantity < 0) {
|
||||
throw new Error('请输入大于等于 0 的数字')
|
||||
}
|
||||
if (!cashChannel && !Number.isInteger(quantity)) {
|
||||
throw new Error('积分必须是整数')
|
||||
}
|
||||
},
|
||||
},
|
||||
]}
|
||||
extra={
|
||||
platformKey
|
||||
? cashChannel
|
||||
? '单位:元,最多两位小数'
|
||||
: `换算率:${formatCreditUnitPriceLabel(unitPriceFen)}`
|
||||
: '积分填整数,现金和统一结算填金额'
|
||||
}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
precision={cashChannel ? 2 : 0}
|
||||
step={cashChannel ? 0.01 : 1}
|
||||
style={{ width: '100%' }}
|
||||
placeholder={cashChannel ? '例如 12.50' : '例如 10'}
|
||||
addonAfter={
|
||||
platformKey ? (cashChannel ? '元' : '积分') : undefined
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} sm={12}>
|
||||
<Form.Item label="折算成本" style={{ marginBottom: 0 }}>
|
||||
<div className="manual-channel-cost-preview">
|
||||
<Typography.Text strong={costFen > 0}>
|
||||
{costFen > 0
|
||||
? `¥${(costFen / 100).toFixed(2)}`
|
||||
: '成本不固定,由客服确认时填写'}
|
||||
</Typography.Text>
|
||||
{platformKey && !cashChannel ? (
|
||||
<Typography.Text type="secondary">
|
||||
1积分={formatCreditUnitPriceYuan(unitPriceFen)}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</div>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
)
|
||||
}}
|
||||
</Form.Item>
|
||||
</div>
|
||||
))}
|
||||
<Button type="dashed" icon={<PlusOutlined />} onClick={() => add()}>
|
||||
添加履约渠道
|
||||
</Button>
|
||||
<Form.ErrorList errors={errors} />
|
||||
</Space>
|
||||
)}
|
||||
</Form.List>
|
||||
<Form.Item name="note" label="备注">
|
||||
<Input.TextArea rows={2} maxLength={300} showCount />
|
||||
</Form.Item>
|
||||
|
||||
@@ -130,6 +130,7 @@ export const FULFILLMENT_MODE_LABELS: Record<string, string> = {
|
||||
affiliate_dash: '自动发货',
|
||||
third_party_voucher: '第三方卡券',
|
||||
manual: '人工客服',
|
||||
on_account: '统一结算',
|
||||
archived: '历史归档',
|
||||
unclassified: '未分类',
|
||||
}
|
||||
@@ -139,6 +140,7 @@ export const FULFILLMENT_MODE_COLORS: Record<string, string> = {
|
||||
affiliate_dash: 'green',
|
||||
third_party_voucher: 'cyan',
|
||||
manual: 'orange',
|
||||
on_account: 'gold',
|
||||
archived: 'default',
|
||||
unclassified: 'gold',
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ import type {
|
||||
} from '@/types/admin'
|
||||
import type { UploadedFile } from '@/types/worker-platform'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
import { formatCreditUnitPriceLabel } from '@/utils/credit-unit-price'
|
||||
import {
|
||||
FUND_ACCOUNT_STATUS_LABELS,
|
||||
FUND_CATEGORY_LABELS,
|
||||
@@ -387,7 +388,7 @@ export default function FundSupportView() {
|
||||
<section className="page-stack">
|
||||
<PageHeader
|
||||
title="我的钱包"
|
||||
description="四个平台钱包:南航 / 乐玩 / ROCK 积分与备用金现金。余额由流水自动计算,不能手工修改。"
|
||||
description="五种履约方式:南航 / 乐玩 / ROCK 积分、备用金现金、统一结算。积分余额与现金余额由流水自动计算,不能手工修改。"
|
||||
extra={
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
@@ -418,51 +419,61 @@ export default function FundSupportView() {
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
<div className="support-wallet-grid">
|
||||
{myCreditWallets.map((wallet) => (
|
||||
<Col xs={24} sm={12} lg={6} key={wallet.platform.key}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title={
|
||||
<Space size={6}>
|
||||
<span>{wallet.platform.label}</span>
|
||||
<Tag color={wallet.platform.creditUnitPriceFen >= 100 ? 'cyan' : 'blue'}>
|
||||
1积分=
|
||||
{wallet.platform.creditUnitPriceFen >= 100 ? '1元' : '1分'}
|
||||
</Tag>
|
||||
</Space>
|
||||
}
|
||||
value={wallet.balanceQuantity}
|
||||
suffix="积分"
|
||||
valueStyle={{
|
||||
color: wallet.balanceQuantity > 0 ? undefined : '#999',
|
||||
}}
|
||||
/>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
折合 ¥{(wallet.balanceAmountFen / 100).toFixed(2)}
|
||||
</Typography.Text>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card>
|
||||
<Card key={wallet.platform.key}>
|
||||
<Statistic
|
||||
title="备用金现金"
|
||||
value={account ? account.balanceFen / 100 : 0}
|
||||
precision={2}
|
||||
prefix="¥"
|
||||
title={
|
||||
<Space size={6}>
|
||||
<span>{wallet.platform.label}</span>
|
||||
<Tag color={wallet.platform.creditUnitPriceFen >= 100 ? 'cyan' : 'blue'}>
|
||||
{formatCreditUnitPriceLabel(wallet.platform.creditUnitPriceFen)}
|
||||
</Tag>
|
||||
</Space>
|
||||
}
|
||||
value={wallet.balanceQuantity}
|
||||
suffix="积分"
|
||||
valueStyle={{
|
||||
color: account && account.balanceFen > 0 ? undefined : '#999',
|
||||
color: wallet.balanceQuantity > 0 ? undefined : '#999',
|
||||
}}
|
||||
/>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{account
|
||||
? `本月发放 ¥${(account.monthInFen / 100).toFixed(2)} · 支出/缴回 ¥${(account.monthOutFen / 100).toFixed(2)}`
|
||||
: '尚未开通备用金账户'}
|
||||
折合 ¥{(wallet.balanceAmountFen / 100).toFixed(2)}
|
||||
</Typography.Text>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
))}
|
||||
<Card>
|
||||
<Statistic
|
||||
title="备用金现金"
|
||||
value={account ? account.balanceFen / 100 : 0}
|
||||
precision={2}
|
||||
prefix="¥"
|
||||
valueStyle={{
|
||||
color: account && account.balanceFen > 0 ? undefined : '#999',
|
||||
}}
|
||||
/>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{account
|
||||
? `本月发放 ¥${(account.monthInFen / 100).toFixed(2)} · 支出/缴回 ¥${(account.monthOutFen / 100).toFixed(2)}`
|
||||
: '尚未开通备用金账户'}
|
||||
</Typography.Text>
|
||||
</Card>
|
||||
<Card>
|
||||
<Statistic
|
||||
title={
|
||||
<Space size={6}>
|
||||
<span>统一结算</span>
|
||||
<Tag color="gold">后对账</Tag>
|
||||
</Space>
|
||||
}
|
||||
value="按单生成"
|
||||
valueStyle={{ color: '#8c6d1f', fontSize: 22 }}
|
||||
/>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
生成虚拟账单,不扣积分或备用金
|
||||
</Typography.Text>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{account ? (
|
||||
<Card>
|
||||
|
||||
@@ -42,6 +42,7 @@ import type { PlatformCreditAccount, PlatformCreditEntry, PlatformCreditOwner }
|
||||
import { hasAdminPermission } from '@/utils/admin-auth'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
import { buildAdminLocalTablePagination } from '@/utils/admin-pagination'
|
||||
import { formatCreditUnitPriceLabel } from '@/utils/credit-unit-price'
|
||||
|
||||
const PLATFORM_TAG_COLORS: Record<string, string> = {
|
||||
nanhang: 'blue',
|
||||
@@ -342,7 +343,7 @@ export function PlatformCreditPanel() {
|
||||
showIcon
|
||||
style={{ marginBottom: 14 }}
|
||||
message="南航 / 乐玩 / ROCK 三平台积分互不通用,每平台一个钱包"
|
||||
description="换算率按平台:南航、乐玩 1 积分 = 1 分;ROCK 1 积分 = 1 元。可在下方按客服直接分配或调整积分;备用金现金在「现金钱包 / 对账中心」管理。"
|
||||
description="换算率按平台展示为人民币金额;南航、乐玩默认 1 积分 = 0.01 元,ROCK 默认 1 积分 = 1.00 元。可在下方按客服直接分配或调整积分;备用金现金在「现金钱包 / 对账中心」管理。"
|
||||
/>
|
||||
|
||||
<Row gutter={[12, 12]} style={{ marginBottom: 12 }}>
|
||||
@@ -359,7 +360,7 @@ export function PlatformCreditPanel() {
|
||||
suffix="积分"
|
||||
prefix={
|
||||
<Tag color={PLATFORM_TAG_COLORS[platform.key] || 'default'}>
|
||||
1积分={platform.creditUnitPriceFen >= 100 ? '1元' : '1分'}
|
||||
{formatCreditUnitPriceLabel(platform.creditUnitPriceFen)}
|
||||
</Tag>
|
||||
}
|
||||
/>
|
||||
@@ -459,7 +460,7 @@ export function PlatformCreditPanel() {
|
||||
<Select
|
||||
options={platforms.map((item) => ({
|
||||
value: item.key,
|
||||
label: `${item.label}(1积分=${item.creditUnitPriceFen >= 100 ? '1元' : '1分'})`,
|
||||
label: `${item.label} · ${formatCreditUnitPriceLabel(item.creditUnitPriceFen)}`,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -38,6 +38,7 @@ const CHANNEL_LABEL: Record<VoucherConsumeChannel, string> = {
|
||||
affiliate_dash: '自动发货',
|
||||
work_order: '接单工单',
|
||||
manual: '人工客服',
|
||||
on_account: '统一结算',
|
||||
third_party_voucher: '第三方卡券',
|
||||
}
|
||||
|
||||
@@ -68,6 +69,10 @@ const MODE_OPTIONS: Record<
|
||||
{ value: 'after_order_received', label: '收到订单后' },
|
||||
{ value: 'manual', label: '人工核销' },
|
||||
],
|
||||
on_account: [
|
||||
{ value: 'after_order_received', label: '收到订单后' },
|
||||
{ value: 'manual', label: '人工核销' },
|
||||
],
|
||||
}
|
||||
|
||||
type PolicyRow = {
|
||||
|
||||
@@ -378,8 +378,11 @@ export function scanAdminManualSkus(payload: { fromDate: string; toDate: string
|
||||
|
||||
export function saveAdminManualConfig(payload: {
|
||||
skuKey: string
|
||||
platformKey: string
|
||||
creditQuantity: number
|
||||
channels: Array<{
|
||||
platformKey: string
|
||||
creditQuantity: number
|
||||
enabled?: boolean
|
||||
}>
|
||||
canonicalProductId?: number | null
|
||||
enabled?: boolean
|
||||
note?: string
|
||||
@@ -493,6 +496,7 @@ export function fetchAdminManualFulfillmentPending(
|
||||
|
||||
export function confirmAdminManualFulfillment(payload: {
|
||||
orderItemId: number
|
||||
platformKey: string
|
||||
costFen?: number
|
||||
payeeName?: string
|
||||
note?: string
|
||||
|
||||
@@ -2,7 +2,9 @@ import { apiDelete, apiGet, apiPost } from '@/lib/http'
|
||||
|
||||
/** 待分类队列(按内部 SKU 聚合)。 */
|
||||
export type PendingChannelGroup = {
|
||||
internal_sku: string
|
||||
group_key: string
|
||||
scope_type: 'internal_sku' | 'source_sku'
|
||||
internal_sku: string | null
|
||||
sku_name: string
|
||||
providers: string[]
|
||||
shop_ids: string[]
|
||||
@@ -30,7 +32,7 @@ export type ChannelRule = {
|
||||
id: number
|
||||
match_key_type: 'internal_sku'
|
||||
match_key: string
|
||||
channel: 'work_order' | 'affiliate_dash' | 'third_party_voucher' | 'manual'
|
||||
channel: 'work_order' | 'affiliate_dash' | 'third_party_voucher' | 'manual' | 'on_account'
|
||||
priority: number
|
||||
enabled: boolean
|
||||
is_fallback: boolean
|
||||
@@ -145,6 +147,7 @@ export type VoucherConsumeChannel =
|
||||
| 'affiliate_dash'
|
||||
| 'work_order'
|
||||
| 'manual'
|
||||
| 'on_account'
|
||||
| 'third_party_voucher'
|
||||
export type VoucherConsumeMode =
|
||||
| 'manual'
|
||||
@@ -261,7 +264,7 @@ export function retryPendingCarriers(payload: { internalSku: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
export function deletePendingChannelGroup(payload: { internalSku: string }) {
|
||||
export function deletePendingChannelGroup(payload: { groupKey: string }) {
|
||||
return apiPost<DeletePendingChannelGroupResult>(
|
||||
'/api/v1/admin/fulfillment/pending/delete',
|
||||
payload,
|
||||
@@ -269,14 +272,20 @@ export function deletePendingChannelGroup(payload: { internalSku: string }) {
|
||||
}
|
||||
|
||||
export function assignChannelByInternalSku(payload: {
|
||||
groupKey?: string
|
||||
internalSku: string
|
||||
mappedInternalSku?: string
|
||||
channel: string
|
||||
note?: string
|
||||
}) {
|
||||
return apiPost<{ updated: number; carrierFailed: number; ruleCreated: boolean }>(
|
||||
'/api/v1/admin/fulfillment/assign',
|
||||
payload,
|
||||
)
|
||||
return apiPost<{
|
||||
updated: number
|
||||
carrierFailed: number
|
||||
ruleCreated: boolean
|
||||
channelSaved: boolean
|
||||
draftProductId: number | null
|
||||
carrierFailures: Array<{ orderItemId: number; reason: string; message: string }>
|
||||
}>('/api/v1/admin/fulfillment/assign', payload)
|
||||
}
|
||||
|
||||
export function assignChannelToItems(payload: {
|
||||
|
||||
@@ -15,6 +15,48 @@
|
||||
linear-gradient(315deg, rgba(31, 41, 55, 0.045), transparent 38%), #f5f7fb;
|
||||
}
|
||||
|
||||
.manual-channel-config-item {
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
border: 1px solid #e6eaf0;
|
||||
border-radius: 8px;
|
||||
background: #fafbfc;
|
||||
}
|
||||
|
||||
.manual-channel-config-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 24px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.manual-channel-cost-preview {
|
||||
display: flex;
|
||||
min-height: 32px;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
padding: 4px 11px;
|
||||
border: 1px solid #e6eaf0;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.manual-channel-cost-preview .ant-typography {
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.support-wallet-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.support-wallet-grid > .ant-card {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.auth-shell {
|
||||
width: min(940px, 100%);
|
||||
min-height: 560px;
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
/**
|
||||
* 履约渠道:唯一真源是 order_items.fulfillment_channel + fulfillment_status。
|
||||
*/
|
||||
export type FulfillmentChannel = 'work_order' | 'affiliate_dash' | 'third_party_voucher' | 'manual'
|
||||
export type FulfillmentChannel =
|
||||
| 'work_order'
|
||||
| 'affiliate_dash'
|
||||
| 'third_party_voucher'
|
||||
| 'manual'
|
||||
| 'on_account'
|
||||
|
||||
/** 商品来源:由 canonical_sku 前缀推导,空串表示未分类。 */
|
||||
export type ProductSource = 'affiliate_dash' | 'work_order' | 'third_party_voucher' | 'manual' | ''
|
||||
@@ -371,10 +376,9 @@ export interface CostExceptionSummary {
|
||||
}
|
||||
|
||||
/**
|
||||
* 人工履约渠道:客服人工有四个钱包。
|
||||
* 南航/乐玩/ROCK 是积分(三平台积分互不通用),备用金是现金。
|
||||
* 人工履约渠道:南航/乐玩/ROCK 积分、备用金现金、统一结算。
|
||||
*/
|
||||
export type ManualPlatformKey = 'nanhang' | 'lewan' | 'rock' | 'fund_cash'
|
||||
export type ManualPlatformKey = 'nanhang' | 'lewan' | 'rock' | 'fund_cash' | 'on_account'
|
||||
|
||||
/** 成本模式:fixed=按积分固定;variable=成本不固定,客服确认履约时手填。 */
|
||||
export type ManualCostMode = 'fixed' | 'variable'
|
||||
@@ -424,6 +428,19 @@ export interface ManualFulfillmentConfig {
|
||||
pendingAssigned: number
|
||||
rematchedItems: number
|
||||
}
|
||||
channels: ManualConfiguredChannel[]
|
||||
}
|
||||
|
||||
export interface ManualConfiguredChannel {
|
||||
platformKey: ManualPlatformKey
|
||||
platformLabel: string
|
||||
creditQuantity: number
|
||||
costMode: ManualCostMode
|
||||
costFen: number | null
|
||||
creditUnitPriceFen: number
|
||||
isFundCash: boolean
|
||||
isOnAccount: boolean
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export interface ManualSkuListResult {
|
||||
@@ -525,9 +542,10 @@ export interface ManualFulfillmentPendingItem {
|
||||
revenueFen: number
|
||||
paidAt: string
|
||||
configId: number
|
||||
platformKey: PlatformCreditKey | 'fund_cash'
|
||||
platformKey: PlatformCreditKey | 'fund_cash' | 'on_account'
|
||||
platformLabel: string
|
||||
isFundCash: boolean
|
||||
isOnAccount: boolean
|
||||
creditQuantity: number
|
||||
creditUnitPriceFen: number
|
||||
costMode: ManualCostMode
|
||||
@@ -535,6 +553,7 @@ export interface ManualFulfillmentPendingItem {
|
||||
costFen: number | null
|
||||
fulfilled: boolean
|
||||
fulfilledAt: string | null
|
||||
channels: ManualConfiguredChannel[]
|
||||
}
|
||||
|
||||
export interface ManualFulfillmentPendingListResult {
|
||||
@@ -553,4 +572,5 @@ export interface ManualFulfillmentConfirmResult {
|
||||
fundExpenseId: number | null
|
||||
creditEntryId: number | null
|
||||
balanceAfter: number | null
|
||||
onAccountBillId: number | null
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ export type AdminOrderItemFulfillmentMode =
|
||||
| 'affiliate_dash'
|
||||
| 'third_party_voucher'
|
||||
| 'manual'
|
||||
| 'on_account'
|
||||
| 'pending'
|
||||
| 'archived'
|
||||
|
||||
|
||||
@@ -225,6 +225,7 @@ export function resolveOrderFulfillmentMode(row: {
|
||||
| 'affiliate_dash'
|
||||
| 'third_party_voucher'
|
||||
| 'manual'
|
||||
| 'on_account'
|
||||
| 'pending'
|
||||
| 'archived'
|
||||
| 'mixed'
|
||||
@@ -240,6 +241,8 @@ export function resolveOrderFulfillmentMode(row: {
|
||||
return { label: '🗄 历史归档', color: 'default' }
|
||||
case 'manual':
|
||||
return { label: '🙋 人工履约', color: 'orange' }
|
||||
case 'on_account':
|
||||
return { label: '统一结算', color: 'gold' }
|
||||
case 'mixed':
|
||||
return { label: '🔀 混合履约', color: 'geekblue' }
|
||||
default:
|
||||
|
||||
@@ -34,6 +34,7 @@ export const adminOrderFulfillmentModeOptions = [
|
||||
{ label: '接单平台', value: 'work_order' },
|
||||
{ label: '第三方卡券', value: 'third_party_voucher' },
|
||||
{ label: '人工履约', value: 'manual' },
|
||||
{ label: '统一结算', value: 'on_account' },
|
||||
{ label: '混合履约', value: 'mixed' },
|
||||
{ label: '待判定', value: 'pending' },
|
||||
{ label: '历史归档', value: 'archived' },
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/** 分 → 元,用于统一展示积分换算率。 */
|
||||
export function formatCreditUnitPriceYuan(value: number | string | null | undefined) {
|
||||
const fen = Number(value)
|
||||
return `${(Number.isFinite(fen) ? fen / 100 : 0).toFixed(2)}元`
|
||||
}
|
||||
|
||||
export function formatCreditUnitPriceLabel(value: number | string | null | undefined) {
|
||||
return `1积分=${formatCreditUnitPriceYuan(value)}`
|
||||
}
|
||||
|
||||
/** 配置金额最多 2 位小数,去掉无意义的尾随 0。 */
|
||||
export function formatCreditQuantity(value: number | string | null | undefined) {
|
||||
const quantity = Number(value)
|
||||
if (!Number.isFinite(quantity)) return '0'
|
||||
return String(Number(quantity.toFixed(2)))
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
formatCreditQuantity,
|
||||
formatCreditUnitPriceLabel,
|
||||
formatCreditUnitPriceYuan,
|
||||
} from '../src/utils/credit-unit-price.ts'
|
||||
|
||||
test('积分换算率统一按元展示,不再使用“分”', () => {
|
||||
assert.equal(formatCreditUnitPriceYuan(1), '0.01元')
|
||||
assert.equal(formatCreditUnitPriceYuan(100), '1.00元')
|
||||
assert.equal(formatCreditUnitPriceLabel(125), '1积分=1.25元')
|
||||
})
|
||||
|
||||
test('人工履约配置量支持小数并去掉无意义尾零', () => {
|
||||
assert.equal(formatCreditQuantity(1.5), '1.5')
|
||||
assert.equal(formatCreditQuantity('2.2500'), '2.25')
|
||||
assert.equal(formatCreditQuantity(0), '0')
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
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('../src/pages/admin/fund/FundSupportView.tsx', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
assert.ok(source.includes('统一结算'), '钱包页必须展示统一结算卡片')
|
||||
assert.ok(source.includes('生成虚拟账单,不扣积分或备用金'))
|
||||
assert.ok(source.includes('support-wallet-grid'))
|
||||
})
|
||||
Reference in New Issue
Block a user