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:
@@ -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) {
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import test from 'node:test'
|
||||
|
||||
function readSource(relativePath: string) {
|
||||
return readFileSync(new URL(relativePath, import.meta.url).pathname, 'utf8')
|
||||
return readFileSync(fileURLToPath(new URL(relativePath, import.meta.url)), 'utf8')
|
||||
}
|
||||
|
||||
test('工单跳到售后:先记录待打开工单,等面板挂载后再弹窗(避免点两次)', () => {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import test from 'node:test'
|
||||
|
||||
test('寄售仓库必须提供通过审核入库时间范围筛选,并透传到库存查询', () => {
|
||||
// Windows 检出为 CRLF,多行断言里的 \n 会匹配失败,先统一换行
|
||||
const source = readFileSync(
|
||||
new URL('../src/pages/admin/panels/ConsignmentInventoryPanel.tsx', import.meta.url).pathname,
|
||||
fileURLToPath(new URL('../src/pages/admin/panels/ConsignmentInventoryPanel.tsx', import.meta.url)),
|
||||
'utf8',
|
||||
).replace(/\r\n/g, '\n')
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import test from 'node:test'
|
||||
|
||||
/**
|
||||
@@ -24,21 +25,21 @@ function assertTopLevelMount(filePath: string, componentName: string) {
|
||||
|
||||
test('库存面板:手动匹配订单弹窗挂在顶层(按钮点了必须能弹出来)', () => {
|
||||
assertTopLevelMount(
|
||||
new URL('../src/pages/admin/panels/ConsignmentInventoryPanel.tsx', import.meta.url).pathname,
|
||||
fileURLToPath(new URL('../src/pages/admin/panels/ConsignmentInventoryPanel.tsx', import.meta.url)),
|
||||
'ConsignmentManualMatchModal',
|
||||
)
|
||||
})
|
||||
|
||||
test('订单面板:手动指定现货弹窗同样挂在顶层', () => {
|
||||
assertTopLevelMount(
|
||||
new URL('../src/pages/admin/panels/WorkOrdersPanel.tsx', import.meta.url).pathname,
|
||||
fileURLToPath(new URL('../src/pages/admin/panels/WorkOrdersPanel.tsx', import.meta.url)),
|
||||
'ConsignmentManualMatchModal',
|
||||
)
|
||||
})
|
||||
|
||||
test('按钮点击要真的把状态设上,弹窗的 open 由该状态驱动', () => {
|
||||
const source = readFileSync(
|
||||
new URL('../src/pages/admin/panels/ConsignmentInventoryPanel.tsx', import.meta.url).pathname,
|
||||
fileURLToPath(new URL('../src/pages/admin/panels/ConsignmentInventoryPanel.tsx', import.meta.url)),
|
||||
'utf8',
|
||||
)
|
||||
assert.ok(source.includes('setManualMatchItem(record)'), '行内按钮必须设置待匹配的现货')
|
||||
@@ -47,7 +48,7 @@ test('按钮点击要真的把状态设上,弹窗的 open 由该状态驱动',
|
||||
|
||||
test('手动匹配入口必须按 consignment.fulfill 门控(提交的是履约动作,不能只挂 manage)', () => {
|
||||
const inventoryPanel = readFileSync(
|
||||
new URL('../src/pages/admin/panels/ConsignmentInventoryPanel.tsx', import.meta.url).pathname,
|
||||
fileURLToPath(new URL('../src/pages/admin/panels/ConsignmentInventoryPanel.tsx', import.meta.url)),
|
||||
'utf8',
|
||||
)
|
||||
// 库存面板:手动匹配订单按钮要与编辑/作废拆开 —— 前者 fulfill(提交打到
|
||||
@@ -64,7 +65,7 @@ test('手动匹配入口必须按 consignment.fulfill 门控(提交的是履
|
||||
)
|
||||
|
||||
const ordersPanel = readFileSync(
|
||||
new URL('../src/pages/admin/panels/WorkOrdersPanel.tsx', import.meta.url).pathname,
|
||||
fileURLToPath(new URL('../src/pages/admin/panels/WorkOrdersPanel.tsx', import.meta.url)),
|
||||
'utf8',
|
||||
)
|
||||
// 拦截弹窗 footer 的入口同样要门控,无 fulfill 权限的人不该看到能直达 403 的按钮
|
||||
@@ -80,7 +81,7 @@ test('手动匹配入口必须按 consignment.fulfill 门控(提交的是履
|
||||
|
||||
test('手动匹配弹窗:数据过期的 409 要清选择并刷新,走内部现货直接履约', () => {
|
||||
const source = readFileSync(
|
||||
new URL('../src/components/admin/ConsignmentManualMatchModal.tsx', import.meta.url).pathname,
|
||||
fileURLToPath(new URL('../src/components/admin/ConsignmentManualMatchModal.tsx', import.meta.url)),
|
||||
'utf8',
|
||||
)
|
||||
// 并发占用/规格在打开弹窗后被改 → 清空选择并重新拉列表,不能让运营循环撞同一个错
|
||||
@@ -103,7 +104,7 @@ test('手动匹配弹窗:数据过期的 409 要清选择并刷新,走内部
|
||||
|
||||
test('发布拦截弹窗:只走内部现货履约,文案停在待加好友而不是冷静期', () => {
|
||||
const source = readFileSync(
|
||||
new URL('../src/pages/admin/panels/WorkOrdersPanel.tsx', import.meta.url).pathname,
|
||||
fileURLToPath(new URL('../src/pages/admin/panels/WorkOrdersPanel.tsx', import.meta.url)),
|
||||
'utf8',
|
||||
)
|
||||
assert.ok(source.includes('fulfillWorkOrderWithConsignment'), '拦截弹窗必须调用内部现货履约')
|
||||
@@ -119,7 +120,7 @@ test('发布拦截弹窗:只走内部现货履约,文案停在待加好友
|
||||
|
||||
test('寄售仓库库存面板:必须包含「通过审核入库时间」列,且详情弹窗正确展示时间', () => {
|
||||
const source = readFileSync(
|
||||
new URL('../src/pages/admin/panels/ConsignmentInventoryPanel.tsx', import.meta.url).pathname,
|
||||
fileURLToPath(new URL('../src/pages/admin/panels/ConsignmentInventoryPanel.tsx', import.meta.url)),
|
||||
'utf8',
|
||||
)
|
||||
assert.ok(source.includes("title: '通过审核入库时间'"), '库存表格必须有「通过审核入库时间」列')
|
||||
@@ -136,7 +137,7 @@ test('寄售仓库库存面板:必须包含「通过审核入库时间」列
|
||||
|
||||
test('手动匹配弹窗:候选订单必须展示创建时间,现货展示审核入库时间', () => {
|
||||
const source = readFileSync(
|
||||
new URL('../src/components/admin/ConsignmentManualMatchModal.tsx', import.meta.url).pathname,
|
||||
fileURLToPath(new URL('../src/components/admin/ConsignmentManualMatchModal.tsx', import.meta.url)),
|
||||
'utf8',
|
||||
)
|
||||
assert.ok(
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import test from 'node:test'
|
||||
|
||||
function read(relativePath: string) {
|
||||
return readFileSync(new URL(relativePath, import.meta.url).pathname, 'utf8')
|
||||
return readFileSync(fileURLToPath(new URL(relativePath, import.meta.url)), 'utf8')
|
||||
}
|
||||
|
||||
test('管理端:订单服务之后必须存在寄卖配置 tab', () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import test from 'node:test'
|
||||
|
||||
/**
|
||||
@@ -13,16 +14,16 @@ import test from 'node:test'
|
||||
|
||||
test('后台全量选项:系统含通用,区服含互通', () => {
|
||||
const source = readFileSync(
|
||||
new URL('../src/utils/consignment-spec.ts', import.meta.url).pathname,
|
||||
fileURLToPath(new URL('../src/utils/consignment-spec.ts', import.meta.url)),
|
||||
'utf8',
|
||||
)
|
||||
assert.ok(source.includes("{ label: '通用', value: '通用' }"), '系统全量选项必须含「通用」')
|
||||
assert.ok(source.includes("value: '互通'"), '区服选项必须包含「互通」')
|
||||
})
|
||||
|
||||
test('苏尔南默认配置:取消代码内置字段,由合并迁移 142 提供五个单选字段', () => {
|
||||
test('苏尔南默认配置:取消代码内置字段,由合并迁移 147 提供五个单选字段', () => {
|
||||
const utils = readFileSync(
|
||||
new URL('../src/utils/consignment-spec.ts', import.meta.url).pathname,
|
||||
fileURLToPath(new URL('../src/utils/consignment-spec.ts', import.meta.url)),
|
||||
'utf8',
|
||||
)
|
||||
assert.ok(!utils.includes('SURNAN_PUBLISH_SYSTEM_OPTIONS'), '前端不再写死苏尔南发布选项')
|
||||
@@ -32,7 +33,7 @@ test('苏尔南默认配置:取消代码内置字段,由合并迁移 142 提
|
||||
|
||||
test('打手端发布表单:按寄卖配置动态渲规格,不再回退内置字段', () => {
|
||||
const source = readFileSync(
|
||||
new URL('../src/pages/worker/WorkerConsignmentPage.tsx', import.meta.url).pathname,
|
||||
fileURLToPath(new URL('../src/pages/worker/WorkerConsignmentPage.tsx', import.meta.url)),
|
||||
'utf8',
|
||||
)
|
||||
assert.ok(source.includes('selectedSpecs.map'), '规格字段必须来自配置动态渲染')
|
||||
@@ -46,7 +47,7 @@ test('打手端发布表单:按寄卖配置动态渲规格,不再回退内
|
||||
test('客服端两个表单(提报审核 / 库存编辑)用后台全量选项,不再自由填写', () => {
|
||||
for (const file of ['ConsignmentSubmissionsPanel.tsx', 'ConsignmentInventoryPanel.tsx']) {
|
||||
const source = readFileSync(
|
||||
new URL(`../src/pages/admin/panels/${file}`, import.meta.url).pathname,
|
||||
fileURLToPath(new URL(`../src/pages/admin/panels/${file}`, import.meta.url)),
|
||||
'utf8',
|
||||
)
|
||||
assert.ok(source.includes('CONSIGNMENT_SYSTEM_OPTIONS'), `${file} 应引用全量系统选项`)
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import test from 'node:test'
|
||||
|
||||
function readPanelSource() {
|
||||
return readFileSync(
|
||||
fileURLToPath(new URL('../src/pages/admin/panels/ConsignmentConfigPanel.tsx', import.meta.url)),
|
||||
'utf8',
|
||||
).replace(/\r\n/g, '\n')
|
||||
}
|
||||
|
||||
test('从接单模板生成:factor 模式必须显式推荐「规格字段(多字段矩阵)」', () => {
|
||||
const source = readPanelSource()
|
||||
assert.ok(
|
||||
source.includes('推荐使用:规格字段(多字段矩阵)'),
|
||||
'factor 模式必须给出推荐文案,运营才知道该用多字段矩阵',
|
||||
)
|
||||
assert.ok(
|
||||
source.includes("proposal.mode === 'factor' ? ("),
|
||||
'推荐文案必须按 proposal.mode 分支渲染',
|
||||
)
|
||||
})
|
||||
|
||||
test('从接单模板生成:product 模式必须显式推荐「商品名下拉」并解释原因', () => {
|
||||
const source = readPanelSource()
|
||||
assert.ok(source.includes('推荐使用:商品名下拉'), '降级为商品下拉时必须说明推荐该模式')
|
||||
assert.ok(source.includes('拆不成同一组维度'), '必须解释为什么不能拆成多字段矩阵')
|
||||
assert.ok(
|
||||
source.includes('无法纳入统一维度的模板(前 5 条)'),
|
||||
'必须列出拆不动的模板示例,便于运营判断是否改名',
|
||||
)
|
||||
})
|
||||
|
||||
test('从接单模板生成:无模板时必须给配置引导,而不是只显示一句报错', () => {
|
||||
const source = readPanelSource()
|
||||
assert.ok(
|
||||
source.includes('请先在「接单模板」里为该分类配置至少 1 条启用模板'),
|
||||
'空状态必须引导去接单模板配置',
|
||||
)
|
||||
assert.ok(
|
||||
source.includes('该分类的启用模板都没有报酬或单价'),
|
||||
'有模板但无价时必须区分提示,避免误以为没有模板',
|
||||
)
|
||||
assert.ok(
|
||||
source.includes('推荐规则:商品名能拆成同一组维度时'),
|
||||
'空状态也要说明两种模式的推荐规则',
|
||||
)
|
||||
assert.ok(
|
||||
!source.includes('该分类没有启用且有价的接单模板,无法生成规格字段'),
|
||||
'旧的死板提示必须被 Empty 引导替换',
|
||||
)
|
||||
})
|
||||
|
||||
test('从接单模板生成:应用按钮按「有价组合数」禁用,空提案不能被应用', () => {
|
||||
const source = readPanelSource()
|
||||
assert.ok(
|
||||
source.includes('templateProposalQuery.data.data.matrix.length === 0'),
|
||||
'应用按钮必须在没有组合价时禁用',
|
||||
)
|
||||
assert.ok(
|
||||
source.includes('proposal.matrix.length === 0 || proposal.fields.length === 0'),
|
||||
'应用动作本身也要兜底拦截空提案',
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import test from 'node:test'
|
||||
|
||||
function read(relativePath: string) {
|
||||
return readFileSync(fileURLToPath(new URL(relativePath, import.meta.url)), 'utf8').replace(
|
||||
/\r\n/g,
|
||||
'\n',
|
||||
)
|
||||
}
|
||||
|
||||
const page = read('../src/pages/worker/WorkerConsignmentPage.tsx')
|
||||
const css = read('../src/styles/worker.css')
|
||||
|
||||
test('模板驱动商品字段:选项超过 8 个必须改用带搜索的下拉,不能用按钮组', () => {
|
||||
assert.ok(
|
||||
page.includes('options.length > 8'),
|
||||
'必须按选项数量降级:地铁逃生 314 个商品全塞进 Radio.Group 会把页面横向撑爆',
|
||||
)
|
||||
assert.ok(page.includes('optionFilterProp="label"'), '几百个商品名必须支持按名称搜索')
|
||||
assert.ok(
|
||||
page.includes('popupMatchSelectWidth={false}'),
|
||||
'下拉宽度要跟随内容,避免长商品名被固定窄列截断',
|
||||
)
|
||||
assert.ok(
|
||||
page.includes("whiteSpace: 'normal'"),
|
||||
'选项文本要允许换行,长商品名不能被 nowrap 撑出弹层',
|
||||
)
|
||||
})
|
||||
|
||||
test('少量选项仍保留按钮组,内置五字段体验不变', () => {
|
||||
assert.ok(
|
||||
page.includes('<Radio.Group') && page.includes('worker-consignment-chips'),
|
||||
'系统/区服/系列/钻级/璀璨仍用按钮组',
|
||||
)
|
||||
assert.ok(
|
||||
page.indexOf('options.length > 8') < page.indexOf('<Radio.Group'),
|
||||
'数量判断必须在按钮组之前生效',
|
||||
)
|
||||
})
|
||||
|
||||
test('规格网格按字段数自适应:单字段铺满、两字段两列', () => {
|
||||
assert.ok(page.includes("' is-single'"), '单字段必须走 is-single')
|
||||
assert.ok(page.includes("' is-duo'"), '两字段必须走 is-duo')
|
||||
assert.ok(
|
||||
css.includes('.worker-consignment-spec-grid.is-single') &&
|
||||
css.includes('grid-template-columns: minmax(0, 1fr);'),
|
||||
'单字段必须铺满整行,否则下拉只剩 168px',
|
||||
)
|
||||
assert.ok(
|
||||
css.includes('.worker-consignment-spec-grid.is-duo') &&
|
||||
css.includes('grid-template-columns: repeat(2, minmax(0, 1fr));'),
|
||||
'两字段必须两列等宽',
|
||||
)
|
||||
})
|
||||
|
||||
test('按钮组兜底换行,避免异常配置再次把页面撑爆', () => {
|
||||
const chipsBlock = css.slice(
|
||||
css.indexOf('.worker-consignment-chips {'),
|
||||
css.indexOf('.worker-consignment-chips {') + 160,
|
||||
)
|
||||
assert.ok(chipsBlock.includes('flex-wrap: wrap'), '按钮组必须允许换行兜底')
|
||||
})
|
||||
@@ -1,14 +1,15 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import test from 'node:test'
|
||||
|
||||
test('快手电子凭证页提供受控的历史券补发并核销入口', () => {
|
||||
const page = readFileSync(
|
||||
new URL('../src/pages/admin/AdminKuaishouIndustryPage.tsx', import.meta.url).pathname,
|
||||
fileURLToPath(new URL('../src/pages/admin/AdminKuaishouIndustryPage.tsx', import.meta.url)),
|
||||
'utf8',
|
||||
)
|
||||
const service = readFileSync(
|
||||
new URL('../src/services/admin/kuaishou-industry.ts', import.meta.url).pathname,
|
||||
fileURLToPath(new URL('../src/services/admin/kuaishou-industry.ts', import.meta.url)),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
@@ -30,7 +31,7 @@ test('快手电子凭证页提供受控的历史券补发并核销入口', () =>
|
||||
|
||||
test('历史券补发并核销结果必须展示发码、核销两个阶段', () => {
|
||||
const page = readFileSync(
|
||||
new URL('../src/pages/admin/AdminKuaishouIndustryPage.tsx', import.meta.url).pathname,
|
||||
fileURLToPath(new URL('../src/pages/admin/AdminKuaishouIndustryPage.tsx', import.meta.url)),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import test from 'node:test'
|
||||
|
||||
test('自动核销最近任务展示并搜索核销码', () => {
|
||||
const page = readFileSync(
|
||||
new URL('../src/pages/admin/panels/VoucherConsumePolicyPanel.tsx', import.meta.url).pathname,
|
||||
fileURLToPath(new URL('../src/pages/admin/panels/VoucherConsumePolicyPanel.tsx', import.meta.url)),
|
||||
'utf8',
|
||||
)
|
||||
const service = readFileSync(
|
||||
new URL('../src/services/admin/fulfillment-channel.ts', import.meta.url).pathname,
|
||||
fileURLToPath(new URL('../src/services/admin/fulfillment-channel.ts', import.meta.url)),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user