feat(consignment): 发布寄卖自动从接单模板带出建议价

- 新纯函数 suggestConsignmentPriceFromRules:模板商品名(安卓暗潮1钻)用与
  订单同一套规格解析器拆解,系统+系列+钻级全对上才命中;系统按通配口径
  (模板写通用/双端两端都算,模板没写系统不命中——宁可不建议不给错价);
  多条命中取最高价
- 新端点 GET /worker/consignment/price-suggestion(requireActiveWorker)
- 发布表单规格选齐后 300ms 防抖请求:同规格去重、过期响应丢弃;命中自动
  填入寄售价并提示来源与条数;卖家手改过价只提示不覆盖,换规格重新带出;
  提交成功状态复位;接口失败静默(本地没配模板保持手填,行为不变)
- 建议:本地模板没配不影响任何现有行为,生产库模板越多建议越准

后端 483(+建议价命中/不符排除/取最高/通用模板用例)/ 前端 54 测试全绿
docs/寄卖系统优化方案.md 记录批次 X
This commit is contained in:
yml2213
2026-09-17 13:14:10 +08:00
parent 4e076ed628
commit 103299a7c9
8 changed files with 255 additions and 2 deletions
@@ -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),
}))
}
@@ -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
* 打手查看自己在接单平台寄卖的手枪列表与状态
@@ -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,
)
})
@@ -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 }
}
@@ -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,
}
}
@@ -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<ReturnType<typeof setTimeout> | 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<string, unknown>) {
// 卖家手动改过价格后,本规格内不再自动覆盖(换规格会重新带出)
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<string, any>) {
@@ -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"
>
<InputNumber
+13
View File
@@ -483,3 +483,16 @@ export function submitWorkerConsignmentGiftEvidence(
payload,
)
}
/** 发布寄卖:按规格(系统/系列/钻级)从「接单模板」带出建议价,如 安卓+暗潮+1钻 → ¥120 */
export function fetchWorkerConsignmentPriceSuggestion(params: {
system: string
series: string
diamondLevel: number
}) {
return apiGet<{
suggestedPriceFen: number | null
matchedCount: number
matchedRules: { ruleKey: string; productName: string; rewardAmountFen: number }[]
}>('/api/v1/worker/consignment/price-suggestion', params)
}
+18
View File
@@ -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 · 规格口径:互通通配 + 后台全量/发布端分物品展示(本轮)
**业务定案**: