feat(consignment): 拆分自动/手动匹配口径并优化内部匹配列表

This commit is contained in:
2026-11-14 18:26:01 +08:00
parent 5ff0ddb58e
commit e97932dcdf
16 changed files with 877 additions and 144 deletions
@@ -58,30 +58,32 @@ test('寄卖配置:规格字段提供「支持筛选」开关并随配置保
assert.ok(configSource.includes('filterable: false,'), '新建字段默认不参与筛选')
})
test('寄卖配置:「支持筛选」与「匹配方式」同行(第3行必须是4列栅格)', () => {
test('寄卖配置:「支持筛选」与两套匹配方式同行(第3行必须是5列栅格)', () => {
assert.ok(
configSource.includes('order-service-field-extra is-four'),
'规格字段第3行必须使用四列栅格类,支持筛选才会留在同一行',
configSource.includes('order-service-field-extra is-match-modes'),
'规格字段第3行必须使用 5 列栅格类,支持筛选才会留在同一行',
)
assert.ok(
!configSource.includes("gridTemplateColumns: 'repeat(3, minmax(0, 1fr))'"),
'不能再用3列 inline 栅格,否则支持筛选会被挤到下一行',
)
// 第3行的四格顺序固定:匹配方式 → 通配值 → 进物品名 → 支持筛选
const rowStart = configSource.indexOf('order-service-field-extra is-four')
// 第3行的五格顺序固定:自动 → 手动 → 通配值 → 进物品名 → 支持筛选
const rowStart = configSource.indexOf('order-service-field-extra is-match-modes')
const rowEnd = configSource.indexOf('</div>', configSource.indexOf('支持筛选', rowStart))
const row = configSource.slice(rowStart, rowEnd)
const order = ['匹配方式', '通配值', '进物品名', '支持筛选'].map((label) => row.indexOf(label))
const order = ['自动匹配方式', '手动匹配方式', '通配值', '进物品名', '支持筛选'].map((label) =>
row.indexOf(label),
)
assert.ok(
order.every((index) => index >= 0) &&
order.every((index, i) => i === 0 || index > order[i - 1]!),
`第3行四格顺序必须是 匹配方式→通配值→进物品名→支持筛选,实际 ${JSON.stringify(order)}`,
`第3行五格顺序必须是 自动→手动→通配值→进物品名→支持筛选,实际 ${JSON.stringify(order)}`,
)
const cssSource = readSource('../src/styles/admin.css')
assert.ok(
cssSource.includes('.order-service-field-extra.is-four'),
'必须提供 is-four 四列栅格样式',
cssSource.includes('.order-service-field-extra.is-match-modes'),
'必须提供 is-match-modes 五列栅格样式',
)
})
@@ -0,0 +1,126 @@
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): string {
return readFileSync(fileURLToPath(new URL(relativePath, import.meta.url)), 'utf8')
}
test('寄卖配置:每个规格字段同时提供自动/手动两套匹配方式', () => {
const source = readSource('../src/pages/admin/panels/ConsignmentConfigPanel.tsx')
assert.ok(source.includes('自动匹配方式'), '必须保留自动匹配方式下拉')
assert.ok(source.includes('手动匹配方式'), '必须新增手动匹配方式下拉')
assert.ok(source.includes("manualMatchMode: 'exact'"), '新增字段与模板生成的字段默认手动精确相等')
assert.ok(
source.includes("value={spec.manualMatchMode ?? 'exact'}"),
'历史配置没有手动口径时按精确相等展示',
)
const typeSource = readSource('../src/types/consignment.ts')
assert.ok(typeSource.includes('manualMatchMode?'), '前端类型必须包含 manualMatchMode')
})
test('寄卖配置:两套匹配方式与通配值/进物品名/支持筛选必须在同一行', () => {
const source = readSource('../src/pages/admin/panels/ConsignmentConfigPanel.tsx')
const css = readSource('../src/styles/admin.css')
assert.ok(
source.includes('order-service-field-extra is-match-modes'),
'字段行必须使用 5 列栅格类',
)
assert.ok(css.includes('.order-service-field-extra.is-match-modes'))
assert.ok(
css.includes('minmax(90px, 0.72fr)'),
'手动匹配方式必须比自动匹配方式窄,避免把通配值挤没',
)
assert.ok(
css.includes('.order-service-field-extra.is-match-modes,'),
'窄屏媒体查询必须包含该 5 列布局,保证折行',
)
})
test('后端:规格字段归一化补 manualMatchMode,历史配置缺省精确相等', () => {
const source = readSource(
'../../backend/src/services/worker-platform/consignment-spec-service.ts',
)
assert.ok(source.includes('manualMatchMode?: ConsignmentSpecMatchMode'))
assert.ok(source.includes('source.manualMatchMode ?? source.manual_match_mode'))
assert.ok(
source.includes(
"return specs.map((spec) => ({ ...spec, matchMode: spec.manualMatchMode ?? 'exact' }))",
),
'手动口径必须把 matchMode 替换成 manualMatchMode(缺省 exact)',
)
})
test('后端:手动候选、手动复检、待匹配看板都使用手动口径', () => {
const engine = readSource(
'../../backend/src/services/worker-platform/consignment-match-engine-service.ts',
)
const pending = readSource(
'../../backend/src/services/worker-platform/consignment-pending-match-service.ts',
)
assert.equal(
(engine.match(/matchScope: 'manual'/g) || []).length,
2,
'手动候选与手动复检必须传 manual 口径',
)
assert.ok(engine.includes("matchScope: 'auto'"), '自动匹配候选必须显式使用 auto 口径')
assert.ok(pending.includes("matchScope: 'manual'"), '一键查询待匹配订单必须按手动口径')
})
test('内部库存匹配弹窗:默认展示全部候选,按打分降序,并显示寄卖单号', () => {
const source = readSource('../src/components/admin/ConsignmentWorkOrderMatchModal.tsx')
assert.ok(source.includes('默认按匹配打分从高到低展示全部候选'), '默认展示口径必须在界面上说明')
assert.ok(
!source.includes("if (!searchable) return (item.matchType || 'exact') === 'exact'"),
'不能再默认只过滤精确匹配',
)
assert.ok(
source.includes('computeConsignmentMatchScore(right) - computeConsignmentMatchScore(left)'),
'列表必须按匹配打分降序排列',
)
assert.ok(
source.includes('buildCandidateHaystack(item).includes(normalizedKeyword)'),
'关键词必须覆盖全部匹配状态',
)
assert.ok(source.includes("title: '寄卖单号'"), '必须新增寄卖单号列')
assert.ok(
source.includes('item.consignmentNo || item.inventoryNo'),
'寄卖单号优先展示提报单号,无提报号回退库存号',
)
assert.ok(source.includes("item.consignmentNo || ''"), '搜索必须能命中寄卖单号')
assert.ok(!source.includes('显示不匹配项'), '「显示不匹配项」勾选框必须删除')
assert.ok(!source.includes('includeConflicts'), '不再保留 includeConflicts 状态')
assert.ok(source.includes('initialInventoryIds'), '必须支持外部预选库存')
assert.ok(source.includes('if (presetInventoryKey)'), '预选库存必须优先于默认自动勾选')
})
test('手动匹配弹窗:状态不允许时仍能搜索,但禁止提交', () => {
const source = readSource('../src/components/admin/ConsignmentWorkOrderMatchModal.tsx')
assert.ok(source.includes('const matchable = data?.matchable !== false'))
assert.ok(source.includes('当前仍可搜索和核对库存'), '只读状态必须给出明确提示')
assert.ok(source.includes('disabled={!canSubmit}'))
assert.ok(
/matchable &&\s+selectedIds\.length > 0/.test(source),
'canSubmit 必须受 matchable 约束',
)
const searchIndex = source.indexOf('搜索寄卖单号 / 库存号 / 物品 / 规格 / 打手')
const optionsBranchIndex = source.indexOf('visibleOptions.length > 0 ? (')
assert.ok(searchIndex > 0)
assert.ok(
searchIndex < optionsBranchIndex,
'搜索框必须常驻在候选分支之外,空列表 / 只读状态也能使用',
)
const typeSource = readSource('../src/types/consignment.ts')
assert.ok(typeSource.includes('matchable: boolean'), '候选响应类型必须带 matchable')
assert.ok(typeSource.includes('blockedReason: string'))
})
@@ -0,0 +1,53 @@
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): string {
return readFileSync(fileURLToPath(new URL(relativePath, import.meta.url)), 'utf8')
}
test('一键查询待匹配订单:每行提供「选择」,打开手动匹配并预选该库存', () => {
const source = readSource('../src/components/admin/ConsignmentPendingMatchModal.tsx')
assert.ok(source.includes("title: '操作'"), '必须新增操作列')
assert.ok(source.includes('选择'), '操作列按钮文案必须是「选择」')
assert.ok(source.includes('setSelectTarget({'), '点击选择必须记录订单与库存')
assert.ok(source.includes('<ConsignmentWorkOrderMatchModal'), '必须挂载手动匹配弹窗')
assert.ok(source.includes('initialInventoryIds'), '必须把选中的库存传给手动匹配弹窗')
assert.ok(source.includes('workOrderId={selectTarget?.workOrderId ?? null}'))
})
test('一键查询待匹配订单:快速匹配按履约权限门控,成功后刷新看板与库存', () => {
const modal = readSource('../src/components/admin/ConsignmentPendingMatchModal.tsx')
const panel = readSource('../src/pages/admin/panels/ConsignmentInventoryPanel.tsx')
assert.ok(modal.includes('canMatch = false'), '默认无权限时不给操作入口')
assert.ok(modal.includes('void query.refetch()'), '匹配成功后必须刷新看板')
assert.ok(modal.includes('onMatched?.()'), '匹配成功后必须通知外层刷新库存')
assert.ok(
panel.includes('canMatch={canFulfillConsignment}'),
'仓库面板必须按 consignment.fulfill 权限传入 canMatch',
)
assert.ok(
panel.includes('onMatched={() => void inventoriesQuery.refetch()}'),
'仓库面板必须刷新库存列表',
)
})
test('一键查询待匹配订单:说明文案不再声称「不直接改单」', () => {
const source = readSource('../src/components/admin/ConsignmentPendingMatchModal.tsx')
assert.ok(!source.includes('不直接改单'), '新增选择匹配后必须去掉只读文案')
assert.ok(source.includes('「手动匹配方式」') || source.includes('手动匹配'))
})
test('一键查询待匹配订单:默认展示全部配对并按打分降序', () => {
const source = readSource('../src/components/admin/ConsignmentPendingMatchModal.tsx')
assert.ok(source.includes('includeConflicts: true'), '默认必须包含冲突(0 分)配对')
assert.ok(!source.includes('显示 0 分不匹配项'), '旧的勾选框必须删除')
assert.ok(source.includes('right.score - left.score'), '配对必须按打分从高到低排列')
assert.ok(source.includes('不匹配 {data?.conflictCount ?? 0}'), '必须展示不匹配数量')
assert.ok(source.includes('默认展示全部配对(含不匹配项)'), '界面必须说明默认口径')
})
@@ -0,0 +1,41 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import test from 'node:test'
test('打手寄卖页:空规格集合必须用稳定引用,避免 React #185 无限渲染', () => {
const source = readFileSync(
fileURLToPath(new URL('../src/pages/worker/WorkerConsignmentPage.tsx', import.meta.url)),
'utf8',
)
assert.ok(
source.includes('const EMPTY_CONSIGNMENT_PROFILES: WorkerConsignmentProfile[] = []'),
'必须定义模块级稳定空数组',
)
assert.ok(
source.includes('consignmentProfilesQuery.data?.data.profiles ?? EMPTY_CONSIGNMENT_PROFILES'),
'接口未返回时不能每次渲染新建 []',
)
assert.ok(
!source.includes('consignmentProfilesQuery.data?.data.profiles || []'),
'禁止使用每次渲染都新建的空数组回退',
)
})
test('打手寄卖页:展开节点同步必须先比较内容,相同就不 setState', () => {
const source = readFileSync(
fileURLToPath(new URL('../src/pages/worker/WorkerConsignmentPage.tsx', import.meta.url)),
'utf8',
)
assert.ok(source.includes('function isSameKeyList('), '必须有 key 列表内容比较')
assert.ok(
source.includes('isSameKeyList(prev, profileGroupKeys) ? prev : profileGroupKeys'),
'内容相同必须返回原引用,避免无限更新',
)
assert.ok(
!/setProfileExpandedKeys\(profileGroupKeys\)\s*\r?\n\s*\}, \[profileGroupKeys\]/.test(source),
'不能无条件写入新数组',
)
})