From 4e83dbb84c236daf6b7a84f7fc64d228ae55ebb3 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 5 Nov 2026 16:14:56 +0800 Subject: [PATCH] =?UTF-8?q?feat(fulfillment):=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E7=BB=93=E7=AE=97=E4=B8=8E=E5=A4=9A=E6=B8=A0?= =?UTF-8?q?=E9=81=93=E4=BA=BA=E5=B7=A5=E5=B1=A5=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migrations/130_voucher_consume_jobs.sql | 14 +- .../131_pending_channel_selection.sql | 18 + .../132_on_account_and_manual_channels.sql | 93 ++++ .../133_manual_credit_quantity_decimal.sql | 21 + .../src/routes/admin/fulfillment-channel.ts | 32 +- .../admin/admin-order-read-helpers.ts | 1 + .../src/services/admin/admin-read-service.ts | 1 + .../cost-center/cost-center-constants.ts | 2 + .../cost-center/cost-center-mappers.ts | 4 +- .../cost-center/cost-resolution-service.ts | 5 +- .../cost-center/cost-snapshot-service.ts | 2 +- .../manual-credit-quantity.test.ts | 33 ++ .../cost-center/manual-credit-quantity.ts | 41 ++ .../manual-fulfillment-config-service.ts | 212 +++++++-- .../cost-center/product-catalog-service.ts | 4 +- .../manual-fulfillment-integrity.test.ts | 11 + .../finance/manual-fulfillment-service.ts | 235 ++++++++-- .../fulfillment/carrier-creation-service.ts | 4 + .../fulfillment/channel-assign-service.ts | 440 ++++++++++++++---- .../src/services/fulfillment/channel-types.ts | 10 +- .../intake-channel-decision-service.ts | 4 +- .../fulfillment/internal-sku-resolver.ts | 1 + .../voucher-consume-policy-service.ts | 10 + .../voucher-consume-queue-service.ts | 3 +- apps/backend/src/types/admin/read-models.ts | 1 + .../admin/ManualFulfillmentConfirmModal.tsx | 115 ++++- .../admin/AdminFulfillmentChannelPage.tsx | 72 ++- .../src/pages/admin/AdminSalesProfitPage.tsx | 2 + .../cost-center/ManualSkuConfigPanel.tsx | 267 ++++++++--- .../src/pages/admin/cost-center/shared.ts | 2 + .../src/pages/admin/fund/FundSupportView.tsx | 87 ++-- .../pages/admin/fund/PlatformCreditPanel.tsx | 7 +- .../panels/VoucherConsumePolicyPanel.tsx | 5 + .../src/services/admin/cost-center.ts | 8 +- .../src/services/admin/fulfillment-channel.ts | 23 +- apps/frontend/src/styles/admin.css | 42 ++ apps/frontend/src/types/admin/cost-center.ts | 30 +- apps/frontend/src/types/admin/orders.ts | 1 + apps/frontend/src/utils/admin-display.ts | 3 + apps/frontend/src/utils/admin-options.ts | 1 + apps/frontend/src/utils/credit-unit-price.ts | 16 + apps/frontend/tests/credit-unit-price.test.ts | 20 + .../fund-support-wallet-on-account.test.ts | 13 + 43 files changed, 1582 insertions(+), 334 deletions(-) create mode 100644 apps/backend/src/db/migrations/131_pending_channel_selection.sql create mode 100644 apps/backend/src/db/migrations/132_on_account_and_manual_channels.sql create mode 100644 apps/backend/src/db/migrations/133_manual_credit_quantity_decimal.sql create mode 100644 apps/backend/src/services/cost-center/manual-credit-quantity.test.ts create mode 100644 apps/backend/src/services/cost-center/manual-credit-quantity.ts create mode 100644 apps/frontend/src/utils/credit-unit-price.ts create mode 100644 apps/frontend/tests/credit-unit-price.test.ts create mode 100644 apps/frontend/tests/fund-support-wallet-on-account.test.ts diff --git a/apps/backend/src/db/migrations/130_voucher_consume_jobs.sql b/apps/backend/src/db/migrations/130_voucher_consume_jobs.sql index ea668b80..7332a001 100644 --- a/apps/backend/src/db/migrations/130_voucher_consume_jobs.sql +++ b/apps/backend/src/db/migrations/130_voucher_consume_jobs.sql @@ -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'; + '稳定去重目标:优先订单项,没有订单项时使用任务或订单'; diff --git a/apps/backend/src/db/migrations/131_pending_channel_selection.sql b/apps/backend/src/db/migrations/131_pending_channel_selection.sql new file mode 100644 index 00000000..2dc3354c --- /dev/null +++ b/apps/backend/src/db/migrations/131_pending_channel_selection.sql @@ -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 必须存在履约渠道'; diff --git a/apps/backend/src/db/migrations/132_on_account_and_manual_channels.sql b/apps/backend/src/db/migrations/132_on_account_and_manual_channels.sql new file mode 100644 index 00000000..3a413e35 --- /dev/null +++ b/apps/backend/src/db/migrations/132_on_account_and_manual_channels.sql @@ -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 已作废'; diff --git a/apps/backend/src/db/migrations/133_manual_credit_quantity_decimal.sql b/apps/backend/src/db/migrations/133_manual_credit_quantity_decimal.sql new file mode 100644 index 00000000..da9dfd45 --- /dev/null +++ b/apps/backend/src/db/migrations/133_manual_credit_quantity_decimal.sql @@ -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) + ); diff --git a/apps/backend/src/routes/admin/fulfillment-channel.ts b/apps/backend/src/routes/admin/fulfillment-channel.ts index d9f4f719..55f0f25f 100644 --- a/apps/backend/src/routes/admin/fulfillment-channel.ts +++ b/apps/backend/src/routes/admin/fulfillment-channel.ts @@ -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, }), }, @@ -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 }, }), }, diff --git a/apps/backend/src/services/admin/admin-order-read-helpers.ts b/apps/backend/src/services/admin/admin-order-read-helpers.ts index b2b48839..049c1b9d 100644 --- a/apps/backend/src/services/admin/admin-order-read-helpers.ts +++ b/apps/backend/src/services/admin/admin-order-read-helpers.ts @@ -144,6 +144,7 @@ const ORDER_ITEM_FULFILLMENT_CHANNELS = new Set([ 'third_party_voucher', 'legacy_platform', 'manual', + 'on_account', 'pending', 'archived', ]) diff --git a/apps/backend/src/services/admin/admin-read-service.ts b/apps/backend/src/services/admin/admin-read-service.ts index 40b9558d..cadd4e45 100644 --- a/apps/backend/src/services/admin/admin-read-service.ts +++ b/apps/backend/src/services/admin/admin-read-service.ts @@ -152,6 +152,7 @@ const ADMIN_ORDER_FULFILLMENT_CHANNELS = new Set([ 'affiliate_dash', 'third_party_voucher', 'manual', + 'on_account', 'legacy_platform', 'mixed', 'pending', diff --git a/apps/backend/src/services/cost-center/cost-center-constants.ts b/apps/backend/src/services/cost-center/cost-center-constants.ts index de0aa318..e0114409 100644 --- a/apps/backend/src/services/cost-center/cost-center-constants.ts +++ b/apps/backend/src/services/cost-center/cost-center-constants.ts @@ -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([ 'affiliate_dash', 'third_party_voucher', 'manual', + 'on_account', 'pending', 'archived', 'unclassified', diff --git a/apps/backend/src/services/cost-center/cost-center-mappers.ts b/apps/backend/src/services/cost-center/cost-center-mappers.ts index 7e5abc56..fc34145c 100644 --- a/apps/backend/src/services/cost-center/cost-center-mappers.ts +++ b/apps/backend/src/services/cost-center/cost-center-mappers.ts @@ -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')) )` } diff --git a/apps/backend/src/services/cost-center/cost-resolution-service.ts b/apps/backend/src/services/cost-center/cost-resolution-service.ts index 67bc7fca..a560000b 100644 --- a/apps/backend/src/services/cost-center/cost-resolution-service.ts +++ b/apps/backend/src/services/cost-center/cost-resolution-service.ts @@ -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, } } diff --git a/apps/backend/src/services/cost-center/cost-snapshot-service.ts b/apps/backend/src/services/cost-center/cost-snapshot-service.ts index c7368980..b1ff75a7 100644 --- a/apps/backend/src/services/cost-center/cost-snapshot-service.ts +++ b/apps/backend/src/services/cost-center/cost-snapshot-service.ts @@ -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' ) ` diff --git a/apps/backend/src/services/cost-center/manual-credit-quantity.test.ts b/apps/backend/src/services/cost-center/manual-credit-quantity.test.ts new file mode 100644 index 00000000..3881e706 --- /dev/null +++ b/apps/backend/src/services/cost-center/manual-credit-quantity.test.ts @@ -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) +}) diff --git a/apps/backend/src/services/cost-center/manual-credit-quantity.ts b/apps/backend/src/services/cost-center/manual-credit-quantity.ts new file mode 100644 index 00000000..0c12d305 --- /dev/null +++ b/apps/backend/src/services/cost-center/manual-credit-quantity.ts @@ -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) +} diff --git a/apps/backend/src/services/cost-center/manual-fulfillment-config-service.ts b/apps/backend/src/services/cost-center/manual-fulfillment-config-service.ts index 99ba48ec..d59432b0 100644 --- a/apps/backend/src/services/cost-center/manual-fulfillment-config-service.ts +++ b/apps/backend/src/services/cost-center/manual-fulfillment-config-service.ts @@ -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 = { lewan: 1, rock: 100, fund_cash: 100, + on_account: 100, } const PLATFORM_LABELS: Record = { @@ -49,6 +55,7 @@ const PLATFORM_LABELS: Record = { 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 = {}) { } 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 = {}) { 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() + for (const item of raw) { + if (!item || typeof item !== 'object' || Array.isArray(item)) continue + const row = item as Record + 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, +) { + 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) { - 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, ) { - 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() + 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 } diff --git a/apps/backend/src/services/cost-center/product-catalog-service.ts b/apps/backend/src/services/cost-center/product-catalog-service.ts index 92956dce..acf6257b 100644 --- a/apps/backend/src/services/cost-center/product-catalog-service.ts +++ b/apps/backend/src/services/cost-center/product-catalog-service.ts @@ -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] diff --git a/apps/backend/src/services/finance/manual-fulfillment-integrity.test.ts b/apps/backend/src/services/finance/manual-fulfillment-integrity.test.ts index fb2a4973..452cddfe 100644 --- a/apps/backend/src/services/finance/manual-fulfillment-integrity.test.ts +++ b/apps/backend/src/services/finance/manual-fulfillment-integrity.test.ts @@ -21,6 +21,17 @@ test('人工成本:固定成本按配置积分 × 平台换算率', () => { ) }) +test('人工成本:备用金和统一结算金额支持两位小数', () => { + assert.deepEqual( + resolveManualFulfillmentCost({ + creditQuantity: 12.34, + unitPriceFen: 100, + providedCostFen: undefined, + }), + { costFen: 1234, costMode: 'fixed' }, + ) +}) + test('人工成本:成本不固定必须手填正数金额', () => { assert.deepEqual( resolveManualFulfillmentCost({ diff --git a/apps/backend/src/services/finance/manual-fulfillment-service.ts b/apps/backend/src/services/finance/manual-fulfillment-service.ts index e51f3a4f..59fc8b4e 100644 --- a/apps/backend/src/services/finance/manual-fulfillment-service.ts +++ b/apps/backend/src/services/finance/manual-fulfillment-service.ts @@ -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 = { 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> { + const result = await query<{ profile_key: string; config_json: Record }>( + `SELECT profile_key, config_json FROM fulfillment_profiles WHERE profile_key LIKE 'manual_%'`, + ) + const prices: Record = { + 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 +}): 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() + const channels: ManualConfiguredChannel[] = [] + + for (const item of source) { + if (!item || typeof item !== 'object' || Array.isArray(item)) continue + const row = item as Record + 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 = {}, 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) { + 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, } diff --git a/apps/backend/src/services/fulfillment/carrier-creation-service.ts b/apps/backend/src/services/fulfillment/carrier-creation-service.ts index 9611a0a9..5b013571 100644 --- a/apps/backend/src/services/fulfillment/carrier-creation-service.ts +++ b/apps/backend/src/services/fulfillment/carrier-creation-service.ts @@ -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: '' } diff --git a/apps/backend/src/services/fulfillment/channel-assign-service.ts b/apps/backend/src/services/fulfillment/channel-assign-service.ts index f2baa987..4e08ab3c 100644 --- a/apps/backend/src/services/fulfillment/channel-assign-service.ts +++ b/apps/backend/src/services/fulfillment/channel-assign-service.ts @@ -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(`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 +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( ` + 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>() - if (internalSkus.length > 0) { + if (groupKeys.length > 0) { const shopRows = await query( ` + 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() 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 { - 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 { + 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 }, } } diff --git a/apps/backend/src/services/fulfillment/channel-types.ts b/apps/backend/src/services/fulfillment/channel-types.ts index 34513644..a0d5c1cf 100644 --- a/apps/backend/src/services/fulfillment/channel-types.ts +++ b/apps/backend/src/services/fulfillment/channel-types.ts @@ -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 = { 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' ) } diff --git a/apps/backend/src/services/fulfillment/intake-channel-decision-service.ts b/apps/backend/src/services/fulfillment/intake-channel-decision-service.ts index 4d5c8ef6..ee92a616 100644 --- a/apps/backend/src/services/fulfillment/intake-channel-decision-service.ts +++ b/apps/backend/src/services/fulfillment/intake-channel-decision-service.ts @@ -200,7 +200,9 @@ export function applyIntakeChannelPlan( reason: channel === 'work_order' ? '内部 SKU 渠道配置指定接单平台' - : '内部 SKU 渠道配置指定第三方卡券', + : channel === 'on_account' + ? '内部 SKU 渠道配置指定统一结算' + : '内部 SKU 渠道配置指定第三方卡券', configured: false, }) } diff --git a/apps/backend/src/services/fulfillment/internal-sku-resolver.ts b/apps/backend/src/services/fulfillment/internal-sku-resolver.ts index a7b5ac4c..f5ac2f43 100644 --- a/apps/backend/src/services/fulfillment/internal-sku-resolver.ts +++ b/apps/backend/src/services/fulfillment/internal-sku-resolver.ts @@ -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 { diff --git a/apps/backend/src/services/fulfillment/voucher-consume-policy-service.ts b/apps/backend/src/services/fulfillment/voucher-consume-policy-service.ts index 356db20c..591c0562 100644 --- a/apps/backend/src/services/fulfillment/voucher-consume-policy-service.ts +++ b/apps/backend/src/services/fulfillment/voucher-consume-policy-service.ts @@ -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', + ]), }, } } diff --git a/apps/backend/src/services/fulfillment/voucher-consume-queue-service.ts b/apps/backend/src/services/fulfillment/voucher-consume-queue-service.ts index b9042aa9..ea852d31 100644 --- a/apps/backend/src/services/fulfillment/voucher-consume-queue-service.ts +++ b/apps/backend/src/services/fulfillment/voucher-consume-queue-service.ts @@ -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 } diff --git a/apps/backend/src/types/admin/read-models.ts b/apps/backend/src/types/admin/read-models.ts index 08034c37..5403f823 100644 --- a/apps/backend/src/types/admin/read-models.ts +++ b/apps/backend/src/types/admin/read-models.ts @@ -13,6 +13,7 @@ export type AdminOrderItemFulfillmentMode = | 'affiliate_dash' | 'legacy_platform' | 'manual' + | 'on_account' | 'pending' export type AdminOrderFulfillmentMode = AdminOrderItemFulfillmentMode | 'mixed' diff --git a/apps/frontend/src/components/admin/ManualFulfillmentConfirmModal.tsx b/apps/frontend/src/components/admin/ManualFulfillmentConfirmModal.tsx index 412bf305..cf3413a5 100644 --- a/apps/frontend/src/components/admin/ManualFulfillmentConfirmModal.tsx +++ b/apps/frontend/src/components/admin/ManualFulfillmentConfirmModal.tsx @@ -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() const [selectedId, setSelectedId] = useState(null) + const [selectedPlatformKey, setSelectedPlatformKey] = useState(null) const [fundAccountId, setFundAccountId] = useState(null) const [creditOwnerAdminUserId, setCreditOwnerAdminUserId] = useState(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 ( , 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({
- 履约渠道: - - {selected?.platformLabel || '-'} - - {selected && !selected.isFundCash ? ( - 消耗 {selected.creditQuantity} 积分 + 本次实际履约渠道: + {selected && configuredChannels.length > 1 ? ( +
- ) : ( + ) : selectedChannel.isOnAccount ? null : (
代录到客服积分账户: + setInternalSkuDrafts((current) => ({ + ...current, + [rowKey(row)]: event.target.value, + })) + } + /> + )} {row.configured_channels?.length ? ( {row.configured_channels.map((channel) => ( @@ -345,7 +372,9 @@ function PendingPanel() { ({ - value: item.key, - label: `${item.label}(${unitLabel(item.key, item.creditUnitPriceFen)})`, - }))} - onChange={() => form.validateFields(['creditQuantity'])} - /> - - - - - - - {previewCostFen > 0 - ? `¥${(previewCostFen / 100).toFixed(2)}(${previewCostFen} 分)` - : '成本不固定,由客服手填'} - - + {(fields, { add, remove }, { errors }) => ( + + {fields.map((field, index) => ( +
+
+ 履约渠道 {index + 1} +
+ + ({ value: item.key, - label: `${item.label}(1积分=${item.creditUnitPriceFen >= 100 ? '1元' : '1分'})`, + label: `${item.label} · ${formatCreditUnitPriceLabel(item.creditUnitPriceFen)}`, }))} /> diff --git a/apps/frontend/src/pages/admin/panels/VoucherConsumePolicyPanel.tsx b/apps/frontend/src/pages/admin/panels/VoucherConsumePolicyPanel.tsx index d80319ae..54e242dd 100644 --- a/apps/frontend/src/pages/admin/panels/VoucherConsumePolicyPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/VoucherConsumePolicyPanel.tsx @@ -38,6 +38,7 @@ const CHANNEL_LABEL: Record = { 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 = { diff --git a/apps/frontend/src/services/admin/cost-center.ts b/apps/frontend/src/services/admin/cost-center.ts index f637bc1d..8adbb34c 100644 --- a/apps/frontend/src/services/admin/cost-center.ts +++ b/apps/frontend/src/services/admin/cost-center.ts @@ -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 diff --git a/apps/frontend/src/services/admin/fulfillment-channel.ts b/apps/frontend/src/services/admin/fulfillment-channel.ts index f894b916..a9284d78 100644 --- a/apps/frontend/src/services/admin/fulfillment-channel.ts +++ b/apps/frontend/src/services/admin/fulfillment-channel.ts @@ -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( '/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: { diff --git a/apps/frontend/src/styles/admin.css b/apps/frontend/src/styles/admin.css index a17259a1..c6f8a0f3 100644 --- a/apps/frontend/src/styles/admin.css +++ b/apps/frontend/src/styles/admin.css @@ -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; diff --git a/apps/frontend/src/types/admin/cost-center.ts b/apps/frontend/src/types/admin/cost-center.ts index fed46143..2d9482df 100644 --- a/apps/frontend/src/types/admin/cost-center.ts +++ b/apps/frontend/src/types/admin/cost-center.ts @@ -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 } diff --git a/apps/frontend/src/types/admin/orders.ts b/apps/frontend/src/types/admin/orders.ts index 8d5498c2..7f643a47 100644 --- a/apps/frontend/src/types/admin/orders.ts +++ b/apps/frontend/src/types/admin/orders.ts @@ -13,6 +13,7 @@ export type AdminOrderItemFulfillmentMode = | 'affiliate_dash' | 'third_party_voucher' | 'manual' + | 'on_account' | 'pending' | 'archived' diff --git a/apps/frontend/src/utils/admin-display.ts b/apps/frontend/src/utils/admin-display.ts index e4e62d8e..f07b6db0 100644 --- a/apps/frontend/src/utils/admin-display.ts +++ b/apps/frontend/src/utils/admin-display.ts @@ -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: diff --git a/apps/frontend/src/utils/admin-options.ts b/apps/frontend/src/utils/admin-options.ts index 06473583..1dd1a838 100644 --- a/apps/frontend/src/utils/admin-options.ts +++ b/apps/frontend/src/utils/admin-options.ts @@ -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' }, diff --git a/apps/frontend/src/utils/credit-unit-price.ts b/apps/frontend/src/utils/credit-unit-price.ts new file mode 100644 index 00000000..5718760d --- /dev/null +++ b/apps/frontend/src/utils/credit-unit-price.ts @@ -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))) +} diff --git a/apps/frontend/tests/credit-unit-price.test.ts b/apps/frontend/tests/credit-unit-price.test.ts new file mode 100644 index 00000000..8ba5c985 --- /dev/null +++ b/apps/frontend/tests/credit-unit-price.test.ts @@ -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') +}) diff --git a/apps/frontend/tests/fund-support-wallet-on-account.test.ts b/apps/frontend/tests/fund-support-wallet-on-account.test.ts new file mode 100644 index 00000000..5b7e40f1 --- /dev/null +++ b/apps/frontend/tests/fund-support-wallet-on-account.test.ts @@ -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')) +})