feat(consignment): 接单模板反推规格字段,打手端大批量选项改下拉

- 新增 consignment-template-spec-service:按「分类 + 子树」解析接单模板商品名,
  全部可拆成统一维度且无组合碰撞时生成多字段矩阵,否则整类降级为商品名下拉;
  选项只存 ruleKey,取价时实时回查模板,模板停用/删除即无价(missingPricePolicy=block)。
- 匹配引擎:模板驱动配置强制使用 profile specs(不再回退库存旧快照),订单侧按
  product_rule_id 反解规格;解析失败按 unknown 处理,不参与自动匹配。
- 管理端:寄卖配置新增「从接单模板生成」,弹窗给出推荐模式(规格字段 / 商品名下拉)、
  降级原因与组合→模板价覆盖矩阵。
- 打手端:选项超过 8 个改用带搜索的下拉(地铁逃生 100 个商品),规格网格按字段数
  自适应铺满;物品名改用商品名,去掉硬编码「苏尔南」后缀。
- 迁移重编号:合并 main 后寄卖线顺延为 147–151,migrate.ts 保留 legacy→new 幂等映射。
- 同工作区的拼单份额报酬口径、超时重匹配兜底、整单/拼单匹配弹窗调整一并提交。

验证:前端 254/254、后端寄卖相关 41/41、双端 tsc 通过;开发库已实跑
发布→审核入库→自动匹配(模板价 1200 → 匹配报酬 1200)。
This commit is contained in:
2026-11-07 12:26:10 +08:00
parent 50ee3553c5
commit 787b8e2771
38 changed files with 2242 additions and 197 deletions
@@ -36,7 +36,11 @@ export default function ConsignmentWorkOrderMatchModal({
})
const data = candidatesQuery.data?.data
const options = data?.options || []
const needed = Math.max(1, Number(data?.totalQuantity || 1) - Number(data?.activeQuantity || 0))
const totalQuantity = Math.max(1, Number(data?.totalQuantity || 1))
// 整单匹配只需一条库存(由该库存一次占满订单总数量);拼单才按剩余份数选多条。
const needed = data?.sharingEnabled
? Math.max(1, totalQuantity - Number(data?.activeQuantity || 0))
: 1
useEffect(() => {
if (!open) {
@@ -46,9 +50,15 @@ export default function ConsignmentWorkOrderMatchModal({
}
if (options.length === 0) return
// 默认只选精确匹配;非精确库存必须人工勾选并填写原因。
const exactOptions = options.filter((item) => (item.matchType || 'exact') === 'exact')
// 整单还要保证一条库存能一次满足订单总数量,否则默认选中也会被后端拒绝。
const exactOptions = options.filter(
(item) =>
(item.matchType || 'exact') === 'exact' &&
(!data?.sharingEnabled || item.remainingQuantity >= 1) &&
(data?.sharingEnabled || item.remainingQuantity >= totalQuantity),
)
setSelectedIds(exactOptions.slice(0, needed).map((item) => item.inventoryId))
}, [open, options, needed])
}, [open, options, needed, data?.sharingEnabled, totalQuantity])
const nonExactSelected = useMemo(
() =>
@@ -120,8 +130,12 @@ export default function ConsignmentWorkOrderMatchModal({
<Alert
type="info"
showIcon
message={`该工单${data?.sharingEnabled ? `拼单 ${data.totalQuantity} 份,已匹配 ${data.activeQuantity} 份` : '为整单'},本次还需 ${needed} 条库存。`}
description="自动匹配只做精确规格;手动匹配可选多条,拼单工单每选一条生成一个份额。选择非精确库存必须填写原因。"
message={
data?.sharingEnabled
? `该工单拼单 ${totalQuantity} 份,已匹配 ${data.activeQuantity} 份,本次还需 ${needed} 份库存。`
: `该工单为整单匹配,请选择 1 条可一次满足 ${totalQuantity} 件的库存。`
}
description="自动匹配只做精确规格;拼单工单可多选,每条库存按剩余数量生成份额。选择非精确库存必须填写原因。"
/>
<PercentTable<ConsignmentMatchCandidate>
rowKey="inventoryId"
@@ -129,6 +143,8 @@ export default function ConsignmentWorkOrderMatchModal({
pagination={false}
dataSource={options}
rowSelection={{
// 整单只能选一条;拼单可多选,每条库存按剩余数量生成份额。
type: data?.sharingEnabled ? 'checkbox' : 'radio',
selectedRowKeys: selectedIds,
onChange: (keys) => setSelectedIds(keys.map((key) => Number(key))),
}}
@@ -10,6 +10,7 @@ import {
SearchOutlined,
SendOutlined,
StopOutlined,
ThunderboltOutlined,
} from '@ant-design/icons'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
@@ -21,6 +22,7 @@ import {
Form,
Input,
InputNumber,
Modal,
Radio,
Select,
Space,
@@ -31,11 +33,14 @@ import {
} from 'antd'
import { useEffect, useMemo, useState, type CSSProperties } from 'react'
import PercentTable from '@/components/common/PercentTable'
import {
archiveAdminConsignmentProfile,
fetchAdminConsignmentProfiles,
fetchAdminConsignmentTemplateSpecProposal,
publishAdminConsignmentProfile,
saveAdminConsignmentProfile,
type ConsignmentTemplateSpecProposal,
} from '@/services/admin/consignment'
import type {
AdminConsignmentProfilesResponse,
@@ -240,6 +245,7 @@ function buildPayload(
values: ConfigFormValues,
specs: ConsignmentSpecField[],
intakeFields: ConsignmentIntakeField[],
specSource: 'manual' | 'template',
) {
const hasSpec = (key: string) => specs.some((spec) => spec.key === key)
return {
@@ -261,6 +267,7 @@ function buildPayload(
quantity: values.fields.quantity,
},
specs,
specSource,
intake: { fields: intakeFields },
nameTemplate: values.nameTemplate,
pricing: {
@@ -384,6 +391,156 @@ const gridStyle: CSSProperties = {
gap: 16,
}
/** 模板提案预览:推荐模式 + 字段建议 + 组合→价格覆盖矩阵。 */
function TemplateProposalPreview({ proposal }: { proposal: ConsignmentTemplateSpecProposal }) {
const dimensionKeys = useMemo(
() => (proposal.fields || []).map((field) => field.key),
[proposal.fields],
)
const pricedCount = proposal.matrix.length
const uncovered = proposal.uncovered || []
const collisions = proposal.collisions || []
if (pricedCount === 0) {
return (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={
proposal.ruleCount === 0
? '该分类还没有启用且有价的接单模板'
: '该分类的启用模板都没有报酬或单价'
}
>
<div style={{ display: 'grid', gap: 6, maxWidth: 640 }}>
{proposal.ruleCount === 0 ? (
<>
<Text type="secondary">
请先在「接单模板」里为该分类配置至少 1 条启用模板,并把「报酬」或「单价」填为大于
0。
</Text>
<Text type="secondary">
配置后的推荐规则:商品名能拆成同一组维度时(例如「安卓暗潮3钻带2璀璨」→
系统×钻级×璀璨),推荐「规格字段」多字段矩阵;商品名格式不统一时(例如一条模板覆盖多个系列或钻级),推荐「商品名下拉」,每个商品直接绑定一条模板价。
</Text>
</>
) : (
<>
<Text type="secondary">
当前有 {proposal.ruleCount} 条启用模板,但「报酬」和「单价」都是 0,无法生成组合价。
</Text>
<Text type="secondary">
请回到「接单模板」补上报酬或单价后再点生成;金额大于 0 才会进入价格矩阵。
</Text>
</>
)}
</div>
</Empty>
)
}
return (
<div style={{ display: 'grid', gap: 12 }}>
<Space size={8} wrap>
<Tag color={proposal.mode === 'factor' ? 'green' : 'gold'}>
{proposal.mode === 'factor' ? '因子字段模式' : '商品名下拉模式'}
</Tag>
<Text type="secondary">
接单模板 {proposal.ruleCount} 条 · 参与定价 {pricedCount} 条
</Text>
</Space>
{proposal.mode === 'factor' ? (
<Alert
type="success"
showIcon
message="推荐使用:规格字段(多字段矩阵)"
description={`${proposal.fields.length} 个维度(${proposal.fields
.map((field) => field.label)
.join(
' × ',
)})共 ${pricedCount} 个组合,每个组合都能对应到一条模板价;保存后按选项实时回查模板价。`}
/>
) : (
<Alert
type="warning"
showIcon
message="推荐使用:商品名下拉"
description={
collisions.length > 0
? `有 ${collisions.length} 组因子组合对应多条模板(价格会互相覆盖),无法组成唯一矩阵,因此改用商品名下拉:每个商品直接绑定一条模板价。`
: `该分类有 ${pricedCount} 条模板的商品名不是统一格式,拆不成同一组维度,因此改用商品名下拉:每个商品直接绑定一条模板价,避免错配。`
}
/>
)}
{proposal.mode === 'product' && uncovered.length > 0 ? (
<div>
<Text strong>无法纳入统一维度的模板(前 5 条)</Text>
<div style={{ marginTop: 4, display: 'grid', gap: 2 }}>
{uncovered.slice(0, 5).map((item) => (
<Text key={item.ruleKey} type="secondary">
· {item.productName}
</Text>
))}
{uncovered.length > 5 ? <Text type="secondary">…共 {uncovered.length} 条</Text> : null}
</div>
</div>
) : null}
<div>
<Text strong>字段建议</Text>
<div style={{ marginTop: 6, display: 'grid', gap: 4 }}>
{(proposal.fields || []).map((field) => (
<Text key={field.key} type="secondary">
{field.label}({field.key}):
{field.options.map((option) => option.label).join(' / ')}
</Text>
))}
</div>
</div>
<div>
<Text strong>组合 → 模板价(实时读取,保存后仍随模板调价自动更新)</Text>
<PercentTable
size="small"
style={{ marginTop: 6 }}
rowKey={(row) => row.ruleKey}
dataSource={proposal.matrix}
pagination={{ pageSize: 8 }}
columns={[
...dimensionKeys.map((key) => ({
title: proposal.fields.find((field) => field.key === key)?.label || key,
dataIndex: ['values', key],
key,
width: 110,
})),
{
title: '模板商品名',
dataIndex: 'productName',
key: 'productName',
ellipsis: true,
},
{
title: '模板价',
dataIndex: 'priceFen',
key: 'priceFen',
width: 100,
render: (value: number) => `¥${(Number(value || 0) / 100).toFixed(2)}`,
},
]}
/>
</div>
{collisions.length > 0 ? (
<Alert
type="warning"
showIcon
message={`有 ${collisions.length} 组因子组合对应多条模板(价格可能不一致),已自动退化为商品名下拉。`}
description={collisions
.slice(0, 3)
.map((item) => `· ${item.productNames.join(' / ')}`)
.join(';')}
/>
) : null}
</div>
)
}
const fieldCellStyle: CSSProperties = {
display: 'grid',
gap: 4,
@@ -399,6 +556,8 @@ export default function ConsignmentConfigPanel() {
const [saving, setSaving] = useState(false)
const [specDrafts, setSpecDrafts] = useState<ConsignmentSpecField[]>([])
const [intakeDrafts, setIntakeDrafts] = useState<ConsignmentIntakeField[]>([])
const [specSource, setSpecSource] = useState<'manual' | 'template'>('manual')
const [proposalOpen, setProposalOpen] = useState(false)
const [form] = Form.useForm<ConfigFormValues>()
const profilesQuery = useQuery({
@@ -476,6 +635,7 @@ export default function ConsignmentConfigPanel() {
if (selectedProfile) {
form.setFieldsValue(toFormValues(selectedProfile))
setSpecDrafts(cloneSpecs(selectedProfile.config.specs))
setSpecSource(selectedProfile.config.specSource === 'template' ? 'template' : 'manual')
setIntakeDrafts(
cloneIntakeFields(
selectedProfile.config.intake?.fields ?? DEFAULT_CONSIGNMENT_INTAKE_FIELDS,
@@ -488,10 +648,53 @@ export default function ConsignmentConfigPanel() {
tabTitle: selectedCategory?.name || '',
})
setSpecDrafts([])
setSpecSource('manual')
setIntakeDrafts(cloneIntakeFields(DEFAULT_CONSIGNMENT_INTAKE_FIELDS))
}
}, [selectedCategoryId, selectedProfile, selectedCategory?.name, form])
const templateProposalQuery = useQuery({
queryKey: ['admin-consignment-template-spec-proposal', selectedCategoryId, proposalOpen],
queryFn: () => fetchAdminConsignmentTemplateSpecProposal(selectedCategoryId || 0),
enabled: proposalOpen && Boolean(selectedCategoryId),
})
/** 应用模板提案:写入规格草稿并切到 template 来源;用户仍可手工微调。 */
function applyTemplateProposal() {
const proposal = templateProposalQuery.data?.data
if (!proposal || proposal.matrix.length === 0 || proposal.fields.length === 0) {
message.error('没有可用的模板规格建议(该分类可能没有启用且有价的接单模板)')
return
}
const specs: ConsignmentSpecField[] = proposal.fields.map((field, index) => ({
key: field.key,
label: field.label,
type: 'select' as const,
required: true,
options: field.options.map((option) => ({
label: option.label,
value: option.value,
enabled: true,
ruleKey: option.ruleKey,
})),
placeholder: '',
helpText: '',
matchMode: 'exact' as const,
wildcardValues: [],
showInItemName: proposal.mode !== 'product',
filterable: true,
sortOrder: (index + 1) * 10,
}))
setSpecDrafts(specs)
setSpecSource('template')
setProposalOpen(false)
message.success(
proposal.mode === 'factor'
? `已按接单模板生成 ${proposal.fields.length} 个规格字段(${proposal.matrix.length} 个组合全部有模板价)`
: `该分类模板无法拆成统一维度,已生成「商品」下拉(${proposal.matrix.length} 个商品全部有模板价)`,
)
}
async function refresh() {
await queryClient.invalidateQueries({ queryKey: ['admin-consignment-profiles'] })
}
@@ -561,7 +764,7 @@ export default function ConsignmentConfigPanel() {
setSaving(true)
try {
await saveAdminConsignmentProfile(
buildPayload(selectedCategoryId, values, specs, intakeDrafts),
buildPayload(selectedCategoryId, values, specs, intakeDrafts, specSource),
)
message.success('寄卖配置已保存')
await refresh()
@@ -955,19 +1158,32 @@ export default function ConsignmentConfigPanel() {
title="规格字段"
description="初始为空;字段随配置版本发布,并写入提报与库存快照"
/>
<Button
type="primary"
ghost
size="small"
icon={<PlusOutlined />}
onClick={addCustomSpec}
>
添加字段
</Button>
<Space size={8}>
<Button
size="small"
icon={<ThunderboltOutlined />}
loading={
templateProposalQuery.isFetching && proposalOpen ? true : undefined
}
onClick={() => setProposalOpen(true)}
>
从接单模板生成
</Button>
<Button
type="primary"
ghost
size="small"
icon={<PlusOutlined />}
onClick={addCustomSpec}
>
添加字段
</Button>
</Space>
</div>
<Text type="secondary">
Key 为 system/serverZone/series/diamondLevel/brillianceCount
时会自动同步旧数据列并沿用旧匹配器;下拉/单选的选项用逗号分隔填写。
{specSource === 'template'
? '规格来源:接单模板(每个组合实时回查本分类模板价,模板停用即无价);生成后仍可手工微调。'
: 'Key 为 system/serverZone/series/diamondLevel/brillianceCount 时会自动同步旧数据列并沿用旧匹配器;下拉/单选的选项用逗号分隔填写。'}
</Text>
{specDrafts.length === 0 ? (
<Empty
@@ -1550,14 +1766,6 @@ export default function ConsignmentConfigPanel() {
addonAfter="分钟"
/>
</Form.Item>
<Form.Item
label="超时发回大厅"
name={['internalMatch', 'fallbackToHall']}
valuePropName="checked"
style={{ marginBottom: 0 }}
>
<Switch checkedChildren="是" unCheckedChildren="否" />
</Form.Item>
</>
) : null}
</div>
@@ -1696,6 +1904,36 @@ export default function ConsignmentConfigPanel() {
</Space>
</div>
</Form>
<Modal
title="从接单模板生成规格字段"
open={proposalOpen}
width={760}
okText="应用到规格字段"
okButtonProps={{
disabled:
!templateProposalQuery.data?.data ||
templateProposalQuery.data.data.matrix.length === 0,
}}
confirmLoading={templateProposalQuery.isFetching}
onOk={applyTemplateProposal}
onCancel={() => setProposalOpen(false)}
>
{templateProposalQuery.isLoading ? (
<Text type="secondary">正在解析该分类的接单模板…</Text>
) : templateProposalQuery.data?.data ? (
<TemplateProposalPreview proposal={templateProposalQuery.data.data} />
) : templateProposalQuery.isError ? (
<Text type="danger">
读取模板建议失败:
{templateProposalQuery.error instanceof Error
? templateProposalQuery.error.message
: '请稍后重试'}
</Text>
) : (
<Text type="secondary">暂无可用的模板建议,请稍后重试。</Text>
)}
</Modal>
</>
) : (
<Empty description="请选择分类" />
@@ -745,7 +745,11 @@ export default function WorkerConsignmentPage() {
</Divider>
<div className="worker-consignment-specs">
<div className="worker-consignment-spec-grid">
<div
className={`worker-consignment-spec-grid${
selectedSpecs.length === 1 ? ' is-single' : selectedSpecs.length === 2 ? ' is-duo' : ''
}`}
>
{selectedSpecs.map((field) => {
const isCountField = field.key === 'diamondLevel' || field.key === 'brillianceCount'
const options = field.options
@@ -804,12 +808,32 @@ export default function WorkerConsignmentPage() {
]}
>
{field.type === 'select' || field.type === 'radio' ? (
<Radio.Group
className={`worker-consignment-chips${isCountField ? ' is-count' : ''}`}
optionType="button"
buttonStyle="solid"
options={options}
/>
options.length > 8 ? (
// 模板驱动「商品」字段可能有几百个选项:按钮组会横向撑爆页面,
// 超过 8 个一律改用带搜索的下拉(AntD 默认虚拟滚动)。
<Select
options={options}
showSearch
allowClear={!field.required}
optionFilterProp="label"
placeholder={field.placeholder || `请选择${field.label}`}
style={{ width: '100%' }}
popupMatchSelectWidth={false}
styles={{ popup: { root: { maxWidth: 520 } } }}
optionRender={(option) => (
<div style={{ whiteSpace: 'normal', lineHeight: 1.45 }}>
{String(option.label ?? '')}
</div>
)}
/>
) : (
<Radio.Group
className={`worker-consignment-chips${isCountField ? ' is-count' : ''}`}
optionType="button"
buttonStyle="solid"
options={options}
/>
)
) : field.type === 'number' ? (
<InputNumber
min={0}
@@ -54,6 +54,33 @@ export function archiveAdminConsignmentProfile(profileId: number | string) {
)
}
/** 模板驱动规格提案:字段建议 + 组合→价格覆盖矩阵(运营确认后再保存为规格) */
export type ConsignmentTemplateSpecProposal = {
mode: 'factor' | 'product'
ruleCount: number
coveredCount: number
uncovered: { productName: string; ruleKey: string }[]
collisions: { values: Record<string, string>; productNames: string[] }[]
fields: {
key: string
label: string
options: { label: string; value: string; ruleKey: string; productName: string }[]
}[]
matrix: {
values: Record<string, string>
productName: string
ruleKey: string
priceFen: number
}[]
}
export function fetchAdminConsignmentTemplateSpecProposal(categoryId: number | string) {
return apiGet<ConsignmentTemplateSpecProposal>(
'/api/v1/admin/worker-platform/consignment/template-spec-proposal',
{ categoryId },
)
}
// --- 内部匹配(工单侧手动匹配) ---
export function fetchAdminConsignmentMatchCandidates(workOrderId: number | string) {
+10
View File
@@ -4504,6 +4504,15 @@ body {
gap: 12px;
}
/* 模板驱动分类可能只有 1–2 个规格字段:按字段数铺满,避免下拉被挤在 168px 的窄列里 */
.worker-consignment-spec-grid.is-single {
grid-template-columns: minmax(0, 1fr);
}
.worker-consignment-spec-grid.is-duo {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
/* 寄售价格 / 寄售数量 / 履约押金始终一行三列等宽 */
.worker-consignment-price-grid {
display: grid;
@@ -4542,6 +4551,7 @@ body {
.worker-consignment-chips {
display: flex !important;
flex-wrap: wrap;
width: 100%;
}
+4
View File
@@ -35,6 +35,8 @@ export type ConsignmentSpecOption = {
label: string
value: string
enabled: boolean
/** 模板驱动规格:选项绑定的接单模板规则键;取价时实时回查模板价格 */
ruleKey?: string
}
export type ConsignmentSpecFieldType = 'text' | 'textarea' | 'select' | 'radio' | 'number' | 'date'
@@ -387,6 +389,8 @@ export type ConsignmentProfileConfig = {
}
/** 动态规格字段(完全由运营配置,默认空) */
specs: ConsignmentSpecField[]
/** 规格来源:manual = 手工维护(默认);template = 从接单模板反推生成 */
specSource: 'manual' | 'template'
/** 物品名模板,支持 {system}/{series}/{category} 等占位符 */
nameTemplate: string
pricing: {