diff --git a/apps/backend/src/routes/worker/consignment.ts b/apps/backend/src/routes/worker/consignment.ts index b43c528c..6325bf4f 100644 --- a/apps/backend/src/routes/worker/consignment.ts +++ b/apps/backend/src/routes/worker/consignment.ts @@ -36,6 +36,7 @@ router.get('/consignment/price-suggestion', requireActiveWorker, async (req, res system: req.query.system, series: req.query.series, diamondLevel: req.query.diamondLevel, + brillianceCount: req.query.brillianceCount, }) res.json({ ok: true, data: result }) } catch (error) { diff --git a/apps/backend/src/services/worker-platform/consignment-matching-service.test.ts b/apps/backend/src/services/worker-platform/consignment-matching-service.test.ts index 1a7fa2e1..141e2896 100644 --- a/apps/backend/src/services/worker-platform/consignment-matching-service.test.ts +++ b/apps/backend/src/services/worker-platform/consignment-matching-service.test.ts @@ -452,3 +452,91 @@ test('发布寄卖建议价:模板写「通用/双端」时两端都命中', ( null, ) }) + +test('extractPeacekeeperSpecs: 支持「璀」字缩写与「无璀/0璀」', () => { + const s1 = extractPeacekeeperSpecs('苹果怒嚎5钻3璀') + assert.equal(s1.diamondLevel, 5) + assert.equal(s1.brillianceCount, 3) + + const s2 = extractPeacekeeperSpecs('苹果怒嚎5钻无璀') + assert.equal(s2.diamondLevel, 5) + assert.equal(s2.brillianceCount, 0) + + const s3 = extractPeacekeeperSpecs('苹果怒嚎5钻0璀') + assert.equal(s3.diamondLevel, 5) + assert.equal(s3.brillianceCount, 0) + + const s4 = extractPeacekeeperSpecs('苹果怒嚎5钻1璀') + assert.equal(s4.diamondLevel, 5) + assert.equal(s4.brillianceCount, 1) + + const s5 = extractPeacekeeperSpecs('苹果怒嚎5钻2璀璨') + assert.equal(s5.diamondLevel, 5) + assert.equal(s5.brillianceCount, 2) +}) + +test('发布寄卖建议价:按璀璨数精准区分价格,不会同钻级多规则全命中取最高', () => { + const rules = [ + { ruleKey: 'rule-5z-0c', productName: '苹果怒嚎5钻', rewardAmountFen: 15000 }, + { ruleKey: 'rule-5z-1c', productName: '苹果怒嚎5钻1璀', rewardAmountFen: 19000 }, + { ruleKey: 'rule-5z-2c', productName: '苹果怒嚎5钻2璀璨', rewardAmountFen: 23000 }, + { ruleKey: 'rule-5z-3c', productName: '苹果怒嚎5钻3璀', rewardAmountFen: 28000 }, + ] + + // 1. 选 0璀(无璀璨),命中 0璀(15000 分,1 条规则,不是 4 条) + const hit0 = suggestConsignmentPriceFromRules(rules, { + system: '苹果', + series: '怒嚎', + diamondLevel: 5, + brillianceCount: 0, + }) + assert.ok(hit0) + assert.equal(hit0.suggestedPriceFen, 15000) + assert.equal(hit0.matchedRules.length, 1) + assert.equal(hit0.matchedRules[0].ruleKey, 'rule-5z-0c') + + // 2. 选 1璀,命中 1璀(19000 分) + const hit1 = suggestConsignmentPriceFromRules(rules, { + system: '苹果', + series: '怒嚎', + diamondLevel: 5, + brillianceCount: 1, + }) + assert.ok(hit1) + assert.equal(hit1.suggestedPriceFen, 19000) + assert.equal(hit1.matchedRules.length, 1) + assert.equal(hit1.matchedRules[0].ruleKey, 'rule-5z-1c') + + // 3. 选 2璀,命中 2璀璨(23000 分) + const hit2 = suggestConsignmentPriceFromRules(rules, { + system: '苹果', + series: '怒嚎', + diamondLevel: 5, + brillianceCount: 2, + }) + assert.ok(hit2) + assert.equal(hit2.suggestedPriceFen, 23000) + assert.equal(hit2.matchedRules.length, 1) + assert.equal(hit2.matchedRules[0].ruleKey, 'rule-5z-2c') + + // 4. 选 3璀,命中 3璀(28000 分) + const hit3 = suggestConsignmentPriceFromRules(rules, { + system: '苹果', + series: '怒嚎', + diamondLevel: 5, + brillianceCount: 3, + }) + assert.ok(hit3) + assert.equal(hit3.suggestedPriceFen, 28000) + assert.equal(hit3.matchedRules.length, 1) + assert.equal(hit3.matchedRules[0].ruleKey, 'rule-5z-3c') + + // 5. 选 4璀(模板库未配 4璀 价格),不命中,返回 null + const hit4 = suggestConsignmentPriceFromRules(rules, { + system: '苹果', + series: '怒嚎', + diamondLevel: 5, + brillianceCount: 4, + }) + assert.equal(hit4, null) +}) diff --git a/apps/backend/src/services/worker-platform/consignment-matching-service.ts b/apps/backend/src/services/worker-platform/consignment-matching-service.ts index a20f8c02..0fca5e05 100644 --- a/apps/backend/src/services/worker-platform/consignment-matching-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-matching-service.ts @@ -68,15 +68,15 @@ function parseSpecsFromText(rawText: string): Omit const text = String(rawText || '').trim() const diamondMatch = text.toLowerCase().match(/([1-7])\s*钻/) - const brillianceZero = /无璀璨|不带璀璨|0\s*璀璨/.test(text) - const brillianceMatch = text.match(/([1-7])\s*璀璨/) + const brillianceZero = /无\s*璀(?:璨)?|不带\s*璀(?:璨)?|0\s*璀(?:璨)?/.test(text) + const brillianceMatch = text.match(/([1-7])\s*璀(?:璨)?/) let brillianceCount = 0 if (brillianceZero) { brillianceCount = 0 } else if (brillianceMatch?.[1]) { brillianceCount = Number(brillianceMatch[1]) - } else if (text.includes('璀璨')) { + } else if (text.includes('璀')) { brillianceCount = 1 } @@ -431,16 +431,28 @@ export type ConsignmentPriceSuggestionRule = { * * 模板的商品名(如「安卓暗潮1钻」)用与订单同一套规格解析器拆解: * 系列、钻级必须完全一致;系统按通配口径比对(规则或提报任一侧是「通用/双端」都算匹配)。 - * 区服不参与——模板价不分微信/QQ。璀璨也不参与——模板价没按璀璨细分,卖家可自行加价。 + * 区服不参与——模板价不分微信/QQ。 + * 若指定了璀璨数(0~7),模板商品解析出的璀璨数必须一致(0璀对应无璀璨/0璀,3璀对应3璀/3璀璨);未指定则忽略璀璨。 * 多条命中取最高价(该规格能卖到的最好价钱),卖家仍可手动改。 */ export function suggestConsignmentPriceFromRules( rules: ConsignmentPriceSuggestionRule[], - spec: { system: string; series: string; diamondLevel: number }, + spec: { + system: string + series: string + diamondLevel: number + brillianceCount?: number | null | undefined + }, ): { suggestedPriceFen: number; matchedRules: ConsignmentPriceSuggestionRule[] } | null { const wantedSystem = normalizePeacekeeperSystem(spec.system) const wantedSeries = normalizePeacekeeperSeries(spec.series) const wantedDiamond = Math.round(Number(spec.diamondLevel || 0)) + const wantedBrilliance = + spec.brillianceCount !== undefined && + spec.brillianceCount !== null && + String(spec.brillianceCount).trim() !== '' + ? Math.max(0, Math.round(Number(spec.brillianceCount || 0))) + : null if (!wantedSystem || !wantedSeries || wantedDiamond <= 0) return null const matched = rules.filter((rule) => { @@ -450,6 +462,9 @@ export function suggestConsignmentPriceFromRules( if (!parsed.system) return false if (parsed.series !== wantedSeries) return false if (Number(parsed.diamondLevel || 0) !== wantedDiamond) return false + if (wantedBrilliance !== null && Number(parsed.brillianceCount || 0) !== wantedBrilliance) { + return false + } return parsed.system === wantedSystem || parsed.system === '通用' || wantedSystem === '通用' }) if (matched.length === 0) return null diff --git a/apps/backend/src/services/worker-platform/consignment-worker-service.test.ts b/apps/backend/src/services/worker-platform/consignment-worker-service.test.ts index a188990a..5fe2139c 100644 --- a/apps/backend/src/services/worker-platform/consignment-worker-service.test.ts +++ b/apps/backend/src/services/worker-platform/consignment-worker-service.test.ts @@ -71,3 +71,15 @@ test('苏尔南必须区分苹果/安卓(不收「通用」),区服固定 ), ) }) + +test('寄卖提报硬校验:璀璨数不能超过钻石等级(1钻最多1璀以此类推)', async () => { + const serviceSource = await readServiceSource() + assert.ok( + serviceSource.includes('brillianceCount > diamondLevel'), + '服务端必须校验璀璨数不能超过钻石等级', + ) + assert.ok( + serviceSource.includes("errorCode: 'brilliance_exceeds_diamond'"), + '超限必须返回专门的错误码', + ) +}) 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 8cbacf2a..52533dec 100644 --- a/apps/backend/src/services/worker-platform/consignment-worker-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-worker-service.ts @@ -80,6 +80,12 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb }) } 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', + }) + } const contactInfo = String(payload.sellerContact || payload.contactInfo || '').trim() if (!contactInfo) { @@ -432,6 +438,7 @@ export async function getWorkerConsignmentPriceSuggestion(payload: { system?: unknown series?: unknown diamondLevel?: unknown + brillianceCount?: unknown }): Promise<{ suggestedPriceFen: number | null matchedCount: number @@ -440,11 +447,22 @@ export async function getWorkerConsignmentPriceSuggestion(payload: { const system = String(payload.system || '').trim() const series = String(payload.series || '').trim() const diamondLevel = Math.round(Number(payload.diamondLevel || 0)) + const brillianceCount = + payload.brillianceCount !== undefined && + payload.brillianceCount !== null && + String(payload.brillianceCount).trim() !== '' + ? Math.max(0, Math.round(Number(payload.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 }) + const suggestion = suggestConsignmentPriceFromRules(rules, { + system, + series, + diamondLevel, + brillianceCount, + }) if (!suggestion) { return { suggestedPriceFen: null, matchedCount: 0, matchedRules: [] } } diff --git a/apps/frontend/src/pages/worker/WorkerConsignmentPage.tsx b/apps/frontend/src/pages/worker/WorkerConsignmentPage.tsx index 55ecd734..809e1d01 100644 --- a/apps/frontend/src/pages/worker/WorkerConsignmentPage.tsx +++ b/apps/frontend/src/pages/worker/WorkerConsignmentPage.tsx @@ -88,7 +88,8 @@ export default function WorkerConsignmentPage() { const [evidenceSubmitting, setEvidenceSubmitting] = useState(false) const [cooldownNow, setCooldownNow] = useState(() => Date.now()) const [form] = Form.useForm() - // 建议价:规格选齐后从「接单模板」带出(安卓+暗潮+1钻 → ¥120);卖家手动改价则只提示不覆盖 + const currentDiamondLevel = Form.useWatch('diamondLevel', form) + // 建议价:规格选齐后从「接单模板」带出(安卓+暗潮+1钻+0璀 → ¥120);卖家手动改价则只提示不覆盖 const [priceSuggestion, setPriceSuggestion] = useState<{ fen: number matchedCount: number @@ -197,6 +198,14 @@ export default function WorkerConsignmentPage() { if (changed && changed.askingPrice !== undefined) { priceTouchedRef.current = true } + // 钻级改变时:璀璨数不能超过钻石等级(如 5钻调成 2钻,原 5璀自动收敛到 2璀) + if (changed && changed.diamondLevel !== undefined) { + const newDia = Number(changed.diamondLevel || 0) + const curBri = form.getFieldValue('brillianceCount') + if (curBri != null && Number(curBri) > newDia) { + form.setFieldsValue({ brillianceCount: newDia }) + } + } const sys = form.getFieldValue('system') const zone = form.getFieldValue('serverZone') const ser = form.getFieldValue('series') @@ -212,11 +221,11 @@ export default function WorkerConsignmentPage() { }) } - // 规格选齐(系统+系列+钻级)后从「接单模板」带出建议价:安卓+暗潮+1钻 → 模板价 ¥120。 + // 规格选齐(系统+系列+钻级+璀璨)后从「接单模板」带出建议价:安卓+暗潮+1钻+0璀 → 模板价 ¥120。 // 防抖 + 同规格去重 + 过期响应丢弃;建议价拿不到不影响发布(静默)。 if (priceFetchTimer.current) clearTimeout(priceFetchTimer.current) - if (sys && ser && dia != null && Number(dia) > 0) { - const queryKey = `${sys}|${ser}|${dia}` + if (sys && ser && dia != null && Number(dia) > 0 && bri != null) { + const queryKey = `${sys}|${ser}|${dia}|${bri}` if (lastPriceQueryKeyRef.current !== queryKey) { lastPriceQueryKeyRef.current = queryKey // 换了规格:重新允许自动带价(上一规格的手改标记不再拦新规格) @@ -229,6 +238,7 @@ export default function WorkerConsignmentPage() { system: String(sys), series: String(ser), diamondLevel: Number(dia), + brillianceCount: Number(bri), }) if (lastPriceQueryKeyRef.current !== queryKey) return const fen = Number(res.data?.suggestedPriceFen || 0) @@ -610,7 +620,21 @@ export default function WorkerConsignmentPage() { ({ + 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), }))} /> diff --git a/apps/frontend/src/services/worker.ts b/apps/frontend/src/services/worker.ts index c05ce20b..ce462ba9 100644 --- a/apps/frontend/src/services/worker.ts +++ b/apps/frontend/src/services/worker.ts @@ -484,11 +484,12 @@ export function submitWorkerConsignmentGiftEvidence( ) } -/** 发布寄卖:按规格(系统/系列/钻级)从「接单模板」带出建议价,如 安卓+暗潮+1钻 → ¥120 */ +/** 发布寄卖:按规格(系统/系列/钻级/璀璨)从「接单模板」带出建议价,如 安卓+暗潮+1钻+0璀 → ¥120 */ export function fetchWorkerConsignmentPriceSuggestion(params: { system: string series: string diamondLevel: number + brillianceCount?: number }) { return apiGet<{ suggestedPriceFen: number | null diff --git a/docs/寄卖系统优化方案.md b/docs/寄卖系统优化方案.md index b7cfb807..4d154ce6 100644 --- a/docs/寄卖系统优化方案.md +++ b/docs/寄卖系统优化方案.md @@ -21,7 +21,8 @@ | `632dddc4` | 上一轮:H5 渠道整体下线 + 凭据字段修复 + 状态机封闭(撤销守卫 / 库存绑定唯一性 / 生命周期解绑)+ 押金归属取台账 | 迁移 `079`、`consignment-*` 五个文件、`work-order-*` 三个仓库、两个前端面板 | | `5d11bf20` | 批次 C+D:指派部分成功补偿、凭证核销如实反馈、空规格不放行 + 履约/指派服务端规格复检 | `consignment-fulfillment-service.ts`、`consignment-matching-service.ts`、`consignment-repo.ts`、履约路由、`WorkOrdersPanel.tsx` | | `d6f6219f` | 批次 F1:内部现货履约停在「待加好友」,客服确认后才进赠送冷却 + 备注快捷项 | `work-order-gift-repo.ts`、履约服务、`WorkOrdersPanel.tsx`、`utils/gift-phase.ts` | -| 本批提交(批次 Y,同一提交内含本文档,故不写自身 hash) | 批次 Y:修生产 079 迁移 23505——建冻结幂等索引前先去重历史重复冻结并退还多冻押金(服务器未应用过 079,经确认直接改原迁移) | `079_drop_consignment_collect_links.sql`、`consignment-payout.test.ts` | +| 本批提交(批次 Z,同一提交内含本文档,故不写自身 hash) | 批次 Z:建议价精准对齐璀璨数(0/1/2/3璀独立匹配避免全中取最高)+ 限制璀璨不超过钻石等级(前端选项禁用与联动收敛 + 后端硬拦截) | `consignment-matching-service.ts`、`consignment-worker-service.ts`、`routes/worker/consignment.ts`、`WorkerConsignmentPage.tsx`、`services/worker.ts` | +| `617794f2` | 批次 Y:修生产 079 迁移 23505——建冻结幂等索引前先去重历史重复冻结并退还多冻押金(服务器未应用过 079,经确认直接改原迁移) | `079_drop_consignment_collect_links.sql`、`consignment-payout.test.ts` | | 本批提交(批次 X,同一提交内含本文档,故不写自身 hash) | 批次 X:发布寄卖自动带建议价——规格选齐后按 系统+系列+钻级 从「接单模板」(work_product_rules)匹配价格自动填入(安卓暗潮1钻 → ¥120),多条命中取最高,手改过不覆盖 | `consignment-matching-service.ts`、`product-match-repo.ts`、`consignment-worker-service.ts`、`routes/worker/consignment.ts`、`WorkerConsignmentPage.tsx` | | 本批提交(批次 W,同一提交内含本文档,故不写自身 hash) | 批次 W:规格口径定案——后台全量(系统 苹果/安卓/通用,区服 微信/QQ/互通),打手端按物品展示(苏尔南:系统二选一、区服唯一「互通」默认选中);「互通」为区服通配值,匹配引擎通配符对称化 | `utils/consignment-spec.ts`、`WorkerConsignmentPage.tsx`、两个面板、`consignment-matching-service.ts`、`consignment-worker-service.ts` | | 本批提交(批次 V,同一提交内含本文档,故不写自身 hash) | 批次 V:复核遗留——拼单份额验收收官补寄卖钩子(押金释放+供货货款)、手动匹配弹窗 409 刷新/关键字透传/无打手禁指派、核销反馈抽共享出口 | `work-order-settlement-repo.ts`、`ConsignmentManualMatchModal.tsx`、`utils/voucher-consume.ts` | @@ -326,6 +327,24 @@ 3. ✅ **测试**:建议价纯函数行为测试(命中/系统不符/钻级不符/系列不符/多命中取最高/通用模板两端命中/无系统模板不命中)。 **验证**:后端 483 / 前端 54 测试全绿;两侧 `typecheck` / `lint:check` / `format:check` / `check:sql` 通过。 + +### 批次 Z · 建议价精准对齐璀璨数 + 璀璨不超过钻石等级(本轮) + +**背景**:生产库接单模板为同钻级配置了不同璀璨的规则(如“苹果怒嚎5钻”分无璀璨、1璀、2璀、3璀), +但因忽略璀璨数导致 5 钻的 4 条规则全部命中并强制取最高价 ¥280,且切换璀璨时不刷新价格;同时业务要求璀璨数不得超过钻石等级。 + +1. ✅ **规格正则支持「璀」字缩写**:`parseSpecsFromText` 支持 `([1-7])\s*璀(?:璨)?` 与 `无\s*璀(?:璨)?|0\s*璀(?:璨)?`, + 避免生产规则名称写 `3璀` 时被误识别为 0 璀。 +2. ✅ **建议价精准对齐璀璨数**:`suggestConsignmentPriceFromRules` 及路由/服务透传 `brillianceCount`, + 要求 `parsed.brillianceCount === wantedBrilliance`;0璀对0璀、1璀对1璀、3璀对3璀,未配对应模板则返回空, + 消除同钻级多规则全命中取最高价的缺陷。 +3. ✅ **发布前端璀璨数不能超过钻石等级**: + - 动态禁用:`BRILLIANCE_OPTIONS` 中 `value > diamondLevel` 自动置灰禁用(1钻最多1璀,2钻最多2璀…)。 + - 自动收敛:调小钻级时(如 5钻改为 2钻),原选璀璨数超出时自动收敛至新钻级(2璀),并联动重算建议价与物品名。 + - 默认值与联动:璀璨数默认 `initialValue={0}`(无),`queryKey` 纳入 `${bri}`,切换璀璨即刻重新带价。 + - 双重校验:前端 Validator 校验 + 后端 `publishWorkerConsignment` 拦截(`brilliance_exceeds_diamond`)。 + +**验证**:后端 487 / 前端 54 测试全绿;两侧 `typecheck` / `lint:check` / `format:check` 通过。 ### 批次 W · 规格口径:互通通配 + 后台全量/发布端分物品展示(本轮) **业务定案**: