From d41ec446796f2b8e1a58701b17ece7b970b763f6 Mon Sep 17 00:00:00 2001 From: zhaoxing <2558691827@qq.com> Date: Thu, 5 Nov 2026 17:09:05 +0800 Subject: [PATCH] =?UTF-8?q?feat(consignment):=20=E5=AF=84=E5=8D=96?= =?UTF-8?q?=E8=A7=84=E6=A0=BC=E4=B8=8E=E4=BB=B7=E6=A0=BC=E5=8A=A8=E6=80=81?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E9=87=8D=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../130_consignment_dynamic_specs.sql | 111 ++ ...131_consignment_surnan_default_profile.sql | 203 ++++ .../worker-platform/consignment-repo.ts | 56 +- .../admin/worker-platform/consignment.ts | 2 + apps/backend/src/routes/worker/consignment.ts | 17 + .../consignment-admin-service.ts | 225 +++- .../consignment-match-engine-service.ts | 197 ++- .../consignment-profile-service.ts | 83 +- .../consignment-spec-service.test.ts | 457 +++++++ .../consignment-spec-service.ts | 789 ++++++++++++ .../consignment-worker-service.ts | 286 +++-- .../admin/ConsignmentWorkOrderMatchModal.tsx | 63 +- .../admin/panels/ConsignmentConfigPanel.tsx | 1077 +++++++++++++---- .../panels/ConsignmentInventoryPanel.tsx | 54 + .../panels/ConsignmentSubmissionsPanel.tsx | 49 + .../pages/worker/WorkerConsignmentPage.tsx | 399 +++--- .../src/services/admin/consignment.ts | 2 + apps/frontend/src/services/worker.ts | 37 +- apps/frontend/src/types/consignment.ts | 65 +- apps/frontend/src/utils/consignment-spec.ts | 148 ++- .../tests/consignment-matching-config.test.ts | 34 + .../tests/consignment-spec-options.test.ts | 28 +- 22 files changed, 3761 insertions(+), 621 deletions(-) create mode 100644 apps/backend/src/db/migrations/130_consignment_dynamic_specs.sql create mode 100644 apps/backend/src/db/migrations/131_consignment_surnan_default_profile.sql create mode 100644 apps/backend/src/services/worker-platform/consignment-spec-service.test.ts create mode 100644 apps/backend/src/services/worker-platform/consignment-spec-service.ts diff --git a/apps/backend/src/db/migrations/130_consignment_dynamic_specs.sql b/apps/backend/src/db/migrations/130_consignment_dynamic_specs.sql new file mode 100644 index 00000000..4c735814 --- /dev/null +++ b/apps/backend/src/db/migrations/130_consignment_dynamic_specs.sql @@ -0,0 +1,111 @@ +-- 130_consignment_dynamic_specs.sql +-- 寄卖动态规格:提报/库存增加 spec_values_json、spec_key、spec_text 与改价上限。 +-- 全部语句幂等,可安全重跑;不改动历史迁移。 + +-- =========================================================================== +-- 1. 提报:动态规格值、匹配 key、展示文本、改价上限 +-- =========================================================================== +ALTER TABLE consignment_submissions + ADD COLUMN IF NOT EXISTS spec_values_json JSONB NOT NULL DEFAULT '{}'::jsonb, + ADD COLUMN IF NOT EXISTS spec_key TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS spec_text TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS price_cap_fen BIGINT NOT NULL DEFAULT 0; + +-- =========================================================================== +-- 2. 库存:同提报;入库后规格值继续随库存走 +-- =========================================================================== +ALTER TABLE consignment_inventories + ADD COLUMN IF NOT EXISTS spec_values_json JSONB NOT NULL DEFAULT '{}'::jsonb, + ADD COLUMN IF NOT EXISTS spec_key TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS spec_text TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS price_cap_fen BIGINT NOT NULL DEFAULT 0; + +-- =========================================================================== +-- 3. 存量回填:内置 5 规格 -> JSON / key / 文本;改价上限取当前售价 +-- =========================================================================== +UPDATE consignment_submissions +SET + spec_values_json = jsonb_strip_nulls( + jsonb_build_object( + 'system', NULLIF(BTRIM(system), ''), + 'serverZone', NULLIF(BTRIM(server_zone), ''), + 'series', NULLIF(BTRIM(series), ''), + 'diamondLevel', diamond_level, + 'brillianceCount', brilliance_count + ) + ), + spec_key = NULLIF( + concat_ws( + '|', + CASE WHEN COALESCE(NULLIF(BTRIM(system), ''), '') <> '' THEN 'system=' || BTRIM(system) END, + CASE WHEN COALESCE(NULLIF(BTRIM(server_zone), ''), '') <> '' THEN 'serverZone=' || BTRIM(server_zone) END, + CASE WHEN COALESCE(NULLIF(BTRIM(series), ''), '') <> '' THEN 'series=' || BTRIM(series) END, + CASE WHEN diamond_level IS NOT NULL THEN 'diamondLevel=' || diamond_level::text END, + CASE WHEN brilliance_count IS NOT NULL THEN 'brillianceCount=' || brilliance_count::text END + ), + '' + ), + spec_text = COALESCE(NULLIF(BTRIM(item_name), ''), ''), + price_cap_fen = CASE + WHEN COALESCE(requested_sale_price_fen, 0) > 0 THEN requested_sale_price_fen + WHEN COALESCE(asking_price_fen, 0) > 0 THEN asking_price_fen + ELSE 0 + END +WHERE spec_key = ''; + +UPDATE consignment_inventories +SET + spec_values_json = jsonb_strip_nulls( + jsonb_build_object( + 'system', NULLIF(BTRIM(system), ''), + 'serverZone', NULLIF(BTRIM(server_zone), ''), + 'series', NULLIF(BTRIM(series), ''), + 'diamondLevel', diamond_level, + 'brillianceCount', brilliance_count + ) + ), + spec_key = NULLIF( + concat_ws( + '|', + CASE WHEN COALESCE(NULLIF(BTRIM(system), ''), '') <> '' THEN 'system=' || BTRIM(system) END, + CASE WHEN COALESCE(NULLIF(BTRIM(server_zone), ''), '') <> '' THEN 'serverZone=' || BTRIM(server_zone) END, + CASE WHEN COALESCE(NULLIF(BTRIM(series), ''), '') <> '' THEN 'series=' || BTRIM(series) END, + CASE WHEN diamond_level IS NOT NULL THEN 'diamondLevel=' || diamond_level::text END, + CASE WHEN brilliance_count IS NOT NULL THEN 'brillianceCount=' || brilliance_count::text END + ), + '' + ), + spec_text = COALESCE(NULLIF(BTRIM(item_name), ''), ''), + price_cap_fen = COALESCE(sale_price_fen, 0) +WHERE spec_key = ''; + +-- =========================================================================== +-- 4. 索引:按配置 + 规格 key 快速筛选在库可匹配库存 +-- =========================================================================== +CREATE INDEX IF NOT EXISTS idx_consignment_inventories_spec_key + ON consignment_inventories (profile_id, spec_key) + WHERE status = 'in_stock' AND allow_matching; + +CREATE INDEX IF NOT EXISTS idx_consignment_submissions_spec_key + ON consignment_submissions (profile_id, spec_key) + WHERE status = 'pending_review'; + +-- =========================================================================== +-- 5. 注释 +-- =========================================================================== +COMMENT ON COLUMN consignment_submissions.spec_values_json IS + '动态规格字段值(key -> 选项值/数字/文本),随配置版本快照固化'; +COMMENT ON COLUMN consignment_submissions.spec_key IS + '参与匹配的规格字段稳定 key(按字段 key 排序的 key=value 串),用于候选筛选'; +COMMENT ON COLUMN consignment_submissions.spec_text IS + '规格展示/搜索文本(字段名称 + 选项名称拼接)'; +COMMENT ON COLUMN consignment_submissions.price_cap_fen IS + '打手改价上限(分):默认取接单模板价/系统建议价;0 = 不限制'; +COMMENT ON COLUMN consignment_inventories.spec_values_json IS + '动态规格字段值(key -> 选项值/数字/文本),随配置版本快照固化'; +COMMENT ON COLUMN consignment_inventories.spec_key IS + '参与匹配的规格字段稳定 key(按字段 key 排序的 key=value 串),用于候选筛选'; +COMMENT ON COLUMN consignment_inventories.spec_text IS + '规格展示/搜索文本(字段名称 + 选项名称拼接)'; +COMMENT ON COLUMN consignment_inventories.price_cap_fen IS + '打手改价上限(分):默认取接单模板价/系统建议价;0 = 不限制'; diff --git a/apps/backend/src/db/migrations/131_consignment_surnan_default_profile.sql b/apps/backend/src/db/migrations/131_consignment_surnan_default_profile.sql new file mode 100644 index 00000000..bc7091ce --- /dev/null +++ b/apps/backend/src/db/migrations/131_consignment_surnan_default_profile.sql @@ -0,0 +1,203 @@ +-- 131_consignment_surnan_default_profile.sql +-- 苏尔南手枪默认寄卖配置:五个规格字段(与旧发布页一致),直接发布 v1。 +-- 幂等:该分类已有寄卖配置时不做任何事,避免覆盖运营已保存/已发布的配置。 + +DO $migration$ +DECLARE + v_category_id BIGINT; + v_profile_id BIGINT; + v_version_id BIGINT; + v_config JSONB; +BEGIN + SELECT id INTO v_category_id + FROM work_categories + WHERE category_key = 'shouqiang' + LIMIT 1; + + IF v_category_id IS NULL THEN + RETURN; + END IF; + + IF EXISTS (SELECT 1 FROM consignment_profiles WHERE category_id = v_category_id) THEN + RETURN; + END IF; + + v_config := $config${ + "version": 1, + "tabTitle": "苏尔南寄卖", + "tabDescription": "苏尔南手枪寄卖:选择规格后按接单模板价带出建议价", + "specs": [ + { + "key": "system", + "label": "操作系统", + "type": "radio", + "required": true, + "options": [ + { "label": "苹果", "value": "苹果", "enabled": true }, + { "label": "安卓", "value": "安卓", "enabled": true } + ], + "placeholder": "", + "helpText": "", + "matchMode": "exact", + "wildcardValues": [], + "showInItemName": true, + "sortOrder": 10 + }, + { + "key": "serverZone", + "label": "游戏区服", + "type": "radio", + "required": true, + "options": [ + { "label": "互通(QQ/微信)", "value": "互通", "enabled": true } + ], + "placeholder": "", + "helpText": "苏尔南手枪当前统一为互通区服,旧库存的微信区/QQ区仍可参与匹配", + "matchMode": "wildcard", + "wildcardValues": ["互通", "通用", "全区", "不限"], + "showInItemName": false, + "sortOrder": 20 + }, + { + "key": "series", + "label": "系列 / 枪系", + "type": "radio", + "required": true, + "options": [ + { "label": "暗潮", "value": "暗潮", "enabled": true }, + { "label": "怒嚎", "value": "怒嚎", "enabled": true }, + { "label": "血宴", "value": "血宴", "enabled": true } + ], + "placeholder": "", + "helpText": "", + "matchMode": "exact", + "wildcardValues": [], + "showInItemName": true, + "sortOrder": 30 + }, + { + "key": "diamondLevel", + "label": "钻石等级", + "type": "radio", + "required": true, + "options": [ + { "label": "1钻", "value": "1", "enabled": true }, + { "label": "2钻", "value": "2", "enabled": true }, + { "label": "3钻", "value": "3", "enabled": true }, + { "label": "4钻", "value": "4", "enabled": true }, + { "label": "5钻", "value": "5", "enabled": true }, + { "label": "6钻", "value": "6", "enabled": true }, + { "label": "7钻", "value": "7", "enabled": true } + ], + "placeholder": "", + "helpText": "", + "matchMode": "minimum", + "wildcardValues": [], + "showInItemName": true, + "sortOrder": 40 + }, + { + "key": "brillianceCount", + "label": "璀璨数", + "type": "radio", + "required": true, + "options": [ + { "label": "无", "value": "0", "enabled": true }, + { "label": "1璀", "value": "1", "enabled": true }, + { "label": "2璀", "value": "2", "enabled": true }, + { "label": "3璀", "value": "3", "enabled": true }, + { "label": "4璀", "value": "4", "enabled": true }, + { "label": "5璀", "value": "5", "enabled": true }, + { "label": "6璀", "value": "6", "enabled": true }, + { "label": "7璀", "value": "7", "enabled": true } + ], + "placeholder": "", + "helpText": "", + "matchMode": "minimum", + "wildcardValues": [], + "showInItemName": true, + "sortOrder": 50 + } + ], + "nameTemplate": "{system}{series}{diamondLevel}钻{brillianceCount}璀璨 苏尔南", + "pricing": { + "priceType": "template_discount", + "discount": 10, + "fixedPriceFen": 0, + "allowWorkerPrice": true, + "minPriceFen": 0, + "missingPricePolicy": "manual" + }, + "fields": { + "system": true, + "serverZone": true, + "series": true, + "diamondLevel": true, + "brillianceCount": true, + "quantity": true + }, + "deposit": { + "enabled": false, + "mode": "fixed", + "fixedAmountFen": 0, + "unitAmountFen": 0 + }, + "serviceFee": { + "enabled": false, + "mode": "percent", + "percent": 0, + "amountFen": 0 + }, + "inventoryLimit": { + "enabled": false, + "maxActiveQuantity": 0 + }, + "matching": { + "mode": "manual", + "autoMatchEnabled": false, + "exactOnly": true, + "preferLowReward": true + }, + "internalMatch": { + "delayMinutes": 30, + "fallbackToHall": true + }, + "timeout": { + "minutes": 30, + "policy": "reopen" + }, + "priceDrop": { + "enabled": false, + "hours": 72, + "mode": "percent", + "value": 10 + } + }$config$::jsonb; + + INSERT INTO consignment_profiles ( + category_id, name, title, description, tab_title, config_json, status, created_at, updated_at + ) VALUES ( + v_category_id, + '苏尔南手枪寄卖', + '苏尔南手枪寄卖', + '苏尔南手枪默认配置:选择规格后按接单模板价带出建议价', + '苏尔南寄卖', + v_config, + 'published', + now(), + now() + ) + RETURNING id INTO v_profile_id; + + INSERT INTO consignment_profile_versions ( + profile_id, version_no, config_json, published_at, created_at + ) VALUES ( + v_profile_id, 1, v_config, now(), now() + ) + RETURNING id INTO v_version_id; + + UPDATE consignment_profiles + SET current_version_id = v_version_id, updated_at = now() + WHERE id = v_profile_id; +END +$migration$; diff --git a/apps/backend/src/repositories/worker-platform/consignment-repo.ts b/apps/backend/src/repositories/worker-platform/consignment-repo.ts index 91d3e800..e4a205b7 100644 --- a/apps/backend/src/repositories/worker-platform/consignment-repo.ts +++ b/apps/backend/src/repositories/worker-platform/consignment-repo.ts @@ -54,6 +54,12 @@ export type ConsignmentInventoryRow = { profile_id?: string | number | null profile_version_id?: string | number | null profile_snapshot_json?: string | Record | null + /** 动态规格值 / 匹配 key / 展示文本 */ + spec_values_json?: string | Record | null + spec_key?: string + spec_text?: string + /** 打手改价上限(分);0 = 不限制 */ + price_cap_fen?: number /** 该批次总数量 / 剩余可匹配数量 */ quantity?: number remaining_quantity?: number @@ -97,6 +103,12 @@ export type ConsignmentSubmissionRow = { profile_id?: string | number | null profile_version_id?: string | number | null profile_snapshot_json?: string | Record | null + /** 动态规格值 / 匹配 key / 展示文本 */ + spec_values_json?: string | Record | null + spec_key?: string + spec_text?: string + /** 打手改价上限(分);0 = 不限制 */ + price_cap_fen?: number /** 本次提报数量与期望售价 */ quantity?: number requested_sale_price_fen?: number @@ -413,6 +425,10 @@ export async function updateConsignmentInventory( brillianceCount?: number | undefined costPriceFen?: number | undefined salePriceFen?: number | undefined + specValuesJson?: Record | undefined + specKey?: string | undefined + specText?: string | undefined + priceCapFen?: number | undefined buyerName?: string | undefined inboundBatch?: string | undefined sellerContact?: string | undefined @@ -467,6 +483,22 @@ export async function updateConsignmentInventory( values.push(input.salePriceFen) updates.push(`sale_price_fen = $${values.length}`) } + if (input.specValuesJson !== undefined) { + values.push(toJsonString(input.specValuesJson)) + updates.push(`spec_values_json = $${values.length}::jsonb`) + } + if (input.specKey !== undefined) { + values.push(input.specKey) + updates.push(`spec_key = $${values.length}`) + } + if (input.specText !== undefined) { + values.push(input.specText) + updates.push(`spec_text = $${values.length}`) + } + if (input.priceCapFen !== undefined) { + values.push(Math.max(0, Math.round(input.priceCapFen))) + updates.push(`price_cap_fen = $${values.length}`) + } if (input.buyerName !== undefined) { values.push(input.buyerName) updates.push(`buyer_name = $${values.length}`) @@ -1060,6 +1092,7 @@ export async function listWorkerConsignmentPage(input: { AND ( cs.submission_no ILIKE ${p} OR cs.item_name ILIKE ${p} + OR cs.spec_text ILIKE ${p} OR cs.system ILIKE ${p} OR cs.server_zone ILIKE ${p} OR cs.series ILIKE ${p} @@ -1069,6 +1102,7 @@ export async function listWorkerConsignmentPage(input: { OR cs.seller_phone ILIKE ${p} OR ci.inventory_no ILIKE ${p} OR ci.item_name ILIKE ${p} + OR ci.spec_text ILIKE ${p} OR ci.seller_game_id ILIKE ${p} OR ci.seller_contact ILIKE ${p} OR ci.seller_phone ILIKE ${p} @@ -1338,6 +1372,10 @@ export async function createConsignmentSubmissionWithClient( profileId?: number | null profileVersionId?: number | null profileSnapshotJson?: Record | null + specValuesJson?: Record | null + specKey?: string + specText?: string + priceCapFen?: number quantity?: number requestedSalePriceFen?: number now: string @@ -1352,6 +1390,7 @@ export async function createConsignmentSubmissionWithClient( worker_id, worker_name, worker_phone, deposit_amount_fen, contact_supporter_id, profile_id, profile_version_id, profile_snapshot_json, quantity, requested_sale_price_fen, + spec_values_json, spec_key, spec_text, price_cap_fen, status, created_at, updated_at ) VALUES ( $1, $2, $3, $4, @@ -1361,7 +1400,8 @@ export async function createConsignmentSubmissionWithClient( $17, $18, $19, $20, $21, $22, $23, $24::jsonb, $25, $26, - 'pending_review', $27, $27 + $27::jsonb, $28, $29, $30, + 'pending_review', $31, $31 ) RETURNING * ` @@ -1392,6 +1432,10 @@ export async function createConsignmentSubmissionWithClient( input.profileSnapshotJson ? toJsonString(input.profileSnapshotJson) : null, Math.max(1, Math.round(Number(input.quantity || 1))), Math.max(0, Math.round(Number(input.requestedSalePriceFen || input.askingPriceFen || 0))), + toJsonString(input.specValuesJson || {}), + String(input.specKey || ''), + String(input.specText || ''), + Math.max(0, Math.round(Number(input.priceCapFen || 0))), input.now, ]) return res.rows[0]! @@ -1463,6 +1507,10 @@ export async function approveConsignmentSubmissionWithInbound(input: { profileId?: number | null | undefined profileVersionId?: number | null | undefined profileSnapshotJson?: string | Record | null | undefined + specValuesJson?: string | Record | null | undefined + specKey?: string | undefined + specText?: string | undefined + priceCapFen?: number | undefined quantity?: number | undefined salePriceFen?: number | undefined now: string @@ -1501,6 +1549,7 @@ export async function approveConsignmentSubmissionWithInbound(input: { contact_supporter_id, profile_id, profile_version_id, profile_snapshot_json, quantity, remaining_quantity, sale_price_fen, listed_at, + spec_values_json, spec_key, spec_text, price_cap_fen, inbound_at, created_at, updated_at ) VALUES ( $1, $2, $3, $4, $5, $6, @@ -1512,6 +1561,7 @@ export async function approveConsignmentSubmissionWithInbound(input: { $25, $26, $27, $28::jsonb, $29, $29, $30, $31, + $32::jsonb, $33, $34, $35, $31, $31, $31 ) RETURNING * @@ -1563,6 +1613,10 @@ export async function approveConsignmentSubmissionWithInbound(input: { ), ), input.now, + toJsonString(input.specValuesJson ?? submission.spec_values_json ?? {}), + input.specKey ?? submission.spec_key ?? '', + input.specText ?? submission.spec_text ?? '', + Math.max(0, Math.round(Number(input.priceCapFen ?? submission.price_cap_fen ?? 0))), ] const invRes = await client.query(invSql, invValues) const inventory = invRes.rows[0]! diff --git a/apps/backend/src/routes/admin/worker-platform/consignment.ts b/apps/backend/src/routes/admin/worker-platform/consignment.ts index 1ef611f2..99099d45 100644 --- a/apps/backend/src/routes/admin/worker-platform/consignment.ts +++ b/apps/backend/src/routes/admin/worker-platform/consignment.ts @@ -128,6 +128,8 @@ router.post( inventoryIds: Array.isArray(req.body?.inventoryIds) ? req.body.inventoryIds.map((id: unknown) => Number(id)) : [], + allowNonExact: Boolean(req.body?.allowNonExact), + reason: String(req.body?.reason || '').trim(), actorName: req.adminSession?.displayName || req.adminSession?.username || 'admin', }), { diff --git a/apps/backend/src/routes/worker/consignment.ts b/apps/backend/src/routes/worker/consignment.ts index d81347fb..437cb66a 100644 --- a/apps/backend/src/routes/worker/consignment.ts +++ b/apps/backend/src/routes/worker/consignment.ts @@ -80,6 +80,7 @@ router.post('/consignment/publish', requireActiveWorker, async (req, res) => { router.get('/consignment/price-suggestion', requireActiveWorker, async (req, res) => { try { const result = await getWorkerConsignmentPriceSuggestion({ + profileId: req.query.profileId, system: req.query.system, series: req.query.series, diamondLevel: req.query.diamondLevel, @@ -91,6 +92,22 @@ router.get('/consignment/price-suggestion', requireActiveWorker, async (req, res } }) +/** + * POST /api/v1/worker/consignment/price-preview + * 动态规格发布时按 profileId + specValues 算价(支持自定义字段与配置价)。 + */ +router.post('/consignment/price-preview', requireActiveWorker, async (req, res) => { + try { + const result = await getWorkerConsignmentPriceSuggestion({ + profileId: req.body?.profileId, + values: req.body?.specValues, + }) + res.json({ ok: true, data: result }) + } catch (error) { + sendRouteError(res, error, '获取价格预览失败', '[worker/consignment/price-preview]') + } +}) + /** * GET /api/v1/worker/consignment/my-items?group=&keyword=&page=&pageSize= * 打手查看自己在接单平台寄卖的手枪列表与状态(服务端搜索 + 分组 + 分页) diff --git a/apps/backend/src/services/worker-platform/consignment-admin-service.ts b/apps/backend/src/services/worker-platform/consignment-admin-service.ts index d5c92a30..ff4f6038 100644 --- a/apps/backend/src/services/worker-platform/consignment-admin-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-admin-service.ts @@ -28,6 +28,14 @@ import { normalizeConsignmentProfileConfig, resolvePriceDropSuggestion, } from './consignment-profile-service.js' +import { + buildConsignmentSpecKey, + buildConsignmentSpecText, + normalizeConsignmentSpecValues, + parseConsignmentSpecValues, + resolveLegacyConsignmentColumns, + type ConsignmentSpecField, +} from './consignment-spec-service.js' import { ensureConsignmentPublishDepositFrozenBySubmissionWithClient, getConsignmentDepositErrorCode, @@ -36,6 +44,55 @@ import { import { publishWorkerWalletRealtimeChange } from '../realtime/realtime-event-service.js' import { extractConsignmentBuyerInfo } from './work-order-material-mappers.js' +function resolveSpecsFromSnapshot(value: unknown): ConsignmentSpecField[] { + const config = value ? normalizeConsignmentProfileConfig(value) : null + return config?.specs ?? [] +} + +function hasSpecValuesPayload(value: unknown): boolean { + return Boolean( + value && + typeof value === 'object' && + !Array.isArray(value) && + Object.keys(value as Record).length > 0, + ) +} + +function mergeLegacySpecValues( + specs: ConsignmentSpecField[], + baseValues: Record, + payload: JsonObject, +): { values: Record; hasLegacyEdit: boolean } { + const values = { ...baseValues } + let hasLegacyEdit = false + if (payload.system !== undefined) { + values.system = String(payload.system).trim() + hasLegacyEdit = true + } + if (payload.serverZone !== undefined) { + values.serverZone = String(payload.serverZone).trim() + hasLegacyEdit = true + } + if (payload.series !== undefined) { + values.series = String(payload.series).trim() + hasLegacyEdit = true + } + if (payload.diamondLevel !== undefined) { + values.diamondLevel = Math.max(1, Math.min(7, Number(payload.diamondLevel))) + hasLegacyEdit = true + } + if (payload.brillianceCount !== undefined) { + values.brillianceCount = Math.max(0, Math.min(7, Number(payload.brillianceCount))) + hasLegacyEdit = true + } + if (!hasLegacyEdit) return { values, hasLegacyEdit } + // 只保留配置里存在的内置字段,避免把已移除的字段重新写回 specKey。 + for (const key of ['system', 'serverZone', 'series', 'diamondLevel', 'brillianceCount']) { + if (!specs.some((spec) => spec.key === key)) delete values[key] + } + return { values, hasLegacyEdit } +} + function throwConsignmentDepositHttpError(error: unknown, restock: boolean): never { const code = getConsignmentDepositErrorCode(error) if (code === 'consignment_deposit_insufficient') { @@ -191,12 +248,16 @@ export function mapConsignmentInventory(row: ConsignmentInventoryRow) { listedAt: row.listed_at || row.inbound_at, priceUpdatedAt: row.price_updated_at || null, priceDropSuggestion, + specs: profileConfig?.specs || [], itemName: row.item_name, system: row.system, serverZone: row.server_zone, series: row.series, diamondLevel: Number(row.diamond_level || 1), brillianceCount: Number(row.brilliance_count || 0), + specValues: parseConsignmentSpecValues(row.spec_values_json), + specText: row.spec_text || '', + priceCapFen: Number(row.price_cap_fen || 0), costPriceFen: Number(row.cost_price_fen || 0), buyerName: row.buyer_name || '', shopName: showingMatchedBuyer ? row.shop_name || '' : '', @@ -261,12 +322,23 @@ export function mapConsignmentInventory(row: ConsignmentInventoryRow) { } export function mapConsignmentSubmission(row: ConsignmentSubmissionRow) { + const profileConfig = row.profile_snapshot_json + ? normalizeConsignmentProfileConfig(row.profile_snapshot_json) + : null return { id: Number(row.id), submissionNo: row.submission_no, gameName: row.game_name, categoryId: row.category_id ? Number(row.category_id) : null, categoryName: row.category_name, + profileId: row.profile_id ? Number(row.profile_id) : null, + profileVersionId: row.profile_version_id ? Number(row.profile_version_id) : null, + specs: profileConfig?.specs || [], + specValues: parseConsignmentSpecValues(row.spec_values_json), + specText: row.spec_text || '', + priceCapFen: Number(row.price_cap_fen || 0), + quantity: Math.max(1, Number(row.quantity || 1)), + requestedSalePriceFen: Number(row.requested_sale_price_fen || 0), itemName: row.item_name, system: row.system, serverZone: row.server_zone, @@ -393,6 +465,69 @@ export async function updateAdminConsignmentInventory( errorCode: 'consignment_allocation_status_update_forbidden', }) } + let specPatch: { + specValuesJson?: Record + specKey?: string + specText?: string + system?: string + serverZone?: string + series?: string + diamondLevel?: number + brillianceCount?: number + } = {} + if (hasSpecValuesPayload(payload.specValues)) { + const inventory = await getConsignmentInventoryById(id) + if (!inventory) { + throw createHttpError('寄卖库存不存在', { + statusCode: 404, + errorCode: 'consignment_inventory_not_found', + }) + } + const config = inventory.profile_snapshot_json + ? normalizeConsignmentProfileConfig(inventory.profile_snapshot_json) + : null + const specs = config?.specs ?? [] + const validation = normalizeConsignmentSpecValues(specs, { specValues: payload.specValues }) + if (validation.errors.length > 0) { + throw createHttpError(validation.errors[0]!, { + statusCode: 400, + errorCode: 'consignment_specs_invalid', + }) + } + const legacy = resolveLegacyConsignmentColumns(validation.values) + specPatch = { + specValuesJson: validation.values, + specKey: buildConsignmentSpecKey(specs, validation.values), + specText: buildConsignmentSpecText(specs, validation.values), + ...(specs.some((spec) => spec.key === 'system') ? { system: legacy.system } : {}), + ...(specs.some((spec) => spec.key === 'serverZone') ? { serverZone: legacy.serverZone } : {}), + ...(specs.some((spec) => spec.key === 'series') ? { series: legacy.series } : {}), + ...(specs.some((spec) => spec.key === 'diamondLevel') + ? { diamondLevel: legacy.diamondLevel } + : {}), + ...(specs.some((spec) => spec.key === 'brillianceCount') + ? { brillianceCount: legacy.brillianceCount } + : {}), + } + } else { + // 客服只改旧版内置字段时,也要同步 spec_values/spec_key,避免 JSON 与旧列不一致。 + const inventory = await getConsignmentInventoryById(id) + if (inventory) { + const specs = resolveSpecsFromSnapshot(inventory.profile_snapshot_json) + const merged = mergeLegacySpecValues( + specs, + parseConsignmentSpecValues(inventory.spec_values_json), + payload, + ) + if (merged.hasLegacyEdit) { + specPatch = { + specValuesJson: merged.values, + specKey: buildConsignmentSpecKey(specs, merged.values), + specText: buildConsignmentSpecText(specs, merged.values), + } + } + } + } const updated = await updateConsignmentInventory(id, { itemName: payload.itemName !== undefined ? String(payload.itemName).trim() : undefined, system: payload.system !== undefined ? String(payload.system).trim() : undefined, @@ -446,6 +581,7 @@ export async function updateAdminConsignmentInventory( ? Number(payload.contactSupporterId) : null : undefined, + ...specPatch, now, }) @@ -665,23 +801,90 @@ export async function approveAdminConsignmentSubmission( ? Number(payload.depositAmountFen) : Number(submission.deposit_amount_fen || 0) + let approvalSpec: { + specValuesJson?: Record + specKey?: string + specText?: string + system?: string + serverZone?: string + series?: string + diamondLevel?: number + brillianceCount?: number + } = {} + if (hasSpecValuesPayload(payload.specValues)) { + const config = submission.profile_snapshot_json + ? normalizeConsignmentProfileConfig(submission.profile_snapshot_json) + : null + const specs = config?.specs ?? [] + const validation = normalizeConsignmentSpecValues(specs, { specValues: payload.specValues }) + if (validation.errors.length > 0) { + throw createHttpError(validation.errors[0]!, { + statusCode: 400, + errorCode: 'consignment_specs_invalid', + }) + } + const legacy = resolveLegacyConsignmentColumns(validation.values) + approvalSpec = { + specValuesJson: validation.values, + specKey: buildConsignmentSpecKey(specs, validation.values), + specText: buildConsignmentSpecText(specs, validation.values), + ...(specs.some((spec) => spec.key === 'system') ? { system: legacy.system } : {}), + ...(specs.some((spec) => spec.key === 'serverZone') ? { serverZone: legacy.serverZone } : {}), + ...(specs.some((spec) => spec.key === 'series') ? { series: legacy.series } : {}), + ...(specs.some((spec) => spec.key === 'diamondLevel') + ? { diamondLevel: legacy.diamondLevel } + : {}), + ...(specs.some((spec) => spec.key === 'brillianceCount') + ? { brillianceCount: legacy.brillianceCount } + : {}), + } + } else { + const specs = resolveSpecsFromSnapshot(submission.profile_snapshot_json) + const merged = mergeLegacySpecValues( + specs, + parseConsignmentSpecValues(submission.spec_values_json), + payload, + ) + if (merged.hasLegacyEdit) { + const legacy = resolveLegacyConsignmentColumns(merged.values) + approvalSpec = { + specValuesJson: merged.values, + specKey: buildConsignmentSpecKey(specs, merged.values), + specText: buildConsignmentSpecText(specs, merged.values), + ...(specs.some((spec) => spec.key === 'system') ? { system: legacy.system } : {}), + ...(specs.some((spec) => spec.key === 'serverZone') + ? { serverZone: legacy.serverZone } + : {}), + ...(specs.some((spec) => spec.key === 'series') ? { series: legacy.series } : {}), + ...(specs.some((spec) => spec.key === 'diamondLevel') + ? { diamondLevel: legacy.diamondLevel } + : {}), + ...(specs.some((spec) => spec.key === 'brillianceCount') + ? { brillianceCount: legacy.brillianceCount } + : {}), + } + } + } + const res = await approveConsignmentSubmissionWithInbound({ submissionId: id, inventoryNo, buyerName: String(payload.buyerName || actorName || '').trim(), inboundBatch: String(payload.inboundBatch || '').trim(), itemName: String(payload.itemName || submission.item_name).trim(), - system: String(payload.system || submission.system).trim(), - serverZone: String(payload.serverZone || submission.server_zone).trim(), - series: String(payload.series || submission.series).trim(), - diamondLevel: Math.max( - 1, - Math.min(7, Number(payload.diamondLevel ?? submission.diamond_level ?? 1)), - ), - brillianceCount: Math.max( - 0, - Math.min(7, Number(payload.brillianceCount ?? submission.brilliance_count ?? 0)), - ), + system: approvalSpec.system ?? String(payload.system || submission.system).trim(), + serverZone: + approvalSpec.serverZone ?? String(payload.serverZone || submission.server_zone).trim(), + series: approvalSpec.series ?? String(payload.series || submission.series).trim(), + diamondLevel: + approvalSpec.diamondLevel ?? + Math.max(1, Math.min(7, Number(payload.diamondLevel ?? submission.diamond_level ?? 1))), + brillianceCount: + approvalSpec.brillianceCount ?? + Math.max(0, Math.min(7, Number(payload.brillianceCount ?? submission.brilliance_count ?? 0))), + specValuesJson: approvalSpec.specValuesJson, + specKey: approvalSpec.specKey, + specText: approvalSpec.specText, costPriceFen, sellerContact: String(payload.sellerContact || submission.contact_info).trim(), sellerGameId: String(payload.sellerGameId || submission.seller_game_id).trim(), 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 1b034fa4..79f6e5c1 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 @@ -20,9 +20,17 @@ import { nowIso } from '../../utils/time.js' import { safeParseJson } from '../admin/admin-query-utils.js' import { buildServiceFeeFen, + normalizeConsignmentProfileConfig, resolveConsignmentProfileForCategory, type ConsignmentProfileSnapshot, } from './consignment-profile-service.js' +import { + evaluateConsignmentSpecMatch, + isBuiltInConsignmentSpecField, + parseConsignmentSpecValues, + type ConsignmentSpecField, + type ConsignmentSpecValues, +} from './consignment-spec-service.js' import { classifyConsignmentMatch, extractPeacekeeperSpecs, @@ -62,20 +70,114 @@ function inventoryMatchesProfile( return Number(inventory.category_id || 0) === profile.categoryId } -function inventoryMatchesWorkOrder( - workOrder: WorkOrderRow, +type InventoryMatchEvaluation = { + specs: ConsignmentSpecField[] + values: ConsignmentSpecValues + orderValues: ConsignmentSpecValues + exact: boolean + partial: boolean + unknown: boolean + conflicts: string[] + unknowns: string[] + details: ReturnType['details'] +} + +function resolveInventorySpecs( inventory: ConsignmentInventoryRow, -): boolean { - if (Number(inventory.remaining_quantity ?? 1) <= 0) return false - if (!inventory.allow_matching) return false - if (inventory.status !== 'in_stock') return false + profile: ConsignmentProfileSnapshot | null, +): ConsignmentSpecField[] { + const config = inventory.profile_snapshot_json + ? normalizeConsignmentProfileConfig(inventory.profile_snapshot_json) + : profile?.config + return config?.specs ?? [] +} + +function resolveInventorySpecValues(inventory: ConsignmentInventoryRow): ConsignmentSpecValues { + const parsed = parseConsignmentSpecValues(inventory.spec_values_json) + if (Object.keys(parsed).length > 0) return parsed + return { + system: inventory.system, + serverZone: inventory.server_zone, + series: inventory.series, + diamondLevel: Number(inventory.diamond_level || 1), + brillianceCount: Number(inventory.brilliance_count || 0), + } +} + +function resolveOrderSpecValues(workOrder: WorkOrderRow): ConsignmentSpecValues { const fields = resolveCollectSubmittedFieldValues(workOrder) - const orderSpecs = extractPeacekeeperSpecs(workOrder.product_name, { + const specs = extractPeacekeeperSpecs(workOrder.product_name, { system: fields.system, serverZone: fields.serverZone, series: fields.series, }) - return classifyConsignmentMatch(orderSpecs, inventory).matchType === 'exact' + const values: ConsignmentSpecValues = {} + for (const [key, value] of Object.entries(fields)) { + if (typeof value === 'string' || typeof value === 'number') values[key] = value + } + if (specs.system) values.system = specs.system + if (specs.serverZone) values.serverZone = specs.serverZone + if (specs.series) values.series = specs.series + if (Number(specs.diamondLevel || 0) > 0) values.diamondLevel = Number(specs.diamondLevel) + values.brillianceCount = Number(specs.brillianceCount || 0) + return values +} + +function evaluateInventoryMatch( + workOrder: WorkOrderRow, + inventory: ConsignmentInventoryRow, + profile: ConsignmentProfileSnapshot | null, +): InventoryMatchEvaluation { + const specs = resolveInventorySpecs(inventory, profile) + const orderValues = resolveOrderSpecValues(workOrder) + const values = resolveInventorySpecValues(inventory) + const hasCustomSpecs = specs.some((spec) => !isBuiltInConsignmentSpecField(spec)) + + if (!hasCustomSpecs) { + // 内置 5 规格沿用原有苏尔南匹配器(通配、低配不能充高配、品类门禁); + // 只有出现自定义规格时才走通用匹配,避免存量行为回归。 + const orderSpecs = extractPeacekeeperSpecs(workOrder.product_name, { + system: String(orderValues.system || ''), + serverZone: String(orderValues.serverZone || ''), + series: String(orderValues.series || ''), + }) + const strict = classifyConsignmentMatch(orderSpecs, inventory) + return { + specs, + values, + orderValues, + exact: strict.matchType === 'exact', + partial: strict.matchType === 'partial', + unknown: false, + conflicts: strict.matchType === 'conflict' ? strict.conflicts : [], + unknowns: [], + details: [], + } + } + + const evaluation = evaluateConsignmentSpecMatch({ specs, orderValues, inventoryValues: values }) + return { + specs, + values, + orderValues, + exact: evaluation.exact, + partial: evaluation.partial, + unknown: evaluation.unknown, + conflicts: evaluation.conflicts, + unknowns: evaluation.unknowns, + details: evaluation.details, + } +} + +function inventoryMatchesWorkOrder( + workOrder: WorkOrderRow, + inventory: ConsignmentInventoryRow, + profile: ConsignmentProfileSnapshot | null = null, +): boolean { + if (Number(inventory.remaining_quantity ?? 1) <= 0) return false + if (!inventory.allow_matching) return false + if (inventory.status !== 'in_stock') return false + return evaluateInventoryMatch(workOrder, inventory, profile).exact } async function resolveProfileForWorkOrder( @@ -196,7 +298,7 @@ function pickCandidateInventories( .filter( (inventory) => inventoryMatchesProfile(inventory, profile) && - inventoryMatchesWorkOrder(workOrder, inventory), + inventoryMatchesWorkOrder(workOrder, inventory, profile), ) .sort((a, b) => { const priceDiff = inventorySalePriceFen(a) - inventorySalePriceFen(b) @@ -364,7 +466,7 @@ async function applySharingMatch( if (remaining <= 0) break if (!inventory.worker_id) continue if (!inventoryMatchesProfile(inventory, profile)) continue - if (!inventoryMatchesWorkOrder(current, inventory)) continue + if (!inventoryMatchesWorkOrder(current, inventory, profile)) continue const allocation = await reserveConsignmentInventoryWithClient(client, { inventoryId: Number(inventory.id), @@ -548,17 +650,36 @@ export async function expireConsignmentInternalMatching(options: { now?: string return { expired: expired.rows.length } } -/** 后台手动匹配弹窗:列出该工单可精确匹配的库存。 */ +/** 后台手动匹配弹窗:列出同配置在库库存,并标注精确/差异/冲突。 */ export async function listConsignmentMatchCandidatesForWorkOrder(workOrderId: number) { const workOrder = await getWorkOrderById(workOrderId) if (!workOrder) { throw createHttpError('工单不存在', { statusCode: 404, errorCode: 'work_order_not_found' }) } const profile = await resolveProfileForWorkOrder(workOrder) - const inventories = (await listActiveConsignmentInventoriesForMatching()).filter((inventory) => - inventoryMatchesProfile(inventory, profile), + const inventories = (await listActiveConsignmentInventoriesForMatching()).filter( + (inventory) => + inventoryMatchesProfile(inventory, profile) && + Number(inventory.remaining_quantity ?? 1) > 0 && + inventory.status === 'in_stock' && + inventory.allow_matching, ) - const options = pickCandidateInventories(workOrder, profile, inventories).map((inventory) => ({ + const evaluated = inventories + .map((inventory) => ({ + inventory, + evaluation: evaluateInventoryMatch(workOrder, inventory, profile), + })) + .sort((a, b) => { + if (a.evaluation.exact !== b.evaluation.exact) return a.evaluation.exact ? -1 : 1 + const priceDiff = inventorySalePriceFen(a.inventory) - inventorySalePriceFen(b.inventory) + if (priceDiff !== 0) return priceDiff + const listedA = Date.parse(String(a.inventory.listed_at || a.inventory.inbound_at || '')) || 0 + const listedB = Date.parse(String(b.inventory.listed_at || b.inventory.inbound_at || '')) || 0 + if (listedA !== listedB) return listedA - listedB + return Number(a.inventory.id) - Number(b.inventory.id) + }) + + const options = evaluated.map(({ inventory, evaluation }) => ({ inventoryId: Number(inventory.id), inventoryNo: inventory.inventory_no, itemName: inventory.item_name, @@ -567,6 +688,19 @@ export async function listConsignmentMatchCandidatesForWorkOrder(workOrderId: nu series: inventory.series, diamondLevel: Number(inventory.diamond_level || 1), brillianceCount: Number(inventory.brilliance_count || 0), + specValues: evaluation.values, + specText: inventory.spec_text || '', + matchType: evaluation.exact + ? 'exact' + : evaluation.conflicts.length > 0 + ? 'conflict' + : evaluation.unknown + ? 'unknown' + : evaluation.partial + ? 'partial' + : 'unknown', + matchReasons: [...evaluation.conflicts, ...evaluation.unknowns], + matchDetails: evaluation.details, quantity: Number(inventory.quantity || 1), remainingQuantity: Number(inventory.remaining_quantity ?? 1), salePriceFen: inventorySalePriceFen(inventory), @@ -593,6 +727,9 @@ export async function matchConsignmentWorkOrderManually(input: { workOrderId: number inventoryIds: number[] actorName?: string + /** 允许人工选择非精确库存(差异/冲突),需同时填写原因 */ + allowNonExact?: boolean + reason?: string }): Promise<{ matched: boolean; matchedCount: number }> { const workOrder = await getWorkOrderById(input.workOrderId) if (!workOrder) { @@ -611,8 +748,9 @@ export async function matchConsignmentWorkOrderManually(input: { }) } const profile = await resolveProfileForWorkOrder(workOrder) - const inventories = (await listActiveConsignmentInventoriesForMatching()).filter((inventory) => - inventoryMatchesProfile(inventory, profile), + const inventories = (await listActiveConsignmentInventoriesForMatching()).filter( + (inventory) => + inventoryMatchesProfile(inventory, profile) && Number(inventory.remaining_quantity ?? 1) > 0, ) const selected = input.inventoryIds .map((id) => inventories.find((inventory) => Number(inventory.id) === Number(id))) @@ -625,6 +763,33 @@ export async function matchConsignmentWorkOrderManually(input: { }) } + const nonExact = selected + .map((inventory) => ({ + inventory, + evaluation: evaluateInventoryMatch(workOrder, inventory, profile), + })) + .filter((item) => !item.evaluation.exact) + if (nonExact.length > 0) { + if (!input.allowNonExact) { + const details = nonExact + .map((item) => { + const reasons = [...item.evaluation.conflicts, ...item.evaluation.unknowns] + return `${item.inventory.inventory_no}(${reasons.join('、') || '规格不一致'})` + }) + .join(';') + throw createHttpError(`所选库存与订单规格不一致:${details}。请确认后再匹配`, { + statusCode: 409, + errorCode: 'consignment_spec_mismatch', + }) + } + if (!String(input.reason || '').trim()) { + throw createHttpError('非精确匹配必须填写原因', { + statusCode: 400, + errorCode: 'consignment_match_reason_required', + }) + } + } + const appliedWorkOrder = { ...workOrder, status: WORK_ORDER_STATUS.INTERNAL_MATCHING } if (!workOrder.sharing_enabled) { await query( 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 01ade1b6..8587c614 100644 --- a/apps/backend/src/services/worker-platform/consignment-profile-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-profile-service.ts @@ -3,12 +3,16 @@ import type { JsonObject } from '../../types/json.js' import { createHttpError } from '../../utils/http.js' import { nowIso } from '../../utils/time.js' import { listAllWorkCategories } from '../../repositories/worker-platform/work-category-repo.js' +import { + DEFAULT_CONSIGNMENT_PRICING, + DEFAULT_CONSIGNMENT_SPECS, + normalizeConsignmentPricing, + normalizeConsignmentSpecs, + type ConsignmentPricingConfig, + type ConsignmentSpecField, +} from './consignment-spec-service.js' -export type ConsignmentPricingConfig = { - useTemplatePrice: boolean - allowWorkerPrice: boolean - minPriceFen: number -} +export type { ConsignmentPricingConfig, ConsignmentSpecField } from './consignment-spec-service.js' export type ConsignmentDepositConfig = { enabled: boolean @@ -69,7 +73,12 @@ export type ConsignmentProfileConfig = { version: 1 tabTitle: string tabDescription: string + /** 旧版发布开关:由 specs 派生,quantity 仍独立控制 */ fields: ConsignmentPublishFields + /** 动态规格字段(完全由运营配置;默认空) */ + specs: ConsignmentSpecField[] + /** 物品名模板,支持 {system}/{series}/{category} 等占位符 */ + nameTemplate: string pricing: ConsignmentPricingConfig deposit: ConsignmentDepositConfig serviceFee: ConsignmentServiceFeeConfig @@ -101,6 +110,7 @@ export type ConsignmentProfileRow = { export type ConsignmentProfileSnapshot = { profileId: number categoryId: number + categoryName: string versionId: number versionNo: number config: ConsignmentProfileConfig @@ -111,18 +121,16 @@ export const DEFAULT_CONSIGNMENT_PROFILE_CONFIG: ConsignmentProfileConfig = { tabTitle: '', tabDescription: '', fields: { - system: true, - serverZone: true, - series: true, - diamondLevel: true, - brillianceCount: true, + system: false, + serverZone: false, + series: false, + diamondLevel: false, + brillianceCount: false, quantity: true, }, - pricing: { - useTemplatePrice: true, - allowWorkerPrice: false, - minPriceFen: 0, - }, + specs: DEFAULT_CONSIGNMENT_SPECS, + nameTemplate: '', + pricing: DEFAULT_CONSIGNMENT_PRICING, deposit: { enabled: false, mode: 'fixed', @@ -197,9 +205,14 @@ function integer(value: unknown, fallback: number, min: number, max: number): nu } export function normalizeConsignmentProfileConfig(value: unknown): ConsignmentProfileConfig { - const source = record(value) + const outer = record(value) + // 兼容直接存 config 的场景,以及库存/提报里 { config: {...}, profileId... } 的快照包装。 + const source = + outer.config && typeof outer.config === 'object' && !Array.isArray(outer.config) + ? record(outer.config) + : outer const fields = record(source.fields) - const pricing = record(source.pricing) + const specs = normalizeConsignmentSpecs(source.specs ?? source.specs_json, fields) const deposit = record(source.deposit) const serviceFee = record(source.serviceFee) const inventoryLimit = record(source.inventoryLimit) @@ -216,36 +229,26 @@ export function normalizeConsignmentProfileConfig(value: unknown): ConsignmentPr timeout.policy === 'cancel_release' || timeout.policy === 'cancel_deduct' ? timeout.policy : 'reopen' + const specKeys = new Set(specs.map((spec) => spec.key)) return { version: 1, tabTitle: text(source.tabTitle, 30), tabDescription: text(source.tabDescription, 200), + // 旧字段保留给旧代码/旧页面;有 specs 时以内置规格是否存在为准。 fields: { - system: bool(fields.system, DEFAULT_CONSIGNMENT_PROFILE_CONFIG.fields.system), - serverZone: bool(fields.serverZone, DEFAULT_CONSIGNMENT_PROFILE_CONFIG.fields.serverZone), - series: bool(fields.series, DEFAULT_CONSIGNMENT_PROFILE_CONFIG.fields.series), - diamondLevel: bool( - fields.diamondLevel, - DEFAULT_CONSIGNMENT_PROFILE_CONFIG.fields.diamondLevel, - ), - brillianceCount: bool( - fields.brillianceCount, - DEFAULT_CONSIGNMENT_PROFILE_CONFIG.fields.brillianceCount, - ), + system: specKeys.has('system'), + serverZone: specKeys.has('serverZone'), + series: specKeys.has('series'), + diamondLevel: specKeys.has('diamondLevel'), + brillianceCount: specKeys.has('brillianceCount'), quantity: bool(fields.quantity, DEFAULT_CONSIGNMENT_PROFILE_CONFIG.fields.quantity), }, - pricing: { - useTemplatePrice: bool( - pricing.useTemplatePrice, - DEFAULT_CONSIGNMENT_PROFILE_CONFIG.pricing.useTemplatePrice, - ), - allowWorkerPrice: bool( - pricing.allowWorkerPrice, - DEFAULT_CONSIGNMENT_PROFILE_CONFIG.pricing.allowWorkerPrice, - ), - minPriceFen: integer(pricing.minPriceFen, 0, 0, 100_000_000), - }, + specs, + nameTemplate: + text(source.nameTemplate ?? source.name_template, 300) || + DEFAULT_CONSIGNMENT_PROFILE_CONFIG.nameTemplate, + pricing: normalizeConsignmentPricing(source.pricing), deposit: { enabled: bool(deposit.enabled, false), mode: depositMode, @@ -525,6 +528,7 @@ export async function resolveConsignmentProfileForCategory( return { profileId: Number(row.id), categoryId: Number(row.category_id), + categoryName: row.category_name || '', versionId: Number(row.current_version_id), versionNo: Number(row.current_version_no || 1), config: normalizeConsignmentProfileConfig(row.config_json), @@ -547,6 +551,7 @@ export async function getPublishedConsignmentProfileById( return { profileId: Number(row.id), categoryId: Number(row.category_id), + categoryName: row.category_name || '', versionId: Number(row.current_version_id), versionNo: Number(row.current_version_no || 1), config: normalizeConsignmentProfileConfig(row.config_json), 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 new file mode 100644 index 00000000..c036d249 --- /dev/null +++ b/apps/backend/src/services/worker-platform/consignment-spec-service.test.ts @@ -0,0 +1,457 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import test from 'node:test' + +import { normalizeConsignmentProfileConfig } from './consignment-profile-service.js' +import { + buildConsignmentItemName, + buildConsignmentSpecKey, + buildConsignmentSpecText, + computeConsignmentPrice, + evaluateConsignmentSpecMatch, + normalizeConsignmentSpecValues, + normalizeConsignmentSpecs, + parseConsignmentSpecValues, + resolveLegacyConsignmentColumns, + resolveWorkerConsignmentPrice, + type ConsignmentPricingConfig, + type ConsignmentSpecField, +} from './consignment-spec-service.js' + +function buildPricing(overrides: Partial = {}): ConsignmentPricingConfig { + return { + priceType: 'template_discount', + discount: 10, + fixedPriceFen: 0, + allowWorkerPrice: true, + minPriceFen: 0, + missingPricePolicy: 'manual', + ...overrides, + } +} + +function option(label: string, value: string) { + return { label, value, enabled: true } +} + +/** 苏尔南手枪种子配置的五个字段(与迁移 131 一致)。 */ +function surnanSpecs(): ConsignmentSpecField[] { + return [ + { + key: 'system', + label: '操作系统', + type: 'radio', + required: true, + options: [option('苹果', '苹果'), option('安卓', '安卓')], + placeholder: '', + helpText: '', + matchMode: 'exact', + wildcardValues: [], + showInItemName: true, + sortOrder: 10, + }, + { + key: 'serverZone', + label: '游戏区服', + type: 'radio', + required: true, + options: [option('互通(QQ/微信)', '互通')], + placeholder: '', + helpText: '', + matchMode: 'wildcard', + wildcardValues: ['互通', '通用', '全区', '不限'], + showInItemName: false, + sortOrder: 20, + }, + { + key: 'series', + label: '系列 / 枪系', + type: 'radio', + required: true, + options: [option('暗潮', '暗潮'), option('怒嚎', '怒嚎'), option('血宴', '血宴')], + placeholder: '', + helpText: '', + matchMode: 'exact', + wildcardValues: [], + showInItemName: true, + sortOrder: 30, + }, + { + key: 'diamondLevel', + label: '钻石等级', + type: 'radio', + required: true, + options: Array.from({ length: 7 }, (_, index) => option(`${index + 1}钻`, String(index + 1))), + placeholder: '', + helpText: '', + matchMode: 'minimum', + wildcardValues: [], + showInItemName: true, + sortOrder: 40, + }, + { + key: 'brillianceCount', + label: '璀璨数', + type: 'radio', + required: true, + options: Array.from({ length: 8 }, (_, index) => + index === 0 ? option('无', '0') : option(`${index}璀`, String(index)), + ), + placeholder: '', + helpText: '', + matchMode: 'minimum', + wildcardValues: [], + showInItemName: true, + sortOrder: 50, + }, + ] +} + +const surnanValues = { + system: '苹果', + serverZone: '互通', + series: '暗潮', + diamondLevel: '3', + brillianceCount: '1', +} + +test('动态规格:默认配置没有任何字段,价格默认十折', () => { + const config = normalizeConsignmentProfileConfig({}) + assert.equal(config.specs.length, 0) + assert.equal(config.pricing.priceType, 'template_discount') + assert.equal(config.pricing.discount, 10) + assert.equal(config.pricing.allowWorkerPrice, true) + assert.equal(config.pricing.missingPricePolicy, 'manual') + assert.equal(config.fields.quantity, true) + assert.equal(config.fields.system, false) +}) + +test('动态规格:支持 6 种字段类型,并保留选项字段语义', () => { + const config = normalizeConsignmentProfileConfig({ + specs: [ + ...surnanSpecs(), + { + key: 'note', + label: '备注', + type: 'textarea', + required: false, + options: [], + }, + { + key: 'joinedAt', + label: '入坑日期', + type: 'date', + required: false, + options: [], + }, + { key: 'bad key', label: '非法', type: 'text' }, + ], + }) + assert.equal(config.specs.length, 7) + assert.equal(config.specs.find((spec) => spec.key === 'note')?.type, 'textarea') + assert.equal(config.specs.find((spec) => spec.key === 'joinedAt')?.type, 'date') + assert.equal(config.specs.find((spec) => spec.key === 'system')?.type, 'radio') + assert.equal(config.fields.system, true) + assert.equal(config.fields.series, true) +}) + +test('动态规格:旧版 fields 开关不再生成任何默认字段', () => { + const specs = normalizeConsignmentSpecs(undefined, { + system: true, + serverZone: false, + series: true, + diamondLevel: false, + brillianceCount: true, + }) + assert.deepEqual(specs, []) +}) + +test('动态规格:必填、选项枚举、璀璨上限与日期格式校验', () => { + const specs = [ + ...surnanSpecs(), + { + key: 'note', + label: '备注', + type: 'textarea', + required: false, + options: [], + placeholder: '', + helpText: '', + matchMode: 'exact' as const, + wildcardValues: [], + showInItemName: false, + sortOrder: 60, + }, + { + key: 'joinedAt', + label: '入坑日期', + type: 'date', + required: false, + options: [], + placeholder: '', + helpText: '', + matchMode: 'exact' as const, + wildcardValues: [], + showInItemName: false, + sortOrder: 70, + }, + ] + const missing = normalizeConsignmentSpecValues(specs, { system: '苹果' }) + assert.ok(missing.errors.some((error) => error.includes('系列'))) + + const invalid = normalizeConsignmentSpecValues(specs, { + ...surnanValues, + system: '鸿蒙', + }) + assert.ok(invalid.errors.some((error) => error.includes('选项无效'))) + + const tooMany = normalizeConsignmentSpecValues(specs, { + ...surnanValues, + diamondLevel: 2, + brillianceCount: 3, + }) + assert.ok(tooMany.errors.some((error) => error.includes('璀璨数不能超过钻石等级'))) + + const badDate = normalizeConsignmentSpecValues(specs, { + ...surnanValues, + joinedAt: '2025-02-30', + }) + assert.ok(badDate.errors.some((error) => error.includes('有效日期'))) + + const longNote = 'x'.repeat(2200) + const normalized = normalizeConsignmentSpecValues(specs, { + ...surnanValues, + joinedAt: '2025-02-28', + note: longNote, + }) + assert.deepEqual(normalized.errors, []) + assert.equal(String(normalized.values.note).length, 2000) + assert.equal(normalized.values.joinedAt, '2025-02-28') +}) + +test('动态规格:spec_key 按字段排序,只包含参与匹配的字段', () => { + const specs: ConsignmentSpecField[] = [ + { ...surnanSpecs()[0]!, key: 'zField', label: 'Z', sortOrder: 1 }, + { ...surnanSpecs()[0]!, key: 'aField', label: 'A', sortOrder: 2 }, + { ...surnanSpecs()[0]!, key: 'ignored', label: '忽略', matchMode: 'ignore' }, + ] + const key = buildConsignmentSpecKey(specs, { zField: '2', aField: '1', ignored: 'x' }) + assert.equal(key, 'aField=1|zField=2') +}) + +test('动态规格:物品名模板与规格展示文本', () => { + const specs = surnanSpecs() + const name = buildConsignmentItemName({ + template: '{system}{serverZone}{series}{diamondLevel}钻{brillianceCount}璀璨 {category}', + specs, + values: surnanValues, + categoryName: '苏尔南手枪', + }) + assert.equal(name, '苹果暗潮3钻1璀璨 苏尔南手枪') + const text = buildConsignmentSpecText(specs, surnanValues) + assert.ok(text.includes('操作系统 苹果')) + assert.ok(text.includes('游戏区服 互通')) + + const fallback = buildConsignmentItemName({ + template: '', + specs: [], + values: {}, + categoryName: '苏尔南手枪', + }) + assert.equal(fallback, '苏尔南手枪') +}) + +test('动态价格:模板折扣价与改价上限等于建议价', () => { + const result = computeConsignmentPrice({ + pricing: buildPricing({ discount: 9.5 }), + templatePriceFen: 13000, + }) + assert.equal(result.priceFen, 12350) + assert.equal(result.capFen, 12350) + assert.equal(result.templatePriceFen, 13000) + assert.equal(result.source, 'template') +}) + +test('动态价格:固定价格可用,且不能高于接单模板价', () => { + const normal = computeConsignmentPrice({ + pricing: buildPricing({ priceType: 'fixed', fixedPriceFen: 10000 }), + templatePriceFen: 13000, + }) + assert.equal(normal.priceFen, 10000) + assert.equal(normal.capFen, 10000) + assert.equal(normal.source, 'fixed') + + const above = resolveWorkerConsignmentPrice({ + pricing: buildPricing({ priceType: 'fixed', fixedPriceFen: 15000 }), + templatePriceFen: 13000, + }) + assert.ok(above.errors.some((error) => error.includes('固定价格不能高于接单模板价'))) + + const noTemplate = resolveWorkerConsignmentPrice({ + pricing: buildPricing({ priceType: 'fixed', fixedPriceFen: 8000 }), + templatePriceFen: 0, + }) + assert.deepEqual(noTemplate.errors, []) + assert.equal(noTemplate.priceFen, 8000) +}) + +test('动态价格:打手改价只能低于系统建议价,且受最低价约束', () => { + const pricing = buildPricing({ discount: 9.5 }) + const tooHigh = resolveWorkerConsignmentPrice({ + pricing, + templatePriceFen: 13000, + submittedPriceFen: 13000, + }) + assert.ok(tooHigh.errors.some((error) => error.includes('不能高于系统建议价'))) + + const lower = resolveWorkerConsignmentPrice({ + pricing, + templatePriceFen: 13000, + submittedPriceFen: 12000, + }) + assert.deepEqual(lower.errors, []) + assert.equal(lower.priceFen, 12000) + assert.equal(lower.capFen, 12350) + + const belowMin = resolveWorkerConsignmentPrice({ + pricing: buildPricing({ discount: 9.5, minPriceFen: 10000 }), + templatePriceFen: 13000, + submittedPriceFen: 9000, + }) + assert.ok(belowMin.errors.some((error) => error.includes('不能少于'))) +}) + +test('动态价格:没有模板价时允许手填,block 策略则拦截', () => { + const manual = resolveWorkerConsignmentPrice({ + pricing: buildPricing({ missingPricePolicy: 'manual' }), + templatePriceFen: 0, + submittedPriceFen: 8000, + }) + assert.deepEqual(manual.errors, []) + assert.equal(manual.priceFen, 8000) + + const blocked = resolveWorkerConsignmentPrice({ + pricing: buildPricing({ missingPricePolicy: 'block' }), + templatePriceFen: 0, + submittedPriceFen: 8000, + }) + assert.ok(blocked.errors.length > 0) +}) + +test('通用匹配:精确、部分、冲突、未知与通配', () => { + const specs = surnanSpecs() + const inventory = { ...surnanValues } + + const exact = evaluateConsignmentSpecMatch({ + specs, + orderValues: { ...inventory }, + inventoryValues: inventory, + }) + assert.equal(exact.exact, true) + + const partial = evaluateConsignmentSpecMatch({ + specs, + orderValues: { ...inventory, diamondLevel: '2' }, + inventoryValues: inventory, + }) + assert.equal(partial.exact, false) + assert.equal(partial.partial, true) + + const conflict = evaluateConsignmentSpecMatch({ + specs, + orderValues: { ...inventory, series: '血宴' }, + inventoryValues: inventory, + }) + assert.equal(conflict.exact, false) + assert.ok(conflict.conflicts.includes('系列 / 枪系')) + + const unknown = evaluateConsignmentSpecMatch({ + specs, + orderValues: { system: '苹果' }, + inventoryValues: inventory, + }) + assert.equal(unknown.exact, false) + assert.ok(unknown.unknowns.length > 0) +}) + +test('通用匹配:自定义字段必须双方同 key 且值一致才算精确', () => { + const config = normalizeConsignmentProfileConfig({ + specs: [ + ...surnanSpecs(), + { + key: 'map', + label: '地图', + type: 'select', + required: true, + options: [ + { label: '海岛', value: '海岛' }, + { label: '沙漠', value: '沙漠' }, + ], + }, + ], + }) + const inventory = { ...surnanValues, map: '海岛' } + const exact = evaluateConsignmentSpecMatch({ + specs: config.specs, + orderValues: { ...inventory }, + inventoryValues: inventory, + }) + assert.equal(exact.exact, true) + + const missingOrderField = evaluateConsignmentSpecMatch({ + specs: config.specs, + orderValues: { ...inventory, map: '' }, + inventoryValues: inventory, + }) + assert.equal(missingOrderField.exact, false) + assert.ok(missingOrderField.unknowns.includes('地图')) +}) + +test('动态规格:旧列仍由兼容 key 同步', () => { + const legacy = resolveLegacyConsignmentColumns({ + ...surnanValues, + diamondLevel: '5', + brillianceCount: '2', + map: '海岛', + }) + assert.deepEqual(legacy, { + system: '苹果', + serverZone: '互通', + series: '暗潮', + diamondLevel: 5, + brillianceCount: 2, + }) +}) + +test('迁移 130 必须包含 JSON、匹配 key、文本与改价上限', async () => { + const sql = await readFile( + new URL('../../db/migrations/130_consignment_dynamic_specs.sql', import.meta.url), + 'utf8', + ) + for (const fragment of [ + 'spec_values_json', + 'spec_key', + 'spec_text', + 'price_cap_fen', + 'idx_consignment_inventories_spec_key', + ]) { + assert.ok(sql.includes(fragment), `迁移 130 缺少 ${fragment}`) + } + assert.ok(parseConsignmentSpecValues({ system: '苹果', diamondLevel: 3 }).system === '苹果') +}) + +test('迁移 131 必须为苏尔南手枪写入已发布的五个单选字段配置', async () => { + const sql = await readFile( + new URL('../../db/migrations/131_consignment_surnan_default_profile.sql', import.meta.url), + 'utf8', + ) + for (const fragment of [ + "category_key = 'shouqiang'", + '"type": "radio"', + '"priceType": "template_discount"', + "'published'", + '"key": "brillianceCount"', + ]) { + assert.ok(sql.includes(fragment), `迁移 131 缺少 ${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 new file mode 100644 index 00000000..c50c987d --- /dev/null +++ b/apps/backend/src/services/worker-platform/consignment-spec-service.ts @@ -0,0 +1,789 @@ +/** + * 寄卖动态规格与价格(单一事实来源)。 + * + * 设计约定: + * - 没有任何「内置字段」:配置初始 specs 为空,字段全部由运营在寄卖配置里维护; + * 苏尔南手枪的五个字段由迁移 131 作为种子配置写入; + * - 字段类型支持:文本 / 长文本 / 下拉 / 单选 / 数字 / 日期; + * - 价格只有两种:接单模板价打折、固定价格;打手只能按系统建议价往下调; + * - 旧列兼容:字段 key 仍为 system/serverZone/series/diamondLevel/brillianceCount 时, + * 继续同步旧列并沿用旧匹配器,保证存量库存与工单不回归。 + */ + +export const BUILT_IN_CONSIGNMENT_SPEC_KEYS = [ + 'system', + 'serverZone', + 'series', + 'diamondLevel', + 'brillianceCount', +] as const + +export type BuiltInConsignmentSpecKey = (typeof BUILT_IN_CONSIGNMENT_SPEC_KEYS)[number] + +export type ConsignmentSpecFieldType = 'text' | 'textarea' | 'select' | 'radio' | 'number' | 'date' +export type ConsignmentSpecMatchMode = 'exact' | 'minimum' | 'wildcard' | 'ignore' + +export type ConsignmentSpecOption = { + label: string + value: string + enabled: boolean +} + +export type ConsignmentSpecField = { + key: string + label: string + type: ConsignmentSpecFieldType + required: boolean + options: ConsignmentSpecOption[] + placeholder: string + helpText: string + matchMode: ConsignmentSpecMatchMode + wildcardValues: string[] + showInItemName: boolean + sortOrder: number +} + +export type ConsignmentPricingConfig = { + /** template_discount = 接单模板价的几折;fixed = 固定价格 */ + priceType: 'template_discount' | 'fixed' + /** 几折,范围 0.1 - 10,例如 9.5 表示 95% */ + discount: number + fixedPriceFen: number + allowWorkerPrice: boolean + minPriceFen: number + /** 没有模板价时:允许打手手填 / 阻止发布 */ + missingPricePolicy: 'block' | 'manual' +} + +export type ConsignmentSpecValues = Record + +export type ConsignmentSpecValidationResult = { + values: ConsignmentSpecValues + errors: string[] +} + +export type ConsignmentPriceResult = { + priceFen: number + capFen: number + templatePriceFen: number + source: 'template' | 'fixed' | 'none' + breakdown: { label: string; amountFen: number }[] +} + +export type WorkerConsignmentPriceResult = { + priceFen: number + capFen: number + templatePriceFen: number + source: 'template' | 'fixed' | 'none' + errors: string[] +} + +export type ConsignmentSpecMatchDetail = { + key: string + label: string + orderValue: string + inventoryValue: string + status: 'match' | 'partial' | 'conflict' | 'unknown' | 'ignored' +} + +export type ConsignmentSpecMatchEvaluation = { + exact: boolean + partial: boolean + unknown: boolean + conflicts: string[] + unknowns: string[] + details: ConsignmentSpecMatchDetail[] +} + +const BUILT_IN_KEY_SET = new Set(BUILT_IN_CONSIGNMENT_SPEC_KEYS) + +const ZONE_WILDCARD_VALUES = ['互通', '通用', '全区', '不限'] +const SYSTEM_WILDCARD_VALUES = ['通用', '双端', '全端'] + +const SYSTEM_ALIASES: Record = { + 苹果: '苹果', + 安卓: '安卓', + 通用: '通用', + 双端: '通用', + 全端: '通用', +} + +const ZONE_ALIASES: Record = { + 微信区: '微信区', + QQ区: 'QQ区', + qq区: 'QQ区', + 互通: '互通', + 通用: '互通', + 全区: '互通', + 不限: '互通', +} + +const SPEC_TYPES: ConsignmentSpecFieldType[] = [ + 'text', + 'textarea', + 'select', + 'radio', + 'number', + 'date', +] + +/** 兼容旧导入名;新配置不再默认带任何字段。 */ +export const DEFAULT_CONSIGNMENT_SPECS: ConsignmentSpecField[] = [] + +export const DEFAULT_CONSIGNMENT_PRICING: ConsignmentPricingConfig = { + priceType: 'template_discount', + discount: 10, + fixedPriceFen: 0, + allowWorkerPrice: true, + minPriceFen: 0, + missingPricePolicy: 'manual', +} + +function record(value: unknown): Record { + if (typeof value === 'string') { + try { + const parsed = JSON.parse(value) + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as Record) + : {} + } catch { + return {} + } + } + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : {} +} + +function text(value: unknown, maxLength: number): string { + return String(value ?? '') + .trim() + .slice(0, maxLength) +} + +function bool(value: unknown, fallback: boolean): boolean { + if (typeof value === 'boolean') return value + if (value === 'true' || value === 1 || value === '1') return true + if (value === 'false' || value === 0 || value === '0') return false + return fallback +} + +function integer(value: unknown, fallback: number, min: number, max: number): number { + const parsed = Number(value) + if (!Number.isFinite(parsed)) return fallback + return Math.min(max, Math.max(min, Math.round(parsed))) +} + +function decimal(value: unknown, fallback: number, min: number, max: number): number { + const parsed = Number(value) + if (!Number.isFinite(parsed)) return fallback + return Math.min(max, Math.max(min, parsed)) +} + +function stringArray(value: unknown, maxItems = 30): string[] { + if (!Array.isArray(value)) return [] + const seen = new Set() + const result: string[] = [] + for (const item of value) { + const normalized = text(item, 60) + if (!normalized || seen.has(normalized)) continue + seen.add(normalized) + result.push(normalized) + if (result.length >= maxItems) break + } + return result +} + +function isBuiltInSpecKey(key: string): key is BuiltInConsignmentSpecKey { + return BUILT_IN_KEY_SET.has(key) +} + +function resolveSpecType(value: unknown): ConsignmentSpecFieldType { + const raw = text(value, 20).toLowerCase() + if (SPEC_TYPES.includes(raw as ConsignmentSpecFieldType)) { + return raw as ConsignmentSpecFieldType + } + // 兼容旧配置与常见别名 + if (raw === 'longtext' || raw === 'long_text' || raw === 'textarea') return 'textarea' + if (raw === 'dropdown' || raw === 'dropdown_select') return 'select' + if (raw === 'single' || raw === 'single_select' || raw === 'radiogroup') return 'radio' + if (raw === 'int' || raw === 'decimal') return 'number' + return 'text' +} + +function normalizeOption(value: unknown): ConsignmentSpecOption | null { + const source = record(value) + const optionValue = text(source.value ?? source.key ?? source.label, 100) + if (!optionValue) return null + return { + label: text(source.label, 80) || optionValue, + value: optionValue, + enabled: bool(source.enabled, true), + } +} + +function normalizeOptions(value: unknown, key: string): ConsignmentSpecOption[] { + const list = Array.isArray(value) ? value : [] + const normalized: ConsignmentSpecOption[] = [] + const seen = new Set() + for (const item of list) { + const option = normalizeOption(item) + if (!option || seen.has(option.value)) continue + seen.add(option.value) + normalized.push(option) + if (normalized.length >= 100) break + } + if (normalized.length > 0) return normalized + return [{ label: key, value: key, enabled: true }] +} + +function normalizeSpecField(value: unknown, index: number): ConsignmentSpecField | null { + const source = record(value) + const key = text(source.key, 40) + if (!key || !/^[a-zA-Z][a-zA-Z0-9_]{0,39}$/.test(key)) return null + + const type = resolveSpecType(source.type) + const options = type === 'select' || type === 'radio' ? normalizeOptions(source.options, key) : [] + const rawMatchMode = text(source.matchMode ?? source.match_mode, 20) + const matchMode: ConsignmentSpecMatchMode = + rawMatchMode === 'minimum' + ? 'minimum' + : rawMatchMode === 'wildcard' + ? 'wildcard' + : rawMatchMode === 'ignore' + ? 'ignore' + : 'exact' + const wildcardValues = stringArray(source.wildcardValues ?? source.wildcard_values) + const fallbackWildcards = + wildcardValues.length > 0 + ? wildcardValues + : key === 'serverZone' + ? [...ZONE_WILDCARD_VALUES] + : key === 'system' + ? [...SYSTEM_WILDCARD_VALUES] + : [] + + return { + key, + label: text(source.label, 80) || key, + type, + required: bool(source.required, true), + options, + placeholder: text(source.placeholder, 120), + helpText: text(source.helpText ?? source.help_text, 300), + matchMode, + wildcardValues: fallbackWildcards, + showInItemName: bool(source.showInItemName ?? source.show_in_item_name, true), + sortOrder: integer(source.sortOrder ?? source.sort_order, (index + 1) * 10, 0, 100_000), + } +} + +/** + * 归一化规格定义:有 specs 数组就按数组处理;没有就是空配置。 + * 不再自动补任何「内置字段」。 + */ +export function normalizeConsignmentSpecs( + value: unknown, + _legacyFields?: Record | null, +): ConsignmentSpecField[] { + if (!Array.isArray(value) || value.length === 0) return [] + const seen = new Set() + const result: ConsignmentSpecField[] = [] + value.forEach((item, index) => { + const field = normalizeSpecField(item, index) + if (!field || seen.has(field.key)) return + seen.add(field.key) + result.push(field) + }) + return result.sort((a, b) => a.sortOrder - b.sortOrder || a.key.localeCompare(b.key)) +} + +export function normalizeConsignmentPricing(value: unknown): ConsignmentPricingConfig { + const source = record(value) + const rawPriceType = text(source.priceType ?? source.price_type, 30) + // 兼容上一版:source=template 视为 10 折;source=profile 尽量映射成固定价。 + const legacySource = text(source.source, 20) + const priceType: ConsignmentPricingConfig['priceType'] = + rawPriceType === 'fixed' || legacySource === 'profile' ? 'fixed' : 'template_discount' + const legacyFixedPriceFen = integer( + source.fixedPriceFen ?? source.fixed_price_fen ?? source.basePriceFen ?? source.base_price_fen, + 0, + 0, + 100_000_000, + ) + const missingPricePolicyRaw = text(source.missingPricePolicy ?? source.missing_price_policy, 30) + return { + priceType, + discount: decimal(source.discount, 10, 0.1, 10), + fixedPriceFen: legacyFixedPriceFen, + allowWorkerPrice: bool( + source.allowWorkerPrice ?? source.allow_worker_price, + DEFAULT_CONSIGNMENT_PRICING.allowWorkerPrice, + ), + minPriceFen: integer(source.minPriceFen ?? source.min_price_fen, 0, 0, 100_000_000), + missingPricePolicy: missingPricePolicyRaw === 'block' ? 'block' : 'manual', + } +} + +function builtInAliases(key: string): string[] { + if (key === 'system') return ['system', 'os'] + if (key === 'serverZone') return ['serverZone', 'server_zone', 'zone'] + if (key === 'series') return ['series', 'gunSeries'] + if (key === 'diamondLevel') return ['diamondLevel', 'diamond_level', 'diamond'] + if (key === 'brillianceCount') return ['brillianceCount', 'brilliance_count', 'brilliance'] + return [key] +} + +function readRawSpecValue(input: Record, key: string): unknown { + for (const alias of builtInAliases(key)) { + if (input[alias] !== undefined && input[alias] !== null && String(input[alias]).trim() !== '') { + return input[alias] + } + } + return undefined +} + +function optionMatches(option: ConsignmentSpecOption, raw: string): boolean { + return option.value === raw || option.value.toLowerCase() === raw.toLowerCase() +} + +function findOption(field: ConsignmentSpecField, raw: string): ConsignmentSpecOption | null { + return field.options.find((option) => optionMatches(option, raw)) || null +} + +function isValidDate(value: string): boolean { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false + const parsed = Date.parse(`${value}T00:00:00Z`) + if (!Number.isFinite(parsed)) return false + return new Date(parsed).toISOString().slice(0, 10) === value +} + +/** + * 校验并归一化一组规格值。错误信息直接给前端展示。 + */ +export function normalizeConsignmentSpecValues( + specs: ConsignmentSpecField[], + input: unknown, +): ConsignmentSpecValidationResult { + const source = record(input) + const nested = record(source.specValues ?? source.spec_values) + const values: ConsignmentSpecValues = {} + const errors: string[] = [] + + for (const field of specs) { + const rawValue = + nested[field.key] !== undefined && + nested[field.key] !== null && + String(nested[field.key]).trim() !== '' + ? nested[field.key] + : readRawSpecValue(source, field.key) + + const raw = rawValue === undefined || rawValue === null ? '' : String(rawValue).trim() + if (!raw) { + if (field.required) errors.push(`请选择或填写「${field.label}」`) + continue + } + + if (field.type === 'select' || field.type === 'radio') { + const option = findOption(field, raw) + if (!option || option.enabled === false) { + errors.push(`「${field.label}」的选项无效:${raw}`) + continue + } + values[field.key] = option.value + continue + } + + if (field.type === 'number') { + const parsed = Number(raw) + if (!Number.isFinite(parsed)) { + errors.push(`「${field.label}」必须是数字`) + continue + } + values[field.key] = Math.round(parsed) + continue + } + + if (field.type === 'date') { + if (!isValidDate(raw)) { + errors.push(`「${field.label}」必须是有效日期(YYYY-MM-DD)`) + continue + } + values[field.key] = raw + continue + } + + const maxLength = field.type === 'textarea' ? 2000 : 200 + values[field.key] = raw.slice(0, maxLength) + } + + const diamond = Number(values.diamondLevel) + const brilliance = Number(values.brillianceCount) + if ( + Number.isFinite(diamond) && + Number.isFinite(brilliance) && + values.diamondLevel !== undefined && + values.brillianceCount !== undefined && + brilliance > diamond + ) { + errors.push('璀璨数不能超过钻石等级(如1钻最多1璀、2钻最多2璀)') + } + + return { values, errors } +} + +function normalizeComparable(field: ConsignmentSpecField, value: unknown): string { + const raw = String(value ?? '').trim() + if (!raw) return '' + if (field.key === 'system') return SYSTEM_ALIASES[raw] || raw + if (field.key === 'serverZone') return ZONE_ALIASES[raw] || ZONE_ALIASES[raw.toLowerCase()] || raw + if (field.type === 'number' || field.key === 'diamondLevel' || field.key === 'brillianceCount') { + const numeric = Number(raw) + if (Number.isFinite(numeric)) return String(Math.round(numeric)) + } + return raw.toLowerCase() +} + +function isNumericSpecField(field: ConsignmentSpecField): boolean { + return field.type === 'number' || field.key === 'diamondLevel' || field.key === 'brillianceCount' +} + +/** 只取参与匹配的字段生成稳定 key(按字段 key 排序,避免字段顺序影响)。 */ +export function buildConsignmentSpecKey( + specs: ConsignmentSpecField[], + values: ConsignmentSpecValues, +): string { + return specs + .filter((field) => field.matchMode !== 'ignore') + .map((field) => [field.key, normalizeComparable(field, values[field.key])] as const) + .filter(([, value]) => value !== '') + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, value]) => `${key}=${value}`) + .join('|') +} + +export function formatConsignmentSpecValue( + field: ConsignmentSpecField, + value: unknown, + options: { forItemName?: boolean } = {}, +): string { + const raw = String(value ?? '').trim() + if (!raw) return '' + // 物品名模板自己会拼「钻 / 璀璨」后缀,这里返回纯数字,避免「3钻钻」「1璀璀璨」。 + if (options.forItemName && (field.key === 'diamondLevel' || field.key === 'brillianceCount')) { + const numeric = Number(raw) + if (!Number.isFinite(numeric)) return raw + if (field.key === 'brillianceCount' && numeric <= 0) return '' + return String(Math.round(numeric)) + } + if (field.type === 'select' || field.type === 'radio') { + const option = findOption(field, raw) + const label = option?.label || raw + if ( + options.forItemName && + field.key === 'serverZone' && + field.wildcardValues.some( + (item) => normalizeComparable(field, item) === normalizeComparable(field, raw), + ) + ) { + return '' + } + return label + } + return raw +} + +export function buildConsignmentSpecText( + specs: ConsignmentSpecField[], + values: ConsignmentSpecValues, +): string { + return specs + .slice() + .sort((a, b) => a.sortOrder - b.sortOrder || a.key.localeCompare(b.key)) + .map((field) => { + const display = formatConsignmentSpecValue(field, values[field.key]) + return display ? `${field.label} ${display}` : '' + }) + .filter(Boolean) + .join(' / ') +} + +export function buildConsignmentItemName(input: { + template: string + specs: ConsignmentSpecField[] + values: ConsignmentSpecValues + categoryName?: string + fallbackSuffix?: string +}): string { + const { template, specs, values, categoryName = '', fallbackSuffix = '' } = input + const tokenValues = new Map() + for (const field of specs) { + tokenValues.set( + field.key, + formatConsignmentSpecValue(field, values[field.key], { forItemName: true }), + ) + } + tokenValues.set('category', categoryName) + tokenValues.set('suffix', fallbackSuffix) + + let result = String(template || '').trim() + if (result) { + result = result.replace( + /\{([a-zA-Z][a-zA-Z0-9_]*)\}/g, + (_, key: string) => tokenValues.get(key) || '', + ) + } else { + result = specs + .filter((field) => field.showInItemName) + .map((field) => formatConsignmentSpecValue(field, values[field.key], { forItemName: true })) + .filter(Boolean) + .join('') + if (fallbackSuffix) result = `${result} ${fallbackSuffix}` + } + const normalized = result.replace(/\s+/g, ' ').trim() + if (normalized) return normalized + // 配置没有字段、模板也为空时,至少用分类名兜底,避免物品名为空。 + return categoryName || fallbackSuffix || '寄卖物品' +} + +/** + * 计算系统建议价与改价上限。 + * - template_discount:接单模板价 × 折扣; + * - fixed:固定价格; + * - 改价上限 = 配置后的建议价(打手只能往下调)。 + */ +export function computeConsignmentPrice(input: { + pricing: ConsignmentPricingConfig + templatePriceFen?: number +}): ConsignmentPriceResult { + const { pricing } = input + const templatePriceFen = Math.max(0, Math.round(Number(input.templatePriceFen || 0))) + const breakdown: { label: string; amountFen: number }[] = [] + let priceFen = 0 + let source: 'template' | 'fixed' | 'none' = 'none' + + if (pricing.priceType === 'fixed') { + priceFen = Math.max(0, Math.round(Number(pricing.fixedPriceFen || 0))) + if (priceFen > 0) { + source = 'fixed' + breakdown.push({ label: '固定价格', amountFen: priceFen }) + } + } else if (templatePriceFen > 0) { + const discount = Math.min(10, Math.max(0.1, Number(pricing.discount || 10))) + priceFen = Math.round((templatePriceFen * discount) / 10) + source = 'template' + breakdown.push({ label: '接单模板价', amountFen: templatePriceFen }) + if (discount !== 10) { + breakdown.push({ label: `${discount} 折`, amountFen: priceFen - templatePriceFen }) + } + } + + priceFen = Math.max(0, Math.round(priceFen)) + return { + priceFen, + capFen: priceFen, + templatePriceFen, + source, + breakdown, + } +} + +/** + * 打手提交价格时决定最终售价: + * - 默认用系统建议价(模板折扣价 / 固定价); + * - 允许改价时只能下调,不能高于建议价; + * - 没有建议价时按 missingPricePolicy 处理(manual=手填,仍受最低价约束)。 + */ +export function resolveWorkerConsignmentPrice(input: { + pricing: ConsignmentPricingConfig + templatePriceFen?: number + submittedPriceFen?: number +}): WorkerConsignmentPriceResult { + const computed = computeConsignmentPrice(input) + const submittedPriceFen = Math.max(0, Math.round(Number(input.submittedPriceFen || 0))) + const minPriceFen = Math.max(0, Number(input.pricing.minPriceFen || 0)) + const errors: string[] = [] + let priceFen = computed.priceFen + + if ( + input.pricing.priceType === 'fixed' && + computed.templatePriceFen > 0 && + computed.priceFen > computed.templatePriceFen + ) { + errors.push(`固定价格不能高于接单模板价 ¥${(computed.templatePriceFen / 100).toFixed(2)}`) + } + + if (priceFen > 0) { + if (submittedPriceFen > 0) { + if (!input.pricing.allowWorkerPrice) { + errors.push('当前寄卖配置不允许修改售价') + } else if (submittedPriceFen > computed.capFen) { + errors.push(`寄售价格不能高于系统建议价 ¥${(computed.capFen / 100).toFixed(2)}`) + } else { + priceFen = submittedPriceFen + } + } + } else if (input.pricing.missingPricePolicy === 'block') { + errors.push('该规格暂无价格,请联系客服补充价格后再提报') + } else if (submittedPriceFen <= 0) { + errors.push('该规格暂无模板价,请填写寄售价格') + } else { + priceFen = submittedPriceFen + } + + if (priceFen > 0 && priceFen < minPriceFen) { + errors.push(`寄售价格不能少于 ¥${(minPriceFen / 100).toFixed(2)}`) + } + if (priceFen > 0) { + priceFen = Math.max(100, priceFen) + } + return { + priceFen, + capFen: computed.capFen, + templatePriceFen: computed.templatePriceFen, + source: computed.source, + errors, + } +} + +/** + * 通用规格匹配:精确 / 最低值 / 通配 / 忽略。 + * 订单侧缺字段 -> unknown(不能算精确);库存侧缺字段 -> conflict。 + */ +export function evaluateConsignmentSpecMatch(input: { + specs: ConsignmentSpecField[] + orderValues: ConsignmentSpecValues + inventoryValues: ConsignmentSpecValues +}): ConsignmentSpecMatchEvaluation { + const details: ConsignmentSpecMatchDetail[] = [] + const conflicts: string[] = [] + const unknowns: string[] = [] + let partial = false + + for (const field of input.specs) { + if (field.matchMode === 'ignore') { + details.push({ + key: field.key, + label: field.label, + orderValue: String(input.orderValues[field.key] ?? ''), + inventoryValue: String(input.inventoryValues[field.key] ?? ''), + status: 'ignored', + }) + continue + } + + const orderRaw = String(input.orderValues[field.key] ?? '').trim() + const inventoryRaw = String(input.inventoryValues[field.key] ?? '').trim() + const detailBase = { + key: field.key, + label: field.label, + orderValue: orderRaw, + inventoryValue: inventoryRaw, + } + + if (!orderRaw) { + unknowns.push(field.label) + details.push({ ...detailBase, status: 'unknown' }) + continue + } + if (!inventoryRaw) { + conflicts.push(`${field.label}(库存未填写)`) + details.push({ ...detailBase, status: 'conflict' }) + continue + } + + const wildcardHit = + (field.matchMode === 'wildcard' || field.wildcardValues.length > 0) && + (field.wildcardValues.some( + (item) => normalizeComparable(field, item) === normalizeComparable(field, orderRaw), + ) || + field.wildcardValues.some( + (item) => normalizeComparable(field, item) === normalizeComparable(field, inventoryRaw), + )) + if (wildcardHit) { + details.push({ ...detailBase, status: 'match' }) + continue + } + + if (isNumericSpecField(field)) { + const orderNumber = Number(orderRaw) + const inventoryNumber = Number(inventoryRaw) + if (!Number.isFinite(orderNumber) || !Number.isFinite(inventoryNumber)) { + if (normalizeComparable(field, orderRaw) === normalizeComparable(field, inventoryRaw)) { + details.push({ ...detailBase, status: 'match' }) + } else { + conflicts.push(field.label) + details.push({ ...detailBase, status: 'conflict' }) + } + continue + } + if (field.matchMode === 'minimum') { + if (inventoryNumber < orderNumber) { + conflicts.push(field.label) + details.push({ ...detailBase, status: 'conflict' }) + } else if (inventoryNumber > orderNumber) { + partial = true + details.push({ ...detailBase, status: 'partial' }) + } else { + details.push({ ...detailBase, status: 'match' }) + } + continue + } + if (inventoryNumber !== orderNumber) { + conflicts.push(field.label) + details.push({ ...detailBase, status: 'conflict' }) + } else { + details.push({ ...detailBase, status: 'match' }) + } + continue + } + + if (normalizeComparable(field, orderRaw) !== normalizeComparable(field, inventoryRaw)) { + conflicts.push(field.label) + details.push({ ...detailBase, status: 'conflict' }) + } else { + details.push({ ...detailBase, status: 'match' }) + } + } + + return { + exact: conflicts.length === 0 && unknowns.length === 0 && !partial, + partial: conflicts.length === 0 && unknowns.length === 0 && partial, + unknown: unknowns.length > 0, + conflicts, + unknowns, + details, + } +} + +/** 旧列同步:key 为 system/serverZone/series/diamondLevel/brillianceCount 时取对应值。 */ +export function resolveLegacyConsignmentColumns(values: ConsignmentSpecValues): { + system: string + serverZone: string + series: string + diamondLevel: number + brillianceCount: number +} { + return { + system: String(values.system ?? '').trim(), + serverZone: String(values.serverZone ?? '').trim(), + series: String(values.series ?? '').trim(), + diamondLevel: Math.max(1, Math.round(Number(values.diamondLevel || 1))), + brillianceCount: Math.max(0, Math.round(Number(values.brillianceCount || 0))), + } +} + +/** 仅用于旧匹配器兼容判断:字段 key 是否属于历史五字段。 */ +export function isBuiltInConsignmentSpecField(field: ConsignmentSpecField): boolean { + return isBuiltInSpecKey(field.key) +} + +/** 把数据库里的 spec_values_json 解析成可用的规格值对象。 */ +export function parseConsignmentSpecValues(value: unknown): ConsignmentSpecValues { + const source = record(value) + const result: ConsignmentSpecValues = {} + for (const [key, raw] of Object.entries(source)) { + if (typeof raw === 'string' || typeof raw === 'number') result[key] = raw + } + return result +} 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 bf02d3b2..9eaab268 100644 --- a/apps/backend/src/services/worker-platform/consignment-worker-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-worker-service.ts @@ -26,6 +26,18 @@ import { resolvePriceDropSuggestion, } from './consignment-profile-service.js' import { suggestConsignmentPriceFromRules } from './consignment-matching-service.js' +import { + DEFAULT_CONSIGNMENT_PRICING, + buildConsignmentItemName, + buildConsignmentSpecKey, + buildConsignmentSpecText, + computeConsignmentPrice, + normalizeConsignmentSpecValues, + resolveLegacyConsignmentColumns, + resolveWorkerConsignmentPrice, + type ConsignmentSpecField, + type ConsignmentSpecValues, +} from './consignment-spec-service.js' import { hasInternalConsignmentFulfillment } from '../../repositories/worker-platform/work-order-gift-repo.js' import type { JsonObject } from '../../types/json.js' import { createHttpError } from '../../utils/http.js' @@ -64,6 +76,16 @@ function mapConsignmentContactSupporter(source: { } } +function parseSpecValues(value: unknown): ConsignmentSpecValues { + const source = toRecord(value) + if (!source) return {} + const result: ConsignmentSpecValues = {} + for (const [key, raw] of Object.entries(source)) { + if (typeof raw === 'string' || typeof raw === 'number') result[key] = raw + } + return result +} + function extractConsignmentGiftEvidenceFromWorkOrder(boundWo?: WorkOrderRow | null) { if (!boundWo) { return { @@ -198,7 +220,30 @@ export async function updateWorkerConsignmentSalePrice( errorCode: 'consignment_inventory_unavailable', }) } + const profileConfig = inventory.profile_snapshot_json + ? normalizeConsignmentProfileConfig(inventory.profile_snapshot_json) + : null + if (profileConfig && !profileConfig.pricing.allowWorkerPrice) { + throw createHttpError('当前寄卖配置不允许打手修改售价,请联系客服', { + statusCode: 403, + errorCode: 'consignment_price_edit_forbidden', + }) + } const priceFen = Math.max(100, Math.round(Number(priceFenInput || 0))) + const capFen = Math.max(0, Math.round(Number(inventory.price_cap_fen || 0))) + if (capFen > 0 && priceFen > capFen) { + throw createHttpError(`寄售价格不能高于系统建议价 ¥${(capFen / 100).toFixed(2)}`, { + statusCode: 409, + errorCode: 'consignment_price_above_cap', + }) + } + const minPriceFen = Math.max(0, Number(profileConfig?.pricing.minPriceFen || 0)) + if (priceFen < minPriceFen) { + throw createHttpError(`寄售价格不能少于 ¥${(minPriceFen / 100).toFixed(2)}`, { + statusCode: 409, + errorCode: 'consignment_price_below_min', + }) + } const updated = await updateConsignmentInventory(inventory.id, { salePriceFen: priceFen, now: nowIso(), @@ -254,58 +299,33 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb } } - const system = String(payload.system || '').trim() - const serverZone = String(payload.serverZone || '').trim() - const series = String(payload.series || '').trim() - if (!system || !serverZone || !series) { - throw createHttpError('请完整填写操作系统、区服与枪系', { + const specs = profile?.config.specs ?? [] + const specValidation = normalizeConsignmentSpecValues(specs, payload) + if (specValidation.errors.length > 0) { + throw createHttpError(specValidation.errors[0]!, { statusCode: 400, - errorCode: 'specs_required', - }) - } - // 苏尔南寄卖必须区分苹果/安卓(不收「通用」);区服支持 微信区/QQ区/互通 三选 - if (system !== '苹果' && system !== '安卓') { - throw createHttpError('苏尔南寄卖的操作系统只能选 苹果 或 安卓', { - statusCode: 400, - errorCode: 'consignment_system_invalid', - }) - } - // 互通 = QQ/微信都能收(苏尔南发布端默认且唯一展示项) - if (serverZone !== '微信区' && serverZone !== 'QQ区' && serverZone !== '互通') { - throw createHttpError('游戏区服只能选 微信区 / QQ区 / 互通', { - statusCode: 400, - errorCode: 'consignment_server_zone_invalid', - }) - } - - if ( - payload.diamondLevel === undefined || - payload.diamondLevel === null || - payload.diamondLevel === '' - ) { - throw createHttpError('请选择钻石等级', { - statusCode: 400, - errorCode: 'diamond_level_required', - }) - } - const diamondLevel = Math.max(1, Math.min(7, Number(payload.diamondLevel))) - if ( - payload.brillianceCount === undefined || - payload.brillianceCount === null || - payload.brillianceCount === '' - ) { - throw createHttpError('请选择璀璨数', { - statusCode: 400, - errorCode: 'brilliance_required', - }) - } - const brillianceCount = Math.max(0, Math.min(7, Number(payload.brillianceCount))) - if (brillianceCount > diamondLevel) { - throw createHttpError('璀璨数不能超过钻石等级(如1钻最多1璀、2钻最多2璀)', { - statusCode: 400, - errorCode: 'brilliance_exceeds_diamond', + errorCode: 'consignment_specs_invalid', }) } + // 配置没有字段时仍兼容旧版平铺参数(老客户端不带 profileId/specValues)。 + const specValues: ConsignmentSpecValues = + specs.length > 0 + ? specValidation.values + : { + ...(String(payload.system || '').trim() ? { system: String(payload.system).trim() } : {}), + ...(String(payload.serverZone || '').trim() + ? { serverZone: String(payload.serverZone).trim() } + : {}), + ...(String(payload.series || '').trim() ? { series: String(payload.series).trim() } : {}), + ...(payload.diamondLevel !== undefined && String(payload.diamondLevel).trim() !== '' + ? { diamondLevel: Math.round(Number(payload.diamondLevel)) } + : {}), + ...(payload.brillianceCount !== undefined && String(payload.brillianceCount).trim() !== '' + ? { brillianceCount: Math.max(0, Math.round(Number(payload.brillianceCount))) } + : {}), + } + const { system, serverZone, series, diamondLevel, brillianceCount } = + resolveLegacyConsignmentColumns(specValues) const contactInfo = String(payload.sellerContact || payload.contactInfo || '').trim() if (!contactInfo) { @@ -338,40 +358,46 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb }) } - // 寄售价:配置开启「模板价」时以接单模板为准;允许打手改价时以打手填写价为准 - // (不低于配置的最低价)。未命中配置的存量调用保持旧行为。 + // 寄售价:按配置算出系统建议价(模板折扣价 / 固定价),打手只能往下调。 + // 没有建议价时按 missingPricePolicy 处理(manual = 允许打手手填,仍受最低价约束)。 const priceSuggestion = await getWorkerConsignmentPriceSuggestion({ - system, - series, - diamondLevel, - brillianceCount, + profileId: profile?.profileId, + specs, + values: specValues, }) - const templatePriceFen = Math.max(0, Math.round(Number(priceSuggestion.suggestedPriceFen || 0))) + const pricing = profile?.config.pricing ?? DEFAULT_CONSIGNMENT_PRICING const submittedPriceFen = Math.max( 0, Math.round(Number(payload.askingPriceFen ?? payload.salePriceFen ?? 0)), ) - let askingPriceFen = templatePriceFen > 0 ? templatePriceFen : submittedPriceFen - if (profile) { - const minPriceFen = Math.max(0, profile.config.pricing.minPriceFen) - if (profile.config.pricing.allowWorkerPrice && submittedPriceFen > 0) { - askingPriceFen = submittedPriceFen - } else if (!profile.config.pricing.useTemplatePrice && submittedPriceFen > 0) { - askingPriceFen = submittedPriceFen - } - askingPriceFen = Math.max(minPriceFen, askingPriceFen) + const priceResolution = resolveWorkerConsignmentPrice({ + pricing, + templatePriceFen: priceSuggestion.templatePriceFen, + submittedPriceFen, + }) + if (priceResolution.errors.length > 0) { + throw createHttpError(priceResolution.errors[0]!, { + statusCode: 400, + errorCode: 'consignment_price_invalid', + }) } - askingPriceFen = Math.max(100, askingPriceFen) + const askingPriceFen = priceResolution.priceFen + const priceCapFen = priceResolution.capFen const depositAmountFen = profile?.config.deposit.enabled ? buildDepositAmountFen(profile.config, quantity) : getConsignmentPublishDepositFen() const sellerPhone = String(payload.sellerPhone || worker.phone || '').trim() - const briStr = brillianceCount > 0 ? `${brillianceCount}璀璨` : '' - // 区服是「互通」(通配)时不拼进物品名:对买家无信息量,"安卓互通暗潮…"读着也别扭 - const zoneLabel = serverZone === '互通' ? '' : serverZone - const defaultItemName = `${system}${zoneLabel}${series}${diamondLevel}钻${briStr} 苏尔南` + const defaultItemName = buildConsignmentItemName({ + template: profile?.config.nameTemplate || '', + specs, + values: specValues, + categoryName: profile?.categoryName || '', + fallbackSuffix: '苏尔南', + }) const itemName = String(payload.itemName || defaultItemName).trim() + const specKey = buildConsignmentSpecKey(specs, specValues) + const specText = buildConsignmentSpecText(specs, specValues) const submissionNo = generateConsignmentSubmissionNo() const now = nowIso() @@ -401,6 +427,9 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb profileVersionId: profile?.versionId ?? null, quantity, salePriceFen: askingPriceFen, + priceCapFen, + specValues, + specText, }, screenshotsJson: screenshots, askingPriceFen, @@ -412,11 +441,16 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb ? { profileId: profile.profileId, categoryId: profile.categoryId, + categoryName: profile.categoryName, versionId: profile.versionId, versionNo: profile.versionNo, config: profile.config, } : null, + specValuesJson: specValues, + specKey, + specText, + priceCapFen, workerId: Number(worker.id), workerName: worker.display_name || worker.username, workerPhone: sellerPhone || worker.phone || '', @@ -450,6 +484,9 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb brillianceCount: submission.brilliance_count, askingPriceFen: submission.asking_price_fen, salePriceFen: Number(submission.requested_sale_price_fen || submission.asking_price_fen || 0), + priceCapFen: Number(submission.price_cap_fen || 0), + specValues, + specText, quantity: Number(submission.quantity || 1), profileId: submission.profile_id ? Number(submission.profile_id) : null, profileVersionId: submission.profile_version_id @@ -574,6 +611,9 @@ export async function listWorkerConsignments( : { giftEvidenceImages: [], giftEvidenceNote: '', giftEvidenceSubmittedAt: '' } // 现货上的对接客服优先级更高:客服可能在审核入库后改派对接人,卖家端应看到最新归属。 const contactSupporter = inv?.contact_supporter_id ? inv : sub + const profileConfig = inv?.profile_snapshot_json + ? normalizeConsignmentProfileConfig(inv.profile_snapshot_json) + : null return { /** 唯一锚点:提报单号贯穿发布→审核→在库→匹配→冷却→赠送→结算 */ consignmentNo: sub.submission_no, @@ -586,18 +626,21 @@ export async function listWorkerConsignments( series: sub.series, diamondLevel: sub.diamond_level, brillianceCount: sub.brilliance_count, + specValues: parseSpecValues(inv?.spec_values_json ?? sub.spec_values_json), + specText: inv?.spec_text || sub.spec_text || '', askingPriceFen: sub.asking_price_fen, salePriceFen: Number( inv?.sale_price_fen ?? sub.requested_sale_price_fen ?? sub.asking_price_fen ?? 0, ), + priceCapFen: Number(inv?.price_cap_fen ?? sub.price_cap_fen ?? 0), quantity: Math.max(1, Number(sub.quantity || inv?.quantity || 1)), remainingQuantity: inv ? Math.max(0, Number(inv.remaining_quantity ?? 0)) : 0, listedAt: inv?.listed_at || null, - canEditPrice: inv?.status === 'in_stock', - priceDropSuggestion: inv?.profile_snapshot_json - ? resolvePriceDropSuggestion(normalizeConsignmentProfileConfig(inv.profile_snapshot_json), { - listedAt: inv.listed_at || inv.inbound_at, - salePriceFen: Number(inv.sale_price_fen || 0), + canEditPrice: inv?.status === 'in_stock' && profileConfig?.pricing.allowWorkerPrice !== false, + priceDropSuggestion: profileConfig + ? resolvePriceDropSuggestion(profileConfig, { + listedAt: inv?.listed_at || inv?.inbound_at || '', + salePriceFen: Number(inv?.sale_price_fen || 0), now: nowIso(), }) : null, @@ -631,6 +674,9 @@ export async function listWorkerConsignments( const evidence = showingMatchedBuyer ? extractConsignmentGiftEvidenceFromWorkOrder(boundWo) : { giftEvidenceImages: [], giftEvidenceNote: '', giftEvidenceSubmittedAt: '' } + const profileConfig = inv.profile_snapshot_json + ? normalizeConsignmentProfileConfig(inv.profile_snapshot_json) + : null return { consignmentNo: '客服手工入库', ...(stage.canConfirmFriend || stage.canSubmitEvidence @@ -645,14 +691,17 @@ export async function listWorkerConsignments( series: inv.series, diamondLevel: inv.diamond_level, brillianceCount: inv.brilliance_count, + specValues: parseSpecValues(inv.spec_values_json), + specText: inv.spec_text || '', askingPriceFen: inv.cost_price_fen, salePriceFen: Number(inv.sale_price_fen || inv.cost_price_fen || 0), + priceCapFen: Number(inv.price_cap_fen || 0), quantity: Math.max(1, Number(inv.quantity || 1)), remainingQuantity: Math.max(0, Number(inv.remaining_quantity ?? 0)), listedAt: inv.listed_at || inv.inbound_at || null, - canEditPrice: inv.status === 'in_stock', - priceDropSuggestion: inv.profile_snapshot_json - ? resolvePriceDropSuggestion(normalizeConsignmentProfileConfig(inv.profile_snapshot_json), { + canEditPrice: inv.status === 'in_stock' && profileConfig?.pricing.allowWorkerPrice !== false, + priceDropSuggestion: profileConfig + ? resolvePriceDropSuggestion(profileConfig, { listedAt: inv.listed_at || inv.inbound_at, salePriceFen: Number(inv.sale_price_fen || 0), now: nowIso(), @@ -774,45 +823,76 @@ export async function submitWorkerConsignmentGiftEvidence( } /** - * 发布寄卖的建议价:按 规格(系统/系列/钻级)从「接单模板」里带出价格, - * 例如模板里有「安卓暗潮1钻 ¥120」,卖家选到 安卓+暗潮+1钻 就自动带出 120。 - * 找不到匹配的模板(本地没配、或规格没配价)就返回 null,前端保持手填。 + * 发布寄卖的建议价: + * - 模板折扣:接单模板价 × 折扣; + * - 固定价格:直接使用固定价,有模板价时校验不得高于模板价; + * - 同时返回改价上限(等于建议价,打手只能往下调)。 */ export async function getWorkerConsignmentPriceSuggestion(payload: { + profileId?: unknown + /** 保留旧参数,价格只依赖系统/系列/钻级/璀璨 */ + specs?: ConsignmentSpecField[] + values?: ConsignmentSpecValues + /** 旧调用兼容:平铺参数 */ system?: unknown series?: unknown diamondLevel?: unknown brillianceCount?: unknown }): Promise<{ suggestedPriceFen: number | null + capFen: number + templatePriceFen: number + source: 'template' | 'fixed' | 'none' matchedCount: number matchedRules: { ruleKey: string; productName: string; rewardAmountFen: number }[] }> { - const system = String(payload.system || '').trim() - const series = String(payload.series || '').trim() - const diamondLevel = Math.round(Number(payload.diamondLevel || 0)) + const profileIdInput = Number(payload.profileId ?? 0) + const profile = + Number.isInteger(profileIdInput) && profileIdInput > 0 + ? await getPublishedConsignmentProfileById(profileIdInput) + : null + const pricing = profile?.config.pricing ?? DEFAULT_CONSIGNMENT_PRICING + const values: ConsignmentSpecValues = { ...(payload.values || {}) } + if (values.system === undefined && payload.system !== undefined) + values.system = String(payload.system || '').trim() + if (values.series === undefined && payload.series !== undefined) + values.series = String(payload.series || '').trim() + if (values.diamondLevel === undefined && payload.diamondLevel !== undefined) { + values.diamondLevel = Math.round(Number(payload.diamondLevel || 0)) + } + if (values.brillianceCount === undefined && payload.brillianceCount !== undefined) { + values.brillianceCount = Math.max(0, Math.round(Number(payload.brillianceCount || 0))) + } + + const system = String(values.system || '').trim() + const series = String(values.series || '').trim() + const diamondLevel = Math.round(Number(values.diamondLevel || 0)) const brillianceCount = - payload.brillianceCount !== undefined && - payload.brillianceCount !== null && - String(payload.brillianceCount).trim() !== '' - ? Math.max(0, Math.round(Number(payload.brillianceCount || 0))) + values.brillianceCount !== undefined && String(values.brillianceCount).trim() !== '' + ? Math.max(0, Math.round(Number(values.brillianceCount || 0))) : undefined - if (!system || !series || diamondLevel <= 0) { - return { suggestedPriceFen: null, matchedCount: 0, matchedRules: [] } - } - const rules = await listEnabledWorkProductRulesForSuggestion() - const suggestion = suggestConsignmentPriceFromRules(rules, { - system, - series, - diamondLevel, - brillianceCount, - }) - if (!suggestion) { - return { suggestedPriceFen: null, matchedCount: 0, matchedRules: [] } + + let templatePriceFen = 0 + let matchedRules: { ruleKey: string; productName: string; rewardAmountFen: number }[] = [] + if (system && series && diamondLevel > 0) { + const rules = await listEnabledWorkProductRulesForSuggestion() + const suggestion = suggestConsignmentPriceFromRules(rules, { + system, + series, + diamondLevel, + brillianceCount, + }) + templatePriceFen = Math.max(0, Math.round(Number(suggestion?.suggestedPriceFen || 0))) + matchedRules = suggestion?.matchedRules || [] } + + const computed = computeConsignmentPrice({ pricing, templatePriceFen }) return { - suggestedPriceFen: suggestion.suggestedPriceFen, - matchedCount: suggestion.matchedRules.length, - matchedRules: suggestion.matchedRules, + suggestedPriceFen: computed.priceFen > 0 ? computed.priceFen : null, + capFen: computed.capFen, + templatePriceFen: computed.templatePriceFen, + source: computed.source, + matchedCount: matchedRules.length, + matchedRules, } } diff --git a/apps/frontend/src/components/admin/ConsignmentWorkOrderMatchModal.tsx b/apps/frontend/src/components/admin/ConsignmentWorkOrderMatchModal.tsx index b41d9fc1..88ce1238 100644 --- a/apps/frontend/src/components/admin/ConsignmentWorkOrderMatchModal.tsx +++ b/apps/frontend/src/components/admin/ConsignmentWorkOrderMatchModal.tsx @@ -1,4 +1,4 @@ -import { App, Alert, Button, Card, Empty, Modal, Space, Tag, Typography } from 'antd' +import { App, Alert, Button, Card, Empty, Input, Modal, Space, Tag, Typography } from 'antd' import { useQuery } from '@tanstack/react-query' import { useEffect, useMemo, useState } from 'react' @@ -27,6 +27,7 @@ export default function ConsignmentWorkOrderMatchModal({ const { message } = App.useApp() const [selectedIds, setSelectedIds] = useState([]) const [submitting, setSubmitting] = useState(false) + const [nonExactReason, setNonExactReason] = useState('') const candidatesQuery = useQuery({ queryKey: ['admin-consignment-match-candidates', workOrderId], @@ -40,13 +41,24 @@ export default function ConsignmentWorkOrderMatchModal({ useEffect(() => { if (!open) { setSelectedIds([]) + setNonExactReason('') return } if (options.length === 0) return - // 默认按缺口选中价格最低的几条,客服可自行调整。 - setSelectedIds(options.slice(0, needed).map((item) => item.inventoryId)) + // 默认只选精确匹配;非精确库存必须人工勾选并填写原因。 + const exactOptions = options.filter((item) => (item.matchType || 'exact') === 'exact') + setSelectedIds(exactOptions.slice(0, needed).map((item) => item.inventoryId)) }, [open, options, needed]) + const nonExactSelected = useMemo( + () => + selectedIds.some((id) => { + const item = options.find((option) => option.inventoryId === id) + return Boolean(item?.matchType && item.matchType !== 'exact') + }), + [options, selectedIds], + ) + const totalPrice = useMemo( () => selectedIds.reduce((sum, id) => { @@ -69,7 +81,7 @@ export default function ConsignmentWorkOrderMatchModal({ + } + style={cardStyle} + > + + 初始为空;字段随配置版本发布,并写入提报与库存快照。Key 为 + system/serverZone/series/diamondLevel/brillianceCount + 时会自动同步旧数据列并沿用旧匹配器。 + + {specDrafts.length === 0 ? ( + + ) : ( +
+ {specDrafts.map((spec, index) => ( + + 字段 {index + 1} + {spec.label || spec.key} + {typeLabelMap.get(spec.type)} + + } + extra={ + +
+ + + ) : null} + + ))} + + )} + + + {/* 3. 价格配置 */} + +
+ + + + {priceType === 'fixed' ? ( + + + + ) : ( + + + + )} + + + + + + + + + + + + + + + +
+
+ + {/* 5. 服务费 */} + +
+ + + + + + + + + + + + + + + + + + + + + +
+
+ + {/* 8. 超时与降价 */} + +
+ + + + + + + + + +
+
+ + +
) : null} - -
- - - 基础与打手端展示 - - - - - - - - - - - 发布字段 - - - - - - - - - - - - - - - - - - - - - - 价格 / 保证金 / 服务费 - - - - - - - - - - - - - - - - - - - - - - - - - - - 数量限制 / 匹配 / 超时 / 降价 - - - - - - - - - - - - - - - - - - - - option.enabled !== false) + .map((option) => ({ label: option.label, value: option.value }))} + /> + ) + ) : field.type === 'number' ? ( + + ) : field.type === 'date' ? ( + + ) : field.type === 'textarea' ? ( + + ) : ( + + )} + + + ))} + + + ) : null} + diff --git a/apps/frontend/src/pages/admin/panels/ConsignmentSubmissionsPanel.tsx b/apps/frontend/src/pages/admin/panels/ConsignmentSubmissionsPanel.tsx index 14f5f4bf..029399d5 100644 --- a/apps/frontend/src/pages/admin/panels/ConsignmentSubmissionsPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/ConsignmentSubmissionsPanel.tsx @@ -166,6 +166,10 @@ export default function ConsignmentSubmissionsPanel() { series: values.series, diamondLevel: Number(values.diamondLevel || 1), brillianceCount: Number(values.brillianceCount || 0), + specValues: + values.specValues && Object.keys(values.specValues).length + ? values.specValues + : undefined, sellerContact: values.sellerContact, sellerGameId: values.sellerGameId, sellerPaymentInfo: values.sellerPaymentInfo, @@ -375,6 +379,10 @@ export default function ConsignmentSubmissionsPanel() { setApproveModalItem(record) approveForm.setFieldsValue({ ...record, + specValues: + record.specValues && Object.keys(record.specValues).length + ? record.specValues + : undefined, costPrice: record.askingPriceFen / 100, sellerContact: record.contactInfo, buyerName: '客服', @@ -667,6 +675,47 @@ export default function ConsignmentSubmissionsPanel() { + {approveModalItem?.specs?.length ? ( + + {approveModalItem.specs.map((field) => ( + + + {field.type === 'select' || field.type === 'radio' ? ( + field.type === 'radio' ? ( + option.enabled !== false) + .map((option) => ({ label: option.label, value: option.value }))} + /> + ) : ( + + ) : field.type === 'textarea' ? ( + + ) : ( + + )} + + + ))} + + ) : null} + {/* 分组二:履约与打手 */}
({ - label: `${v} 钻`, - value: v, -})) - -const BRILLIANCE_OPTIONS = [ - { label: '无璀璨 (0)', value: 0 }, - ...[1, 2, 3, 4, 5, 6, 7].map((v) => ({ label: `${v} 璀璨`, value: v })), -] - type RecordFilter = 'all' | 'reviewing' | 'active' | 'finished' const DEPOSIT_TIP_KEY = 'worker-consignment-deposit-tip-seen' @@ -176,11 +160,11 @@ export default function WorkerConsignmentPage() { const [cooldownNow, setCooldownNow] = useState(() => Date.now()) const [form] = Form.useForm() const currentDiamondLevel = Form.useWatch('diamondLevel', form) - // 寄售价:完全由后台「接单模板」决定(规格选齐后自动带出),卖家只读不可改。 + // 寄售价:按后台配置(模板折扣价 / 固定价)自动带出,打手只能往下调。 const [priceState, setPriceState] = useState< | { status: 'idle' } | { status: 'loading' } - | { status: 'matched'; fen: number } + | { status: 'matched'; fen: number; capFen: number } | { status: 'empty' } >({ status: 'idle' }) const priceFetchTimer = useRef | null>(null) @@ -218,6 +202,7 @@ export default function WorkerConsignmentPage() { consignmentProfiles.find((profile) => profile.profileId === selectedProfileId) || consignmentProfiles[0] || null + const selectedSpecs: ConsignmentSpecField[] = selectedProfile?.config.specs ?? [] useEffect(() => { if (selectedProfileId === null && consignmentProfiles.length > 0) { setSelectedProfileId(consignmentProfiles[0]!.profileId) @@ -262,9 +247,7 @@ export default function WorkerConsignmentPage() { 2000, ) const publishDepositYuan = publishDepositFen / 100 - const manualPriceAllowed = - selectedProfile?.config.pricing.allowWorkerPrice === true || - (Boolean(selectedProfile) && !selectedProfile!.config.pricing.useTemplatePrice) + const manualPriceAllowed = selectedProfile?.config.pricing.allowWorkerPrice !== false const availableAmount = Number(worker?.wallet?.availableAmount || 0) const canPublish = availableAmount >= publishDepositFen const canConsign = canWorkerPublishConsignment({ @@ -338,6 +321,24 @@ export default function WorkerConsignmentPage() { }) }, [profileQuery.isLoading, modal, publishDepositYuan]) + function collectSpecValues(): ConsignmentSpecValues { + const values: ConsignmentSpecValues = {} + for (const field of selectedSpecs) { + const raw = form.getFieldValue(field.key) + if (raw === undefined || raw === null || String(raw).trim() === '') continue + if ( + field.type === 'date' && + typeof raw === 'object' && + typeof (raw as { format?: unknown }).format === 'function' + ) { + values[field.key] = (raw as { format: (pattern: string) => string }).format('YYYY-MM-DD') + continue + } + values[field.key] = typeof raw === 'number' ? raw : String(raw) + } + return values + } + function handleFormValuesChange(changed: Record) { // 钻级改变时:璀璨数不能超过钻石等级(如 5钻调成 2钻,原 5璀自动收敛到 2璀) if (changed && changed.diamondLevel !== undefined) { @@ -347,28 +348,22 @@ export default function WorkerConsignmentPage() { form.setFieldsValue({ brillianceCount: newDia }) } } - const sys = form.getFieldValue('system') - const zone = form.getFieldValue('serverZone') - const ser = form.getFieldValue('series') - const dia = form.getFieldValue('diamondLevel') - const bri = form.getFieldValue('brillianceCount') - if (sys && zone && ser && dia != null && bri != null) { - const briNum = Number(bri) - const briStr = briNum > 0 ? `${briNum}璀璨` : '' - // 区服是通配值(互通/通用)时不拼进物品名:对买家无信息量,"安卓互通暗潮…"读着也别扭 - const zoneLabel = zone === '互通' || zone === '通用' ? '' : zone - form.setFieldsValue({ - itemName: `${sys}${zoneLabel}${ser}${dia}钻${briStr} 苏尔南`, - }) - } - // 规格选齐(系统+系列+钻级+璀璨)后从「接单模板」带出寄售价:安卓+暗潮+1钻+0璀 → 模板价 ¥120。 - // 价格只读、卖家不用填;只有规格真的变了才重新匹配(改联系方式等字段不会触发)。 - const specKey = - sys && ser && dia != null && Number(dia) > 0 && bri != null - ? `${sys}|${ser}|${dia}|${bri}` - : '' - if (!specKey) { + const specValues = collectSpecValues() + const itemName = buildSpecItemName( + selectedProfile?.config.nameTemplate || '', + selectedSpecs, + specValues, + selectedProfile?.categoryName || '', + ) + if (itemName) form.setFieldsValue({ itemName }) + + const missing = selectedSpecs.some( + (field) => + field.required && + (specValues[field.key] === undefined || String(specValues[field.key]).trim() === ''), + ) + if (missing) { if (lastPriceQueryKeyRef.current !== '') { lastPriceQueryKeyRef.current = '' setPriceState({ status: 'idle' }) @@ -376,6 +371,8 @@ export default function WorkerConsignmentPage() { } return } + + const specKey = JSON.stringify(specValues) if (specKey === lastPriceQueryKeyRef.current) return lastPriceQueryKeyRef.current = specKey setPriceState({ status: 'loading' }) @@ -385,17 +382,18 @@ export default function WorkerConsignmentPage() { priceFetchTimer.current = setTimeout(() => { void (async () => { try { - const res = await fetchWorkerConsignmentPriceSuggestion({ - system: String(sys), - series: String(ser), - diamondLevel: Number(dia), - brillianceCount: Number(bri), + const res = await fetchWorkerConsignmentPricePreview({ + profileId: selectedProfile?.profileId, + specValues, }) if (lastPriceQueryKeyRef.current !== specKey) return const fen = Number(res.data?.suggestedPriceFen || 0) + const capFen = Number(res.data?.capFen || 0) if (fen > 0) { - setPriceState({ status: 'matched', fen }) - form.setFieldsValue({ askingPrice: fen / 100 }) + setPriceState({ status: 'matched', fen, capFen: capFen > 0 ? capFen : fen }) + if (manualPriceAllowed) { + form.setFieldsValue({ askingPrice: fen / 100 }) + } } else { setPriceState({ status: 'empty' }) } @@ -434,22 +432,34 @@ export default function WorkerConsignmentPage() { return } - // 寄售价:配置允许改价/不使用模板价时按打手填写;否则必须匹配到接单模板价。 + // 寄售价:按后台配置带出系统建议价;允许改价时可下调,但不能高于建议价。 const minPriceFen = Math.max(0, selectedProfile?.config.pricing.minPriceFen || 0) - let askingPriceFen = priceState.status === 'matched' ? priceState.fen : 0 + const suggestedPriceFen = priceState.status === 'matched' ? priceState.fen : 0 + const capPriceFen = priceState.status === 'matched' ? priceState.capFen : 0 + let askingPriceFen = suggestedPriceFen if (manualPriceAllowed) { - askingPriceFen = Math.max(0, Math.round(Number(values.askingPrice || 0) * 100)) - if (askingPriceFen < Math.max(100, minPriceFen)) { - message.error(`寄售价格不能少于 ¥${(Math.max(100, minPriceFen) / 100).toFixed(2)}`) + const submittedPriceFen = Math.max(0, Math.round(Number(values.askingPrice || 0) * 100)) + if (submittedPriceFen > 0) { + if (capPriceFen > 0 && submittedPriceFen > capPriceFen) { + message.error(`寄售价格不能高于系统建议价 ¥${(capPriceFen / 100).toFixed(2)}`) + return + } + if (submittedPriceFen < Math.max(100, minPriceFen)) { + message.error(`寄售价格不能少于 ¥${(Math.max(100, minPriceFen) / 100).toFixed(2)}`) + return + } + askingPriceFen = submittedPriceFen + } else if (suggestedPriceFen <= 0) { + message.error('该规格暂无价格,请填写寄售价格') return } } else { if (priceState.status === 'loading') { - message.warning('正在匹配接单模板价,请稍候') + message.warning('正在计算系统建议价,请稍候') return } if (priceState.status !== 'matched') { - message.error('该规格暂无接单模板价,请联系客服补充模板后再提报') + message.error('该规格暂无价格,请联系客服补充配置后再提报') return } if (askingPriceFen < 100) { @@ -458,11 +468,15 @@ export default function WorkerConsignmentPage() { } } - const briNum = Number(values.brillianceCount || 0) - const briStr = briNum > 0 ? `${briNum}璀璨` : '' + const specValues = collectSpecValues() const itemName = String(values.itemName || '').trim() || - `${values.system}${values.serverZone}${values.series}${values.diamondLevel}钻${briStr} 苏尔南` + buildSpecItemName( + selectedProfile?.config.nameTemplate || '', + selectedSpecs, + specValues, + selectedProfile?.categoryName || '', + ) setSubmitting(true) try { @@ -470,11 +484,12 @@ export default function WorkerConsignmentPage() { profileId: selectedProfile?.profileId, quantity: Math.max(1, Number(values.quantity || 1)), itemName, - system: values.system, - serverZone: values.serverZone, - series: values.series, - diamondLevel: Number(values.diamondLevel || 1), - brillianceCount: Number(values.brillianceCount || 0), + specValues, + system: String(specValues.system || ''), + serverZone: String(specValues.serverZone || ''), + series: String(specValues.series || ''), + diamondLevel: Number(specValues.diamondLevel || 1), + brillianceCount: Number(specValues.brillianceCount || 0), askingPriceFen, sellerContact: values.sellerContact, sellerPhone: values.sellerPhone || worker?.phone || '', @@ -653,94 +668,105 @@ export default function WorkerConsignmentPage() {
- - - - - - - - - - - ({ - label: `${o.value}钻`, - value: o.value, - }))} - /> - - ({ - validator(_, value) { - const dia = getFieldValue('diamondLevel') - if (value != null && dia != null && Number(value) > Number(dia)) { - return Promise.reject( - new Error(`璀璨数不能超过钻石等级(当前${dia}钻最多选${dia}璀)`), - ) - } - return Promise.resolve() - }, - }), - ]} - initialValue={0} - className="worker-consignment-spec-card is-count" - > - ({ - label: o.value === 0 ? '无' : `${o.value}璀`, - value: o.value, - disabled: currentDiamondLevel != null && o.value > Number(currentDiamondLevel), - }))} - /> - + {selectedSpecs.map((field) => { + const isCountField = field.key === 'diamondLevel' || field.key === 'brillianceCount' + const options = field.options + .filter((option) => option.enabled !== false) + .map((option) => ({ + label: option.label, + value: option.value, + disabled: + field.key === 'brillianceCount' && + currentDiamondLevel != null && + Number(option.value) > Number(currentDiamondLevel), + })) + const defaultValue = + field.key === 'serverZone' + ? options.some((option) => option.value === '互通') + ? '互通' + : options.length === 1 + ? options[0]?.value + : undefined + : field.key === 'brillianceCount' + ? 0 + : undefined + return ( + unknown }) => ({ + validator(_: unknown, value: unknown) { + const diamond = getFieldValue('diamondLevel') + if ( + value != null && + diamond != null && + Number(value) > Number(diamond) + ) { + return Promise.reject( + new Error( + `璀璨数不能超过钻石等级(当前${diamond}钻最多选${diamond}璀)`, + ), + ) + } + return Promise.resolve() + }, + }), + ] + : []), + ]} + > + {field.type === 'select' ? ( + + )} + + ) + })}
@@ -748,8 +774,8 @@ export default function WorkerConsignmentPage() { label="寄售价格" tooltip={ manualPriceAllowed - ? '当前寄卖配置允许自定义售价,价格不得低于配置的最低价。' - : '由后台「接单模板」按系统/系列/钻级/璀璨自动匹配,卖家无需填写;需要调整请联系客服。' + ? `默认按后台配置带出(模板折扣价 / 固定价),可下调,不能高于系统建议价${selectedProfile?.config.pricing.minPriceFen ? `;最低 ¥${(selectedProfile.config.pricing.minPriceFen / 100).toFixed(2)}` : ''}。` + : '由后台寄卖配置自动带出,卖家不可修改;需要调整请联系客服。' } className="worker-consignment-spec-card is-price" > @@ -757,8 +783,14 @@ export default function WorkerConsignmentPage() { 0 + ? priceState.capFen / 100 + : undefined + } precision={2} addonBefore="¥" + placeholder="选完规格后自动带出" style={{ width: '100%' }} /> @@ -766,12 +798,12 @@ export default function WorkerConsignmentPage() {
{priceState.status === 'loading' ? ( - 匹配模板价… + 计算建议价… ) : priceState.status === 'matched' ? ( ¥{(priceState.fen / 100).toFixed(0)} ) : priceState.status === 'empty' ? ( - 该规格暂无模板价,请联系客服 + 该规格暂无价格,请联系客服 ) : ( 选完规格后自动带出 )} @@ -1032,6 +1064,17 @@ export default function WorkerConsignmentPage() { } const profileId = Number(String(key).replace('publish-', '')) if (Number.isInteger(profileId) && profileId > 0) { + if (profileId !== selectedProfileId) { + const nextProfile = consignmentProfiles.find((item) => item.profileId === profileId) + const nextSpecs = nextProfile?.config.specs ?? [] + const cleared: Record = {} + for (const field of nextSpecs) cleared[field.key] = undefined + cleared.askingPrice = undefined + cleared.itemName = undefined + form.setFieldsValue(cleared) + setPriceState({ status: 'idle' }) + lastPriceQueryKeyRef.current = '' + } setSelectedProfileId(profileId) } setActiveTab('publish') @@ -1474,13 +1517,19 @@ function ConsignmentRecordCard({ {record.label}
- + {record.specText ? ( + + {record.specText} + + ) : ( + + )}
寄售价{' '} @@ -1695,12 +1744,18 @@ function ConsignmentRecordCard({ > 0 ? record.priceCapFen / 100 : undefined} precision={2} addonBefore="¥" value={priceYuan} onChange={(value) => setPriceYuan(Number(value || 0))} style={{ width: '100%' }} /> + {record.priceCapFen && record.priceCapFen > 0 ? ( + + 最高不能超过系统建议价 ¥{(record.priceCapFen / 100).toFixed(2)} + + ) : null} ) diff --git a/apps/frontend/src/services/admin/consignment.ts b/apps/frontend/src/services/admin/consignment.ts index 8ac01866..20c12dfd 100644 --- a/apps/frontend/src/services/admin/consignment.ts +++ b/apps/frontend/src/services/admin/consignment.ts @@ -54,6 +54,8 @@ export function fetchAdminConsignmentMatchCandidates(workOrderId: number | strin export function matchAdminConsignmentWorkOrder(payload: { workOrderId: number inventoryIds: number[] + allowNonExact?: boolean + reason?: string }) { return apiPost<{ matched: boolean; matchedCount: number }>( '/api/v1/admin/worker-platform/consignment/match', diff --git a/apps/frontend/src/services/worker.ts b/apps/frontend/src/services/worker.ts index 0c094217..c034752a 100644 --- a/apps/frontend/src/services/worker.ts +++ b/apps/frontend/src/services/worker.ts @@ -31,6 +31,7 @@ import type { ConsignmentBuyerInfo, ConsignmentContactSupporter, ConsignmentSellerInfo, + ConsignmentSpecValues, WorkerConsignmentProfile, } from '@/types/consignment' @@ -378,11 +379,13 @@ export type WorkerConsignmentPublishPayload = { profileId?: number quantity?: number itemName?: string - system: string - serverZone: string - series: string - diamondLevel: number - brillianceCount: number + /** 动态规格值(新链路);内置 5 字段仍可平铺传,旧调用兼容 */ + specValues?: ConsignmentSpecValues + system?: string + serverZone?: string + series?: string + diamondLevel?: number + brillianceCount?: number askingPriceFen: number sellerContact: string sellerPhone?: string @@ -453,6 +456,11 @@ export type WorkerConsignmentRecord = { brillianceCount: number askingPriceFen: number salePriceFen?: number + /** 打手改价上限(分);0 = 不限制 */ + priceCapFen?: number + /** 动态规格值与展示文本 */ + specValues?: ConsignmentSpecValues + specText?: string quantity?: number remainingQuantity?: number listedAt?: string | null @@ -574,6 +582,7 @@ export function submitWorkerConsignmentGiftEvidence( /** 发布寄卖:按规格(系统/系列/钻级/璀璨)从「接单模板」带出建议价,如 安卓+暗潮+1钻+0璀 → ¥120 */ export function fetchWorkerConsignmentPriceSuggestion(params: { + profileId?: number system: string series: string diamondLevel: number @@ -581,11 +590,29 @@ export function fetchWorkerConsignmentPriceSuggestion(params: { }) { return apiGet<{ suggestedPriceFen: number | null + capFen: number + templatePriceFen: number + source: 'template' | 'fixed' | 'none' matchedCount: number matchedRules: { ruleKey: string; productName: string; rewardAmountFen: number }[] }>('/api/v1/worker/consignment/price-suggestion', params) } +/** 动态规格发布:按 profileId + specValues 算价(模板折扣价 / 固定价)。 */ +export function fetchWorkerConsignmentPricePreview(payload: { + profileId?: number + specValues: ConsignmentSpecValues +}) { + return apiPost<{ + suggestedPriceFen: number | null + capFen: number + templatePriceFen: number + source: 'template' | 'fixed' | 'none' + matchedCount: number + matchedRules: { ruleKey: string; productName: string; rewardAmountFen: number }[] + }>('/api/v1/worker/consignment/price-preview', payload) +} + export type OrderServiceField = { key: string label: string diff --git a/apps/frontend/src/types/consignment.ts b/apps/frontend/src/types/consignment.ts index 59648dae..89633306 100644 --- a/apps/frontend/src/types/consignment.ts +++ b/apps/frontend/src/types/consignment.ts @@ -28,6 +28,31 @@ export type ConsignmentContactFields = { contactSupporterName?: string } +/** 动态规格值:key -> 选项值/数字/文本 */ +export type ConsignmentSpecValues = Record + +export type ConsignmentSpecOption = { + label: string + value: string + enabled: boolean +} + +export type ConsignmentSpecFieldType = 'text' | 'textarea' | 'select' | 'radio' | 'number' | 'date' + +export type ConsignmentSpecField = { + key: string + label: string + type: ConsignmentSpecFieldType + required: boolean + options: ConsignmentSpecOption[] + placeholder: string + helpText: string + matchMode: 'exact' | 'minimum' | 'wildcard' | 'ignore' + wildcardValues: string[] + showInItemName: boolean + sortOrder: number +} + /** * 客服列表 / 详情接口额外返回的寄卖凭证字段(提报列表与库存列表都有)。 * @@ -98,6 +123,13 @@ export type ConsignmentInventory = ConsignmentEvidenceFields & salePriceFen?: number listedAt?: string | null priceUpdatedAt?: string | null + /** 动态规格值与展示文本 */ + specValues?: ConsignmentSpecValues + specText?: string + /** 该库存快照对应的规格定义(旧库存为空,前端回退固定 5 项) */ + specs?: ConsignmentSpecField[] + /** 打手改价上限(分);0 = 不限制 */ + priceCapFen?: number canEditPrice?: boolean priceDropSuggestion?: { suggestedPriceFen: number @@ -180,6 +212,11 @@ export type ConsignmentSubmission = ConsignmentEvidenceFields & /** 分类级寄卖配置与数量/售价 */ profileId?: number | null profileVersionId?: number | null + /** 动态规格值与展示文本 */ + specValues?: ConsignmentSpecValues + specText?: string + specs?: ConsignmentSpecField[] + priceCapFen?: number quantity?: number requestedSalePriceFen?: number workerId?: number | null @@ -307,7 +344,20 @@ export type ConsignmentProfileConfig = { brillianceCount: boolean quantity: boolean } - pricing: { useTemplatePrice: boolean; allowWorkerPrice: boolean; minPriceFen: number } + /** 动态规格字段(完全由运营配置,默认空) */ + specs: ConsignmentSpecField[] + /** 物品名模板,支持 {system}/{series}/{category} 等占位符 */ + nameTemplate: string + pricing: { + /** 接单模板价打折 / 固定价格 */ + priceType: 'template_discount' | 'fixed' + /** 几折,0.1 - 10,例如 9.5 */ + discount: number + fixedPriceFen: number + allowWorkerPrice: boolean + minPriceFen: number + missingPricePolicy: 'block' | 'manual' + } deposit: { enabled: boolean mode: 'fixed' | 'per_quantity' @@ -332,6 +382,8 @@ export type ConsignmentProfileConfig = { } } +export type ConsignmentPricingConfig = ConsignmentProfileConfig['pricing'] + export type ConsignmentProfile = { profileId: number categoryId: number @@ -381,6 +433,17 @@ export type ConsignmentMatchCandidate = { series: string diamondLevel: number brillianceCount: number + specValues?: ConsignmentSpecValues + specText?: string + matchType?: 'exact' | 'partial' | 'conflict' | 'unknown' + matchReasons?: string[] + matchDetails?: Array<{ + key: string + label: string + orderValue: string + inventoryValue: string + status: 'match' | 'partial' | 'conflict' | 'unknown' | 'ignored' + }> quantity: number remainingQuantity: number salePriceFen: number diff --git a/apps/frontend/src/utils/consignment-spec.ts b/apps/frontend/src/utils/consignment-spec.ts index f9f310d8..f2b887eb 100644 --- a/apps/frontend/src/utils/consignment-spec.ts +++ b/apps/frontend/src/utils/consignment-spec.ts @@ -1,16 +1,13 @@ +import type { + ConsignmentPricingConfig, + ConsignmentSpecField, + ConsignmentSpecFieldType, + ConsignmentSpecValues, +} from '@/types/consignment' + /** - * 寄卖规格选项(单一来源)。 - * - * 两套口径: - * 1. 后台全量(CONSIGNMENT_SYSTEM_OPTIONS / CONSIGNMENT_ZONE_OPTIONS):给客服端 - * 提报审核、库存编辑用 —— 系统 苹果/安卓/通用,区服 微信区/QQ区/互通。 - * 2. 发布端按物品类型展示(SURNAN_*):打手端「我要寄卖」按物品类型给选项 —— - * 苏尔南必须区分 苹果/安卓(不给通用),区服固定「互通」(QQ/微信互通,默认选中)。 - * - * 「互通」在匹配引擎里是区服通配值:互通现货 = 微信/QQ 订单都匹配,反之亦然; - * 订单文案里的「通用/全区/不限」会归一化成通配(见后端 ZONE_WILDCARDS)。 - * 系统侧的「通用」同理是系统通配(订单文案里的「双端/全端」也归一化过来)。 - * 后续其他品类规则不同(有的不分端),在这里按品类加条目即可。 + * 旧版客服手工入库表单的选项常量。 + * 新链路以「每个寄卖配置的 specs」为准,这里只给没有配置快照的旧数据兜底。 */ export const CONSIGNMENT_SYSTEM_OPTIONS = [ { label: '苹果', value: '苹果' }, @@ -24,13 +21,124 @@ export const CONSIGNMENT_ZONE_OPTIONS = [ { label: '互通(QQ/微信)', value: '互通' }, ] as const -/** 打手端发布 · 苏尔南:系统必须二选一,区服固定「互通」 */ -export const SURNAN_PUBLISH_SYSTEM_OPTIONS = [ - { label: '苹果', value: '苹果' }, - { label: '安卓', value: '安卓' }, -] as const +export const SPEC_FIELD_TYPE_OPTIONS: Array<{ label: string; value: ConsignmentSpecFieldType }> = [ + { label: '文本', value: 'text' }, + { label: '长文本', value: 'textarea' }, + { label: '下拉', value: 'select' }, + { label: '单选', value: 'radio' }, + { label: '数字', value: 'number' }, + { label: '日期', value: 'date' }, +] -export const SURNAN_PUBLISH_ZONE_OPTIONS = [{ label: '互通(QQ/微信)', value: '互通' }] as const +export function specFieldTypeLabel(type: ConsignmentSpecFieldType): string { + return SPEC_FIELD_TYPE_OPTIONS.find((item) => item.value === type)?.label || '文本' +} -/** 苏尔南发布时区服的默认值(唯一选项,自动选中) */ -export const SURNAN_DEFAULT_ZONE = '互通' +export function isOptionSpecField(field: Pick): boolean { + return field.type === 'select' || field.type === 'radio' +} + +export function formatSpecValue( + field: ConsignmentSpecField, + value: unknown, + options: { forItemName?: boolean } = {}, +): string { + const raw = String(value ?? '').trim() + if (!raw) return '' + if (options.forItemName && (field.key === 'diamondLevel' || field.key === 'brillianceCount')) { + const numeric = Number(raw) + if (!Number.isFinite(numeric)) return raw + if (field.key === 'brillianceCount' && numeric <= 0) return '' + return String(Math.round(numeric)) + } + if (isOptionSpecField(field)) { + const option = field.options.find((item) => item.value === raw) + if (options.forItemName && field.key === 'serverZone' && field.wildcardValues.includes(raw)) { + return '' + } + return option?.label || raw + } + return raw +} + +export function buildSpecText( + specs: ConsignmentSpecField[], + values: ConsignmentSpecValues | undefined, +): string { + if (!values) return '' + return specs + .slice() + .sort((a, b) => a.sortOrder - b.sortOrder || a.key.localeCompare(b.key)) + .map((field) => { + const display = formatSpecValue(field, values[field.key]) + return display ? `${field.label} ${display}` : '' + }) + .filter(Boolean) + .join(' / ') +} + +export function buildSpecItemName( + template: string, + specs: ConsignmentSpecField[], + values: ConsignmentSpecValues | undefined, + categoryName = '', +): string { + if (!values) return categoryName + const tokens = new Map() + for (const field of specs) { + tokens.set(field.key, formatSpecValue(field, values[field.key], { forItemName: true })) + } + tokens.set('category', categoryName) + let result = String(template || '').trim() + if (result) { + result = result.replace( + /\{([a-zA-Z][a-zA-Z0-9_]*)\}/g, + (_, key: string) => tokens.get(key) || '', + ) + } else { + result = specs + .filter((field) => field.showInItemName) + .map((field) => formatSpecValue(field, values[field.key], { forItemName: true })) + .filter(Boolean) + .join('') + } + const normalized = result.replace(/\s+/g, ' ').trim() + return normalized || categoryName || '寄卖物品' +} + +export type SpecPriceResult = { + priceFen: number + capFen: number + templatePriceFen: number + breakdown: Array<{ label: string; amountFen: number }> +} + +/** 与后端 computeConsignmentPrice 保持同口径,仅用于配置面板预览。 */ +export function computeSpecPrice( + pricing: ConsignmentPricingConfig, + templatePriceFen = 0, +): SpecPriceResult { + const template = Math.max(0, Math.round(Number(templatePriceFen || 0))) + const breakdown: Array<{ label: string; amountFen: number }> = [] + let priceFen = 0 + + if (pricing.priceType === 'fixed') { + priceFen = Math.max(0, Math.round(Number(pricing.fixedPriceFen || 0))) + if (priceFen > 0) breakdown.push({ label: '固定价格', amountFen: priceFen }) + } else if (template > 0) { + const discount = Math.min(10, Math.max(0.1, Number(pricing.discount || 10))) + priceFen = Math.round((template * discount) / 10) + breakdown.push({ label: '接单模板价', amountFen: template }) + if (discount !== 10) { + breakdown.push({ label: `${discount} 折`, amountFen: priceFen - template }) + } + } + + priceFen = Math.max(0, Math.round(priceFen)) + return { + priceFen, + capFen: priceFen, + templatePriceFen: template, + breakdown, + } +} diff --git a/apps/frontend/tests/consignment-matching-config.test.ts b/apps/frontend/tests/consignment-matching-config.test.ts index aa1df7c3..4b5fa0a1 100644 --- a/apps/frontend/tests/consignment-matching-config.test.ts +++ b/apps/frontend/tests/consignment-matching-config.test.ts @@ -61,3 +61,37 @@ test('工单页:必须挂载内部库存匹配弹窗,并支持状态 interna assert.ok(shared.includes("internal_matching: '内部匹配中'"), '状态文案必须包含内部匹配中') assert.ok(shared.includes("internal_matching: 'magenta'"), '状态配色必须包含内部匹配中') }) + +test('寄卖配置面板:独立功能卡片、空字段初始状态与价格配置', () => { + const panel = read('../src/pages/admin/panels/ConsignmentConfigPanel.tsx') + for (const label of [ + '规格字段', + '添加字段', + '基础信息', + '价格配置', + '履约保证金', + '服务费', + '库存数量限制', + '匹配设置', + '超时与降价提示', + '接单模板价打折', + '固定价格', + '模板折扣', + '物品名模板', + ]) { + assert.ok(panel.includes(label), `寄卖配置面板缺少「${label}」`) + } + assert.ok(!panel.includes('DEFAULT_SPEC_FIELDS'), '面板不能预置任何内置字段') + assert.ok(panel.includes('useState([])'), '规格字段初始必须为空') + assert.ok(panel.includes('specDrafts.map'), '规格字段必须可动态增删/排序') + assert.ok(panel.includes('SPEC_FIELD_TYPE_OPTIONS'), '字段类型必须支持 6 种选项') +}) + +test('打手端:动态规格 + 系统建议价 + 改价不得高于建议价', () => { + const page = read('../src/pages/worker/WorkerConsignmentPage.tsx') + assert.ok(page.includes('selectedSpecs.map'), '规格必须按配置动态渲染') + assert.ok(page.includes('fetchWorkerConsignmentPricePreview'), '必须走动态价格预览') + assert.ok(page.includes('不能高于系统建议价'), '前端必须提示改价上限') + assert.ok(page.includes('specValues'), '发布必须提交动态规格值') + assert.ok(page.includes('config.pricing.allowWorkerPrice'), '改价能力必须按配置') +}) diff --git a/apps/frontend/tests/consignment-spec-options.test.ts b/apps/frontend/tests/consignment-spec-options.test.ts index 626d2e64..ed30c9be 100644 --- a/apps/frontend/tests/consignment-spec-options.test.ts +++ b/apps/frontend/tests/consignment-spec-options.test.ts @@ -20,32 +20,26 @@ test('后台全量选项:系统含通用,区服含互通', () => { assert.ok(source.includes("value: '互通'"), '区服选项必须包含「互通」') }) -test('苏尔南发布选项:系统只有苹果/安卓,区服唯一「互通」且为默认值', () => { - const source = readFileSync( +test('苏尔南默认配置:取消代码内置字段,由迁移 131 提供五个单选字段', () => { + const utils = readFileSync( new URL('../src/utils/consignment-spec.ts', import.meta.url).pathname, 'utf8', ) - const surnanSystem = source.slice( - source.indexOf('SURNAN_PUBLISH_SYSTEM_OPTIONS'), - source.indexOf('SURNAN_PUBLISH_ZONE_OPTIONS'), - ) - assert.ok(!surnanSystem.includes("value: '通用'"), '苏尔南发布不允许再选系统「通用」') - assert.ok(source.includes("export const SURNAN_DEFAULT_ZONE = '互通'"), '苏尔南区服默认「互通」') - const surnanZone = source.slice(source.indexOf('SURNAN_PUBLISH_ZONE_OPTIONS')) - assert.ok( - !surnanZone.slice(0, surnanZone.indexOf('\n\n')).includes("value: '微信区'"), - '苏尔南发布端区服只有互通一个选项', - ) + assert.ok(!utils.includes('SURNAN_PUBLISH_SYSTEM_OPTIONS'), '前端不再写死苏尔南发布选项') + assert.ok(!utils.includes('DEFAULT_SPEC_FIELDS'), '前端不再内置默认 5 个字段') + assert.ok(utils.includes('SPEC_FIELD_TYPE_OPTIONS'), '字段类型选项必须集中维护') }) -test('打手端发布表单:用苏尔南选项集 + 区服默认值,不引后台全量', () => { +test('打手端发布表单:按寄卖配置动态渲规格,不再回退内置字段', () => { const source = readFileSync( new URL('../src/pages/worker/WorkerConsignmentPage.tsx', import.meta.url).pathname, 'utf8', ) - assert.ok(source.includes('SURNAN_PUBLISH_SYSTEM_OPTIONS')) - assert.ok(source.includes('SURNAN_PUBLISH_ZONE_OPTIONS')) - assert.ok(source.includes('initialValue={SURNAN_DEFAULT_ZONE}'), '区服默认值要落到表单项上') + assert.ok(source.includes('selectedSpecs.map'), '规格字段必须来自配置动态渲染') + assert.ok(source.includes('config.specs'), '必须读取寄卖配置的 specs') + assert.ok(!source.includes('DEFAULT_SPEC_FIELDS'), '空配置不能回退内置 5 项') + assert.ok(source.includes('fetchWorkerConsignmentPricePreview'), '价格必须走动态预览接口') + assert.ok(source.includes('buildSpecItemName'), '物品名必须按名称模板生成') assert.ok(!source.includes('[...CONSIGNMENT_'), '发布端不直接引用后台全量选项') })