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
@@ -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',
)