diff --git a/apps/backend/src/db/migrate.ts b/apps/backend/src/db/migrate.ts index 39c51e44..e084bc98 100644 --- a/apps/backend/src/db/migrate.ts +++ b/apps/backend/src/db/migrate.ts @@ -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() { diff --git a/apps/backend/src/db/migrations/142_consignment_platform.sql b/apps/backend/src/db/migrations/147_consignment_platform.sql similarity index 99% rename from apps/backend/src/db/migrations/142_consignment_platform.sql rename to apps/backend/src/db/migrations/147_consignment_platform.sql index 5d0aa95b..e5985926 100644 --- a/apps/backend/src/db/migrations/142_consignment_platform.sql +++ b/apps/backend/src/db/migrations/147_consignment_platform.sql @@ -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 苏尔南默认寄卖配置 diff --git a/apps/backend/src/db/migrations/143_consignment_sms_logs.sql b/apps/backend/src/db/migrations/148_consignment_sms_logs.sql similarity index 95% rename from apps/backend/src/db/migrations/143_consignment_sms_logs.sql rename to apps/backend/src/db/migrations/148_consignment_sms_logs.sql index b43771c7..e5ddea08 100644 --- a/apps/backend/src/db/migrations/143_consignment_sms_logs.sql +++ b/apps/backend/src/db/migrations/148_consignment_sms_logs.sql @@ -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 ( diff --git a/apps/backend/src/db/migrations/144_consignment_inventory_category_backfill.sql b/apps/backend/src/db/migrations/149_consignment_inventory_category_backfill.sql similarity index 94% rename from apps/backend/src/db/migrations/144_consignment_inventory_category_backfill.sql rename to apps/backend/src/db/migrations/149_consignment_inventory_category_backfill.sql index 0f7cff8c..647a50b1 100644 --- a/apps/backend/src/db/migrations/144_consignment_inventory_category_backfill.sql +++ b/apps/backend/src/db/migrations/149_consignment_inventory_category_backfill.sql @@ -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, diff --git a/apps/backend/src/db/migrations/145_work_order_consignment_original_reward.sql b/apps/backend/src/db/migrations/150_work_order_consignment_original_reward.sql similarity index 86% rename from apps/backend/src/db/migrations/145_work_order_consignment_original_reward.sql rename to apps/backend/src/db/migrations/150_work_order_consignment_original_reward.sql index baef267a..734e79df 100644 --- a/apps/backend/src/db/migrations/145_work_order_consignment_original_reward.sql +++ b/apps/backend/src/db/migrations/150_work_order_consignment_original_reward.sql @@ -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(按库存售价-服务费结算)。 -- 这里固化“匹配前的原始工单报酬”,所有释放库存的路径恢复它,避免超时/取消/退款后 -- 大厅继续展示寄卖结算价、或二次匹配在已被改写的金额上继续扣服务费。 diff --git a/apps/backend/src/db/migrations/151_work_order_internal_matching_deadline_guard.sql b/apps/backend/src/db/migrations/151_work_order_internal_matching_deadline_guard.sql new file mode 100644 index 00000000..c00635ce --- /dev/null +++ b/apps/backend/src/db/migrations/151_work_order_internal_matching_deadline_guard.sql @@ -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; diff --git a/apps/backend/src/repositories/worker-platform/product-match-repo.ts b/apps/backend/src/repositories/worker-platform/product-match-repo.ts index fe0df77a..f195cbcc 100644 --- a/apps/backend/src/repositories/worker-platform/product-match-repo.ts +++ b/apps/backend/src/repositories/worker-platform/product-match-repo.ts @@ -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 { + 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 { + 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), + } +} diff --git a/apps/backend/src/repositories/worker-platform/work-order-issue-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-issue-repo.ts index 5495e53b..93788664 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-issue-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-issue-repo.ts @@ -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 diff --git a/apps/backend/src/repositories/worker-platform/work-order-settlement-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-settlement-repo.ts index 185d1d2f..364408ff 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-settlement-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-settlement-repo.ts @@ -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, diff --git a/apps/backend/src/routes/admin/worker-platform/consignment.ts b/apps/backend/src/routes/admin/worker-platform/consignment.ts index 44154433..e0379b83 100644 --- a/apps/backend/src/routes/admin/worker-platform/consignment.ts +++ b/apps/backend/src/routes/admin/worker-platform/consignment.ts @@ -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( diff --git a/apps/backend/src/services/worker-platform/consignment-fulfillment-service.ts b/apps/backend/src/services/worker-platform/consignment-fulfillment-service.ts index 329f0326..db4e13f6 100644 --- a/apps/backend/src/services/worker-platform/consignment-fulfillment-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-fulfillment-service.ts @@ -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 { + 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) // 服务端规格复检:不能只依赖前端弹窗展示的匹配结果 diff --git a/apps/backend/src/services/worker-platform/consignment-match-engine-guards.test.ts b/apps/backend/src/services/worker-platform/consignment-match-engine-guards.test.ts new file mode 100644 index 00000000..b3bae116 --- /dev/null +++ b/apps/backend/src/services/worker-platform/consignment-match-engine-guards.test.ts @@ -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 有截止时间', + ) +}) diff --git a/apps/backend/src/services/worker-platform/consignment-match-engine-service.ts b/apps/backend/src/services/worker-platform/consignment-match-engine-service.ts index 8b59a718..d01b8b2e 100644 --- a/apps/backend/src/services/worker-platform/consignment-match-engine-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-match-engine-service.ts @@ -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 { + 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( ` @@ -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>() + +export function resolveOrderSpecOverride( + workOrder: WorkOrderRow, + profile: ConsignmentProfileSnapshot | null, +): Promise { + 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>() + 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), ) diff --git a/apps/backend/src/services/worker-platform/consignment-matching.integration.test.ts b/apps/backend/src/services/worker-platform/consignment-matching.integration.test.ts index c732a90f..ada5c158 100644 --- a/apps/backend/src/services/worker-platform/consignment-matching.integration.test.ts +++ b/apps/backend/src/services/worker-platform/consignment-matching.integration.test.ts @@ -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>('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) { diff --git a/apps/backend/src/services/worker-platform/consignment-payout.test.ts b/apps/backend/src/services/worker-platform/consignment-payout.test.ts index c3c4540a..4b9e3a56 100644 --- a/apps/backend/src/services/worker-platform/consignment-payout.test.ts +++ b/apps/backend/src/services/worker-platform/consignment-payout.test.ts @@ -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(净售价 × 数量)', ) }) diff --git a/apps/backend/src/services/worker-platform/consignment-profile-service.test.ts b/apps/backend/src/services/worker-platform/consignment-profile-service.test.ts index 7cc8b9c2..cae4db72 100644 --- a/apps/backend/src/services/worker-platform/consignment-profile-service.test.ts +++ b/apps/backend/src/services/worker-platform/consignment-profile-service.test.ts @@ -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}`) } }) diff --git a/apps/backend/src/services/worker-platform/consignment-profile-service.ts b/apps/backend/src/services/worker-platform/consignment-profile-service.ts index be4a213c..dd60e1e9 100644 --- a/apps/backend/src/services/worker-platform/consignment-profile-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-profile-service.ts @@ -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, diff --git a/apps/backend/src/services/worker-platform/consignment-spec-service.test.ts b/apps/backend/src/services/worker-platform/consignment-spec-service.test.ts index 088b19a3..e1da3180 100644 --- a/apps/backend/src/services/worker-platform/consignment-spec-service.test.ts +++ b/apps/backend/src/services/worker-platform/consignment-spec-service.test.ts @@ -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}`) } }) diff --git a/apps/backend/src/services/worker-platform/consignment-spec-service.ts b/apps/backend/src/services/worker-platform/consignment-spec-service.ts index 04d93757..7ab569be 100644 --- a/apps/backend/src/services/worker-platform/consignment-spec-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-spec-service.ts @@ -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('') diff --git a/apps/backend/src/services/worker-platform/consignment-template-spec-service.ts b/apps/backend/src/services/worker-platform/consignment-template-spec-service.ts new file mode 100644 index 00000000..d9622a35 --- /dev/null +++ b/apps/backend/src/services/worker-platform/consignment-template-spec-service.ts @@ -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 + +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 + 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; 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() + 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() + 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() + 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 + 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 | 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 = {} + 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 { + const rules = await listEnabledPricedWorkProductRulesForCategorySubtree(categoryId) + return buildTemplateSpecProposal(rules) +} diff --git a/apps/backend/src/services/worker-platform/consignment-template-spec.test.ts b/apps/backend/src/services/worker-platform/consignment-template-spec.test.ts new file mode 100644 index 00000000..9dcee08a --- /dev/null +++ b/apps/backend/src/services/worker-platform/consignment-template-spec.test.ts @@ -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') +}) diff --git a/apps/backend/src/services/worker-platform/consignment-worker-service.ts b/apps/backend/src/services/worker-platform/consignment-worker-service.ts index f84395ed..008b8feb 100644 --- a/apps/backend/src/services/worker-platform/consignment-worker-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-worker-service.ts @@ -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)) diff --git a/apps/backend/src/services/worker-platform/worker-order-timeout-service.ts b/apps/backend/src/services/worker-platform/worker-order-timeout-service.ts index 71bce148..5e6de0f5 100644 --- a/apps/backend/src/services/worker-platform/worker-order-timeout-service.ts +++ b/apps/backend/src/services/worker-platform/worker-order-timeout-service.ts @@ -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 { + 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), diff --git a/apps/frontend/src/components/admin/ConsignmentWorkOrderMatchModal.tsx b/apps/frontend/src/components/admin/ConsignmentWorkOrderMatchModal.tsx index 88ce1238..cdebb7c4 100644 --- a/apps/frontend/src/components/admin/ConsignmentWorkOrderMatchModal.tsx +++ b/apps/frontend/src/components/admin/ConsignmentWorkOrderMatchModal.tsx @@ -36,7 +36,11 @@ export default function ConsignmentWorkOrderMatchModal({ }) const data = candidatesQuery.data?.data const options = data?.options || [] - const needed = Math.max(1, Number(data?.totalQuantity || 1) - Number(data?.activeQuantity || 0)) + const totalQuantity = Math.max(1, Number(data?.totalQuantity || 1)) + // 整单匹配只需一条库存(由该库存一次占满订单总数量);拼单才按剩余份数选多条。 + const needed = data?.sharingEnabled + ? Math.max(1, totalQuantity - Number(data?.activeQuantity || 0)) + : 1 useEffect(() => { if (!open) { @@ -46,9 +50,15 @@ export default function ConsignmentWorkOrderMatchModal({ } if (options.length === 0) return // 默认只选精确匹配;非精确库存必须人工勾选并填写原因。 - const exactOptions = options.filter((item) => (item.matchType || 'exact') === 'exact') + // 整单还要保证一条库存能一次满足订单总数量,否则默认选中也会被后端拒绝。 + const exactOptions = options.filter( + (item) => + (item.matchType || 'exact') === 'exact' && + (!data?.sharingEnabled || item.remainingQuantity >= 1) && + (data?.sharingEnabled || item.remainingQuantity >= totalQuantity), + ) setSelectedIds(exactOptions.slice(0, needed).map((item) => item.inventoryId)) - }, [open, options, needed]) + }, [open, options, needed, data?.sharingEnabled, totalQuantity]) const nonExactSelected = useMemo( () => @@ -120,8 +130,12 @@ export default function ConsignmentWorkOrderMatchModal({ rowKey="inventoryId" @@ -129,6 +143,8 @@ export default function ConsignmentWorkOrderMatchModal({ pagination={false} dataSource={options} rowSelection={{ + // 整单只能选一条;拼单可多选,每条库存按剩余数量生成份额。 + type: data?.sharingEnabled ? 'checkbox' : 'radio', selectedRowKeys: selectedIds, onChange: (keys) => setSelectedIds(keys.map((key) => Number(key))), }} diff --git a/apps/frontend/src/pages/admin/panels/ConsignmentConfigPanel.tsx b/apps/frontend/src/pages/admin/panels/ConsignmentConfigPanel.tsx index 733feed0..69755f0a 100644 --- a/apps/frontend/src/pages/admin/panels/ConsignmentConfigPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/ConsignmentConfigPanel.tsx @@ -10,6 +10,7 @@ import { SearchOutlined, SendOutlined, StopOutlined, + ThunderboltOutlined, } from '@ant-design/icons' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { @@ -21,6 +22,7 @@ import { Form, Input, InputNumber, + Modal, Radio, Select, Space, @@ -31,11 +33,14 @@ import { } from 'antd' import { useEffect, useMemo, useState, type CSSProperties } from 'react' +import PercentTable from '@/components/common/PercentTable' import { archiveAdminConsignmentProfile, fetchAdminConsignmentProfiles, + fetchAdminConsignmentTemplateSpecProposal, publishAdminConsignmentProfile, saveAdminConsignmentProfile, + type ConsignmentTemplateSpecProposal, } from '@/services/admin/consignment' import type { AdminConsignmentProfilesResponse, @@ -240,6 +245,7 @@ function buildPayload( values: ConfigFormValues, specs: ConsignmentSpecField[], intakeFields: ConsignmentIntakeField[], + specSource: 'manual' | 'template', ) { const hasSpec = (key: string) => specs.some((spec) => spec.key === key) return { @@ -261,6 +267,7 @@ function buildPayload( quantity: values.fields.quantity, }, specs, + specSource, intake: { fields: intakeFields }, nameTemplate: values.nameTemplate, pricing: { @@ -384,6 +391,156 @@ const gridStyle: CSSProperties = { gap: 16, } +/** 模板提案预览:推荐模式 + 字段建议 + 组合→价格覆盖矩阵。 */ +function TemplateProposalPreview({ proposal }: { proposal: ConsignmentTemplateSpecProposal }) { + const dimensionKeys = useMemo( + () => (proposal.fields || []).map((field) => field.key), + [proposal.fields], + ) + const pricedCount = proposal.matrix.length + const uncovered = proposal.uncovered || [] + const collisions = proposal.collisions || [] + + if (pricedCount === 0) { + return ( + +
+ {proposal.ruleCount === 0 ? ( + <> + + 请先在「接单模板」里为该分类配置至少 1 条启用模板,并把「报酬」或「单价」填为大于 + 0。 + + + 配置后的推荐规则:商品名能拆成同一组维度时(例如「安卓暗潮3钻带2璀璨」→ + 系统×钻级×璀璨),推荐「规格字段」多字段矩阵;商品名格式不统一时(例如一条模板覆盖多个系列或钻级),推荐「商品名下拉」,每个商品直接绑定一条模板价。 + + + ) : ( + <> + + 当前有 {proposal.ruleCount} 条启用模板,但「报酬」和「单价」都是 0,无法生成组合价。 + + + 请回到「接单模板」补上报酬或单价后再点生成;金额大于 0 才会进入价格矩阵。 + + + )} +
+
+ ) + } + + return ( +
+ + + {proposal.mode === 'factor' ? '因子字段模式' : '商品名下拉模式'} + + + 接单模板 {proposal.ruleCount} 条 · 参与定价 {pricedCount} 条 + + + {proposal.mode === 'factor' ? ( + field.label) + .join( + ' × ', + )})共 ${pricedCount} 个组合,每个组合都能对应到一条模板价;保存后按选项实时回查模板价。`} + /> + ) : ( + 0 + ? `有 ${collisions.length} 组因子组合对应多条模板(价格会互相覆盖),无法组成唯一矩阵,因此改用商品名下拉:每个商品直接绑定一条模板价。` + : `该分类有 ${pricedCount} 条模板的商品名不是统一格式,拆不成同一组维度,因此改用商品名下拉:每个商品直接绑定一条模板价,避免错配。` + } + /> + )} + {proposal.mode === 'product' && uncovered.length > 0 ? ( +
+ 无法纳入统一维度的模板(前 5 条) +
+ {uncovered.slice(0, 5).map((item) => ( + + · {item.productName} + + ))} + {uncovered.length > 5 ? …共 {uncovered.length} 条 : null} +
+
+ ) : null} +
+ 字段建议 +
+ {(proposal.fields || []).map((field) => ( + + {field.label}({field.key}): + {field.options.map((option) => option.label).join(' / ')} + + ))} +
+
+
+ 组合 → 模板价(实时读取,保存后仍随模板调价自动更新) + row.ruleKey} + dataSource={proposal.matrix} + pagination={{ pageSize: 8 }} + columns={[ + ...dimensionKeys.map((key) => ({ + title: proposal.fields.find((field) => field.key === key)?.label || key, + dataIndex: ['values', key], + key, + width: 110, + })), + { + title: '模板商品名', + dataIndex: 'productName', + key: 'productName', + ellipsis: true, + }, + { + title: '模板价', + dataIndex: 'priceFen', + key: 'priceFen', + width: 100, + render: (value: number) => `¥${(Number(value || 0) / 100).toFixed(2)}`, + }, + ]} + /> +
+ {collisions.length > 0 ? ( + `· ${item.productNames.join(' / ')}`) + .join(';')} + /> + ) : null} +
+ ) +} + const fieldCellStyle: CSSProperties = { display: 'grid', gap: 4, @@ -399,6 +556,8 @@ export default function ConsignmentConfigPanel() { const [saving, setSaving] = useState(false) const [specDrafts, setSpecDrafts] = useState([]) const [intakeDrafts, setIntakeDrafts] = useState([]) + const [specSource, setSpecSource] = useState<'manual' | 'template'>('manual') + const [proposalOpen, setProposalOpen] = useState(false) const [form] = Form.useForm() const profilesQuery = useQuery({ @@ -476,6 +635,7 @@ export default function ConsignmentConfigPanel() { if (selectedProfile) { form.setFieldsValue(toFormValues(selectedProfile)) setSpecDrafts(cloneSpecs(selectedProfile.config.specs)) + setSpecSource(selectedProfile.config.specSource === 'template' ? 'template' : 'manual') setIntakeDrafts( cloneIntakeFields( selectedProfile.config.intake?.fields ?? DEFAULT_CONSIGNMENT_INTAKE_FIELDS, @@ -488,10 +648,53 @@ export default function ConsignmentConfigPanel() { tabTitle: selectedCategory?.name || '', }) setSpecDrafts([]) + setSpecSource('manual') setIntakeDrafts(cloneIntakeFields(DEFAULT_CONSIGNMENT_INTAKE_FIELDS)) } }, [selectedCategoryId, selectedProfile, selectedCategory?.name, form]) + const templateProposalQuery = useQuery({ + queryKey: ['admin-consignment-template-spec-proposal', selectedCategoryId, proposalOpen], + queryFn: () => fetchAdminConsignmentTemplateSpecProposal(selectedCategoryId || 0), + enabled: proposalOpen && Boolean(selectedCategoryId), + }) + + /** 应用模板提案:写入规格草稿并切到 template 来源;用户仍可手工微调。 */ + function applyTemplateProposal() { + const proposal = templateProposalQuery.data?.data + if (!proposal || proposal.matrix.length === 0 || proposal.fields.length === 0) { + message.error('没有可用的模板规格建议(该分类可能没有启用且有价的接单模板)') + return + } + const specs: ConsignmentSpecField[] = proposal.fields.map((field, index) => ({ + key: field.key, + label: field.label, + type: 'select' as const, + required: true, + options: field.options.map((option) => ({ + label: option.label, + value: option.value, + enabled: true, + ruleKey: option.ruleKey, + })), + placeholder: '', + helpText: '', + matchMode: 'exact' as const, + wildcardValues: [], + showInItemName: proposal.mode !== 'product', + filterable: true, + sortOrder: (index + 1) * 10, + })) + setSpecDrafts(specs) + setSpecSource('template') + setProposalOpen(false) + message.success( + proposal.mode === 'factor' + ? `已按接单模板生成 ${proposal.fields.length} 个规格字段(${proposal.matrix.length} 个组合全部有模板价)` + : `该分类模板无法拆成统一维度,已生成「商品」下拉(${proposal.matrix.length} 个商品全部有模板价)`, + ) + } + async function refresh() { await queryClient.invalidateQueries({ queryKey: ['admin-consignment-profiles'] }) } @@ -561,7 +764,7 @@ export default function ConsignmentConfigPanel() { setSaving(true) try { await saveAdminConsignmentProfile( - buildPayload(selectedCategoryId, values, specs, intakeDrafts), + buildPayload(selectedCategoryId, values, specs, intakeDrafts, specSource), ) message.success('寄卖配置已保存') await refresh() @@ -955,19 +1158,32 @@ export default function ConsignmentConfigPanel() { title="规格字段" description="初始为空;字段随配置版本发布,并写入提报与库存快照" /> - + + + + - Key 为 system/serverZone/series/diamondLevel/brillianceCount - 时会自动同步旧数据列并沿用旧匹配器;下拉/单选的选项用逗号分隔填写。 + {specSource === 'template' + ? '规格来源:接单模板(每个组合实时回查本分类模板价,模板停用即无价);生成后仍可手工微调。' + : 'Key 为 system/serverZone/series/diamondLevel/brillianceCount 时会自动同步旧数据列并沿用旧匹配器;下拉/单选的选项用逗号分隔填写。'} {specDrafts.length === 0 ? ( - - - ) : null} @@ -1696,6 +1904,36 @@ export default function ConsignmentConfigPanel() { + + setProposalOpen(false)} + > + {templateProposalQuery.isLoading ? ( + 正在解析该分类的接单模板… + ) : templateProposalQuery.data?.data ? ( + + ) : templateProposalQuery.isError ? ( + + 读取模板建议失败: + {templateProposalQuery.error instanceof Error + ? templateProposalQuery.error.message + : '请稍后重试'} + + ) : ( + 暂无可用的模板建议,请稍后重试。 + )} + ) : ( diff --git a/apps/frontend/src/pages/worker/WorkerConsignmentPage.tsx b/apps/frontend/src/pages/worker/WorkerConsignmentPage.tsx index 50e80c5b..5a1c5353 100644 --- a/apps/frontend/src/pages/worker/WorkerConsignmentPage.tsx +++ b/apps/frontend/src/pages/worker/WorkerConsignmentPage.tsx @@ -745,7 +745,11 @@ export default function WorkerConsignmentPage() {
-
+
{selectedSpecs.map((field) => { const isCountField = field.key === 'diamondLevel' || field.key === 'brillianceCount' const options = field.options @@ -804,12 +808,32 @@ export default function WorkerConsignmentPage() { ]} > {field.type === 'select' || field.type === 'radio' ? ( - + options.length > 8 ? ( + // 模板驱动「商品」字段可能有几百个选项:按钮组会横向撑爆页面, + // 超过 8 个一律改用带搜索的下拉(AntD 默认虚拟滚动)。 +