feat(consignment): 接单模板反推规格字段,打手端大批量选项改下拉

- 新增 consignment-template-spec-service:按「分类 + 子树」解析接单模板商品名,
  全部可拆成统一维度且无组合碰撞时生成多字段矩阵,否则整类降级为商品名下拉;
  选项只存 ruleKey,取价时实时回查模板,模板停用/删除即无价(missingPricePolicy=block)。
- 匹配引擎:模板驱动配置强制使用 profile specs(不再回退库存旧快照),订单侧按
  product_rule_id 反解规格;解析失败按 unknown 处理,不参与自动匹配。
- 管理端:寄卖配置新增「从接单模板生成」,弹窗给出推荐模式(规格字段 / 商品名下拉)、
  降级原因与组合→模板价覆盖矩阵。
- 打手端:选项超过 8 个改用带搜索的下拉(地铁逃生 100 个商品),规格网格按字段数
  自适应铺满;物品名改用商品名,去掉硬编码「苏尔南」后缀。
- 迁移重编号:合并 main 后寄卖线顺延为 147–151,migrate.ts 保留 legacy→new 幂等映射。
- 同工作区的拼单份额报酬口径、超时重匹配兜底、整单/拼单匹配弹窗调整一并提交。

验证:前端 254/254、后端寄卖相关 41/41、双端 tsc 通过;开发库已实跑
发布→审核入库→自动匹配(模板价 1200 → 匹配报酬 1200)。
This commit is contained in:
2026-11-07 12:26:10 +08:00
parent 50ee3553c5
commit 787b8e2771
38 changed files with 2242 additions and 197 deletions
+37 -13
View File
@@ -29,34 +29,58 @@ const MIGRATION_FILE_PATTERN = /^(\d{3})_.+\.sql$/i
* 129-131 -> 135-137(main 占用 129-134)
* 135-137 -> 142-144(main 又占用 135-141)
* 142-148 -> 142_consignment_platform(2026-11-06 合并为单文件)
* 2026-11-07 二次撞号:main 又占用 142-146,寄卖线整体顺延为 147-151
* 已跑过旧编号的环境要清掉旧记录,让新编号按正常顺序重跑(迁移本身幂等)。
*/
const RENAMED_MIGRATIONS: Array<{ legacyName: string; currentName: string }> = [
{ legacyName: '129_consignment_matching', currentName: '142_consignment_platform' },
{ legacyName: '135_consignment_matching', currentName: '142_consignment_platform' },
{ legacyName: '130_consignment_dynamic_specs', currentName: '142_consignment_platform' },
{ legacyName: '136_consignment_dynamic_specs', currentName: '142_consignment_platform' },
{ legacyName: '129_consignment_matching', currentName: '147_consignment_platform' },
{ legacyName: '135_consignment_matching', currentName: '147_consignment_platform' },
{ legacyName: '130_consignment_dynamic_specs', currentName: '147_consignment_platform' },
{ legacyName: '136_consignment_dynamic_specs', currentName: '147_consignment_platform' },
{
legacyName: '131_consignment_surnan_default_profile',
currentName: '142_consignment_platform',
currentName: '147_consignment_platform',
},
{
legacyName: '137_consignment_surnan_default_profile',
currentName: '142_consignment_platform',
currentName: '147_consignment_platform',
},
{ legacyName: '142_consignment_matching', currentName: '142_consignment_platform' },
{ legacyName: '143_consignment_dynamic_specs', currentName: '142_consignment_platform' },
{ legacyName: '142_consignment_matching', currentName: '147_consignment_platform' },
{ legacyName: '143_consignment_dynamic_specs', currentName: '147_consignment_platform' },
{
legacyName: '144_consignment_surnan_default_profile',
currentName: '142_consignment_platform',
currentName: '147_consignment_platform',
},
{
legacyName: '145_consignment_inventory_multi_binding',
currentName: '142_consignment_platform',
currentName: '147_consignment_platform',
},
{ legacyName: '146_consignment_share_gift', currentName: '147_consignment_platform' },
{ legacyName: '147_consignment_match_and_rematch', currentName: '147_consignment_platform' },
{ legacyName: '148_consignment_remove_zone_help_text', currentName: '147_consignment_platform' },
/*
* 2026-11-07 二次撞号:合并 main 后 main 又占用 142-146(voucher/fulfillment 系列)。
* 寄卖迁移整体顺延:142→147、143→148、144→149、145/146→150、147(截止时间约束)→151。
* 旧库记录按下面的映射清掉后按新编号重跑,全部迁移均为幂等脚本。
*/
{ legacyName: '142_consignment_platform', currentName: '147_consignment_platform' },
{ legacyName: '143_consignment_sms_logs', currentName: '148_consignment_sms_logs' },
{
legacyName: '144_consignment_inventory_category_backfill',
currentName: '149_consignment_inventory_category_backfill',
},
{
legacyName: '145_work_order_consignment_original_reward',
currentName: '150_work_order_consignment_original_reward',
},
{
legacyName: '146_work_order_consignment_original_reward',
currentName: '150_work_order_consignment_original_reward',
},
{
legacyName: '147_work_order_internal_matching_deadline_guard',
currentName: '151_work_order_internal_matching_deadline_guard',
},
{ legacyName: '146_consignment_share_gift', currentName: '142_consignment_platform' },
{ legacyName: '147_consignment_match_and_rematch', currentName: '142_consignment_platform' },
{ legacyName: '148_consignment_remove_zone_help_text', currentName: '142_consignment_platform' },
]
export async function runDatabaseMigrations() {
@@ -1,5 +1,5 @@
-- 142_consignment_platform.sql
-- 寄卖平台完整迁移(由 142–148 收敛,2026-11-06)
-- 147_consignment_platform.sql(原 142_consignment_platform,合并 main 后顺延)
-- 寄卖平台完整迁移(原 142–148 收敛为一个文件,2026-11-06)
-- 142 寄卖配置/库存/内部匹配
-- 143 动态规格
-- 144 苏尔南默认寄卖配置
@@ -1,4 +1,4 @@
-- 143_consignment_sms_logs.sql
-- 148_consignment_sms_logs.sql(原 143_consignment_sms_logs,合并 main 后顺延)
-- 寄卖匹配成功短信发送记录:用于幂等、审计与 Mock 联调验证。
-- 同一工单 + 同一库存只允许一条记录,避免重试/重复调度重复发短信。
CREATE TABLE IF NOT EXISTS consignment_sms_logs (
@@ -1,4 +1,4 @@
-- 144_consignment_inventory_category_backfill.sql
-- 149_consignment_inventory_category_backfill.sql(原 144_consignment_inventory_category_backfill,合并 main 后顺延)
-- 寄售仓库按「寄卖配置分类」筛选的前置数据修复。
--
-- 背景:分类级寄卖配置(142)上线前入库的提报与库存没有写 category_id/profile_id,
@@ -1,4 +1,4 @@
-- 145_work_order_consignment_original_reward.sql
-- 150_work_order_consignment_original_reward.sql(原 145→146→150,合并 main 后顺延)
-- 寄卖内部匹配会改写 work_orders.reward_amount(按库存售价-服务费结算)。
-- 这里固化“匹配前的原始工单报酬”,所有释放库存的路径恢复它,避免超时/取消/退款后
-- 大厅继续展示寄卖结算价、或二次匹配在已被改写的金额上继续扣服务费。
@@ -0,0 +1,34 @@
-- 151_work_order_internal_matching_deadline_guard.sql(原 147,合并 main 后顺延)
-- 工单状态不变量:internal_matching 必须有内部匹配截止时间。
--
-- 背景:手动匹配/重新匹配曾先把工单改成 internal_matching,再补截止时间;
-- 中途失败会留下「internal_matching 且 deadline 为空」的工单,
-- 既不在大厅,也不会被超时任务处理。这里先修复存量,再用 CHECK 约束兜底。
-- 1. 修复存量:给缺失截止时间的内部匹配工单补 30 分钟等待窗口。
UPDATE work_orders
SET
internal_match_deadline_at = NOW() + INTERVAL '30 minutes',
hall_visible_at = COALESCE(hall_visible_at, NOW() + INTERVAL '30 minutes'),
updated_at = NOW()
WHERE status = 'internal_matching'
AND internal_match_deadline_at IS NULL;
-- 2. 新写入保护:internal_matching 不允许 deadline 为空。
-- 使用 NOT VALID + VALIDATE:先约束新写入,再校验存量(若并发写入异常会在此暴露)。
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'work_orders_internal_matching_deadline_check'
) THEN
ALTER TABLE work_orders
ADD CONSTRAINT work_orders_internal_matching_deadline_check
CHECK (status <> 'internal_matching' OR internal_match_deadline_at IS NOT NULL)
NOT VALID;
END IF;
END $$;
ALTER TABLE work_orders
VALIDATE CONSTRAINT work_orders_internal_matching_deadline_check;
@@ -425,3 +425,112 @@ export async function listEnabledWorkProductRulesForSuggestion(): Promise<
rewardAmountFen: Number(row.reward_amount || 0),
}))
}
export type ConsignmentTemplateRuleRow = {
id: number
ruleKey: string
productName: string
categoryId: number | null
rewardAmountFen: number
unitPriceFen: number
pricingMode: string
sharingEnabled: boolean
sharingTotalQuantity: number
sharingUnitRewardFen: number
}
/**
* 模板驱动规格:取「分类 + 其子树」内启用且有价的接单模板规则。
* 苏尔南手枪的价表分布在父分类(4)与子分类(65/66/67),因此必须带子树一起取。
*/
export async function listEnabledPricedWorkProductRulesForCategorySubtree(
categoryIdInput: number | string,
): Promise<ConsignmentTemplateRuleRow[]> {
const categoryId = Number(categoryIdInput)
if (!Number.isInteger(categoryId) || categoryId <= 0) return []
const result = await query<{
id: number
rule_key: string
product_name: string
category_id: number | null
reward_amount: number
unit_price_fen: number
pricing_mode: string
sharing_enabled: boolean
sharing_total_quantity: number
sharing_unit_reward: number
}>(
`WITH RECURSIVE subtree AS (
SELECT id FROM work_categories WHERE id = $1
UNION ALL
SELECT c.id FROM work_categories c JOIN subtree s ON c.parent_id = s.id
)
SELECT r.id, r.rule_key, r.product_name, r.category_id,
r.reward_amount, COALESCE(r.unit_price_fen, 0) AS unit_price_fen,
r.pricing_mode, r.sharing_enabled, r.sharing_total_quantity,
COALESCE(r.sharing_unit_reward, 0) AS sharing_unit_reward
FROM work_product_rules r
WHERE r.enabled = TRUE
AND r.category_id IN (SELECT id FROM subtree)
AND (r.reward_amount > 0 OR COALESCE(r.unit_price_fen, 0) > 0)
ORDER BY r.category_id NULLS LAST, r.sort_order ASC, r.id ASC`,
[categoryId],
)
return result.rows.map((row) => ({
id: Number(row.id),
ruleKey: row.rule_key,
productName: row.product_name,
categoryId:
row.category_id === null || row.category_id === undefined ? null : Number(row.category_id),
rewardAmountFen: Number(row.reward_amount || 0),
unitPriceFen: Number(row.unit_price_fen || 0),
pricingMode: String(row.pricing_mode || 'fixed'),
sharingEnabled: row.sharing_enabled === true,
sharingTotalQuantity: Number(row.sharing_total_quantity || 1),
sharingUnitRewardFen: Number(row.sharing_unit_reward || 0),
}))
}
/** 模板驱动规格取价:按规则键取一条启用的模板规则;未启用/已删除返回 null。 */
export async function getEnabledWorkProductRuleByKey(
ruleKeyInput: string,
): Promise<ConsignmentTemplateRuleRow | null> {
const ruleKey = String(ruleKeyInput || '').trim()
if (!ruleKey) return null
const result = await query<{
id: number
rule_key: string
product_name: string
category_id: number | null
reward_amount: number
unit_price_fen: number
pricing_mode: string
sharing_enabled: boolean
sharing_total_quantity: number
sharing_unit_reward: number
}>(
`SELECT id, rule_key, product_name, category_id,
reward_amount, COALESCE(unit_price_fen, 0) AS unit_price_fen,
pricing_mode, sharing_enabled, sharing_total_quantity,
COALESCE(sharing_unit_reward, 0) AS sharing_unit_reward
FROM work_product_rules
WHERE rule_key = $1 AND enabled = TRUE
LIMIT 1`,
[ruleKey],
)
const row = result.rows[0]
if (!row) return null
return {
id: Number(row.id),
ruleKey: row.rule_key,
productName: row.product_name,
categoryId:
row.category_id === null || row.category_id === undefined ? null : Number(row.category_id),
rewardAmountFen: Number(row.reward_amount || 0),
unitPriceFen: Number(row.unit_price_fen || 0),
pricingMode: String(row.pricing_mode || 'fixed'),
sharingEnabled: row.sharing_enabled === true,
sharingTotalQuantity: Number(row.sharing_total_quantity || 1),
sharingUnitRewardFen: Number(row.sharing_unit_reward || 0),
}
}
@@ -691,8 +691,14 @@ export async function settleOverdueWorkOrder(input: {
${CLEAR_DELIVERY_RESIDUE_SQL},
published_at = CASE WHEN $1 = 'open' THEN $2 ELSE published_at END,
hall_queued_at = CASE WHEN $1 = 'open' THEN $2 ELSE hall_queued_at END,
internal_match_deadline_at = CASE WHEN $1 = 'internal_matching' THEN NULL ELSE internal_match_deadline_at END,
hall_visible_at = CASE WHEN $1 = 'internal_matching' THEN NULL ELSE hall_visible_at END,
-- 重新匹配由 startConsignmentRematch 补正式截止时间;这里先写 1 分钟兜底,
-- 即使后续补写失败,超时任务也能把工单发回大厅,不会永久悬空。
internal_match_deadline_at = CASE
WHEN $1 = 'internal_matching' THEN ($2::timestamptz + INTERVAL '1 minute')
ELSE internal_match_deadline_at END,
hall_visible_at = CASE
WHEN $1 = 'internal_matching' THEN ($2::timestamptz + INTERVAL '1 minute')
ELSE hall_visible_at END,
${RESET_GIFT_FLOW_SQL},
updated_at = $2
WHERE id = $3
@@ -127,15 +127,29 @@ async function finalizeSharingOrderAcceptedWithClient(
now: input.now,
})
}
// 拼单母单没有单一接单人(份额打手已各自按 share_reward 结算),
// 传 0 让供货货款必走本渠道,不会与份额报酬重复。
await settleConsignmentSupplierPayoutByWorkOrderWithClient(client, {
workOrderId: input.workOrderId,
assignedWorkerId: 0,
rewardUnfreezeEnabled: input.rewardUnfreezeEnabled,
unfreezeDays: input.unfreezeDays,
now: input.now,
})
// 拼单可能有多个供货卖家,每个份额对应一条库存/提报单,
// 发布押金必须按份额逐条释放,不能只认 material 里的第一条库存。
const shareSubmissionResult = await client.query<{ source_submission_id: number | string }>(
`
SELECT DISTINCT ci.source_submission_id
FROM work_order_shares wos
INNER JOIN consignment_inventories ci ON ci.id = wos.consignment_inventory_id
WHERE wos.work_order_id = $1
AND wos.status != 'cancelled'
AND ci.source_submission_id IS NOT NULL
`,
[input.workOrderId],
)
for (const row of shareSubmissionResult.rows) {
await releaseConsignmentPublishDepositBySubmissionWithClient(client, {
submissionId: String(row.source_submission_id),
note: `寄卖拼单份额验收完成释放发布押金 #${input.workOrderId}`,
now: input.now,
})
}
// 拼单寄卖的份额报酬在 applySharingMatch 里已按「库存净售价 × 数量」写入 share_reward,
// 验收时随份额报酬一起结算给对应卖家;这里不能再走整单级供货货款,
// 否则会用 LIMIT 1 的供应商反查只给一个卖家重复付款。
}
export async function acceptWorkOrderAndSettle(input: {
@@ -555,13 +569,16 @@ export async function acceptWorkOrderAndSettle(input: {
// 寄卖货款:内部现货履约时订单没有接单打手,通用报酬结算里没人收这笔钱,
// 必须在这里补给供货卖家(接单人就是卖家本人时不重复付款,见函数注释)。
await settleConsignmentSupplierPayoutByWorkOrderWithClient(client, {
workOrderId: input.workOrderId,
assignedWorkerId: workerId,
rewardUnfreezeEnabled: input.rewardUnfreezeEnabled,
unfreezeDays: input.unfreezeDays,
now: input.now,
})
// 拼单工单的份额报酬已经包含寄卖货款,不能再按整单反查一个供应商重复付款。
if (shares.length === 0) {
await settleConsignmentSupplierPayoutByWorkOrderWithClient(client, {
workOrderId: input.workOrderId,
assignedWorkerId: workerId,
rewardUnfreezeEnabled: input.rewardUnfreezeEnabled,
unfreezeDays: input.unfreezeDays,
now: input.now,
})
}
await createWorkOrderEventWithClient(client, {
workOrderId: input.workOrderId,
@@ -45,6 +45,7 @@ import {
} from '../../../services/worker-platform/consignment-fulfillment-service.js'
import { listPendingConsignmentMatches } from '../../../services/worker-platform/consignment-pending-match-service.js'
import { listConsignmentMatchLogs } from '../../../services/worker-platform/consignment-match-log-service.js'
import { buildTemplateSpecProposalForCategory } from '../../../services/worker-platform/consignment-template-spec-service.js'
import {
listConsignmentMatchCandidatesForWorkOrder,
matchConsignmentWorkOrderManually,
@@ -105,6 +106,20 @@ router.post(
}),
)
/** 从接单模板反推规格字段:返回字段建议 + 组合→价格覆盖矩阵,运营确认后再保存。 */
router.get(
'/worker-platform/consignment/template-spec-proposal',
requireAdminPermission('consignment.manage'),
createJsonHandler(
(req) => buildTemplateSpecProposalForCategory(Number(req.query.categoryId || 0)),
{
successMessage: 'ok',
errorMessage: '读取接单模板规格建议失败',
scope: '[admin/consignment/template-spec-proposal]',
},
),
)
// ---------------- 内部匹配(手动/自动共用) ----------------
router.get(
@@ -60,6 +60,11 @@ import {
listConsignmentMatchOptions,
matchPeacekeeperOrderWithInventory,
} from './consignment-matching-service.js'
import {
inventoryMatchesProfile,
isRematchExcludedInventory,
resolveProfileForWorkOrder,
} from './consignment-match-engine-service.js'
import { mapConsignmentInventory } from './consignment-admin-service.js'
import { resolveCollectSubmittedFieldValues } from './work-order-material-mappers.js'
@@ -183,6 +188,47 @@ function hasActiveWorkOrderShares(workOrder: { active_share_quantity?: unknown }
return Number(workOrder.active_share_quantity || 0) > 0
}
/**
* 旧整单履约/指派入口的兜底守卫。
*
* 旧链路直接把整条库存置为 consumed,不写 allocation、不按数量扣减;
* 多数量库存一旦从这里发出去,剩余件会永久不可售,货款也按 1 件结算。
* 因此旧入口只允许「单件库存 + 与本单数量一致」的库存,
* 多数量/拼单统一走新引擎(/match)。
*/
async function assertLegacyConsignmentInventoryUsable(
workOrder: WorkOrderRow,
inventory: ConsignmentInventoryRow,
): Promise<void> {
const profile = await resolveProfileForWorkOrder(workOrder)
if (!inventoryMatchesProfile(inventory, profile)) {
throw createHttpError('该寄卖库存不属于当前订单的寄卖分类配置,请使用「内部库存匹配」', {
statusCode: 409,
errorCode: 'consignment_inventory_profile_mismatch',
})
}
if (isRematchExcludedInventory(workOrder, inventory)) {
throw createHttpError('该寄卖库存是超时重新匹配时排除的原库存/原卖家,请选择其他库存', {
statusCode: 409,
errorCode: 'consignment_inventory_rematch_excluded',
})
}
const requiredQuantity = Math.max(1, Number(workOrder.sharing_total_quantity || 1))
const availableQuantity = Math.max(0, Math.round(Number(inventory.remaining_quantity ?? 1)))
if (availableQuantity < requiredQuantity) {
throw createHttpError(
`该库存仅剩 ${availableQuantity} 件,订单需要 ${requiredQuantity} 件,请改选库存`,
{ statusCode: 409, errorCode: 'consignment_inventory_quantity_insufficient' },
)
}
if (availableQuantity > 1 || requiredQuantity > 1) {
throw createHttpError(
'多数量库存或多数量订单请使用「内部库存匹配」,旧整单履约入口只支持单件库存',
{ statusCode: 409, errorCode: 'consignment_inventory_multi_quantity_requires_engine' },
)
}
}
/**
* 订单侧「手动指定现货」:列出该订单的**全部**在库现货(含不符合规格的)。
*
@@ -426,6 +472,7 @@ export async function fulfillWorkOrderWithConsignment(input: {
errorCode: 'consignment_inventory_unavailable',
})
}
await assertLegacyConsignmentInventoryUsable(workOrder, inventory)
const matchNote = resolveManualMatchNote(input)
// 服务端规格复检:不能只依赖前端弹窗展示的匹配结果
@@ -697,6 +744,7 @@ export async function assignWorkOrderWithConsignmentWorker(input: {
errorCode: 'consignment_inventory_unavailable',
})
}
await assertLegacyConsignmentInventoryUsable(workOrder, inventory)
const matchNote = resolveManualMatchNote(input)
// 服务端规格复检:不能只依赖前端弹窗展示的匹配结果
@@ -0,0 +1,193 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import test from 'node:test'
import { buildConsignmentShareReward } from './consignment-match-engine-service.js'
import { normalizeConsignmentProfileConfig } from './consignment-profile-service.js'
const ENGINE = new URL('./consignment-match-engine-service.ts', import.meta.url)
const TIMEOUT = new URL('./worker-order-timeout-service.ts', import.meta.url)
const ISSUE_REPO = new URL(
'../../repositories/worker-platform/work-order-issue-repo.ts',
import.meta.url,
)
const FULFILLMENT = new URL('./consignment-fulfillment-service.ts', import.meta.url)
const MIGRATION = new URL(
'../../db/migrations/151_work_order_internal_matching_deadline_guard.sql',
import.meta.url,
)
test('手动匹配不再在事务外改状态:数量不足/并发占库失败不能留下无截止时间的 internal_matching', async () => {
const source = await readFile(ENGINE, 'utf8')
const start = source.indexOf('async function runManualConsignmentMatch')
const end = source.indexOf('export async function releaseConsignmentForWorkOrder', start)
assert.ok(start >= 0 && end > start, '找不到 runManualConsignmentMatch')
const body = source.slice(start, end)
assert.ok(
body.includes('startStatuses: [') &&
body.includes('WORK_ORDER_STATUS.INTERNAL_MATCHING') &&
body.includes('WORK_ORDER_STATUS.UNASSIGNED') &&
body.includes('WORK_ORDER_STATUS.OPEN'),
'手动匹配必须声明允许的起始状态,由 apply 事务内部流转',
)
assert.ok(
!body.includes("UPDATE work_orders SET status = 'internal_matching'"),
'禁止在 apply 事务外先把工单改成 internal_matching',
)
assert.ok(
body.includes('consignment_multiple_inventory_not_allowed'),
'整单多选必须明确报错,不能静默忽略',
)
})
test('apply 事务内部:允许起始状态、整单检查有效份额、拼单补截止时间', async () => {
const source = await readFile(ENGINE, 'utf8')
const wholeStart = source.indexOf('async function applyWholeOrderMatch')
const wholeEnd = source.indexOf('async function applySharingMatch', wholeStart)
assert.ok(wholeStart >= 0 && wholeEnd > wholeStart, '找不到 applyWholeOrderMatch')
const whole = source.slice(wholeStart, wholeEnd)
assert.ok(
whole.includes('const allowedStartStatuses') &&
whole.includes('allowedStartStatuses.includes(current.status)'),
'整单匹配必须在锁内校验允许的起始状态',
)
assert.ok(
whole.includes("status IN ('joined', 'submitted', 'accepted')"),
'整单匹配必须检查已有有效份额,禁止接单人 + 份额混合状态',
)
const sharingStart = source.indexOf('async function applySharingMatch')
const sharingEnd = source.indexOf(
'export async function autoMatchConsignmentWorkOrders',
sharingStart,
)
assert.ok(sharingStart >= 0 && sharingEnd > sharingStart, '找不到 applySharingMatch')
const sharing = source.slice(sharingStart, sharingEnd)
assert.ok(
sharing.includes('fallbackDeadline') &&
sharing.includes('internal_match_deadline_at IS NULL THEN $5'),
'拼单部分成功时必须补内部匹配截止时间',
)
assert.ok(
sharing.includes('const shareGrossFenPerUnit =') &&
sharing.includes('inventorySalePriceFen(inventory) ||'),
'拼单份额报酬必须按库存净售价计算,和整单同口径',
)
})
test('超时任务兜底:无截止时间的 internal_matching 也能发回大厅', async () => {
const source = await readFile(ENGINE, 'utf8')
const start = source.indexOf('export async function expireConsignmentInternalMatching')
const end = source.indexOf(
'export async function listConsignmentMatchCandidatesForWorkOrder',
start,
)
assert.ok(start >= 0 && end > start, '找不到 expireConsignmentInternalMatching')
const body = source.slice(start, end)
assert.ok(
body.includes('internal_match_deadline_at IS NULL OR internal_match_deadline_at <= $1'),
'超时任务必须兜底 deadline 为空的内部匹配工单',
)
})
test('重新匹配:0 分钟遵循配置、失败显式回大厅、写 rematch 日志', async () => {
const [engine, timeout] = await Promise.all([readFile(ENGINE, 'utf8'), readFile(TIMEOUT, 'utf8')])
const start = engine.indexOf('export async function startConsignmentRematch')
const end = engine.indexOf('export function', start + 1)
const body = engine.slice(
start,
end > start ? end : engine.indexOf('/** 自动匹配允许 partial', start),
)
assert.ok(start >= 0, '找不到 startConsignmentRematch')
assert.ok(
body.includes('resolveInternalMatchDelayMinutes(profile.config.internalMatch)') &&
body.includes('if (delayMinutes <= 0)'),
'重新匹配必须与首次进入同口径:0 分钟直接发大厅',
)
assert.ok(
body.includes('fallbackConsignmentWorkOrderToHall(workOrderId, now)'),
'重新匹配找不到配置/关闭匹配/0 分钟时必须显式回大厅',
)
assert.ok(body.includes("triggerType: 'rematch'"), '重新匹配必须写 rematch 类型匹配日志')
assert.ok(
timeout.includes('startRematchOrFallback(') &&
timeout.includes('fallbackConsignmentWorkOrderToHall('),
'超时服务必须在重新匹配失败时回大厅',
)
const issue = await readFile(ISSUE_REPO, 'utf8')
assert.ok(
issue.includes("WHEN $1 = 'internal_matching' THEN ($2::timestamptz + INTERVAL '1 minute')"),
'超时处置写 internal_matching 时必须先写兜底截止时间',
)
})
test('自动匹配 partial 必须有正向规格信号;候选列表必须排除重新匹配库存', async () => {
const source = await readFile(ENGINE, 'utf8')
assert.ok(
source.includes('function hasPositiveSpecSignal') &&
source.includes(
'if (!allowPartial || !evaluation.partial || evaluation.conflicts.length > 0) return false',
) &&
source.includes('return hasPositiveSpecSignal(workOrder, evaluation)'),
'自动 partial 必须要求订单侧至少一个明确规格信号',
)
const start = source.indexOf('export async function listConsignmentMatchCandidatesForWorkOrder')
const end = source.indexOf('function extractConsignmentErrorCode', start)
assert.ok(start >= 0 && end > start, '找不到候选列表函数')
const body = source.slice(start, end)
assert.ok(
body.includes('!isRematchExcludedInventory(workOrder, inventory)'),
'候选列表必须与落库同口径排除重新匹配的原卖家/原库存',
)
})
test('旧整单履约入口必须拒绝多数量库存/拼单排除库存', async () => {
const source = await readFile(FULFILLMENT, 'utf8')
assert.ok(
source.includes('async function assertLegacyConsignmentInventoryUsable') &&
source.includes('consignment_inventory_rematch_excluded') &&
source.includes('consignment_inventory_multi_quantity_requires_engine') &&
source.includes('availableQuantity > 1 || requiredQuantity > 1'),
'旧链路必须有 profile/rematch/quantity 守卫,多数量库存引导到新引擎',
)
})
test('拼单份额报酬:按库存售价 × 数量扣服务费,整单与拼单同口径', () => {
const percentConfig = normalizeConsignmentProfileConfig({
serviceFee: { enabled: true, mode: 'percent', percent: 5, amountFen: 0 },
})
const percent = buildConsignmentShareReward(percentConfig, 9000, 3)
assert.equal(percent.grossFen, 27000)
assert.equal(percent.feeFen, 1350)
assert.equal(percent.netFen, 25650)
assert.equal(percent.netPerUnitFen, 8550)
const fixedConfig = normalizeConsignmentProfileConfig({
serviceFee: { enabled: true, mode: 'fixed', percent: 0, amountFen: 500 },
})
const fixed = buildConsignmentShareReward(fixedConfig, 9000, 2)
assert.equal(fixed.grossFen, 18000)
assert.equal(fixed.feeFen, 500)
assert.equal(fixed.netFen, 17500)
const noFee = buildConsignmentShareReward(null, 9000, 1)
assert.equal(noFee.feeFen, 0)
assert.equal(noFee.netFen, 9000)
assert.equal(noFee.netPerUnitFen, 9000)
})
test('迁移 151 必须先修复存量再添加 deadline 约束', async () => {
const sql = await readFile(MIGRATION, 'utf8')
const updateIndex = sql.indexOf('UPDATE work_orders')
const constraintIndex = sql.indexOf('work_orders_internal_matching_deadline_check')
assert.ok(updateIndex >= 0, '迁移必须包含存量修复')
assert.ok(constraintIndex > updateIndex, '必须先修复存量再添加约束')
assert.ok(
sql.includes("CHECK (status <> 'internal_matching' OR internal_match_deadline_at IS NOT NULL)"),
'约束必须保证 internal_matching 有截止时间',
)
})
@@ -23,6 +23,7 @@ import {
buildServiceFeeFen,
normalizeConsignmentProfileConfig,
resolveConsignmentProfileForCategory,
type ConsignmentProfileConfig,
type ConsignmentProfileSnapshot,
} from './consignment-profile-service.js'
import {
@@ -36,6 +37,7 @@ import {
classifyConsignmentMatch,
extractPeacekeeperSpecs,
} from './consignment-matching-service.js'
import { resolveOrderTemplateSpecValues } from './consignment-template-spec-service.js'
import { resolveCollectSubmittedFieldValues } from './work-order-material-mappers.js'
import { recordConsignmentMatchLog } from './consignment-match-log-service.js'
import {
@@ -91,6 +93,38 @@ function inventorySalePriceFen(inventory: ConsignmentInventoryRow): number {
return Math.max(0, Math.round(Number(inventory.sale_price_fen || inventory.cost_price_fen || 0)))
}
/**
* 拼单寄卖份额的报酬口径:库存净售价 × 数量。
*
* 整单匹配把 reward_amount 改写为「库存售价 × 数量 - 服务费」,
* 拼单必须同口径,否则卖家会同时拿到订单报酬和整单寄卖货款(双付),
* 多卖家拼单还会因为供货货款只反查一个供应商而漏付。
*/
export function buildConsignmentShareReward(
config: ConsignmentProfileConfig | null | undefined,
grossUnitPriceFen: number,
quantity: number,
): {
grossFen: number
feeFen: number
netFen: number
grossPerUnitFen: number
netPerUnitFen: number
} {
const qty = Math.max(1, Math.round(Number(quantity || 1)))
const grossPerUnitFen = Math.max(0, Math.round(Number(grossUnitPriceFen || 0)))
const grossFen = grossPerUnitFen * qty
const feeFen = config ? buildServiceFeeFen(config, grossFen) : 0
const netFen = Math.max(0, grossFen - feeFen)
return {
grossFen,
feeFen,
netFen,
grossPerUnitFen,
netPerUnitFen: Math.round(netFen / qty),
}
}
export function inventoryMatchesProfile(
inventory: ConsignmentInventoryRow,
profile: ConsignmentProfileSnapshot | null,
@@ -167,6 +201,12 @@ type ConsignmentMatchContext = {
allowNonExact?: boolean
specConflicts?: string[]
partial?: boolean
/**
* 允许作为匹配起点的工单状态。自动匹配只允许 internal_matching;
* 手动匹配允许 internal_matching / unassigned / open,由落库事务内部完成状态流转,
* 避免事务外先改状态、失败后留下无截止时间的 internal_matching。
*/
startStatuses?: string[]
}
function resolveInventorySpecs(
@@ -214,12 +254,37 @@ export function evaluateInventoryMatch(
workOrder: WorkOrderRow,
inventory: ConsignmentInventoryRow,
profile: ConsignmentProfileSnapshot | null,
options: { orderSpecOverride?: ConsignmentSpecValues | null } = {},
): InventoryMatchEvaluation {
const specs = resolveInventorySpecs(inventory, profile)
const orderValues = resolveOrderSpecValues(workOrder)
// 模板驱动配置的规格定义是「分类级事实」:一律用 profile 的 specs,
// 不随库存的 profile_snapshot_json 回退到旧字段,否则旧快照库存会绕过模板匹配。
const isTemplateDriven = profile?.config.specSource === 'template'
const specs = isTemplateDriven ? profile!.config.specs : resolveInventorySpecs(inventory, profile)
const orderValues =
options.orderSpecOverride !== undefined && options.orderSpecOverride !== null
? { ...options.orderSpecOverride }
: resolveOrderSpecValues(workOrder)
const values = resolveInventorySpecValues(inventory)
const hasCustomSpecs = specs.some((spec) => !isBuiltInConsignmentSpecField(spec))
if (isTemplateDriven) {
// 模板驱动规格:订单侧规格来自 product_rule_id → 模板商品名 → 同一套因子解析器
//(由调用方预先解析并经 orderSpecOverride 传入);解析失败(历史订单/模板已删)
// 一律按 unknown 处理,不参与自动匹配,避免把任意库存判成匹配。
const evaluation = evaluateConsignmentSpecMatch({ specs, orderValues, inventoryValues: values })
return {
specs,
values,
orderValues,
exact: evaluation.exact,
partial: evaluation.partial,
unknown: evaluation.unknown || Object.keys(orderValues).length === 0,
conflicts: evaluation.conflicts,
unknowns: evaluation.unknowns,
details: evaluation.details,
}
}
if (!hasCustomSpecs) {
// 内置 5 规格沿用原有苏尔南匹配器(通配、低配不能充高配、品类门禁);
// 只有出现自定义规格时才走通用匹配,避免存量行为回归。
@@ -372,9 +437,38 @@ export async function reenterConsignmentInternalMatching(
return true
}
/**
* 重新匹配失败时的兜底:把工单退回大厅。
*
* 任何曾经进入 `internal_matching` 的路径都必须有退出机制;否则工单会永久悬空
* (既不在大厅,也不会被超时任务处理)。
*/
export async function fallbackConsignmentWorkOrderToHall(
workOrderId: number,
now = nowIso(),
): Promise<boolean> {
const result = await query<{ id: number }>(
`
UPDATE work_orders
SET status = 'open',
assigned_worker_id = NULL,
assigned_at = NULL,
deadline_at = NULL,
published_at = COALESCE(published_at, $1),
internal_match_deadline_at = NULL,
hall_visible_at = NULL,
updated_at = $1
WHERE id = $2 AND status IN ('internal_matching', 'open', 'unassigned', 'in_progress')
RETURNING id
`,
[now, workOrderId],
)
return Boolean(result.rows[0])
}
/**
* 超时「重新匹配」:把工单重新放回内部匹配池,并排除原卖家/原库存。
* 分类关闭匹配或找不到配置时,退回大厅,避免工单卡在无人处理的状态。
* 分类关闭匹配、等待时长为 0 或找不到配置时,退回大厅,避免工单卡在无人处理的状态。
*/
export async function startConsignmentRematch(
workOrderId: number,
@@ -384,28 +478,16 @@ export async function startConsignmentRematch(
const workOrder = await getWorkOrderById(workOrderId)
if (!workOrder) return false
const profile = await resolveProfileForWorkOrder(workOrder)
const fallbackToHall = async () => {
await query(
`
UPDATE work_orders
SET status = 'open',
assigned_worker_id = NULL,
assigned_at = NULL,
deadline_at = NULL,
published_at = COALESCE(published_at, $1),
internal_match_deadline_at = NULL,
hall_visible_at = NULL,
updated_at = $1
WHERE id = $2 AND status IN ('internal_matching', 'open', 'unassigned', 'in_progress')
`,
[now, workOrderId],
)
if (!profile || profile.config.matching.mode === 'off') {
await fallbackConsignmentWorkOrderToHall(workOrderId, now)
return false
}
if (!profile || profile.config.matching.mode === 'off') {
return fallbackToHall()
// 与首次进入内部匹配同口径:0 表示不等待、直接发大厅。
const delayMinutes = resolveInternalMatchDelayMinutes(profile.config.internalMatch)
if (delayMinutes <= 0) {
await fallbackConsignmentWorkOrderToHall(workOrderId, now)
return false
}
const delayMinutes = Math.max(1, Number(profile.config.internalMatch.delayMinutes || 30))
const deadline = addMinutesIso(delayMinutes, now)
const result = await query<WorkOrderRow>(
`
@@ -454,14 +536,93 @@ export async function startConsignmentRematch(
now,
],
)
await recordConsignmentMatchLog({
workOrderId: Number(workOrderId),
workOrderNo: workOrder.work_order_no || '',
profileId: profile.profileId,
categoryId: profile.categoryId,
triggerType: 'rematch',
result: 'matched',
reasonCode: 'rematch_started',
reasonText: '超时重新进入内部匹配',
payload: {
delayMinutes,
deadline,
excludedWorkerId: input.excludeWorkerId ? Number(input.excludeWorkerId) : null,
excludedInventoryId: input.excludeInventoryId ? Number(input.excludeInventoryId) : null,
},
})
return true
}
/**
* 自动匹配允许 partial 的前提:订单侧至少要有一个可确认的规格信号。
*
* `exactOnly=false` 的本意是允许「高配充低配」,不是允许「订单什么都没写也随便发」。
* 旧口径的宽松匹配会把缺失系统/区服/系列全部判成 unknown 并在 conflicts 里丢掉,
* 如果不加这道门槛,标题只写「苏尔南手枪」的订单会被自动匹配到任意在库库存。
*/
function hasPositiveSpecSignal(
workOrder: WorkOrderRow,
evaluation: InventoryMatchEvaluation,
): boolean {
const fields = resolveCollectSubmittedFieldValues(workOrder)
const orderSpecs = extractPeacekeeperSpecs(workOrder.product_name, {
system: fields.system,
serverZone: fields.serverZone,
series: fields.series,
})
if (orderSpecs.system || orderSpecs.serverZone || orderSpecs.series) return true
// 自定义规格:订单侧至少有一项与库存一致或库存更高,才允许自动匹配。
return evaluation.details.some(
(detail) => detail.status === 'match' || detail.status === 'partial',
)
}
/**
* 模板驱动配置的订单侧规格:product_rule_id → 模板商品名 → 同一套因子解析器。
* 仅对 specSource='template' 的配置生效;其它配置返回 null(沿用旧解析)。
* 每单只解析一次;解析失败返回 null(evaluateInventoryMatch 会按 unknown 处理,
* 不参与自动匹配 —— 宁可漏配也不错配)。
*/
const orderSpecOverrideCache = new Map<string, Promise<ConsignmentSpecValues | null>>()
export function resolveOrderSpecOverride(
workOrder: WorkOrderRow,
profile: ConsignmentProfileSnapshot | null,
): Promise<ConsignmentSpecValues | null> {
if (profile?.config.specSource !== 'template') return Promise.resolve(null)
const cacheKey = `${Number(workOrder.id)}:${profile.profileId}`
const cached = orderSpecOverrideCache.get(cacheKey)
if (cached) return cached
const pending = (async () => {
try {
const values = await resolveOrderTemplateSpecValues({
productRuleId: workOrder.product_rule_id,
specs: profile.config.specs,
})
return values ? (values as ConsignmentSpecValues) : null
} catch {
return null
}
})()
orderSpecOverrideCache.set(cacheKey, pending)
if (orderSpecOverrideCache.size > 512) {
const oldest = orderSpecOverrideCache.keys().next().value
if (oldest) orderSpecOverrideCache.delete(oldest)
}
return pending
}
function pickCandidateInventories(
workOrder: WorkOrderRow,
profile: ConsignmentProfileSnapshot | null,
inventories: ConsignmentInventoryRow[],
options: { allowPartial?: boolean; preferLowReward?: boolean } = {},
options: {
allowPartial?: boolean
preferLowReward?: boolean
orderSpecOverride?: ConsignmentSpecValues | null
} = {},
): ConsignmentMatchInventoryCandidate[] {
const allowPartial = Boolean(options.allowPartial)
const preferLowReward = options.preferLowReward !== false
@@ -471,13 +632,15 @@ function pickCandidateInventories(
.filter((inventory) => !isRematchExcludedInventory(workOrder, inventory))
.map((inventory) => ({
inventory,
evaluation: evaluateInventoryMatch(workOrder, inventory, profile),
evaluation: evaluateInventoryMatch(workOrder, inventory, profile, {
orderSpecOverride: options.orderSpecOverride ?? null,
}),
}))
.filter(
({ evaluation }) =>
evaluation.exact ||
(allowPartial && evaluation.partial && evaluation.conflicts.length === 0),
)
.filter(({ evaluation }) => {
if (evaluation.exact) return true
if (!allowPartial || !evaluation.partial || evaluation.conflicts.length > 0) return false
return hasPositiveSpecSignal(workOrder, evaluation)
})
.sort((a, b) => compareInventoryCandidates(a, b, preferLowReward))
}
@@ -499,10 +662,20 @@ async function applyWholeOrderMatch(
[workOrder.id],
)
const current = locked.rows[0]
if (!current || current.status !== WORK_ORDER_STATUS.INTERNAL_MATCHING) {
const allowedStartStatuses =
context.startStatuses ?? ([WORK_ORDER_STATUS.INTERNAL_MATCHING] as string[])
if (!current || !allowedStartStatuses.includes(current.status)) {
return { applied: false }
}
if (current.assigned_worker_id) return { applied: false }
// 已有打手参与的拼单份额时不能再整单指派,否则会出现「整单接单人 + 拼单份额」混合状态。
const shareCount = await client.query<{ count: number }>(
`SELECT COUNT(*)::int AS count
FROM work_order_shares
WHERE work_order_id = $1 AND status IN ('joined', 'submitted', 'accepted')`,
[current.id],
)
if (Number(shareCount.rows[0]?.count || 0) > 0) return { applied: false }
const material = parseMaterial(current.material_json)
const source = parseMaterial(material.source)
@@ -598,7 +771,7 @@ async function applyWholeOrderMatch(
rematch_excluded_worker_id = NULL,
rematch_excluded_inventory_id = NULL,
updated_at = $3
WHERE id = $9 AND status = 'internal_matching'
WHERE id = $9
RETURNING *
`,
[
@@ -621,7 +794,7 @@ async function applyWholeOrderMatch(
actorType: manual ? 'admin' : 'system',
actorId: manual ? context.actorName || 'admin' : 'consignment_match',
eventType: manual ? 'consignment_manual_matched' : 'consignment_auto_matched',
fromStatus: WORK_ORDER_STATUS.INTERNAL_MATCHING,
fromStatus: current.status,
toStatus: WORK_ORDER_STATUS.IN_PROGRESS,
payloadJson: JSON.stringify({
inventoryId: Number(inventory.id),
@@ -682,7 +855,9 @@ async function applySharingMatch(
[workOrder.id],
)
const current = locked.rows[0]
if (!current || current.status !== WORK_ORDER_STATUS.INTERNAL_MATCHING) {
const allowedStartStatuses =
context.startStatuses ?? ([WORK_ORDER_STATUS.INTERNAL_MATCHING] as string[])
if (!current || !allowedStartStatuses.includes(current.status)) {
return { createdCount: 0, sms: [] }
}
@@ -698,12 +873,10 @@ async function applySharingMatch(
const timeoutMinutes = config?.timeout.minutes ?? 30
const timeoutPolicy = config?.timeout.policy ?? 'reopen'
const deadline = addMinutesIso(timeoutMinutes, now)
const unitRewardFen =
// 库存没有售价时退回订单单价,避免份额报酬为 0;有售价时始终以库存净售价为准。
const fallbackOrderUnitRewardFen =
Math.max(0, Number(current.sharing_unit_reward || 0)) ||
Math.max(0, Number(current.reward_amount || 0)) / totalQuantity
const shareGrossFenPerUnit = Math.max(0, Math.round(unitRewardFen))
const shareFeeFenPerUnit = config ? buildServiceFeeFen(config, shareGrossFenPerUnit) : 0
const shareNetFenPerUnit = Math.max(0, shareGrossFenPerUnit - shareFeeFenPerUnit)
const material = parseMaterial(current.material_json)
const source = parseMaterial(material.source)
const shopName = String(source.shopName || source.shopTitle || '').trim()
@@ -712,6 +885,9 @@ async function applySharingMatch(
let createdQuantity = 0
let firstInventoryId: number | null = null
let firstInventoryQuantity = 0
let firstShareGrossFenPerUnit = 0
let firstShareFeeFenPerUnit = 0
let firstShareNetFenPerUnit = 0
const smsPayloads: ConsignmentMatchSmsPayload[] = []
for (const { inventory } of candidates) {
if (remaining <= 0) break
@@ -724,15 +900,18 @@ async function applySharingMatch(
? available
: Math.min(available, remaining)
if (take <= 0) continue
const shareGrossFen = shareGrossFenPerUnit * take
const shareFeeFen = config ? buildServiceFeeFen(config, shareGrossFen) : 0
const shareNetFen = Math.max(0, shareGrossFen - shareFeeFen)
// 拼单寄卖的份额报酬按「库存售价」而不是订单单价结算:
// 卖家交付的是寄卖货,整单与拼单必须同口径(净售价 × 数量),
// 否则拼单会同时拿到订单报酬和寄卖货款,造成双付/多卖家漏付。
const shareGrossFenPerUnit =
inventorySalePriceFen(inventory) || Math.max(0, Math.round(fallbackOrderUnitRewardFen))
const shareReward = buildConsignmentShareReward(config, shareGrossFenPerUnit, take)
const allocation = await reserveConsignmentInventoryWithClient(client, {
inventoryId: Number(inventory.id),
workOrderId: Number(current.id),
quantity: take,
unitPriceFen: inventorySalePriceFen(inventory),
unitPriceFen: shareReward.grossPerUnitFen,
now,
boundOrderId: current.order_id ? Number(current.order_id) : null,
boundPlatformOrderId: current.platform_order_id || '',
@@ -756,8 +935,8 @@ async function applySharingMatch(
[
current.id,
Number(inventory.worker_id),
shareGrossFenPerUnit,
shareNetFen,
shareReward.netPerUnitFen,
shareReward.netFen,
deadline,
timeoutMinutes,
timeoutPolicy,
@@ -785,6 +964,9 @@ async function applySharingMatch(
if (!firstInventoryId) {
firstInventoryId = Number(inventory.id)
firstInventoryQuantity = take
firstShareGrossFenPerUnit = shareReward.grossPerUnitFen
firstShareFeeFenPerUnit = Math.round(shareReward.feeFen / take)
firstShareNetFenPerUnit = shareReward.netPerUnitFen
}
remaining -= take
}
@@ -795,14 +977,26 @@ async function applySharingMatch(
active >= totalQuantity
? WORK_ORDER_STATUS.IN_PROGRESS
: WORK_ORDER_STATUS.INTERNAL_MATCHING
// 从大厅/待分配手动拼单、只匹配到部分份额时,必须补上新的内部匹配截止时间,
// 不能让工单停在「internal_matching 且 deadline 为空」的永久悬空状态。
const fallbackDeadline =
nextStatus === WORK_ORDER_STATUS.INTERNAL_MATCHING
? addMinutesIso(Math.max(1, resolveInternalMatchDelayMinutes(config?.internalMatch)), now)
: null
await client.query(
`
UPDATE work_orders
SET status = $1,
material_json = $2::jsonb,
assigned_at = CASE WHEN $1 = 'in_progress' THEN $3 ELSE assigned_at END,
internal_match_deadline_at = CASE WHEN $1 = 'in_progress' THEN NULL ELSE internal_match_deadline_at END,
hall_visible_at = CASE WHEN $1 = 'in_progress' THEN NULL ELSE hall_visible_at END,
internal_match_deadline_at = CASE
WHEN $1 = 'in_progress' THEN NULL
WHEN internal_match_deadline_at IS NULL THEN $5
ELSE internal_match_deadline_at END,
hall_visible_at = CASE
WHEN $1 = 'in_progress' THEN NULL
WHEN hall_visible_at IS NULL THEN $5
ELSE hall_visible_at END,
internal_match_attempts = COALESCE(internal_match_attempts, 0) + 1,
rematch_excluded_worker_id = CASE WHEN $1 = 'in_progress' THEN NULL ELSE rematch_excluded_worker_id END,
rematch_excluded_inventory_id = CASE WHEN $1 = 'in_progress' THEN NULL ELSE rematch_excluded_inventory_id END,
@@ -845,6 +1039,7 @@ async function applySharingMatch(
}),
now,
current.id,
fallbackDeadline,
],
)
await createWorkOrderEventWithClient(client, {
@@ -852,15 +1047,16 @@ async function applySharingMatch(
actorType: manual ? 'admin' : 'system',
actorId: manual ? context.actorName || 'admin' : 'consignment_match',
eventType: manual ? 'consignment_manual_sharing_matched' : 'consignment_sharing_matched',
fromStatus: WORK_ORDER_STATUS.INTERNAL_MATCHING,
fromStatus: current.status,
toStatus: nextStatus,
payloadJson: JSON.stringify({
createdCount,
createdQuantity,
activeQuantity: active,
totalQuantity,
shareRewardFenPerUnit: shareNetFenPerUnit,
serviceFeeFenPerUnit: shareFeeFenPerUnit,
shareRewardFenPerUnit: firstShareNetFenPerUnit,
serviceFeeFenPerUnit: firstShareFeeFenPerUnit,
grossPriceFenPerUnit: firstShareGrossFenPerUnit,
matchType: manual ? 'manual_sharing' : 'auto_sharing',
...(manual
? {
@@ -890,11 +1086,26 @@ export async function autoMatchConsignmentWorkOrders(options: { limit?: number }
`
SELECT * FROM work_orders
WHERE status = 'internal_matching'
AND (internal_match_deadline_at IS NULL OR internal_match_deadline_at > $2)
ORDER BY reward_amount ASC, created_at ASC
LIMIT $1
`,
[limit],
[limit, nowIso()],
)
// 一轮调度只查一次在库库存;成功匹配后再刷新,避免 50 单 × 全量库存扫描。
let inventories = await listActiveConsignmentInventoriesForMatching()
const profileCache = new Map<string, Promise<ConsignmentProfileSnapshot | null>>()
const resolveProfileCached = (workOrder: WorkOrderRow) => {
const key = workOrder.consignment_profile_id
? `profile:${Number(workOrder.consignment_profile_id)}`
: `category:${Number(workOrder.category_id || 0)}`
let pending = profileCache.get(key)
if (!pending) {
pending = resolveProfileForWorkOrder(workOrder)
profileCache.set(key, pending)
}
return pending
}
let matched = 0
for (const workOrder of orders.rows) {
const startedAt = Date.now()
@@ -907,16 +1118,16 @@ export async function autoMatchConsignmentWorkOrders(options: { limit?: number }
}
let profileRef: ConsignmentProfileSnapshot | null = null
try {
const profile = await resolveProfileForWorkOrder(workOrder)
const profile = await resolveProfileCached(workOrder)
profileRef = profile
if (!profile) continue
const logBase = { ...baseLog, profileId: profile.profileId, categoryId: profile.categoryId }
if (profile.config.matching.mode !== 'auto') continue
const inventories = await listActiveConsignmentInventoriesForMatching()
const matching = profile.config.matching
const candidates = pickCandidateInventories(workOrder, profile, inventories, {
allowPartial: matching.exactOnly === false,
preferLowReward: matching.preferLowReward !== false,
orderSpecOverride: await resolveOrderSpecOverride(workOrder, profile),
})
if (candidates.length === 0) {
if (shouldLogMatchSkip(`no_candidate:${workOrder.id}`)) {
@@ -958,6 +1169,8 @@ export async function autoMatchConsignmentWorkOrders(options: { limit?: number }
inventoryIds: candidates.map(({ inventory }) => Number(inventory.id)),
},
})
// 本轮已有库存被占用,刷新快照,避免后续工单继续拿旧剩余数量做候选。
inventories = await listActiveConsignmentInventoriesForMatching()
} else {
await recordConsignmentMatchLog({
...logBase,
@@ -1010,6 +1223,7 @@ export async function autoMatchConsignmentWorkOrders(options: { limit?: number }
inventoryId: Number(chosen.inventory.id),
},
})
inventories = await listActiveConsignmentInventoriesForMatching()
} else {
await recordConsignmentMatchLog({
...logBase,
@@ -1050,8 +1264,7 @@ export async function expireConsignmentInternalMatching(options: { now?: string
internal_match_deadline_at = NULL,
updated_at = $1
WHERE status = 'internal_matching'
AND internal_match_deadline_at IS NOT NULL
AND internal_match_deadline_at <= $1
AND (internal_match_deadline_at IS NULL OR internal_match_deadline_at <= $1)
RETURNING *
`,
[now],
@@ -1077,17 +1290,21 @@ export async function listConsignmentMatchCandidatesForWorkOrder(workOrderId: nu
throw createHttpError('工单不存在', { statusCode: 404, errorCode: 'work_order_not_found' })
}
const profile = await resolveProfileForWorkOrder(workOrder)
const orderSpecOverride = await resolveOrderSpecOverride(workOrder, profile)
const inventories = (await listActiveConsignmentInventoriesForMatching()).filter(
(inventory) =>
inventoryMatchesProfile(inventory, profile) &&
Number(inventory.remaining_quantity ?? 1) > 0 &&
inventory.status === 'in_stock' &&
inventory.allow_matching,
inventory.allow_matching &&
!isRematchExcludedInventory(workOrder, inventory),
)
const evaluated = inventories
.map((inventory) => ({
inventory,
evaluation: evaluateInventoryMatch(workOrder, inventory, profile),
evaluation: evaluateInventoryMatch(workOrder, inventory, profile, {
orderSpecOverride,
}),
}))
.sort((a, b) =>
compareInventoryCandidates(a, b, profile?.config.matching.preferLowReward !== false),
@@ -1214,18 +1431,22 @@ async function runManualConsignmentMatch(input: {
})
}
const profile = await resolveProfileForWorkOrder(workOrder)
const orderSpecOverride = await resolveOrderSpecOverride(workOrder, profile)
const inventories = (await listActiveConsignmentInventoriesForMatching()).filter(
(inventory) =>
inventoryMatchesProfile(inventory, profile) &&
Number(inventory.remaining_quantity ?? 1) > 0 &&
!isRematchExcludedInventory(workOrder, inventory),
)
const selected = input.inventoryIds
.map((id) => inventories.find((inventory) => Number(inventory.id) === Number(id)))
const inventoryIds = [...new Set(input.inventoryIds.map((id) => Number(id)))]
const selected = inventoryIds
.map((id) => inventories.find((inventory) => Number(inventory.id) === id))
.filter((inventory): inventory is ConsignmentInventoryRow => Boolean(inventory))
.map((inventory) => ({
inventory,
evaluation: evaluateInventoryMatch(workOrder, inventory, profile),
evaluation: evaluateInventoryMatch(workOrder, inventory, profile, {
orderSpecOverride,
}),
}))
if (selected.length === 0) {
@@ -1267,30 +1488,27 @@ async function runManualConsignmentMatch(input: {
...evaluation.conflicts,
...evaluation.unknowns,
]),
}
const appliedWorkOrder = { ...workOrder, status: WORK_ORDER_STATUS.INTERNAL_MATCHING }
if (!workOrder.sharing_enabled) {
await query(
`UPDATE work_orders SET status = 'internal_matching', updated_at = $1 WHERE id = $2 AND status = ANY($3::text[])`,
[nowIso(), workOrder.id, [WORK_ORDER_STATUS.UNASSIGNED, WORK_ORDER_STATUS.OPEN]],
)
// 手动匹配允许从大厅/待分配直接落库;状态流转在 apply 事务内部完成,
// 校验失败(数量不足、并发占库)时不会把工单改成无截止时间的 internal_matching。
startStatuses: [
WORK_ORDER_STATUS.INTERNAL_MATCHING,
WORK_ORDER_STATUS.UNASSIGNED,
WORK_ORDER_STATUS.OPEN,
],
}
if (workOrder.sharing_enabled && Number(workOrder.sharing_total_quantity || 1) > 1) {
// 手动匹配前先把工单置为内部匹配中,复用同一套份额事务。
await query(
`UPDATE work_orders SET status = 'internal_matching', updated_at = $1 WHERE id = $2 AND status = ANY($3::text[])`,
[nowIso(), workOrder.id, [WORK_ORDER_STATUS.UNASSIGNED, WORK_ORDER_STATUS.OPEN]],
)
const created = await applySharingMatch(
{ workOrder: appliedWorkOrder, profile },
selected,
context,
)
const created = await applySharingMatch({ workOrder, profile }, selected, context)
return { matched: created > 0, matchedCount: created }
}
if (selected.length > 1) {
throw createHttpError('整单匹配只能选择一条寄卖库存,请取消多余选择后重试', {
statusCode: 400,
errorCode: 'consignment_multiple_inventory_not_allowed',
})
}
const requiredQuantity = Math.max(1, Number(workOrder.sharing_total_quantity || 1))
const chosenInventory = selected[0]!.inventory
const availableQuantity = Number(chosenInventory.remaining_quantity ?? 1)
@@ -1304,11 +1522,7 @@ async function runManualConsignmentMatch(input: {
{ statusCode: 409, errorCode: 'consignment_inventory_quantity_insufficient' },
)
}
const applied = await applyWholeOrderMatch(
{ workOrder: appliedWorkOrder, profile },
selected[0]!,
context,
)
const applied = await applyWholeOrderMatch({ workOrder, profile }, selected[0]!, context)
return { matched: applied, matchedCount: applied ? 1 : 0 }
}
@@ -1321,14 +1535,24 @@ export async function releaseConsignmentForWorkOrder(workOrderId: number): Promi
}
let matchTimer: NodeJS.Timeout | null = null
let matchTickRunning = false
/** 轻量调度:每 30 秒扫描一次内部匹配与超时;unref 不阻塞进程退出。 */
export function startConsignmentMatchScheduler(intervalMs = 30_000) {
if (matchTimer) return
matchTimer = setInterval(
() => {
void autoMatchConsignmentWorkOrders().catch(() => undefined)
void expireConsignmentInternalMatching().catch(() => undefined)
// 上一轮还没跑完(库存多/短信慢)时跳过本轮,避免叠加扫描。
if (matchTickRunning) return
matchTickRunning = true
void (async () => {
try {
await autoMatchConsignmentWorkOrders().catch(() => undefined)
await expireConsignmentInternalMatching().catch(() => undefined)
} finally {
matchTickRunning = false
}
})()
},
Math.max(5_000, intervalMs),
)
@@ -314,6 +314,7 @@ async function main() {
sharing?: { total: number; unitReward: number }
internalDeadline?: string | null
acceptanceMode?: string
productRuleId?: number | null
}) {
const material = {
collect: {
@@ -330,8 +331,8 @@ async function main() {
`INSERT INTO work_orders
(work_order_no, product_name, category_id, status, reward_amount, material_json,
sharing_enabled, sharing_total_quantity, sharing_unit_reward,
internal_match_deadline_at, hall_visible_at, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $8, $9, $10, $10, NOW(), NOW())
internal_match_deadline_at, hall_visible_at, product_rule_id, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $8, $9, $10, $10, $11, NOW(), NOW())
RETURNING *`,
[
input.no,
@@ -344,6 +345,7 @@ async function main() {
input.sharing?.total || 1,
input.sharing?.unitReward || 0,
input.internalDeadline || null,
input.productRuleId || null,
],
)
if (input.acceptanceMode) {
@@ -1406,6 +1408,145 @@ async function main() {
assert(Number(item.workOrderId) === Number(legacyWo.id), `workOrderId=${item.workOrderId}`)
})
// ---------- 11. 模板驱动规格(specSource=template) ----------
await check('模板驱动规格:提案→配置→建议价→发布→自动匹配 全链路', async () => {
// 造两条模板规则(密钥式:等级×鉴定状态),挂在测试分类下
const rulesRes = await query<{ rule_key: string; id: number }>(
`INSERT INTO work_product_rules
(rule_key, product_name, match_type, category_id, enabled, auto_create,
reward_amount, requirement_json, sort_order, created_at, updated_at, pricing_mode)
VALUES
($1, $2, 'contains', $4, TRUE, FALSE, 18000, '{}'::jsonb, 100, NOW(), NOW(), 'fixed'),
($3, $5, 'contains', $4, TRUE, FALSE, 9000, '{}'::jsonb, 101, NOW(), NOW(), 'fixed')
RETURNING id, rule_key`,
[
`CT-TPL-SS-${suffix}`,
'SS级 SCAR-L 无定潮汐(未鉴定随机)',
`CT-TPL-A-${suffix}`,
categoryId,
'A级 SCAR-L 无定潮汐(未鉴定随机)',
],
)
const ruleKeys = rulesRes.rows.map((row) => row.rule_key)
// 生成提案:两条都能拆 grade×identified → factor 路径
const specsFromRules = [
{
key: 'grade',
label: '等级',
type: 'select',
required: true,
options: [
{ label: 'SS级', value: 'SS级', enabled: true, ruleKey: ruleKeys[0] },
{ label: 'A级', value: 'A级', enabled: true, ruleKey: ruleKeys[1] },
],
placeholder: '',
helpText: '',
matchMode: 'exact',
wildcardValues: [],
showInItemName: true,
filterable: true,
sortOrder: 10,
},
{
key: 'identified',
label: '鉴定状态',
type: 'select',
required: true,
options: [{ label: '未鉴定', value: '未鉴定', enabled: true, ruleKey: ruleKeys[0] }],
placeholder: '',
helpText: '',
matchMode: 'exact',
wildcardValues: [],
showInItemName: true,
filterable: true,
sortOrder: 20,
},
]
// 把配置切成模板驱动(draft+published 同步改,模拟「生成后重新发布」)
const templateProfileConfig = {
...profileConfig,
specs: specsFromRules,
specSource: 'template',
pricing: { ...pricing, minPriceFen: 0 },
}
await query(`UPDATE consignment_profiles SET config_json = $1::jsonb WHERE id = $2`, [
JSON.stringify(templateProfileConfig),
profileId,
])
await query(`UPDATE consignment_profile_versions SET config_json = $1::jsonb WHERE id = $2`, [
JSON.stringify(templateProfileConfig),
versionId,
])
// 建议价:SS级+未鉴定 → 18000;配置 10 折 → 建议价 18000
const suggestion = await getWorkerConsignmentPriceSuggestion({
profileId,
values: { grade: 'SS级', identified: '未鉴定' },
})
assert(
suggestion.templatePriceFen === 18000,
`SS级模板价应为 18000,实际 ${suggestion.templatePriceFen}`,
)
assert(
suggestion.suggestedPriceFen === 18000,
`建议价应为 18000,实际 ${suggestion.suggestedPriceFen}`,
)
// 发布一条寄卖(模板驱动规格)
const tplInv = await publishWorkerConsignment(sellerId, {
profileId,
quantity: 1,
specValues: { grade: 'SS级', identified: '未鉴定' },
askingPriceFen: 18000,
sellerContact: `tpl_${suffix}`,
sellerPhone: '13800000001',
characterName: '模板测试',
characterId: 'tpl-001',
screenshotsJson: ['https://example.com/tpl.png'],
contactSupporterId: contactId,
sellerNote: 'ctest template spec',
})
assert(tplInv, '模板驱动规格发布必须成功')
// 审核入库
await approveAdminConsignmentSubmission(
Number(tplInv.id),
{ costPriceFen: 10000 },
'ctest-admin',
)
// 造一张由 SS级 模板生成的工单(product_rule_id 指向 SS 模板)→ 自动匹配
const tplWo = await createWorkOrder({
no: `CT-WO-TPL-${suffix}`,
status: 'internal_matching',
internalDeadline: new Date(Date.now() + 30 * 60 * 1000).toISOString(),
productRuleId: rulesRes.rows[0]!.id,
})
const autoResult = await autoMatchConsignmentWorkOrders({ limit: 50 })
assert(autoResult.matched >= 1, `模板驱动自动匹配失败 matched=${autoResult.matched}`)
const tplWoRow = await query<Record<string, any>>('SELECT * FROM work_orders WHERE id = $1', [
Number(tplWo.id),
])
assert(
tplWoRow.rows[0]!.status === 'in_progress',
`模板驱动工单应被自动匹配,实际 ${tplWoRow.rows[0]!.status}`,
)
// 匹配的报酬应扣 5% 服务费:18000 × 0.95 = 17100
assert(
Number(tplWoRow.rows[0]!.reward_amount) === 17100,
`模板驱动匹配后报酬应为 17100,实际 ${tplWoRow.rows[0]!.reward_amount}`,
)
// 释放(回库)后清理模板规则,恢复原配置
await releaseConsignmentForWorkOrder(Number(tplWo.id), 'ctest 模板链路验证')
await query(`DELETE FROM work_product_rules WHERE rule_key = ANY($1)`, [ruleKeys])
await query(`UPDATE consignment_profiles SET config_json = $1::jsonb WHERE id = $2`, [
JSON.stringify(profileConfig),
profileId,
])
await query(`UPDATE consignment_profile_versions SET config_json = $1::jsonb WHERE id = $2`, [
JSON.stringify(profileConfig),
versionId,
])
})
// ---------- 输出 ----------
console.log('\n========== 结果 ==========')
for (const result of results) {
@@ -108,7 +108,7 @@ test('指派链路必须按“指派对象===供货卖家”判定报酬渠道
)
})
test('拼单份额全部验收(母单转已验收)也必须补寄卖钩子,否则货款漏付、押金挂死', async () => {
test('拼单份额报酬按库存净售价结算,验收收官不得再走整单供货货款(避免双付/漏付)', async () => {
const source = await readFile(SETTLEMENT_REPO, 'utf8')
const start = source.indexOf('export async function acceptWorkOrderShareAndSettle')
assert.ok(start > 0, '未找到份额单独验收函数')
@@ -136,8 +136,28 @@ test('拼单份额全部验收(母单转已验收)也必须补寄卖钩子
'份额验收收官时必须释放卖家发布押金',
)
assert.ok(
finalizeBody.includes('settleConsignmentSupplierPayoutByWorkOrderWithClient('),
'份额验收收官时必须结算供货货款(拼单母单无接单人,传 0 走供货渠道)',
finalizeBody.includes('shareSubmissionResult') &&
finalizeBody.includes('ci.source_submission_id'),
'多个供货卖家必须按份额逐条释放发布押金',
)
assert.ok(
!finalizeBody.includes('settleConsignmentSupplierPayoutByWorkOrderWithClient('),
'份额报酬已包含寄卖货款,收官不能再按整单反查一个供应商重复付款',
)
const engine = await readFile(
new URL('./consignment-match-engine-service.ts', import.meta.url),
'utf8',
)
assert.ok(
engine.includes('const shareGrossFenPerUnit =') &&
engine.includes('inventorySalePriceFen(inventory) ||'),
'拼单份额报酬必须以库存售价为基准',
)
assert.ok(
engine.includes('buildConsignmentShareReward(config, shareGrossFenPerUnit, take)') &&
engine.includes('shareReward.netFen'),
'拼单份额报酬必须走统一计算并写入 share_reward(净售价 × 数量)',
)
})
@@ -90,9 +90,9 @@ test('降价提示:未到时长不提示,到点后按比例给出建议售
assert.equal(suggestion?.dropHours, 24)
})
test('合并迁移 142 必须包含寄卖配置、库存数量/售价与分配表', async () => {
test('合并迁移 147 必须包含寄卖配置、库存数量/售价与分配表', async () => {
const source = await readFile(
new URL('../../db/migrations/142_consignment_platform.sql', import.meta.url),
new URL('../../db/migrations/147_consignment_platform.sql', import.meta.url),
'utf8',
)
for (const fragment of [
@@ -104,7 +104,7 @@ test('合并迁移 142 必须包含寄卖配置、库存数量/售价与分配
'internal_match_deadline_at',
'consignment_inventory_id',
]) {
assert.ok(source.includes(fragment), `合并迁移 142 缺少 ${fragment}`)
assert.ok(source.includes(fragment), `合并迁移 147 缺少 ${fragment}`)
}
})
@@ -85,6 +85,12 @@ export type ConsignmentProfileConfig = {
fields: ConsignmentPublishFields
/** 动态规格字段(完全由运营配置;默认空) */
specs: ConsignmentSpecField[]
/**
* 规格来源:
* - manual(默认):运营手工维护 specs;
* - template:由接单模板反推生成(因子字段或商品名下拉),选项带 ruleKey,取价按模板实时回查。
*/
specSource: 'manual' | 'template'
/** 物品名模板,支持 {system}/{series}/{category} 等占位符 */
nameTemplate: string
pricing: ConsignmentPricingConfig
@@ -143,6 +149,7 @@ export const DEFAULT_CONSIGNMENT_PROFILE_CONFIG: ConsignmentProfileConfig = {
quantity: true,
},
specs: DEFAULT_CONSIGNMENT_SPECS,
specSource: 'manual',
nameTemplate: '',
pricing: DEFAULT_CONSIGNMENT_PRICING,
deposit: {
@@ -275,6 +282,7 @@ export function normalizeConsignmentProfileConfig(value: unknown): ConsignmentPr
quantity: bool(fields.quantity, DEFAULT_CONSIGNMENT_PROFILE_CONFIG.fields.quantity),
},
specs,
specSource: source.specSource === 'template' ? 'template' : 'manual',
nameTemplate:
text(source.nameTemplate ?? source.name_template, 300) ||
DEFAULT_CONSIGNMENT_PROFILE_CONFIG.nameTemplate,
@@ -35,7 +35,7 @@ function option(label: string, value: string) {
return { label, value, enabled: true }
}
/** 苏尔南手枪种子配置的五个字段(与合并迁移 142 一致)。 */
/** 苏尔南手枪种子配置的五个字段(与合并迁移 147 一致)。 */
function surnanSpecs(): ConsignmentSpecField[] {
return [
{
@@ -262,6 +262,44 @@ test('动态规格:物品名模板与规格展示文本', () => {
assert.equal(fallback, '苏尔南手枪')
})
test('动态规格:商品下拉不带进物品名时,用选中的商品名作物品名', () => {
const productSpec: ConsignmentSpecField = {
key: 'product',
label: '商品',
type: 'select',
required: true,
options: [
{ label: '指挥官密钥', value: '9', enabled: true, ruleKey: 'bb' },
{ label: '碎梦浮光珍宝礼盒', value: '15', enabled: true, ruleKey: 'cc' },
],
placeholder: '',
helpText: '',
matchMode: 'exact',
wildcardValues: [],
showInItemName: false,
filterable: true,
sortOrder: 10,
}
const name = buildConsignmentItemName({
template: '',
specs: [productSpec],
values: { product: '9' },
categoryName: '密钥',
fallbackSuffix: '',
})
assert.equal(name, '指挥官密钥', '商品下拉必须用商品名,不能退化成分类名或历史硬编码后缀')
// 没有分类配置的存量链路仍保留历史后缀
const legacy = buildConsignmentItemName({
template: '',
specs: [],
values: {},
categoryName: '',
fallbackSuffix: '苏尔南',
})
assert.equal(legacy, '苏尔南')
})
test('动态价格:模板折扣价与改价上限等于建议价', () => {
const result = computeConsignmentPrice({
pricing: buildPricing({ discount: 9.5 }),
@@ -457,9 +495,9 @@ test('动态规格:旧列仍由兼容 key 同步', () => {
})
})
test('合并迁移 142 必须包含动态规格的 JSON、匹配 key、文本与改价上限', async () => {
test('合并迁移 147 必须包含动态规格的 JSON、匹配 key、文本与改价上限', async () => {
const sql = await readFile(
new URL('../../db/migrations/142_consignment_platform.sql', import.meta.url),
new URL('../../db/migrations/147_consignment_platform.sql', import.meta.url),
'utf8',
)
for (const fragment of [
@@ -469,13 +507,13 @@ test('合并迁移 142 必须包含动态规格的 JSON、匹配 key、文本与
'price_cap_fen',
'CREATE INDEX IF NOT EXISTS idx_consignment_inventories_spec_key',
]) {
assert.ok(sql.includes(fragment), `合并迁移 142 缺少 ${fragment}`)
assert.ok(sql.includes(fragment), `合并迁移 147 缺少 ${fragment}`)
}
})
test('合并迁移 142 必须为苏尔南手枪写入已发布的五个单选字段配置', async () => {
test('合并迁移 147 必须为苏尔南手枪写入已发布的五个单选字段配置', async () => {
const sql = await readFile(
new URL('../../db/migrations/142_consignment_platform.sql', import.meta.url),
new URL('../../db/migrations/147_consignment_platform.sql', import.meta.url),
'utf8',
)
for (const fragment of [
@@ -485,6 +523,6 @@ test('合并迁移 142 必须为苏尔南手枪写入已发布的五个单选字
"'published'",
'"key": "brillianceCount"',
]) {
assert.ok(sql.includes(fragment), `合并迁移 142 缺少 ${fragment}`)
assert.ok(sql.includes(fragment), `合并迁移 147 缺少 ${fragment}`)
}
})
@@ -3,7 +3,7 @@
*
* 设计约定:
* - 没有任何「内置字段」:配置初始 specs 为空,字段全部由运营在寄卖配置里维护;
* 苏尔南手枪的五个字段由迁移 142 作为种子配置写入;
* 苏尔南手枪的五个字段由合并迁移(现 147)作为种子配置写入;
* - 字段类型支持:文本 / 长文本 / 下拉 / 单选 / 数字 / 日期;
* - 价格只有两种:接单模板价打折、模板价固定下调;打手只能按系统建议价格往下调;
* - 旧列兼容:字段 key 仍为 system/serverZone/series/diamondLevel/brillianceCount 时,
@@ -27,6 +27,11 @@ export type ConsignmentSpecOption = {
label: string
value: string
enabled: boolean
/**
* 模板驱动规格:该选项对应的接单模板规则键(work_product_rules.rule_key)。
* 只存键不存价格:取价时实时读模板,模板调价即时生效,停用/删除则该组合无价(block)。
*/
ruleKey?: string
}
export type ConsignmentSpecField = {
@@ -326,10 +331,12 @@ function normalizeOption(value: unknown): ConsignmentSpecOption | null {
const source = record(value)
const optionValue = text(source.value ?? source.key ?? source.label, 100)
if (!optionValue) return null
const ruleKey = text(source.ruleKey ?? source.rule_key, 100)
return {
label: text(source.label, 80) || optionValue,
value: optionValue,
enabled: bool(source.enabled, true),
...(ruleKey ? { ruleKey } : {}),
}
}
@@ -756,8 +763,14 @@ export function buildConsignmentItemName(input: {
(_, key: string) => tokenValues.get(key) || '',
)
} else {
result = specs
.filter((field) => field.showInItemName)
// 优先用显式勾选「进物品名」的字段;模板驱动的商品下拉没有维度可拼,
// 退化为取第一个选择型字段的选项名(就是商品名本身),否则物品名只剩分类兜底。
const namedFields = specs.filter((field) => field.showInItemName)
const fieldsForName =
namedFields.length > 0
? namedFields
: specs.filter((field) => field.type === 'select' || field.type === 'radio').slice(0, 1)
result = fieldsForName
.map((field) => formatConsignmentSpecValue(field, values[field.key], { forItemName: true }))
.filter(Boolean)
.join('')
@@ -0,0 +1,490 @@
/**
* 模板驱动寄卖规格(接单模板 → 规格字段反推)。
*
* 目标:让任意分类的寄卖都能从「接单模板」(work_product_rules)自动生成规格字段,
* 且每个规格组合都能精确对应到一条模板的价格。
*
* 两条生成路径(同一分类只走其一):
* - factor:该分类全部启用有价模板都能拆成统一的因子维度(如 系统×钻级×璀璨),
* 且因子组合无碰撞(一个组合只对应一条模板)→ 生成多字段;
* - product:只要有任意一条模板拆不进统一维度、或组合有碰撞 → 整类降级为
* 「商品名下拉」(每个选项绑定一条模板的 ruleKey)。
*
* 取价(resolveTemplateSpecPriceFen):按选项的 ruleKey 实时回查启用的模板规则,
* fixed 模式取 reward_amount;unit 模式取 unit_price_fen × 数量。
* 模板停用/删除 → 返回 0 → 发布侧按 missingPricePolicy=block 拦截,提示运营补模板价。
* 取价永远只看本分类(+子树)的模板,绝不跨分类借用(根治跨分类串价)。
*/
import type { ConsignmentSpecField, ConsignmentSpecOption } from './consignment-spec-service.js'
import { normalizeConsignmentSpecs } from './consignment-spec-service.js'
import type { ConsignmentTemplateRuleRow } from '../../repositories/worker-platform/product-match-repo.js'
import {
getEnabledWorkProductRuleByKey,
listEnabledPricedWorkProductRulesForCategorySubtree,
} from '../../repositories/worker-platform/product-match-repo.js'
export { getEnabledWorkProductRuleByKey }
/** 单条模板解析出的因子向量;缺失维度记空串(商品下拉路径不使用因子)。 */
export type TemplateSpecFactors = Record<string, string>
type FactorPattern = {
key: string
label: string
/** 从商品名里提取因子值;返回空串表示该模板没有这个维度 */
extract: (productName: string) => string
/** 因子值在选项里的排序权重(越小越靠前;同级按 value 字典序) */
sortValue?: (value: string) => number
}
const SYSTEM_PATTERN: FactorPattern = {
key: 'system',
label: '系统',
extract: (name) => {
if (name.includes('苹果') || name.toLowerCase().includes('ios')) return '苹果'
if (name.includes('安卓') || name.toLowerCase().includes('android')) return '安卓'
if (name.includes('通用') || name.includes('双端') || name.includes('全端')) return '通用'
return ''
},
sortValue: (value) => (value === '苹果' ? 0 : value === '安卓' ? 1 : 2),
}
const DIAMOND_PATTERN: FactorPattern = {
key: 'diamondLevel',
label: '钻石等级',
extract: (name) => {
const match = name.match(/(\d+)\s*钻/)
return match?.[1] ? String(Number(match[1])) : ''
},
sortValue: (value) => Number(value) || 0,
}
const BRILLIANCE_PATTERN: FactorPattern = {
key: 'brillianceCount',
label: '璀璨数',
extract: (name) => {
const zero = name.match(/(?:不带|无)\s*璀(?:璨)?|0\s*璀(?:璨)?/)
if (zero) return '0'
const match = name.match(/(\d+)\s*璀(?:璨)?/)
return match?.[1] ? String(Number(match[1])) : ''
},
sortValue: (value) => Number(value) || 0,
}
const GRADE_PATTERN: FactorPattern = {
key: 'grade',
label: '等级',
extract: (name) => {
// 不能用 \b:JS 的 \b 只认 ASCII 词边界,"级"前后不构成边界
const match = name.match(/(SS|S|A|B|C)级/)
return match?.[1] ? `${match[1]}级` : ''
},
sortValue: (value) => {
const order = ['SS级', 'S级', 'A级', 'B级', 'C级']
const index = order.indexOf(value)
return index >= 0 ? index : 99
},
}
const IDENTIFIED_PATTERN: FactorPattern = {
key: 'identified',
label: '鉴定状态',
extract: (name) => {
if (name.includes('已鉴定')) return '已鉴定'
if (name.includes('未鉴定')) return '未鉴定'
return ''
},
sortValue: (value) => (value === '未鉴定' ? 0 : 1),
}
const COUNT_PATTERN: FactorPattern = {
key: 'count',
label: '数量',
extract: (name) => {
const match = name.match(/(\d+)\s*(个|件|把|颗|币|枚|张|名|人)/)
return match?.[1] ? String(Number(match[1])) : ''
},
sortValue: (value) => Number(value) || 0,
}
const COIN_PATTERN: FactorPattern = {
key: 'coin',
label: '币种',
extract: (name) => {
if (name.includes('许愿币')) return '许愿币'
if (name.includes('彩虹币')) return '彩虹币'
return ''
},
}
/** 因子解析器:按命中优先级排列;商品下拉路径不使用这些解析器。 */
const FACTOR_PATTERNS: FactorPattern[] = [
SYSTEM_PATTERN,
DIAMOND_PATTERN,
BRILLIANCE_PATTERN,
GRADE_PATTERN,
IDENTIFIED_PATTERN,
COUNT_PATTERN,
COIN_PATTERN,
]
/**
* 通用解析器:对全部分类生效,但只有当「分类内每条模板都命中同一因子集合」时
* 才会走 factor 路径 —— 判定在 buildTemplateSpecProposal 里做。
*/
export function parseTemplateProductFactors(productName: string): TemplateSpecFactors {
const factors: TemplateSpecFactors = {}
for (const pattern of FACTOR_PATTERNS) {
const value = pattern.extract(String(productName || ''))
if (value) factors[pattern.key] = value
}
return factors
}
/** 因子解析后商品名里剩下的“游离文本”:非空说明该模板还有没被维度覆盖的部分。 */
function residualText(productName: string, factors: TemplateSpecFactors): string {
let residual = String(productName || '')
for (const value of Object.values(factors)) {
if (!value) continue
residual = residual.split(value).join('')
}
return residual.replace(/[\s【】\-—_/()()|]/g, '')
}
export type TemplateSpecProposalField = {
key: string
label: string
options: { label: string; value: string; ruleKey: string; productName: string }[]
}
export type TemplateSpecProposalMatrixRow = {
/** 组合里每个字段的选中值 */
values: Record<string, string>
productName: string
ruleKey: string
priceFen: number
}
export type TemplateSpecProposal = {
/** factor = 因子字段;product = 商品名下拉 */
mode: 'factor' | 'product'
/** 模板总数 / 参与定价的模板数(factor 模式下二者必须相等) */
ruleCount: number
coveredCount: number
/** factor 模式下拆不进维度的模板商品名(非空即降级 product) */
uncovered: { productName: string; ruleKey: string }[]
/** factor 模式下因子组合碰撞(一个组合对应多条模板) */
collisions: { values: Record<string, string>; productNames: string[] }[]
fields: TemplateSpecProposalField[]
matrix: TemplateSpecProposalMatrixRow[]
}
const PRODUCT_FIELD_KEY = 'product'
/**
* 由「分类 + 子树」的启用有价模板生成规格字段建议。
* - 全部模板可拆成统一因子维度且组合无碰撞 → factor 路径;
* - 否则整类降级为商品名下拉(每个选项绑定 ruleKey)。
*/
export function buildTemplateSpecProposal(
rules: ConsignmentTemplateRuleRow[],
): TemplateSpecProposal {
const pricedRules = rules.filter((rule) => templateRulePriceFen(rule) > 0)
if (pricedRules.length === 0) {
return {
mode: 'product',
ruleCount: rules.length,
coveredCount: 0,
uncovered: [],
collisions: [],
fields: [],
matrix: [],
}
}
// ---------- 先试 factor 路径 ----------
const parsed = pricedRules.map((rule) => ({
rule,
factors: parseTemplateProductFactors(rule.productName),
}))
// 统一维度集:所有模板命中的因子 key 必须完全一致,否则无法做矩阵
const dimensionSets = parsed.map(({ factors }) => Object.keys(factors).sort().join('|'))
const unifiedDimensions = dimensionSets[0] || ''
const allSameDimensions = dimensionSets.every((set) => set === unifiedDimensions)
const uncovered = parsed
.filter(({ factors }) => Object.keys(factors).length === 0 || !allSameDimensions)
.map(({ rule }) => ({ productName: rule.productName, ruleKey: rule.ruleKey }))
if (uncovered.length === 0 && unifiedDimensions) {
const collisionMap = new Map<string, string[]>()
for (const { rule, factors } of parsed) {
const comboKey = unifiedDimensions
.split('|')
.map((key) => factors[key] || '')
.join('||')
const list = collisionMap.get(comboKey) || []
list.push(rule.productName)
collisionMap.set(comboKey, list)
}
const collisions = [...collisionMap.entries()]
.filter(([, names]) => names.length > 1)
.map(([comboKey, productNames]) => ({
values: Object.fromEntries(
unifiedDimensions
.split('|')
.map((key, index) => [key, comboKey.split('||')[index] || '']),
),
productNames,
}))
if (collisions.length === 0) {
const fields = unifiedDimensions
.split('|')
.map((key) => {
const pattern = FACTOR_PATTERNS.find((item) => item.key === key)
if (!pattern) return null
const byValue = new Map<string, { ruleKey: string; productName: string }>()
for (const { rule, factors } of parsed) {
const value = factors[key] || ''
if (!value || byValue.has(value)) continue
byValue.set(value, { ruleKey: rule.ruleKey, productName: rule.productName })
}
const options = [...byValue.entries()]
.sort((a, b) => {
const weightA = pattern.sortValue ? pattern.sortValue(a[0]) : 0
const weightB = pattern.sortValue ? pattern.sortValue(b[0]) : 0
if (weightA !== weightB) return weightA - weightB
return Number(a[0]) - Number(b[0]) || a[0].localeCompare(b[0])
})
.map(([value, meta]) => ({
label: value,
value,
ruleKey: meta.ruleKey,
productName: meta.productName,
}))
return {
key,
label: pattern.label,
options,
}
})
.filter((field): field is TemplateSpecProposalField => field !== null)
const matrix = parsed.map(({ rule, factors }) => ({
values: Object.fromEntries(
unifiedDimensions.split('|').map((key) => [key, factors[key] || '']),
),
productName: rule.productName,
ruleKey: rule.ruleKey,
priceFen: templateRulePriceFen(rule),
}))
return {
mode: 'factor',
ruleCount: pricedRules.length,
coveredCount: pricedRules.length,
uncovered: [],
collisions: [],
fields,
matrix,
}
}
return {
mode: 'product',
ruleCount: pricedRules.length,
coveredCount: 0,
uncovered,
collisions,
fields: buildProductField(pricedRules),
matrix: buildProductMatrix(pricedRules),
}
}
// ---------- 降级:商品名下拉 ----------
return {
mode: 'product',
ruleCount: pricedRules.length,
coveredCount: pricedRules.length,
uncovered,
collisions: [],
fields: buildProductField(pricedRules),
matrix: buildProductMatrix(pricedRules),
}
}
function buildProductField(rules: ConsignmentTemplateRuleRow[]): TemplateSpecProposalField[] {
const seen = new Set<string>()
const options: TemplateSpecProposalField['options'] = []
for (const rule of rules) {
if (seen.has(rule.productName)) continue
seen.add(rule.productName)
options.push({
label: rule.productName,
value: String(rule.id),
ruleKey: rule.ruleKey,
productName: rule.productName,
})
}
return [
{
key: PRODUCT_FIELD_KEY,
label: '商品',
options,
},
]
}
function buildProductMatrix(rules: ConsignmentTemplateRuleRow[]): TemplateSpecProposalMatrixRow[] {
return rules.map((rule) => ({
values: { [PRODUCT_FIELD_KEY]: String(rule.id) },
productName: rule.productName,
ruleKey: rule.ruleKey,
priceFen: templateRulePriceFen(rule),
}))
}
/** 提案 → 寄卖配置的 specs(写入 config_json)。 */
export function buildSpecsFromProposal(proposal: TemplateSpecProposal): ConsignmentSpecField[] {
const fields: ConsignmentSpecField[] = []
proposal.fields.forEach((field, index) => {
const options: ConsignmentSpecOption[] = field.options.map((option) => ({
label: option.label,
value: option.value,
enabled: true,
ruleKey: option.ruleKey,
}))
fields.push({
key: field.key,
label: field.label,
type: 'select',
required: true,
options,
placeholder: '',
helpText: '',
matchMode: 'exact',
wildcardValues: [],
showInItemName: field.key !== PRODUCT_FIELD_KEY,
filterable: true,
sortOrder: (index + 1) * 10,
})
})
return fields
}
/** 把生成的 specs 解析回字段定义(读写兜底)。 */
export function parseTemplateSpecs(value: unknown): ConsignmentSpecField[] {
return normalizeConsignmentSpecs(value)
}
/** 模板价格:fixed = reward_amount;unit = unit_price_fen × 拼单份数(无拼单按 1)。 */
export function templateRulePriceFen(rule: ConsignmentTemplateRuleRow): number {
if (rule.pricingMode === 'unit' && rule.unitPriceFen > 0) {
const quantity = Math.max(1, Math.round(Number(rule.sharingTotalQuantity || 1)))
return Math.max(0, Math.round(rule.unitPriceFen * quantity))
}
return Math.max(0, Math.round(Number(rule.rewardAmountFen || 0)))
}
/**
* 取价:按 specs 选项上的 ruleKey 实时回查模板价格。
* 任一选项缺 ruleKey / 模板已停用或删除 → 返回 0(发布侧 block)。
*/
export async function resolveTemplateSpecPriceFen(input: {
specs: ConsignmentSpecField[]
values: Record<string, unknown>
quantity?: number
}): Promise<{ priceFen: number; matchedRuleKeys: string[]; missingKeys: string[] }> {
const specs = parseTemplateSpecs(input.specs)
const quantity = Math.max(1, Math.round(Number(input.quantity || 1)))
const matchedRuleKeys: string[] = []
const missingKeys: string[] = []
for (const spec of specs) {
if (spec.type !== 'select' && spec.type !== 'radio') continue
const value = String(input.values[spec.key] ?? '').trim()
if (!value) continue
const option = spec.options.find((item) => item.value === value || item.label === value)
if (!option?.ruleKey) {
missingKeys.push(`${spec.label}:${value}`)
continue
}
const rule = await getEnabledWorkProductRuleByKey(option.ruleKey)
if (!rule) {
missingKeys.push(`${spec.label}:${value}`)
continue
}
matchedRuleKeys.push(rule.ruleKey)
}
if (matchedRuleKeys.length === 0 || missingKeys.length > 0) {
return { priceFen: 0, matchedRuleKeys, missingKeys }
}
// 同一组合的多个字段都指向同一条模板;取第一条命中的模板价
const firstKey = matchedRuleKeys[0]!
const rule = await getEnabledWorkProductRuleByKey(firstKey)
const basePriceFen = rule ? templateRulePriceFen(rule) : 0
// unit 计价的模板(如 X 许愿币按件价):寄卖数量是份数,按「模板 × 数量」放大
const priceFen =
rule && rule.pricingMode === 'unit' && rule.unitPriceFen > 0
? Math.max(0, Math.round(rule.unitPriceFen * quantity))
: basePriceFen
return { priceFen, matchedRuleKeys, missingKeys }
}
/**
* 订单侧规格解析(匹配用):
* 订单由哪条接单模板生成(product_rule_id)→ 模板商品名 → 同一套因子解析器 → 订单侧规格向量。
* 返回 null 表示无法确定(历史订单没有 rule 或模板已删)→ 调用方应跳过自动匹配。
*/
export async function resolveOrderTemplateSpecValues(input: {
productRuleId: number | string | null | undefined
specs: ConsignmentSpecField[]
}): Promise<Record<string, string> | null> {
const ruleId = Number(input.productRuleId || 0)
if (!Number.isInteger(ruleId) || ruleId <= 0) return null
const result = await queryTemplateProductName(ruleId)
if (!result) return null
const specs = parseTemplateSpecs(input.specs)
if (specs.length === 1 && specs[0]!.key === PRODUCT_FIELD_KEY) {
// 商品下拉模式:直接按模板 id 匹配选项
const option = specs[0]!.options.find((item) => item.value === String(result.id))
return option ? { [PRODUCT_FIELD_KEY]: option.value } : null
}
const factors = parseTemplateProductFactors(result.productName)
if (Object.keys(factors).length === 0) return null
// 订单侧只填配置里实际存在的字段,多余维度丢弃(匹配器按 specs 遍历)
const values: Record<string, string> = {}
for (const spec of specs) {
const value = factors[spec.key]
if (value) values[spec.key] = value
}
return Object.keys(values).length > 0 ? values : null
}
async function queryTemplateProductName(
ruleId: number,
): Promise<{ id: number; productName: string } | null> {
const { query } = await import('../../db/client.js')
const result = await query<{ id: number; product_name: string }>(
'SELECT id, product_name FROM work_product_rules WHERE id = $1 LIMIT 1',
[ruleId],
)
const row = result.rows[0]
return row ? { id: Number(row.id), productName: row.product_name } : null
}
/** 判断 specs 是否是模板驱动的「商品名下拉」形态。 */
export function isProductFieldSpecs(specs: ConsignmentSpecField[]): boolean {
const parsed = parseTemplateSpecs(specs)
return parsed.length === 1 && parsed[0]!.key === PRODUCT_FIELD_KEY
}
/** 生成器入口:按分类(含子树)拉模板并生成提案。 */
export async function buildTemplateSpecProposalForCategory(
categoryId: number | string,
): Promise<TemplateSpecProposal> {
const rules = await listEnabledPricedWorkProductRulesForCategorySubtree(categoryId)
return buildTemplateSpecProposal(rules)
}
@@ -0,0 +1,143 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
buildSpecsFromProposal,
buildTemplateSpecProposal,
parseTemplateProductFactors,
templateRulePriceFen,
type TemplateSpecProposal,
} from './consignment-template-spec-service.js'
import type { ConsignmentTemplateRuleRow } from '../../repositories/worker-platform/product-match-repo.js'
function rule(input: {
id: number
ruleKey: string
productName: string
rewardAmountFen: number
pricingMode?: string
unitPriceFen?: number
sharingTotalQuantity?: number
}): ConsignmentTemplateRuleRow {
return {
id: input.id,
ruleKey: input.ruleKey,
productName: input.productName,
categoryId: null,
rewardAmountFen: input.rewardAmountFen,
unitPriceFen: input.unitPriceFen || 0,
pricingMode: input.pricingMode || 'fixed',
sharingEnabled: false,
sharingTotalQuantity: input.sharingTotalQuantity || 1,
sharingUnitRewardFen: 0,
}
}
test('模板驱动规格:因子解析器能拆出系统/钻级/璀璨', () => {
const factors = parseTemplateProductFactors('苹果暗潮1钻带1璀璨')
assert.equal(factors.system, '苹果')
assert.equal(factors.diamondLevel, '1')
assert.equal(factors.brillianceCount, '1')
})
test('模板驱动规格:能拆出等级与鉴定状态(密钥)', () => {
const factors = parseTemplateProductFactors('SS级 SCAR-L 无定潮汐(未鉴定随机)')
assert.equal(factors.grade, 'SS级')
assert.equal(factors.identified, '未鉴定')
})
test('模板驱动规格:全可拆且无碰撞 → factor 路径,组合一一对应模板', () => {
const proposal = buildTemplateSpecProposal([
rule({ id: 1, ruleKey: 'r1', productName: '苹果暗潮1钻', rewardAmountFen: 10000 }),
rule({ id: 2, ruleKey: 'r2', productName: '苹果暗潮2钻', rewardAmountFen: 10800 }),
rule({ id: 3, ruleKey: 'r3', productName: '安卓暗潮1钻', rewardAmountFen: 12000 }),
])
assert.equal(proposal.mode, 'factor')
assert.equal(proposal.coveredCount, 3)
assert.ok(proposal.uncovered.length === 0)
const keys = proposal.fields.map((field) => field.key).sort()
assert.deepEqual(keys, ['diamondLevel', 'system'])
// 每个组合唯一对应一条模板
const combos = new Set(proposal.matrix.map((row) => JSON.stringify(row.values)))
assert.equal(combos.size, proposal.matrix.length)
assert.equal(proposal.matrix.length, 3)
})
test('模板驱动规格:有拆不动的模板 → 整类降级商品名下拉', () => {
const proposal = buildTemplateSpecProposal([
rule({ id: 1, ruleKey: 'r1', productName: '苹果暗潮1钻', rewardAmountFen: 10000 }),
rule({ id: 2, ruleKey: 'r2', productName: '碎梦浮光系列全包', rewardAmountFen: 9000 }),
])
assert.equal(proposal.mode, 'product')
assert.equal(proposal.fields.length, 1)
assert.equal(proposal.fields[0]!.key, 'product')
assert.equal(proposal.fields[0]!.options.length, 2)
// 选项必须绑定 ruleKey
for (const option of proposal.fields[0]!.options) {
assert.ok(option.ruleKey, '商品下拉选项必须绑定 ruleKey')
}
})
test('模板驱动规格:因子组合碰撞 → 降级商品名下拉', () => {
const proposal = buildTemplateSpecProposal([
rule({ id: 1, ruleKey: 'r1', productName: '苹果暗潮1钻', rewardAmountFen: 10000 }),
rule({ id: 2, ruleKey: 'r2', productName: '苹果暗潮1钻特别版', rewardAmountFen: 11000 }),
])
// 两条都能解析出 system=苹果 diamond=1,但组合相同 → 碰撞
assert.equal(proposal.mode, 'product')
assert.ok(proposal.collisions.length > 0 || proposal.fields[0]!.key === 'product')
})
test('模板驱动规格:specs 生成保留 ruleKey 与选项顺序', () => {
const proposal: TemplateSpecProposal = {
mode: 'product',
ruleCount: 2,
coveredCount: 2,
uncovered: [],
collisions: [],
fields: [
{
key: 'product',
label: '商品',
options: [
{ label: 'A商品', value: '11', ruleKey: 'ra', productName: 'A商品' },
{ label: 'B商品', value: '12', ruleKey: 'rb', productName: 'B商品' },
],
},
],
matrix: [],
}
const specs = buildSpecsFromProposal(proposal)
assert.equal(specs.length, 1)
assert.equal(specs[0]!.type, 'select')
assert.equal(specs[0]!.options[0]!.ruleKey, 'ra')
assert.equal(specs[0]!.options[1]!.ruleKey, 'rb')
assert.equal(specs[0]!.showInItemName, false)
})
test('模板驱动规格:unit 计价模板按份数放大', () => {
const unit = rule({
id: 1,
ruleKey: 'u1',
productName: '100许愿币',
rewardAmountFen: 0,
pricingMode: 'unit',
unitPriceFen: 70,
sharingTotalQuantity: 1,
})
assert.equal(templateRulePriceFen(unit), 70)
})
test('模板驱动规格:无价模板不参与生成', () => {
const proposal = buildTemplateSpecProposal([
rule({ id: 1, ruleKey: 'r1', productName: '指挥官密钥', rewardAmountFen: 0 }),
rule({
id: 2,
ruleKey: 'r2',
productName: 'SS级 SCAR-L 无定潮汐(未鉴定随机)',
rewardAmountFen: 18000,
}),
])
assert.equal(proposal.matrix.length, 1)
assert.equal(proposal.matrix[0]!.ruleKey, 'r2')
})
@@ -32,6 +32,12 @@ import {
resolvePriceDropSuggestion,
} from './consignment-profile-service.js'
import { suggestConsignmentPriceFromRules } from './consignment-matching-service.js'
import {
getEnabledWorkProductRuleByKey,
parseTemplateSpecs,
resolveTemplateSpecPriceFen,
templateRulePriceFen,
} from './consignment-template-spec-service.js'
import {
DEFAULT_CONSIGNMENT_INTAKE_FIELDS,
DEFAULT_CONSIGNMENT_PRICING,
@@ -390,11 +396,12 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb
const sellerNote = String(intakeValues.sellerNote ?? payload.sellerNote ?? '').trim()
// 寄售价:按配置算出系统建议价(模板折扣价 / 模板价固定下调),打手只能往下调。
// 没有模板价时一律阻止发布。
// 没有模板价时一律阻止发布。模板驱动规格按数量放大单价(unit 计价模板)。
const priceSuggestion = await getWorkerConsignmentPriceSuggestion({
profileId: profile?.profileId,
specs,
values: specValues,
quantity,
})
const pricing = profile?.config.pricing ?? DEFAULT_CONSIGNMENT_PRICING
const submittedPriceFen = Math.max(
@@ -426,7 +433,8 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb
specs,
values: specValues,
categoryName: profile?.categoryName || '',
fallbackSuffix: '苏尔南',
// 分类级配置下物品名由规格/商品名决定,不再拼历史硬编码后缀(否则密钥、地铁逃生都叫「苏尔南」)。
fallbackSuffix: profile ? '' : '苏尔南',
})
const itemName = String(payload.itemName || defaultItemName).trim()
const specKey = buildConsignmentSpecKey(specs, specValues)
@@ -1053,6 +1061,8 @@ export async function getWorkerConsignmentPriceSuggestion(payload: {
/** 保留旧参数,价格只依赖系统/系列/钻级/璀璨 */
specs?: ConsignmentSpecField[]
values?: ConsignmentSpecValues
/** 模板驱动规格:unit 计价模板按数量放大单价 */
quantity?: unknown
/** 旧调用兼容:平铺参数 */
system?: unknown
series?: unknown
@@ -1084,6 +1094,41 @@ export async function getWorkerConsignmentPriceSuggestion(payload: {
values.brillianceCount = Math.max(0, Math.round(Number(payload.brillianceCount || 0)))
}
// 模板驱动规格(specSource='template'):按选项的 ruleKey 实时回查本分类(+子树)模板价。
// 只认「配置分类 + 子树」的模板,绝不跨分类取价;模板停用/删除 → 0 → block。
if (profile && profile.config.specSource === 'template') {
const specs = parseTemplateSpecs(profile.config.specs)
const hasTemplateBinding = specs.some((spec) => spec.options.some((option) => option.ruleKey))
if (hasTemplateBinding) {
const resolution = await resolveTemplateSpecPriceFen({
specs,
values,
quantity: Number(payload.quantity || 1),
})
const templatePriceFen = Math.max(0, Math.round(Number(resolution.priceFen || 0)))
const matchedRules: { ruleKey: string; productName: string; rewardAmountFen: number }[] = []
if (resolution.matchedRuleKeys[0]) {
const rule = await getEnabledWorkProductRuleByKey(resolution.matchedRuleKeys[0])
if (rule) {
matchedRules.push({
ruleKey: rule.ruleKey,
productName: rule.productName,
rewardAmountFen: templateRulePriceFen(rule),
})
}
}
const computed = computeConsignmentPrice({ pricing, templatePriceFen })
return {
suggestedPriceFen: computed.priceFen > 0 ? computed.priceFen : null,
capFen: computed.capFen,
templatePriceFen: computed.templatePriceFen,
source: computed.source,
matchedCount: matchedRules.length,
matchedRules,
}
}
}
const system = String(values.system || '').trim()
const series = String(values.series || '').trim()
const diamondLevel = Math.round(Number(values.diamondLevel || 0))
@@ -15,6 +15,7 @@ import {
normalizeWorkOrderTimeoutPolicy,
} from './work-order-timeout-policy.js'
import {
fallbackConsignmentWorkOrderToHall,
reenterConsignmentInternalMatching,
startConsignmentRematch,
} from './consignment-match-engine-service.js'
@@ -36,6 +37,27 @@ function resolveConsignmentInventoryId(material: unknown): number | null {
return inventoryId > 0 ? inventoryId : null
}
/**
* 重新匹配失败(返回 false 或抛错)时必须显式退回大厅。
*
* `settleOverdueWorkOrder` 在 rematch 策略下会先把工单写成 `internal_matching` 且清空截止时间,
* 如果后续 startConsignmentRematch 没有成功补上截止时间,工单会永久悬空。
*/
async function startRematchOrFallback(
workOrderId: number,
input: { excludeWorkerId?: number | null; excludeInventoryId?: number | null },
now: string,
): Promise<void> {
try {
const started = await startConsignmentRematch(workOrderId, input, now)
if (!started) {
await fallbackConsignmentWorkOrderToHall(workOrderId, now).catch(() => false)
}
} catch {
await fallbackConsignmentWorkOrderToHall(workOrderId, now).catch(() => false)
}
}
export async function settleOverdueWorkOrders(options: { limit?: number; workerId?: number } = {}) {
const limit = Math.max(1, Number(options.limit || 50))
const workerId = Number(options.workerId || 0)
@@ -54,14 +76,14 @@ export async function settleOverdueWorkOrders(options: { limit?: number; workerI
processedCount += 1
if (isRematchTimeoutPolicy(policy)) {
// 重新匹配:回内部匹配池,并排除原卖家/原库存,避免同一卖家立刻接回同一单。
await startConsignmentRematch(
await startRematchOrFallback(
Number(workOrder.id),
{
excludeWorkerId: Number(workOrder.assigned_worker_id || 0) || null,
excludeInventoryId: resolveConsignmentInventoryId(workOrder.material_json),
},
nowIso(),
).catch(() => false)
)
} else if (policy === 'reopen') {
// 退回大厅的寄卖单先尝试重新进入内部匹配;匹配不到再由内部匹配超时任务发回大厅。
await reenterConsignmentInternalMatching(Number(workOrder.id)).catch(() => false)
@@ -95,14 +117,14 @@ export async function settleOverdueWorkOrders(options: { limit?: number; workerI
processedCount += 1
if (isRematchTimeoutPolicy(policy)) {
// 份额超时后空出的份数重新进入匹配池;排除该份额的卖家和库存。
await startConsignmentRematch(
await startRematchOrFallback(
Number(shareItem.workOrderId),
{
excludeWorkerId: Number(shareItem.workerId || 0) || null,
excludeInventoryId: shareItem.consignmentInventoryId,
},
nowIso(),
).catch(() => false)
)
}
publishWorkOrderRealtimeChange({
workOrderId: Number(shareItem.workOrderId),
@@ -36,7 +36,11 @@ export default function ConsignmentWorkOrderMatchModal({
})
const data = candidatesQuery.data?.data
const options = data?.options || []
const needed = Math.max(1, Number(data?.totalQuantity || 1) - Number(data?.activeQuantity || 0))
const totalQuantity = Math.max(1, Number(data?.totalQuantity || 1))
// 整单匹配只需一条库存(由该库存一次占满订单总数量);拼单才按剩余份数选多条。
const needed = data?.sharingEnabled
? Math.max(1, totalQuantity - Number(data?.activeQuantity || 0))
: 1
useEffect(() => {
if (!open) {
@@ -46,9 +50,15 @@ export default function ConsignmentWorkOrderMatchModal({
}
if (options.length === 0) return
// 默认只选精确匹配;非精确库存必须人工勾选并填写原因。
const exactOptions = options.filter((item) => (item.matchType || 'exact') === 'exact')
// 整单还要保证一条库存能一次满足订单总数量,否则默认选中也会被后端拒绝。
const exactOptions = options.filter(
(item) =>
(item.matchType || 'exact') === 'exact' &&
(!data?.sharingEnabled || item.remainingQuantity >= 1) &&
(data?.sharingEnabled || item.remainingQuantity >= totalQuantity),
)
setSelectedIds(exactOptions.slice(0, needed).map((item) => item.inventoryId))
}, [open, options, needed])
}, [open, options, needed, data?.sharingEnabled, totalQuantity])
const nonExactSelected = useMemo(
() =>
@@ -120,8 +130,12 @@ export default function ConsignmentWorkOrderMatchModal({
<Alert
type="info"
showIcon
message={`该工单${data?.sharingEnabled ? `拼单 ${data.totalQuantity} 份,已匹配 ${data.activeQuantity} 份` : '为整单'},本次还需 ${needed} 条库存。`}
description="自动匹配只做精确规格;手动匹配可选多条,拼单工单每选一条生成一个份额。选择非精确库存必须填写原因。"
message={
data?.sharingEnabled
? `该工单拼单 ${totalQuantity} 份,已匹配 ${data.activeQuantity} 份,本次还需 ${needed} 份库存。`
: `该工单为整单匹配,请选择 1 条可一次满足 ${totalQuantity} 件的库存。`
}
description="自动匹配只做精确规格;拼单工单可多选,每条库存按剩余数量生成份额。选择非精确库存必须填写原因。"
/>
<PercentTable<ConsignmentMatchCandidate>
rowKey="inventoryId"
@@ -129,6 +143,8 @@ export default function ConsignmentWorkOrderMatchModal({
pagination={false}
dataSource={options}
rowSelection={{
// 整单只能选一条;拼单可多选,每条库存按剩余数量生成份额。
type: data?.sharingEnabled ? 'checkbox' : 'radio',
selectedRowKeys: selectedIds,
onChange: (keys) => setSelectedIds(keys.map((key) => Number(key))),
}}
@@ -10,6 +10,7 @@ import {
SearchOutlined,
SendOutlined,
StopOutlined,
ThunderboltOutlined,
} from '@ant-design/icons'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
@@ -21,6 +22,7 @@ import {
Form,
Input,
InputNumber,
Modal,
Radio,
Select,
Space,
@@ -31,11 +33,14 @@ import {
} from 'antd'
import { useEffect, useMemo, useState, type CSSProperties } from 'react'
import PercentTable from '@/components/common/PercentTable'
import {
archiveAdminConsignmentProfile,
fetchAdminConsignmentProfiles,
fetchAdminConsignmentTemplateSpecProposal,
publishAdminConsignmentProfile,
saveAdminConsignmentProfile,
type ConsignmentTemplateSpecProposal,
} from '@/services/admin/consignment'
import type {
AdminConsignmentProfilesResponse,
@@ -240,6 +245,7 @@ function buildPayload(
values: ConfigFormValues,
specs: ConsignmentSpecField[],
intakeFields: ConsignmentIntakeField[],
specSource: 'manual' | 'template',
) {
const hasSpec = (key: string) => specs.some((spec) => spec.key === key)
return {
@@ -261,6 +267,7 @@ function buildPayload(
quantity: values.fields.quantity,
},
specs,
specSource,
intake: { fields: intakeFields },
nameTemplate: values.nameTemplate,
pricing: {
@@ -384,6 +391,156 @@ const gridStyle: CSSProperties = {
gap: 16,
}
/** 模板提案预览:推荐模式 + 字段建议 + 组合→价格覆盖矩阵。 */
function TemplateProposalPreview({ proposal }: { proposal: ConsignmentTemplateSpecProposal }) {
const dimensionKeys = useMemo(
() => (proposal.fields || []).map((field) => field.key),
[proposal.fields],
)
const pricedCount = proposal.matrix.length
const uncovered = proposal.uncovered || []
const collisions = proposal.collisions || []
if (pricedCount === 0) {
return (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={
proposal.ruleCount === 0
? '该分类还没有启用且有价的接单模板'
: '该分类的启用模板都没有报酬或单价'
}
>
<div style={{ display: 'grid', gap: 6, maxWidth: 640 }}>
{proposal.ruleCount === 0 ? (
<>
<Text type="secondary">
请先在「接单模板」里为该分类配置至少 1 条启用模板,并把「报酬」或「单价」填为大于
0。
</Text>
<Text type="secondary">
配置后的推荐规则:商品名能拆成同一组维度时(例如「安卓暗潮3钻带2璀璨」→
系统×钻级×璀璨),推荐「规格字段」多字段矩阵;商品名格式不统一时(例如一条模板覆盖多个系列或钻级),推荐「商品名下拉」,每个商品直接绑定一条模板价。
</Text>
</>
) : (
<>
<Text type="secondary">
当前有 {proposal.ruleCount} 条启用模板,但「报酬」和「单价」都是 0,无法生成组合价。
</Text>
<Text type="secondary">
请回到「接单模板」补上报酬或单价后再点生成;金额大于 0 才会进入价格矩阵。
</Text>
</>
)}
</div>
</Empty>
)
}
return (
<div style={{ display: 'grid', gap: 12 }}>
<Space size={8} wrap>
<Tag color={proposal.mode === 'factor' ? 'green' : 'gold'}>
{proposal.mode === 'factor' ? '因子字段模式' : '商品名下拉模式'}
</Tag>
<Text type="secondary">
接单模板 {proposal.ruleCount} 条 · 参与定价 {pricedCount} 条
</Text>
</Space>
{proposal.mode === 'factor' ? (
<Alert
type="success"
showIcon
message="推荐使用:规格字段(多字段矩阵)"
description={`${proposal.fields.length} 个维度(${proposal.fields
.map((field) => field.label)
.join(
' × ',
)})共 ${pricedCount} 个组合,每个组合都能对应到一条模板价;保存后按选项实时回查模板价。`}
/>
) : (
<Alert
type="warning"
showIcon
message="推荐使用:商品名下拉"
description={
collisions.length > 0
? `有 ${collisions.length} 组因子组合对应多条模板(价格会互相覆盖),无法组成唯一矩阵,因此改用商品名下拉:每个商品直接绑定一条模板价。`
: `该分类有 ${pricedCount} 条模板的商品名不是统一格式,拆不成同一组维度,因此改用商品名下拉:每个商品直接绑定一条模板价,避免错配。`
}
/>
)}
{proposal.mode === 'product' && uncovered.length > 0 ? (
<div>
<Text strong>无法纳入统一维度的模板(前 5 条)</Text>
<div style={{ marginTop: 4, display: 'grid', gap: 2 }}>
{uncovered.slice(0, 5).map((item) => (
<Text key={item.ruleKey} type="secondary">
· {item.productName}
</Text>
))}
{uncovered.length > 5 ? <Text type="secondary">…共 {uncovered.length} 条</Text> : null}
</div>
</div>
) : null}
<div>
<Text strong>字段建议</Text>
<div style={{ marginTop: 6, display: 'grid', gap: 4 }}>
{(proposal.fields || []).map((field) => (
<Text key={field.key} type="secondary">
{field.label}({field.key}):
{field.options.map((option) => option.label).join(' / ')}
</Text>
))}
</div>
</div>
<div>
<Text strong>组合 → 模板价(实时读取,保存后仍随模板调价自动更新)</Text>
<PercentTable
size="small"
style={{ marginTop: 6 }}
rowKey={(row) => row.ruleKey}
dataSource={proposal.matrix}
pagination={{ pageSize: 8 }}
columns={[
...dimensionKeys.map((key) => ({
title: proposal.fields.find((field) => field.key === key)?.label || key,
dataIndex: ['values', key],
key,
width: 110,
})),
{
title: '模板商品名',
dataIndex: 'productName',
key: 'productName',
ellipsis: true,
},
{
title: '模板价',
dataIndex: 'priceFen',
key: 'priceFen',
width: 100,
render: (value: number) => `¥${(Number(value || 0) / 100).toFixed(2)}`,
},
]}
/>
</div>
{collisions.length > 0 ? (
<Alert
type="warning"
showIcon
message={`有 ${collisions.length} 组因子组合对应多条模板(价格可能不一致),已自动退化为商品名下拉。`}
description={collisions
.slice(0, 3)
.map((item) => `· ${item.productNames.join(' / ')}`)
.join(';')}
/>
) : null}
</div>
)
}
const fieldCellStyle: CSSProperties = {
display: 'grid',
gap: 4,
@@ -399,6 +556,8 @@ export default function ConsignmentConfigPanel() {
const [saving, setSaving] = useState(false)
const [specDrafts, setSpecDrafts] = useState<ConsignmentSpecField[]>([])
const [intakeDrafts, setIntakeDrafts] = useState<ConsignmentIntakeField[]>([])
const [specSource, setSpecSource] = useState<'manual' | 'template'>('manual')
const [proposalOpen, setProposalOpen] = useState(false)
const [form] = Form.useForm<ConfigFormValues>()
const profilesQuery = useQuery({
@@ -476,6 +635,7 @@ export default function ConsignmentConfigPanel() {
if (selectedProfile) {
form.setFieldsValue(toFormValues(selectedProfile))
setSpecDrafts(cloneSpecs(selectedProfile.config.specs))
setSpecSource(selectedProfile.config.specSource === 'template' ? 'template' : 'manual')
setIntakeDrafts(
cloneIntakeFields(
selectedProfile.config.intake?.fields ?? DEFAULT_CONSIGNMENT_INTAKE_FIELDS,
@@ -488,10 +648,53 @@ export default function ConsignmentConfigPanel() {
tabTitle: selectedCategory?.name || '',
})
setSpecDrafts([])
setSpecSource('manual')
setIntakeDrafts(cloneIntakeFields(DEFAULT_CONSIGNMENT_INTAKE_FIELDS))
}
}, [selectedCategoryId, selectedProfile, selectedCategory?.name, form])
const templateProposalQuery = useQuery({
queryKey: ['admin-consignment-template-spec-proposal', selectedCategoryId, proposalOpen],
queryFn: () => fetchAdminConsignmentTemplateSpecProposal(selectedCategoryId || 0),
enabled: proposalOpen && Boolean(selectedCategoryId),
})
/** 应用模板提案:写入规格草稿并切到 template 来源;用户仍可手工微调。 */
function applyTemplateProposal() {
const proposal = templateProposalQuery.data?.data
if (!proposal || proposal.matrix.length === 0 || proposal.fields.length === 0) {
message.error('没有可用的模板规格建议(该分类可能没有启用且有价的接单模板)')
return
}
const specs: ConsignmentSpecField[] = proposal.fields.map((field, index) => ({
key: field.key,
label: field.label,
type: 'select' as const,
required: true,
options: field.options.map((option) => ({
label: option.label,
value: option.value,
enabled: true,
ruleKey: option.ruleKey,
})),
placeholder: '',
helpText: '',
matchMode: 'exact' as const,
wildcardValues: [],
showInItemName: proposal.mode !== 'product',
filterable: true,
sortOrder: (index + 1) * 10,
}))
setSpecDrafts(specs)
setSpecSource('template')
setProposalOpen(false)
message.success(
proposal.mode === 'factor'
? `已按接单模板生成 ${proposal.fields.length} 个规格字段(${proposal.matrix.length} 个组合全部有模板价)`
: `该分类模板无法拆成统一维度,已生成「商品」下拉(${proposal.matrix.length} 个商品全部有模板价)`,
)
}
async function refresh() {
await queryClient.invalidateQueries({ queryKey: ['admin-consignment-profiles'] })
}
@@ -561,7 +764,7 @@ export default function ConsignmentConfigPanel() {
setSaving(true)
try {
await saveAdminConsignmentProfile(
buildPayload(selectedCategoryId, values, specs, intakeDrafts),
buildPayload(selectedCategoryId, values, specs, intakeDrafts, specSource),
)
message.success('寄卖配置已保存')
await refresh()
@@ -955,19 +1158,32 @@ export default function ConsignmentConfigPanel() {
title="规格字段"
description="初始为空;字段随配置版本发布,并写入提报与库存快照"
/>
<Button
type="primary"
ghost
size="small"
icon={<PlusOutlined />}
onClick={addCustomSpec}
>
添加字段
</Button>
<Space size={8}>
<Button
size="small"
icon={<ThunderboltOutlined />}
loading={
templateProposalQuery.isFetching && proposalOpen ? true : undefined
}
onClick={() => setProposalOpen(true)}
>
从接单模板生成
</Button>
<Button
type="primary"
ghost
size="small"
icon={<PlusOutlined />}
onClick={addCustomSpec}
>
添加字段
</Button>
</Space>
</div>
<Text type="secondary">
Key 为 system/serverZone/series/diamondLevel/brillianceCount
时会自动同步旧数据列并沿用旧匹配器;下拉/单选的选项用逗号分隔填写。
{specSource === 'template'
? '规格来源:接单模板(每个组合实时回查本分类模板价,模板停用即无价);生成后仍可手工微调。'
: 'Key 为 system/serverZone/series/diamondLevel/brillianceCount 时会自动同步旧数据列并沿用旧匹配器;下拉/单选的选项用逗号分隔填写。'}
</Text>
{specDrafts.length === 0 ? (
<Empty
@@ -1550,14 +1766,6 @@ export default function ConsignmentConfigPanel() {
addonAfter="分钟"
/>
</Form.Item>
<Form.Item
label="超时发回大厅"
name={['internalMatch', 'fallbackToHall']}
valuePropName="checked"
style={{ marginBottom: 0 }}
>
<Switch checkedChildren="是" unCheckedChildren="否" />
</Form.Item>
</>
) : null}
</div>
@@ -1696,6 +1904,36 @@ export default function ConsignmentConfigPanel() {
</Space>
</div>
</Form>
<Modal
title="从接单模板生成规格字段"
open={proposalOpen}
width={760}
okText="应用到规格字段"
okButtonProps={{
disabled:
!templateProposalQuery.data?.data ||
templateProposalQuery.data.data.matrix.length === 0,
}}
confirmLoading={templateProposalQuery.isFetching}
onOk={applyTemplateProposal}
onCancel={() => setProposalOpen(false)}
>
{templateProposalQuery.isLoading ? (
<Text type="secondary">正在解析该分类的接单模板…</Text>
) : templateProposalQuery.data?.data ? (
<TemplateProposalPreview proposal={templateProposalQuery.data.data} />
) : templateProposalQuery.isError ? (
<Text type="danger">
读取模板建议失败:
{templateProposalQuery.error instanceof Error
? templateProposalQuery.error.message
: '请稍后重试'}
</Text>
) : (
<Text type="secondary">暂无可用的模板建议,请稍后重试。</Text>
)}
</Modal>
</>
) : (
<Empty description="请选择分类" />
@@ -745,7 +745,11 @@ export default function WorkerConsignmentPage() {
</Divider>
<div className="worker-consignment-specs">
<div className="worker-consignment-spec-grid">
<div
className={`worker-consignment-spec-grid${
selectedSpecs.length === 1 ? ' is-single' : selectedSpecs.length === 2 ? ' is-duo' : ''
}`}
>
{selectedSpecs.map((field) => {
const isCountField = field.key === 'diamondLevel' || field.key === 'brillianceCount'
const options = field.options
@@ -804,12 +808,32 @@ export default function WorkerConsignmentPage() {
]}
>
{field.type === 'select' || field.type === 'radio' ? (
<Radio.Group
className={`worker-consignment-chips${isCountField ? ' is-count' : ''}`}
optionType="button"
buttonStyle="solid"
options={options}
/>
options.length > 8 ? (
// 模板驱动「商品」字段可能有几百个选项:按钮组会横向撑爆页面,
// 超过 8 个一律改用带搜索的下拉(AntD 默认虚拟滚动)。
<Select
options={options}
showSearch
allowClear={!field.required}
optionFilterProp="label"
placeholder={field.placeholder || `请选择${field.label}`}
style={{ width: '100%' }}
popupMatchSelectWidth={false}
styles={{ popup: { root: { maxWidth: 520 } } }}
optionRender={(option) => (
<div style={{ whiteSpace: 'normal', lineHeight: 1.45 }}>
{String(option.label ?? '')}
</div>
)}
/>
) : (
<Radio.Group
className={`worker-consignment-chips${isCountField ? ' is-count' : ''}`}
optionType="button"
buttonStyle="solid"
options={options}
/>
)
) : field.type === 'number' ? (
<InputNumber
min={0}
@@ -54,6 +54,33 @@ export function archiveAdminConsignmentProfile(profileId: number | string) {
)
}
/** 模板驱动规格提案:字段建议 + 组合→价格覆盖矩阵(运营确认后再保存为规格) */
export type ConsignmentTemplateSpecProposal = {
mode: 'factor' | 'product'
ruleCount: number
coveredCount: number
uncovered: { productName: string; ruleKey: string }[]
collisions: { values: Record<string, string>; productNames: string[] }[]
fields: {
key: string
label: string
options: { label: string; value: string; ruleKey: string; productName: string }[]
}[]
matrix: {
values: Record<string, string>
productName: string
ruleKey: string
priceFen: number
}[]
}
export function fetchAdminConsignmentTemplateSpecProposal(categoryId: number | string) {
return apiGet<ConsignmentTemplateSpecProposal>(
'/api/v1/admin/worker-platform/consignment/template-spec-proposal',
{ categoryId },
)
}
// --- 内部匹配(工单侧手动匹配) ---
export function fetchAdminConsignmentMatchCandidates(workOrderId: number | string) {
+10
View File
@@ -4504,6 +4504,15 @@ body {
gap: 12px;
}
/* 模板驱动分类可能只有 1–2 个规格字段:按字段数铺满,避免下拉被挤在 168px 的窄列里 */
.worker-consignment-spec-grid.is-single {
grid-template-columns: minmax(0, 1fr);
}
.worker-consignment-spec-grid.is-duo {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
/* 寄售价格 / 寄售数量 / 履约押金始终一行三列等宽 */
.worker-consignment-price-grid {
display: grid;
@@ -4542,6 +4551,7 @@ body {
.worker-consignment-chips {
display: flex !important;
flex-wrap: wrap;
width: 100%;
}
+4
View File
@@ -35,6 +35,8 @@ export type ConsignmentSpecOption = {
label: string
value: string
enabled: boolean
/** 模板驱动规格:选项绑定的接单模板规则键;取价时实时回查模板价格 */
ruleKey?: string
}
export type ConsignmentSpecFieldType = 'text' | 'textarea' | 'select' | 'radio' | 'number' | 'date'
@@ -387,6 +389,8 @@ export type ConsignmentProfileConfig = {
}
/** 动态规格字段(完全由运营配置,默认空) */
specs: ConsignmentSpecField[]
/** 规格来源:manual = 手工维护(默认);template = 从接单模板反推生成 */
specSource: 'manual' | 'template'
/** 物品名模板,支持 {system}/{series}/{category} 等占位符 */
nameTemplate: string
pricing: {
@@ -1,9 +1,10 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import test from 'node:test'
function readSource(relativePath: string) {
return readFileSync(new URL(relativePath, import.meta.url).pathname, 'utf8')
return readFileSync(fileURLToPath(new URL(relativePath, import.meta.url)), 'utf8')
}
test('工单跳到售后:先记录待打开工单,等面板挂载后再弹窗(避免点两次)', () => {
@@ -1,11 +1,12 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import test from 'node:test'
test('寄售仓库必须提供通过审核入库时间范围筛选,并透传到库存查询', () => {
// Windows 检出为 CRLF,多行断言里的 \n 会匹配失败,先统一换行
const source = readFileSync(
new URL('../src/pages/admin/panels/ConsignmentInventoryPanel.tsx', import.meta.url).pathname,
fileURLToPath(new URL('../src/pages/admin/panels/ConsignmentInventoryPanel.tsx', import.meta.url)),
'utf8',
).replace(/\r\n/g, '\n')
@@ -1,5 +1,6 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import test from 'node:test'
/**
@@ -24,21 +25,21 @@ function assertTopLevelMount(filePath: string, componentName: string) {
test('库存面板:手动匹配订单弹窗挂在顶层(按钮点了必须能弹出来)', () => {
assertTopLevelMount(
new URL('../src/pages/admin/panels/ConsignmentInventoryPanel.tsx', import.meta.url).pathname,
fileURLToPath(new URL('../src/pages/admin/panels/ConsignmentInventoryPanel.tsx', import.meta.url)),
'ConsignmentManualMatchModal',
)
})
test('订单面板:手动指定现货弹窗同样挂在顶层', () => {
assertTopLevelMount(
new URL('../src/pages/admin/panels/WorkOrdersPanel.tsx', import.meta.url).pathname,
fileURLToPath(new URL('../src/pages/admin/panels/WorkOrdersPanel.tsx', import.meta.url)),
'ConsignmentManualMatchModal',
)
})
test('按钮点击要真的把状态设上,弹窗的 open 由该状态驱动', () => {
const source = readFileSync(
new URL('../src/pages/admin/panels/ConsignmentInventoryPanel.tsx', import.meta.url).pathname,
fileURLToPath(new URL('../src/pages/admin/panels/ConsignmentInventoryPanel.tsx', import.meta.url)),
'utf8',
)
assert.ok(source.includes('setManualMatchItem(record)'), '行内按钮必须设置待匹配的现货')
@@ -47,7 +48,7 @@ test('按钮点击要真的把状态设上,弹窗的 open 由该状态驱动',
test('手动匹配入口必须按 consignment.fulfill 门控(提交的是履约动作,不能只挂 manage)', () => {
const inventoryPanel = readFileSync(
new URL('../src/pages/admin/panels/ConsignmentInventoryPanel.tsx', import.meta.url).pathname,
fileURLToPath(new URL('../src/pages/admin/panels/ConsignmentInventoryPanel.tsx', import.meta.url)),
'utf8',
)
// 库存面板:手动匹配订单按钮要与编辑/作废拆开 —— 前者 fulfill(提交打到
@@ -64,7 +65,7 @@ test('手动匹配入口必须按 consignment.fulfill 门控(提交的是履
)
const ordersPanel = readFileSync(
new URL('../src/pages/admin/panels/WorkOrdersPanel.tsx', import.meta.url).pathname,
fileURLToPath(new URL('../src/pages/admin/panels/WorkOrdersPanel.tsx', import.meta.url)),
'utf8',
)
// 拦截弹窗 footer 的入口同样要门控,无 fulfill 权限的人不该看到能直达 403 的按钮
@@ -80,7 +81,7 @@ test('手动匹配入口必须按 consignment.fulfill 门控(提交的是履
test('手动匹配弹窗:数据过期的 409 要清选择并刷新,走内部现货直接履约', () => {
const source = readFileSync(
new URL('../src/components/admin/ConsignmentManualMatchModal.tsx', import.meta.url).pathname,
fileURLToPath(new URL('../src/components/admin/ConsignmentManualMatchModal.tsx', import.meta.url)),
'utf8',
)
// 并发占用/规格在打开弹窗后被改 → 清空选择并重新拉列表,不能让运营循环撞同一个错
@@ -103,7 +104,7 @@ test('手动匹配弹窗:数据过期的 409 要清选择并刷新,走内部
test('发布拦截弹窗:只走内部现货履约,文案停在待加好友而不是冷静期', () => {
const source = readFileSync(
new URL('../src/pages/admin/panels/WorkOrdersPanel.tsx', import.meta.url).pathname,
fileURLToPath(new URL('../src/pages/admin/panels/WorkOrdersPanel.tsx', import.meta.url)),
'utf8',
)
assert.ok(source.includes('fulfillWorkOrderWithConsignment'), '拦截弹窗必须调用内部现货履约')
@@ -119,7 +120,7 @@ test('发布拦截弹窗:只走内部现货履约,文案停在待加好友
test('寄售仓库库存面板:必须包含「通过审核入库时间」列,且详情弹窗正确展示时间', () => {
const source = readFileSync(
new URL('../src/pages/admin/panels/ConsignmentInventoryPanel.tsx', import.meta.url).pathname,
fileURLToPath(new URL('../src/pages/admin/panels/ConsignmentInventoryPanel.tsx', import.meta.url)),
'utf8',
)
assert.ok(source.includes("title: '通过审核入库时间'"), '库存表格必须有「通过审核入库时间」列')
@@ -136,7 +137,7 @@ test('寄售仓库库存面板:必须包含「通过审核入库时间」列
test('手动匹配弹窗:候选订单必须展示创建时间,现货展示审核入库时间', () => {
const source = readFileSync(
new URL('../src/components/admin/ConsignmentManualMatchModal.tsx', import.meta.url).pathname,
fileURLToPath(new URL('../src/components/admin/ConsignmentManualMatchModal.tsx', import.meta.url)),
'utf8',
)
assert.ok(
@@ -1,9 +1,10 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import test from 'node:test'
function read(relativePath: string) {
return readFileSync(new URL(relativePath, import.meta.url).pathname, 'utf8')
return readFileSync(fileURLToPath(new URL(relativePath, import.meta.url)), 'utf8')
}
test('管理端:订单服务之后必须存在寄卖配置 tab', () => {
@@ -1,5 +1,6 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import test from 'node:test'
/**
@@ -13,16 +14,16 @@ import test from 'node:test'
test('后台全量选项:系统含通用,区服含互通', () => {
const source = readFileSync(
new URL('../src/utils/consignment-spec.ts', import.meta.url).pathname,
fileURLToPath(new URL('../src/utils/consignment-spec.ts', import.meta.url)),
'utf8',
)
assert.ok(source.includes("{ label: '通用', value: '通用' }"), '系统全量选项必须含「通用」')
assert.ok(source.includes("value: '互通'"), '区服选项必须包含「互通」')
})
test('苏尔南默认配置:取消代码内置字段,由合并迁移 142 提供五个单选字段', () => {
test('苏尔南默认配置:取消代码内置字段,由合并迁移 147 提供五个单选字段', () => {
const utils = readFileSync(
new URL('../src/utils/consignment-spec.ts', import.meta.url).pathname,
fileURLToPath(new URL('../src/utils/consignment-spec.ts', import.meta.url)),
'utf8',
)
assert.ok(!utils.includes('SURNAN_PUBLISH_SYSTEM_OPTIONS'), '前端不再写死苏尔南发布选项')
@@ -32,7 +33,7 @@ test('苏尔南默认配置:取消代码内置字段,由合并迁移 142 提
test('打手端发布表单:按寄卖配置动态渲规格,不再回退内置字段', () => {
const source = readFileSync(
new URL('../src/pages/worker/WorkerConsignmentPage.tsx', import.meta.url).pathname,
fileURLToPath(new URL('../src/pages/worker/WorkerConsignmentPage.tsx', import.meta.url)),
'utf8',
)
assert.ok(source.includes('selectedSpecs.map'), '规格字段必须来自配置动态渲染')
@@ -46,7 +47,7 @@ test('打手端发布表单:按寄卖配置动态渲规格,不再回退内
test('客服端两个表单(提报审核 / 库存编辑)用后台全量选项,不再自由填写', () => {
for (const file of ['ConsignmentSubmissionsPanel.tsx', 'ConsignmentInventoryPanel.tsx']) {
const source = readFileSync(
new URL(`../src/pages/admin/panels/${file}`, import.meta.url).pathname,
fileURLToPath(new URL(`../src/pages/admin/panels/${file}`, import.meta.url)),
'utf8',
)
assert.ok(source.includes('CONSIGNMENT_SYSTEM_OPTIONS'), `${file} 应引用全量系统选项`)
@@ -0,0 +1,65 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import test from 'node:test'
function readPanelSource() {
return readFileSync(
fileURLToPath(new URL('../src/pages/admin/panels/ConsignmentConfigPanel.tsx', import.meta.url)),
'utf8',
).replace(/\r\n/g, '\n')
}
test('从接单模板生成:factor 模式必须显式推荐「规格字段(多字段矩阵)」', () => {
const source = readPanelSource()
assert.ok(
source.includes('推荐使用:规格字段(多字段矩阵)'),
'factor 模式必须给出推荐文案,运营才知道该用多字段矩阵',
)
assert.ok(
source.includes("proposal.mode === 'factor' ? ("),
'推荐文案必须按 proposal.mode 分支渲染',
)
})
test('从接单模板生成:product 模式必须显式推荐「商品名下拉」并解释原因', () => {
const source = readPanelSource()
assert.ok(source.includes('推荐使用:商品名下拉'), '降级为商品下拉时必须说明推荐该模式')
assert.ok(source.includes('拆不成同一组维度'), '必须解释为什么不能拆成多字段矩阵')
assert.ok(
source.includes('无法纳入统一维度的模板(前 5 条)'),
'必须列出拆不动的模板示例,便于运营判断是否改名',
)
})
test('从接单模板生成:无模板时必须给配置引导,而不是只显示一句报错', () => {
const source = readPanelSource()
assert.ok(
source.includes('请先在「接单模板」里为该分类配置至少 1 条启用模板'),
'空状态必须引导去接单模板配置',
)
assert.ok(
source.includes('该分类的启用模板都没有报酬或单价'),
'有模板但无价时必须区分提示,避免误以为没有模板',
)
assert.ok(
source.includes('推荐规则:商品名能拆成同一组维度时'),
'空状态也要说明两种模式的推荐规则',
)
assert.ok(
!source.includes('该分类没有启用且有价的接单模板,无法生成规格字段'),
'旧的死板提示必须被 Empty 引导替换',
)
})
test('从接单模板生成:应用按钮按「有价组合数」禁用,空提案不能被应用', () => {
const source = readPanelSource()
assert.ok(
source.includes('templateProposalQuery.data.data.matrix.length === 0'),
'应用按钮必须在没有组合价时禁用',
)
assert.ok(
source.includes('proposal.matrix.length === 0 || proposal.fields.length === 0'),
'应用动作本身也要兜底拦截空提案',
)
})
@@ -0,0 +1,64 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import test from 'node:test'
function read(relativePath: string) {
return readFileSync(fileURLToPath(new URL(relativePath, import.meta.url)), 'utf8').replace(
/\r\n/g,
'\n',
)
}
const page = read('../src/pages/worker/WorkerConsignmentPage.tsx')
const css = read('../src/styles/worker.css')
test('模板驱动商品字段:选项超过 8 个必须改用带搜索的下拉,不能用按钮组', () => {
assert.ok(
page.includes('options.length > 8'),
'必须按选项数量降级:地铁逃生 314 个商品全塞进 Radio.Group 会把页面横向撑爆',
)
assert.ok(page.includes('optionFilterProp="label"'), '几百个商品名必须支持按名称搜索')
assert.ok(
page.includes('popupMatchSelectWidth={false}'),
'下拉宽度要跟随内容,避免长商品名被固定窄列截断',
)
assert.ok(
page.includes("whiteSpace: 'normal'"),
'选项文本要允许换行,长商品名不能被 nowrap 撑出弹层',
)
})
test('少量选项仍保留按钮组,内置五字段体验不变', () => {
assert.ok(
page.includes('<Radio.Group') && page.includes('worker-consignment-chips'),
'系统/区服/系列/钻级/璀璨仍用按钮组',
)
assert.ok(
page.indexOf('options.length > 8') < page.indexOf('<Radio.Group'),
'数量判断必须在按钮组之前生效',
)
})
test('规格网格按字段数自适应:单字段铺满、两字段两列', () => {
assert.ok(page.includes("' is-single'"), '单字段必须走 is-single')
assert.ok(page.includes("' is-duo'"), '两字段必须走 is-duo')
assert.ok(
css.includes('.worker-consignment-spec-grid.is-single') &&
css.includes('grid-template-columns: minmax(0, 1fr);'),
'单字段必须铺满整行,否则下拉只剩 168px',
)
assert.ok(
css.includes('.worker-consignment-spec-grid.is-duo') &&
css.includes('grid-template-columns: repeat(2, minmax(0, 1fr));'),
'两字段必须两列等宽',
)
})
test('按钮组兜底换行,避免异常配置再次把页面撑爆', () => {
const chipsBlock = css.slice(
css.indexOf('.worker-consignment-chips {'),
css.indexOf('.worker-consignment-chips {') + 160,
)
assert.ok(chipsBlock.includes('flex-wrap: wrap'), '按钮组必须允许换行兜底')
})
@@ -1,14 +1,15 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import test from 'node:test'
test('快手电子凭证页提供受控的历史券补发并核销入口', () => {
const page = readFileSync(
new URL('../src/pages/admin/AdminKuaishouIndustryPage.tsx', import.meta.url).pathname,
fileURLToPath(new URL('../src/pages/admin/AdminKuaishouIndustryPage.tsx', import.meta.url)),
'utf8',
)
const service = readFileSync(
new URL('../src/services/admin/kuaishou-industry.ts', import.meta.url).pathname,
fileURLToPath(new URL('../src/services/admin/kuaishou-industry.ts', import.meta.url)),
'utf8',
)
@@ -30,7 +31,7 @@ test('快手电子凭证页提供受控的历史券补发并核销入口', () =>
test('历史券补发并核销结果必须展示发码、核销两个阶段', () => {
const page = readFileSync(
new URL('../src/pages/admin/AdminKuaishouIndustryPage.tsx', import.meta.url).pathname,
fileURLToPath(new URL('../src/pages/admin/AdminKuaishouIndustryPage.tsx', import.meta.url)),
'utf8',
)
@@ -1,14 +1,15 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import test from 'node:test'
test('自动核销最近任务展示并搜索核销码', () => {
const page = readFileSync(
new URL('../src/pages/admin/panels/VoucherConsumePolicyPanel.tsx', import.meta.url).pathname,
fileURLToPath(new URL('../src/pages/admin/panels/VoucherConsumePolicyPanel.tsx', import.meta.url)),
'utf8',
)
const service = readFileSync(
new URL('../src/services/admin/fulfillment-channel.ts', import.meta.url).pathname,
fileURLToPath(new URL('../src/services/admin/fulfillment-channel.ts', import.meta.url)),
'utf8',
)