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:
@@ -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() {
|
||||
|
||||
+2
-2
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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),
|
||||
)
|
||||
|
||||
+143
-2
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user