From 103299a7c9e628f330522673732f6a6081a4b870 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 17 Sep 2026 13:14:10 +0800 Subject: [PATCH] =?UTF-8?q?feat(consignment):=20=E5=8F=91=E5=B8=83?= =?UTF-8?q?=E5=AF=84=E5=8D=96=E8=87=AA=E5=8A=A8=E4=BB=8E=E6=8E=A5=E5=8D=95?= =?UTF-8?q?=E6=A8=A1=E6=9D=BF=E5=B8=A6=E5=87=BA=E5=BB=BA=E8=AE=AE=E4=BB=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新纯函数 suggestConsignmentPriceFromRules:模板商品名(安卓暗潮1钻)用与 订单同一套规格解析器拆解,系统+系列+钻级全对上才命中;系统按通配口径 (模板写通用/双端两端都算,模板没写系统不命中——宁可不建议不给错价); 多条命中取最高价 - 新端点 GET /worker/consignment/price-suggestion(requireActiveWorker) - 发布表单规格选齐后 300ms 防抖请求:同规格去重、过期响应丢弃;命中自动 填入寄售价并提示来源与条数;卖家手改过价只提示不覆盖,换规格重新带出; 提交成功状态复位;接口失败静默(本地没配模板保持手填,行为不变) - 建议:本地模板没配不影响任何现有行为,生产库模板越多建议越准 后端 483(+建议价命中/不符排除/取最高/通用模板用例)/ 前端 54 测试全绿 docs/寄卖系统优化方案.md 记录批次 X --- .../worker-platform/product-match-repo.ts | 17 +++++ apps/backend/src/routes/worker/consignment.ts | 18 ++++++ .../consignment-matching-service.test.ts | 56 +++++++++++++++++ .../consignment-matching-service.ts | 38 +++++++++++ .../consignment-worker-service.ts | 34 ++++++++++ .../pages/worker/WorkerConsignmentPage.tsx | 63 ++++++++++++++++++- apps/frontend/src/services/worker.ts | 13 ++++ docs/寄卖系统优化方案.md | 18 ++++++ 8 files changed, 255 insertions(+), 2 deletions(-) diff --git a/apps/backend/src/repositories/worker-platform/product-match-repo.ts b/apps/backend/src/repositories/worker-platform/product-match-repo.ts index 2f69eda3..fe0df77a 100644 --- a/apps/backend/src/repositories/worker-platform/product-match-repo.ts +++ b/apps/backend/src/repositories/worker-platform/product-match-repo.ts @@ -408,3 +408,20 @@ export async function pruneWorkProductMatchLogs( remainingTotal, } } + +/** 寄卖建议价:取所有启用且有价格的接单模板规则(发布寄卖时按规格带出建议价) */ +export async function listEnabledWorkProductRulesForSuggestion(): Promise< + { ruleKey: string; productName: string; rewardAmountFen: number }[] +> { + const result = await query<{ rule_key: string; product_name: string; reward_amount: number }>( + `SELECT rule_key, product_name, reward_amount + FROM work_product_rules + WHERE enabled = TRUE AND reward_amount > 0 + ORDER BY sort_order ASC, id ASC`, + ) + return result.rows.map((row) => ({ + ruleKey: row.rule_key, + productName: row.product_name, + rewardAmountFen: Number(row.reward_amount || 0), + })) +} diff --git a/apps/backend/src/routes/worker/consignment.ts b/apps/backend/src/routes/worker/consignment.ts index 5e29170b..b43c528c 100644 --- a/apps/backend/src/routes/worker/consignment.ts +++ b/apps/backend/src/routes/worker/consignment.ts @@ -2,6 +2,7 @@ import { Router } from 'express' import { confirmWorkerConsignmentFriendAdded, + getWorkerConsignmentPriceSuggestion, listWorkerConsignments, publishWorkerConsignment, submitWorkerConsignmentGiftEvidence, @@ -25,6 +26,23 @@ router.post('/consignment/publish', requireActiveWorker, async (req, res) => { } }) +/** + * GET /api/v1/worker/consignment/price-suggestion?system=&series=&diamondLevel= + * 发布寄卖时按规格从「接单模板」带出建议价(安卓+暗潮+1钻 → 模板价 ¥120) + */ +router.get('/consignment/price-suggestion', requireActiveWorker, async (req, res) => { + try { + const result = await getWorkerConsignmentPriceSuggestion({ + system: req.query.system, + series: req.query.series, + diamondLevel: req.query.diamondLevel, + }) + res.json({ ok: true, data: result }) + } catch (error) { + sendRouteError(res, error, '获取建议价失败', '[worker/consignment/price-suggestion]') + } +}) + /** * GET /api/v1/worker/consignment/my-items * 打手查看自己在接单平台寄卖的手枪列表与状态 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 59f90ff4..1a7fa2e1 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 @@ -4,6 +4,7 @@ import test from 'node:test' import type { ConsignmentInventoryRow } from '../../repositories/worker-platform/consignment-repo.js' import { describeConsignmentSpecDifferences, + suggestConsignmentPriceFromRules, extractPeacekeeperSpecs, findMatchingPeacekeeperInventories, matchPeacekeeperOrderWithInventory, @@ -396,3 +397,58 @@ test('真冲突仍然排除:互通救不了系统不符,也救不了系列 null, ) }) + +test('发布寄卖建议价:按 系统+系列+钻级 从接单模板带出(安卓暗潮1钻 → ¥120)', () => { + const rules = [ + { ruleKey: 'rule-6zuan', productName: '安卓暗潮6钻', rewardAmountFen: 50000 }, + { ruleKey: 'rule-1zuan', productName: '安卓暗潮1钻', rewardAmountFen: 12000 }, + // 模板名里带区服/别名的也要能解析出来 + { ruleKey: 'rule-1zuan-qq', productName: '安卓QQ区暗潮1钻', rewardAmountFen: 9999 }, + { ruleKey: 'rule-5zuan-ios', productName: '苹果暗潮5钻', rewardAmountFen: 30000 }, + // 没配价格的规则不参与建议 + { ruleKey: 'rule-2zuan-free', productName: '安卓暗潮2钻', rewardAmountFen: 0 }, + ] + + const hit = suggestConsignmentPriceFromRules(rules, { + system: '安卓', + series: '暗潮', + diamondLevel: 1, + }) + assert.ok(hit, '安卓+暗潮+1钻 应命中模板') + assert.equal(hit.suggestedPriceFen, 12000, '多条命中取最高价(12000 > 9999)') + assert.equal(hit.matchedRules.length, 2) + + // 系统不符不命中:苹果的 1钻 没配价 + assert.equal( + suggestConsignmentPriceFromRules(rules, { system: '苹果', series: '暗潮', diamondLevel: 1 }), + null, + ) + // 钻级不符不命中:2钻规则没价格、6钻是另一档 + assert.equal( + suggestConsignmentPriceFromRules(rules, { system: '安卓', series: '暗潮', diamondLevel: 2 }), + null, + ) + // 系列不符不命中 + assert.equal( + suggestConsignmentPriceFromRules(rules, { system: '安卓', series: '怒嚎', diamondLevel: 1 }), + null, + ) +}) + +test('发布寄卖建议价:模板写「通用/双端」时两端都命中', () => { + const rules = [{ ruleKey: 'rule-dual', productName: '通用暗潮3钻', rewardAmountFen: 20000 }] + for (const system of ['安卓', '苹果']) { + const hit = suggestConsignmentPriceFromRules(rules, { + system, + series: '暗潮', + diamondLevel: 3, + }) + assert.equal(hit?.suggestedPriceFen, 20000, `${system} 应命中通用模板`) + } + // 模板没写系统则不命中(口径保守,宁可不建议也不给错价) + const noSystem = [{ ruleKey: 'r', productName: '暗潮3钻', rewardAmountFen: 20000 }] + assert.equal( + suggestConsignmentPriceFromRules(noSystem, { system: '安卓', series: '暗潮', diamondLevel: 3 }), + 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 f307e4e5..a20f8c02 100644 --- a/apps/backend/src/services/worker-platform/consignment-matching-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-matching-service.ts @@ -418,3 +418,41 @@ export function findMatchingPeacekeeperInventories( return Number(a.inventory.id) - Number(b.inventory.id) }) } + +/** 接单模板规则(只取建议价要用的字段) */ +export type ConsignmentPriceSuggestionRule = { + ruleKey: string + productName: string + rewardAmountFen: number +} + +/** + * 从「接单模板」(work_product_rules)里给寄卖提报找建议价。 + * + * 模板的商品名(如「安卓暗潮1钻」)用与订单同一套规格解析器拆解: + * 系列、钻级必须完全一致;系统按通配口径比对(规则或提报任一侧是「通用/双端」都算匹配)。 + * 区服不参与——模板价不分微信/QQ。璀璨也不参与——模板价没按璀璨细分,卖家可自行加价。 + * 多条命中取最高价(该规格能卖到的最好价钱),卖家仍可手动改。 + */ +export function suggestConsignmentPriceFromRules( + rules: ConsignmentPriceSuggestionRule[], + spec: { system: string; series: string; diamondLevel: number }, +): { suggestedPriceFen: number; matchedRules: ConsignmentPriceSuggestionRule[] } | null { + const wantedSystem = normalizePeacekeeperSystem(spec.system) + const wantedSeries = normalizePeacekeeperSeries(spec.series) + const wantedDiamond = Math.round(Number(spec.diamondLevel || 0)) + if (!wantedSystem || !wantedSeries || wantedDiamond <= 0) return null + + const matched = rules.filter((rule) => { + const rewardFen = Math.round(Number(rule.rewardAmountFen || 0)) + if (rewardFen <= 0) return false + const parsed = extractPeacekeeperSpecs(rule.productName) + if (!parsed.system) return false + if (parsed.series !== wantedSeries) return false + if (Number(parsed.diamondLevel || 0) !== wantedDiamond) return false + return parsed.system === wantedSystem || parsed.system === '通用' || wantedSystem === '通用' + }) + if (matched.length === 0) return null + const suggestedPriceFen = Math.max(...matched.map((rule) => Math.round(rule.rewardAmountFen))) + return { suggestedPriceFen, matchedRules: matched } +} 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 a5f37e05..8cbacf2a 100644 --- a/apps/backend/src/services/worker-platform/consignment-worker-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-worker-service.ts @@ -8,8 +8,10 @@ import { listConsignmentSubmissions, } from '../../repositories/worker-platform/consignment-repo.js' import { getWorkerUserById } from '../../repositories/worker-platform/index.js' +import { listEnabledWorkProductRulesForSuggestion } from '../../repositories/worker-platform/product-match-repo.js' import { listWorkOrdersByIds } from '../../repositories/worker-platform/work-order-query-repo.js' import { deriveConsignmentItemStage } from '../../domain/consignment-item-stage.js' +import { suggestConsignmentPriceFromRules } from './consignment-matching-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' @@ -420,3 +422,35 @@ export async function submitWorkerConsignmentGiftEvidence( const { workOrderId } = await resolveSellerConsignmentWorkOrder(workerId, submissionId) return submitWorkerOrderAcceptance(workOrderId, payload, session, { asConsignmentSupplier: true }) } + +/** + * 发布寄卖的建议价:按 规格(系统/系列/钻级)从「接单模板」里带出价格, + * 例如模板里有「安卓暗潮1钻 ¥120」,卖家选到 安卓+暗潮+1钻 就自动带出 120。 + * 找不到匹配的模板(本地没配、或规格没配价)就返回 null,前端保持手填。 + */ +export async function getWorkerConsignmentPriceSuggestion(payload: { + system?: unknown + series?: unknown + diamondLevel?: unknown +}): Promise<{ + suggestedPriceFen: number | null + 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)) + if (!system || !series || diamondLevel <= 0) { + return { suggestedPriceFen: null, matchedCount: 0, matchedRules: [] } + } + const rules = await listEnabledWorkProductRulesForSuggestion() + const suggestion = suggestConsignmentPriceFromRules(rules, { system, series, diamondLevel }) + if (!suggestion) { + return { suggestedPriceFen: null, matchedCount: 0, matchedRules: [] } + } + return { + suggestedPriceFen: suggestion.suggestedPriceFen, + matchedCount: suggestion.matchedRules.length, + matchedRules: suggestion.matchedRules, + } +} diff --git a/apps/frontend/src/pages/worker/WorkerConsignmentPage.tsx b/apps/frontend/src/pages/worker/WorkerConsignmentPage.tsx index 8b8b7674..55ecd734 100644 --- a/apps/frontend/src/pages/worker/WorkerConsignmentPage.tsx +++ b/apps/frontend/src/pages/worker/WorkerConsignmentPage.tsx @@ -30,11 +30,12 @@ import { Tag, Typography, } from 'antd' -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import ImageUpload from '@/components/files/ImageUpload' import { confirmWorkerConsignmentFriendAdded, + fetchWorkerConsignmentPriceSuggestion, fetchWorkerConsignments, fetchWorkerProfile, publishWorkerConsignment, @@ -87,6 +88,14 @@ export default function WorkerConsignmentPage() { const [evidenceSubmitting, setEvidenceSubmitting] = useState(false) const [cooldownNow, setCooldownNow] = useState(() => Date.now()) const [form] = Form.useForm() + // 建议价:规格选齐后从「接单模板」带出(安卓+暗潮+1钻 → ¥120);卖家手动改价则只提示不覆盖 + const [priceSuggestion, setPriceSuggestion] = useState<{ + fen: number + matchedCount: number + } | null>(null) + const priceTouchedRef = useRef(false) + const priceFetchTimer = useRef | null>(null) + const lastPriceQueryKeyRef = useRef('') const profileQuery = useQuery({ queryKey: ['worker-profile'], @@ -183,7 +192,11 @@ export default function WorkerConsignmentPage() { }) }, [profileQuery.isLoading, modal, publishDepositYuan]) - function handleFormValuesChange() { + function handleFormValuesChange(changed: Record) { + // 卖家手动改过价格后,本规格内不再自动覆盖(换规格会重新带出) + if (changed && changed.askingPrice !== undefined) { + priceTouchedRef.current = true + } const sys = form.getFieldValue('system') const zone = form.getFieldValue('serverZone') const ser = form.getFieldValue('series') @@ -198,6 +211,44 @@ export default function WorkerConsignmentPage() { itemName: `${sys}${zoneLabel}${ser}${dia}钻${briStr} 苏尔南`, }) } + + // 规格选齐(系统+系列+钻级)后从「接单模板」带出建议价:安卓+暗潮+1钻 → 模板价 ¥120。 + // 防抖 + 同规格去重 + 过期响应丢弃;建议价拿不到不影响发布(静默)。 + if (priceFetchTimer.current) clearTimeout(priceFetchTimer.current) + if (sys && ser && dia != null && Number(dia) > 0) { + const queryKey = `${sys}|${ser}|${dia}` + if (lastPriceQueryKeyRef.current !== queryKey) { + lastPriceQueryKeyRef.current = queryKey + // 换了规格:重新允许自动带价(上一规格的手改标记不再拦新规格) + priceTouchedRef.current = false + } + priceFetchTimer.current = setTimeout(() => { + void (async () => { + try { + const res = await fetchWorkerConsignmentPriceSuggestion({ + system: String(sys), + series: String(ser), + diamondLevel: Number(dia), + }) + if (lastPriceQueryKeyRef.current !== queryKey) return + const fen = Number(res.data?.suggestedPriceFen || 0) + if (fen > 0) { + setPriceSuggestion({ fen, matchedCount: Number(res.data?.matchedCount || 0) }) + if (!priceTouchedRef.current) { + form.setFieldsValue({ askingPrice: fen / 100 }) + } + } else { + setPriceSuggestion(null) + } + } catch { + // 建议价是锦上添花,失败不打扰卖家 + } + })() + }, 300) + } else { + lastPriceQueryKeyRef.current = '' + setPriceSuggestion(null) + } } async function handlePublishSubmit(values: Record) { @@ -245,6 +296,9 @@ export default function WorkerConsignmentPage() { message.success(`寄卖提报成功,已暂扣履约押金 ¥${publishDepositYuan.toFixed(0)}`) form.resetFields() + setPriceSuggestion(null) + priceTouchedRef.current = false + lastPriceQueryKeyRef.current = '' setScreenshots([]) await Promise.all([myConsignmentsQuery.refetch(), profileQuery.refetch()]) setRecordFilter('reviewing') @@ -576,6 +630,11 @@ export default function WorkerConsignmentPage() { name="askingPrice" label="寄售价格" rules={[{ required: true, message: '请填写寄售价格' }]} + extra={ + priceSuggestion + ? `已按接单模板匹配 ${priceSuggestion.matchedCount} 条规则,建议价 ¥${(priceSuggestion.fen / 100).toFixed(0)}(可修改)` + : undefined + } className="worker-consignment-spec-card is-price" > ('/api/v1/worker/consignment/price-suggestion', params) +} diff --git a/docs/寄卖系统优化方案.md b/docs/寄卖系统优化方案.md index d0447a69..2084ab58 100644 --- a/docs/寄卖系统优化方案.md +++ b/docs/寄卖系统优化方案.md @@ -21,6 +21,7 @@ | `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` | +| 本批提交(批次 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` | | 本批提交(批次 U,同一提交内含本文档,故不写自身 hash) | 批次 U:复核修复——占库 allocation_status 撞 080 CHECK(真实库必 500)、指派非供货卖家时货款双付、手动匹配入口按 fulfill 权限门控 | `consignment-repo.ts`、`consignment-fulfillment-service.ts`、两个面板、三个测试文件 | @@ -291,6 +292,23 @@ 规格冲突 → 409 `consignment_spec_mismatch`;非 exact 且未显式传 `allowPartial` → 409 `consignment_spec_partial_requires_confirm`。 前端在"部分匹配"候选上先 `modal.confirm` 危险确认,再带 `allowPartial: true` 重试,高配充低配的合法玩法保留但不可误触。 3. ⏳ 匹配下推 SQL + 批量匹配接口 + 工单列表角标(P2-8)未做:当前仍是"取全量在库 → 内存比对"。 + +### 批次 X · 发布寄卖自动带「接单模板」建议价(本轮) + +**需求**:接单平台已按规格配好模板价(生产库几十条,如 安卓暗潮1钻 ¥120 / 6钻 ¥500), +卖家发布寄卖时按规格自动带出,不用手填/猜价。 + +1. ✅ **后端**:纯函数 `suggestConsignmentPriceFromRules` —— 模板商品名(「安卓暗潮1钻」) + 用与订单同一套 `extractPeacekeeperSpecs` 解析,系统+系列+钻级全对上才命中(系统按通配口径, + 模板写「通用/双端」两端都算;模板没写系统则不命中,宁可不建议不给错价);多条命中取最高价。 + 新端点 `GET /worker/consignment/price-suggestion`(requireActiveWorker)。 +2. ✅ **前端**:发布表单规格选齐(系统+系列+钻级)后 300ms 防抖请求;同规格去重、过期响应丢弃; + 命中则自动填入寄售价并提示「已按接单模板匹配 N 条规则,建议价 ¥X(可修改)」; + **卖家手动改过价则只提示不覆盖**,换规格后重新允许自动带出;提交成功后状态复位。 + 建议价接口失败静默(锦上添花,不影响发布)。本地没配模板时无建议价、保持手填,行为不变。 +3. ✅ **测试**:建议价纯函数行为测试(命中/系统不符/钻级不符/系列不符/多命中取最高/通用模板两端命中/无系统模板不命中)。 + +**验证**:后端 483 / 前端 54 测试全绿;两侧 `typecheck` / `lint:check` / `format:check` / `check:sql` 通过。 ### 批次 W · 规格口径:互通通配 + 后台全量/发布端分物品展示(本轮) **业务定案**: