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
@@ -1,14 +1,16 @@
import { useQuery } from '@tanstack/react-query'
import { ReloadOutlined } from '@ant-design/icons'
import { Alert, Button, Checkbox, Empty, Input, Modal, Space, Tag, Typography } from 'antd'
import { Alert, Button, Empty, Input, Modal, Space, Tag, Typography } from 'antd'
import type { TableColumnsType } from 'antd'
import { useState } from 'react'
import { useMemo, useState } from 'react'
import PercentTable from '@/components/common/PercentTable'
import { fetchPendingPistolMatches } from '@/services/admin/consignment'
import type { PendingPistolMatchPair } from '@/types/consignment'
import { formatDateTime } from '@/utils/date-time'
import ConsignmentWorkOrderMatchModal from './ConsignmentWorkOrderMatchModal'
const { Text } = Typography
function resolveScoreTag(pair: PendingPistolMatchPair) {
@@ -36,23 +38,33 @@ export default function ConsignmentPendingMatchModal({
onClose,
categoryId,
categoryName,
canMatch = false,
onMatched,
}: {
open: boolean
onClose: () => void
/** 只统计该寄卖分类;不传 = 全部 */
categoryId?: number | null
categoryName?: string
/** 有 consignment.fulfill 权限时才显示「选择」列 */
canMatch?: boolean
/** 快速匹配成功后通知外层刷新库存 */
onMatched?: () => void
}) {
const [keyword, setKeyword] = useState('')
const [includeConflicts, setIncludeConflicts] = useState(false)
const [selectTarget, setSelectTarget] = useState<{
workOrderId: number
inventoryId: number
} | null>(null)
const query = useQuery({
queryKey: ['admin-pending-pistol-matches', categoryId ?? null, keyword, includeConflicts],
queryKey: ['admin-pending-pistol-matches', categoryId ?? null, keyword],
queryFn: () =>
fetchPendingPistolMatches({
categoryId: categoryId ?? undefined,
keyword,
includeConflicts,
// 默认展示全部配对:冲突项(0 分)也要能看到,便于人工核对。
includeConflicts: true,
limit: 300,
}),
enabled: open,
@@ -60,7 +72,11 @@ export default function ConsignmentPendingMatchModal({
retry: false,
})
const data = query.data?.data
const pairs = data?.pairs || []
// 后端已按分数降序;这里再按打分排一次,保证调整排序口径时界面始终一致。
const pairs = useMemo(
() => [...(data?.pairs || [])].sort((left, right) => right.score - left.score),
[data?.pairs],
)
const columns: TableColumnsType<PendingPistolMatchPair> = [
{
@@ -225,6 +241,30 @@ export default function ConsignmentPendingMatchModal({
</Space>
),
},
...(canMatch
? [
{
title: '操作',
key: 'action',
width: 100,
render: (_: unknown, record: PendingPistolMatchPair) => (
<Button
size="small"
type="primary"
ghost
onClick={() =>
setSelectTarget({
workOrderId: record.workOrderId,
inventoryId: record.inventoryId,
})
}
>
选择
</Button>
),
},
]
: []),
]
return (
@@ -246,10 +286,14 @@ export default function ConsignmentPendingMatchModal({
showIcon
message={
categoryName
? `只统计「${categoryName}」分类下的待匹配订单 × 在库待匹配现货,不直接改单。`
: '按寄卖配置匹配全部待匹配订单 × 在库待匹配现货,不直接改单。'
? `只统计「${categoryName}」分类下的待匹配订单 × 在库待匹配现货。`
: '按寄卖配置匹配全部待匹配订单 × 在库待匹配现货。'
}
description={
canMatch
? '默认展示全部配对(含不匹配项),按打分从高到低排列;点「选择」会打开手动匹配并预选该库存,确认后直接占用库存。数量不足或规格不匹配时需要人工补选。'
: '默认展示全部配对(含不匹配项),按打分从高到低排列;当前账号没有履约权限,只能查看。'
}
description="有寄卖配置的订单按该分类的规格逐项判定(精确 / 非精确 / 不匹配);内部匹配中、待分配、待抢单的工单都会纳入。数量不足或没有正向规格信号的非精确项会单独标记,自动匹配不会选中。"
/>
<Space wrap size={12}>
@@ -261,7 +305,7 @@ export default function ConsignmentPendingMatchModal({
<Tag color="orange">非精确 {data?.partialCount ?? 0}</Tag>
<Tag color="purple">数量不足 {data?.quantityShortCount ?? 0}</Tag>
<Tag color="cyan">可自动 {data?.autoMatchableCount ?? 0}</Tag>
{includeConflicts ? <Tag>不匹配 {data?.conflictCount ?? 0}</Tag> : null}
<Tag>不匹配 {data?.conflictCount ?? 0}</Tag>
</Space>
<Space wrap size={12}>
@@ -271,12 +315,6 @@ export default function ConsignmentPendingMatchModal({
style={{ width: 420 }}
onSearch={(value) => setKeyword(value.trim())}
/>
<Checkbox
checked={includeConflicts}
onChange={(event) => setIncludeConflicts(event.target.checked)}
>
显示 0 分不匹配项
</Checkbox>
<Button
icon={<ReloadOutlined />}
loading={query.isFetching}
@@ -316,18 +354,23 @@ export default function ConsignmentPendingMatchModal({
showTotal: (total) => `共 ${total} 条`,
}}
locale={{
emptyText: (
<Empty
description={
includeConflicts
? '当前分类下没有可展示的配对'
: '当前分类下没有可匹配组合;可勾选「显示 0 分不匹配项」查看冲突'
}
/>
),
emptyText: <Empty description="当前分类下没有可匹配组合" />,
}}
/>
</Space>
<ConsignmentWorkOrderMatchModal
key={
selectTarget ? `${selectTarget.workOrderId}-${selectTarget.inventoryId}` : 'pending-match'
}
open={Boolean(selectTarget)}
workOrderId={selectTarget?.workOrderId ?? null}
initialInventoryIds={selectTarget ? [selectTarget.inventoryId] : undefined}
onClose={() => setSelectTarget(null)}
onMatched={() => {
void query.refetch()
onMatched?.()
}}
/>
</Modal>
)
}
@@ -1,16 +1,4 @@
import {
App,
Alert,
Button,
Card,
Checkbox,
Empty,
Input,
Modal,
Space,
Tag,
Typography,
} from 'antd'
import { App, Alert, Button, Card, Empty, Input, Modal, Space, Tag, Typography } from 'antd'
import { useQuery } from '@tanstack/react-query'
import { useEffect, useMemo, useState } from 'react'
@@ -30,6 +18,29 @@ function money(fen: number) {
return `¥${((Number(fen || 0) || 0) / 100).toFixed(2)}`
}
const EMPTY_INVENTORY_IDS: number[] = []
/** 搜索框命中范围:单号 / 物品 / 规格 / 打手 / 匹配说明。 */
function buildCandidateHaystack(item: ConsignmentMatchCandidate): string {
return [
item.consignmentNo || '',
item.inventoryNo,
item.itemName,
item.specText || '',
Object.values(item.specValues || {}).join(' '),
item.workerName || '',
(item.matchReasons || []).join(' '),
describeConsignmentMatch(item),
]
.join(' ')
.toLowerCase()
}
function sameIdList(left: number[], right: number[]): boolean {
if (left.length !== right.length) return false
return left.every((value, index) => value === right[index])
}
function resolveScoreTag(item: ConsignmentMatchCandidate) {
const type = item.matchType || 'exact'
const score = computeConsignmentMatchScore(item)
@@ -43,19 +54,23 @@ function resolveScoreTag(item: ConsignmentMatchCandidate) {
export default function ConsignmentWorkOrderMatchModal({
open,
workOrderId,
initialInventoryIds,
onClose,
onMatched,
}: {
open: boolean
workOrderId: number | null
/** 外部带入的预选库存(例如待匹配看板点「选择」),允许是非精确项 */
initialInventoryIds?: number[]
onClose: () => void
onMatched?: () => void
}) {
const { message } = App.useApp()
const [selectedIds, setSelectedIds] = useState<number[]>([])
const [includeConflicts, setIncludeConflicts] = useState(false)
const [keyword, setKeyword] = useState('')
const [submitting, setSubmitting] = useState(false)
const [nonExactReason, setNonExactReason] = useState('')
const presetInventoryKey = (initialInventoryIds || EMPTY_INVENTORY_IDS).join(',')
const candidatesQuery = useQuery({
queryKey: ['admin-consignment-match-candidates', workOrderId],
@@ -70,17 +85,25 @@ export default function ConsignmentWorkOrderMatchModal({
const totalQuantity = Math.max(1, Number(data?.totalQuantity || 1))
const activeQuantity = Math.max(0, Number(data?.activeQuantity || 0))
const sharingEnabled = Boolean(data?.sharingEnabled)
// 状态不允许时接口只读返回候选:仍可搜索/核对库存,但禁止提交。
const matchable = data?.matchable !== false
const blockedReason = data?.blockedReason || '当前工单状态不允许匹配寄卖库存'
const remainingNeeded = sharingEnabled
? Math.max(0, totalQuantity - activeQuantity)
: totalQuantity
const visibleOptions = useMemo(
() =>
includeConflicts
? options
: options.filter((item) => (item.matchType || 'exact') !== 'conflict'),
[includeConflicts, options],
)
const normalizedKeyword = keyword.trim().toLowerCase()
// 默认展示全部候选,按匹配打分降序;关键词只做过滤,不改变排序口径。
const visibleOptions = useMemo(() => {
const filtered = normalizedKeyword
? options.filter((item) => buildCandidateHaystack(item).includes(normalizedKeyword))
: options
return [...filtered].sort((left, right) => {
const scoreDiff = computeConsignmentMatchScore(right) - computeConsignmentMatchScore(left)
if (scoreDiff !== 0) return scoreDiff
return Number(right.remainingQuantity || 0) - Number(left.remainingQuantity || 0)
})
}, [normalizedKeyword, options])
const selectedOptions = useMemo(
() => options.filter((item) => selectedIds.includes(item.inventoryId)),
@@ -117,7 +140,7 @@ export default function ConsignmentWorkOrderMatchModal({
useEffect(() => {
if (!open) {
setSelectedIds((prev) => (prev.length ? [] : prev))
setIncludeConflicts(false)
setKeyword('')
setNonExactReason('')
return
}
@@ -126,6 +149,16 @@ export default function ConsignmentWorkOrderMatchModal({
return
}
// 外部预选优先:待匹配看板点「选择」时直接勾中对应库存(可能是非精确项)。
if (presetInventoryKey) {
const presetIds = presetInventoryKey
.split(',')
.map((value) => Number(value))
.filter((id) => Number.isInteger(id) && id > 0)
setSelectedIds((prev) => (sameIdList(prev, presetIds) ? prev : presetIds))
return
}
// 默认只选精确库存;整单必须一条满足总量,拼单按剩余份数累加到一次拼满。
const exactOptions = options.filter(
(item) => (item.matchType || 'exact') === 'exact' && Number(item.remainingQuantity || 0) > 0,
@@ -134,7 +167,8 @@ export default function ConsignmentWorkOrderMatchModal({
const chosen = exactOptions.find(
(item) => Number(item.remainingQuantity || 0) >= totalQuantity,
)
setSelectedIds(chosen ? [chosen.inventoryId] : [])
const nextIds = chosen ? [chosen.inventoryId] : []
setSelectedIds((prev) => (sameIdList(prev, nextIds) ? prev : nextIds))
return
}
@@ -145,20 +179,8 @@ export default function ConsignmentWorkOrderMatchModal({
ids.push(item.inventoryId)
covered += Math.max(0, Number(item.remainingQuantity || 0))
}
setSelectedIds(ids)
}, [open, options, remainingNeeded, sharingEnabled, totalQuantity])
// 隐藏不匹配项时,顺手清掉已选中的冲突库存,避免提交到不可见的行。
useEffect(() => {
if (includeConflicts) return
setSelectedIds((prev) => {
const next = prev.filter((id) => {
const item = options.find((option) => option.inventoryId === id)
return !item || (item.matchType || 'exact') !== 'conflict'
})
return next.length === prev.length ? prev : next
})
}, [includeConflicts, options])
setSelectedIds((prev) => (sameIdList(prev, ids) ? prev : ids))
}, [open, options, presetInventoryKey, remainingNeeded, sharingEnabled, totalQuantity])
const nonExactSelected = useMemo(
() =>
@@ -170,6 +192,7 @@ export default function ConsignmentWorkOrderMatchModal({
)
const canSubmit =
matchable &&
selectedIds.length > 0 &&
coverage.sufficient &&
(!nonExactSelected || nonExactReason.trim().length > 0)
@@ -240,11 +263,45 @@ export default function ConsignmentWorkOrderMatchModal({
}
/>
) : null}
{!candidatesQuery.isLoading && !candidatesQuery.isError ? (
<Space wrap size={8} style={{ marginBottom: 12 }}>
<Input.Search
allowClear
placeholder="搜索寄卖单号 / 库存号 / 物品 / 规格 / 打手"
style={{ width: 320 }}
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
/>
<Typography.Text type="secondary">
显示 {visibleOptions.length} / 候选 {options.length} 条
</Typography.Text>
</Space>
) : null}
{!candidatesQuery.isLoading && !candidatesQuery.isError && options.length === 0 ? (
<Empty description="没有同配置的在库寄卖库存" />
) : null}
{!candidatesQuery.isError && options.length > 0 ? (
{!candidatesQuery.isLoading &&
!candidatesQuery.isError &&
options.length > 0 &&
visibleOptions.length === 0 ? (
<Empty
description={
keyword.trim()
? '没有匹配该关键词的库存,换个寄卖单号/库存号/物品/打手试试'
: '没有可展示的库存'
}
/>
) : null}
{!candidatesQuery.isError && visibleOptions.length > 0 ? (
<Space direction="vertical" size={12} style={{ width: '100%' }}>
{!matchable ? (
<Alert
type="warning"
showIcon
message={blockedReason}
description="当前仍可搜索和核对库存;补充资料/重新发布到待分配或待抢单后才能提交匹配。"
/>
) : null}
{data?.matchingMode === 'off' ? (
<Alert
type="warning"
@@ -257,30 +314,23 @@ export default function ConsignmentWorkOrderMatchModal({
type={remainingNeeded <= 0 ? 'warning' : 'info'}
showIcon
message={infoMessage}
description="待分配工单也可直接人工匹配;候选仍按寄卖配置打分,非精确/冲突库存必须人工勾选并填写原因。拼单必须一次拼满,失败不会占用任何库存。"
description="默认按匹配打分从高到低展示全部候选;非精确/冲突必须填写人工原因;拼单必须一次拼满,失败不会占用任何库存。"
/>
<Space wrap size={8}>
<Tag color="blue">
{sharingEnabled ? `待匹配 ${remainingNeeded} 份` : `整单 ${totalQuantity} 件`}
</Tag>
{sharingEnabled ? <Tag>已匹配 {activeQuantity} 份</Tag> : null}
<Tag>候选 {visibleOptions.length} 条</Tag>
<Tag color="green">精确 {counts.exact}</Tag>
<Tag color="orange">非精确 {counts.partial}</Tag>
<Tag>冲突 {counts.conflict}</Tag>
<Checkbox
checked={includeConflicts}
onChange={(event) => setIncludeConflicts(event.target.checked)}
>
显示不匹配项
</Checkbox>
</Space>
<PercentTable<ConsignmentMatchCandidate>
rowKey="inventoryId"
size="small"
loading={candidatesQuery.isFetching}
pagination={{ pageSize: 10, showSizeChanger: false }}
scroll={{ x: 1050, y: 420 }}
scroll={{ x: 1210, y: 420 }}
dataSource={visibleOptions}
rowSelection={{
// 整单只能选一条;拼单可多选,必须覆盖剩余份数才能提交。
@@ -306,6 +356,19 @@ export default function ConsignmentWorkOrderMatchModal({
)
},
},
{
title: '寄卖单号',
key: 'consignmentNo',
width: 170,
render: (_, item) => (
<Typography.Text
copyable={{ text: item.consignmentNo || item.inventoryNo }}
style={{ fontSize: 12 }}
>
{item.consignmentNo || item.inventoryNo || '-'}
</Typography.Text>
),
},
{
title: '寄卖库存',
key: 'inventory',
@@ -158,6 +158,13 @@ function nextCustomSpecKey(specs: ConsignmentSpecField[]): string {
return `custom_${index}`
}
const SPEC_MATCH_MODE_OPTIONS = [
{ value: 'exact', label: '精确相等' },
{ value: 'minimum', label: '库存不低于订单' },
{ value: 'wildcard', label: '通配' },
{ value: 'ignore', label: '仅展示,不参与匹配' },
]
function newCustomSpec(specs: ConsignmentSpecField[]): ConsignmentSpecField {
return {
key: nextCustomSpecKey(specs),
@@ -168,6 +175,7 @@ function newCustomSpec(specs: ConsignmentSpecField[]): ConsignmentSpecField {
placeholder: '',
helpText: '',
matchMode: 'exact',
manualMatchMode: 'exact',
wildcardValues: [],
showInItemName: true,
filterable: false,
@@ -722,6 +730,7 @@ export default function ConsignmentConfigPanel() {
placeholder: '',
helpText: '',
matchMode: 'exact' as const,
manualMatchMode: 'exact' as const,
wildcardValues: [],
showInItemName: proposal.mode !== 'product',
filterable: true,
@@ -1429,20 +1438,25 @@ export default function ConsignmentConfigPanel() {
) : null}
</div>
<div className="order-service-field-extra is-four">
<div className="order-service-field-extra is-match-modes">
<div style={fieldCellStyle}>
<Text type="secondary">匹配方式</Text>
<Text type="secondary">自动匹配方式</Text>
<Select
value={spec.matchMode}
options={[
{ value: 'exact', label: '精确相等' },
{ value: 'minimum', label: '库存不低于订单' },
{ value: 'wildcard', label: '通配' },
{ value: 'ignore', label: '仅展示,不参与匹配' },
]}
options={SPEC_MATCH_MODE_OPTIONS}
onChange={(value) => updateSpec(index, { matchMode: value })}
/>
</div>
<div style={fieldCellStyle}>
<Text type="secondary">手动匹配方式</Text>
<Select
value={spec.manualMatchMode ?? 'exact'}
options={SPEC_MATCH_MODE_OPTIONS}
onChange={(value) =>
updateSpec(index, { manualMatchMode: value })
}
/>
</div>
<div style={fieldCellStyle}>
<Text type="secondary">通配值</Text>
<Select
@@ -1640,7 +1640,9 @@ export default function ConsignmentInventoryPanel() {
open={pendingMatchOpen}
categoryId={categoryId}
categoryName={activeFilterCategory?.name}
canMatch={canFulfillConsignment}
onClose={() => setPendingMatchOpen(false)}
onMatched={() => void inventoriesQuery.refetch()}
/>
<ConsignmentMatchLogsModal open={matchLogsOpen} onClose={() => setMatchLogsOpen(false)} />
@@ -83,6 +83,17 @@ const DEPOSIT_TIP_KEY = 'worker-consignment-deposit-tip-seen'
const ACTIVE_TAB_STORAGE_KEY = 'worker_consignment_active_tab'
const RECORD_FILTER_STORAGE_KEY = 'worker_consignment_record_filter'
/**
* 稳定空数组:`data?.data.profiles || []` 每次渲染都新建引用,
* 会让下游 useMemo / useEffect 每轮都失效,最终触发 React #185 无限渲染。
*/
const EMPTY_CONSIGNMENT_PROFILES: WorkerConsignmentProfile[] = []
function isSameKeyList(left: Array<string | number>, right: Array<string | number>): boolean {
if (left.length !== right.length) return false
return left.every((value, index) => value === right[index])
}
function getInitialActiveTab(): 'publish' | 'records' {
if (typeof window === 'undefined') return 'publish'
try {
@@ -252,7 +263,8 @@ export default function WorkerConsignmentPage() {
queryKey: ['worker-consignment-profiles'],
queryFn: () => fetchWorkerConsignmentProfiles(),
})
const consignmentProfiles = consignmentProfilesQuery.data?.data.profiles || []
const consignmentProfiles =
consignmentProfilesQuery.data?.data.profiles ?? EMPTY_CONSIGNMENT_PROFILES
const orderedConsignmentProfiles = useMemo(
() => orderWorkerProfiles(consignmentProfiles),
[consignmentProfiles],
@@ -323,7 +335,10 @@ export default function WorkerConsignmentPage() {
[profileTreeData],
)
useEffect(() => {
setProfileExpandedKeys(profileGroupKeys)
// 内容相同就不 setState,避免 profileGroupKeys 重新计算时再次触发渲染。
setProfileExpandedKeys((prev) =>
isSameKeyList(prev, profileGroupKeys) ? prev : profileGroupKeys,
)
}, [profileGroupKeys])
const selectedProfile: WorkerConsignmentProfile | null =
orderedConsignmentProfiles.find((profile) => profile.profileId === selectedProfileId) ||
+10 -10
View File
@@ -2236,17 +2236,17 @@
}
/*
* 规格字段第 3 行(匹配方式 / 通配值 / 进物品名 / 支持筛选)共 4 格。
* 之前用 inline gridTemplateColumns 写死 3 列,把「支持筛选」挤到了下一行;
* inline 样式还会盖掉 960px 媒体查询,窄屏下这一行无法折行。
* 改成 CSS 类后:4 格同行,窄屏按 2×2 折行。
* 规格字段第 3 行:自动匹配方式 / 手动匹配方式 / 通配值 / 进物品名 / 支持筛选,共 5 格同行。
* 手动匹配方式刻意比自动窄(0.72fr),避免两个同等宽下拉把通配值挤没;
* 窄屏(960px 以下)按 2 列折行。
*/
.order-service-field-extra.is-four {
.order-service-field-extra.is-match-modes {
grid-template-columns:
minmax(110px, 0.8fr)
minmax(200px, 1.5fr)
minmax(90px, 0.7fr)
minmax(90px, 0.7fr);
minmax(110px, 1fr)
minmax(90px, 0.72fr)
minmax(180px, 1.45fr)
minmax(90px, 0.55fr)
minmax(90px, 0.55fr);
}
.order-service-field-extra-tip {
@@ -2386,7 +2386,7 @@
}
.order-service-field-extra,
.order-service-field-extra.is-four,
.order-service-field-extra.is-match-modes,
.order-service-switch-grid,
.order-service-switch-grid.is-auto-publish,
.order-service-status-step-grid {
+9
View File
@@ -50,6 +50,8 @@ export type ConsignmentSpecField = {
placeholder: string
helpText: string
matchMode: 'exact' | 'minimum' | 'wildcard' | 'ignore'
/** 手动匹配口径(内部库存匹配 / 一键查询待匹配订单),缺省按精确相等 */
manualMatchMode?: 'exact' | 'minimum' | 'wildcard' | 'ignore'
wildcardValues: string[]
showInItemName: boolean
/** 是否在后台「寄售仓库」作为筛选项(选项类字段默认开启) */
@@ -546,6 +548,8 @@ export type WorkerConsignmentProfile = {
export type ConsignmentMatchCandidate = {
inventoryId: number
inventoryNo: string
/** 寄卖单号:提报单号(SUB…)优先,无提报单时回退库存号 */
consignmentNo?: string
itemName: string
system: string
serverZone: string
@@ -582,5 +586,10 @@ export type ConsignmentMatchCandidatesResponse = {
sharingEnabled: boolean
totalQuantity: number
activeQuantity: number
/** 当前工单是否允许真正落库匹配;false 时只能搜索/查看库存 */
matchable: boolean
/** matchable=false 时的原因文案 */
blockedReason: string
workOrderStatus: string
options: ConsignmentMatchCandidate[]
}
@@ -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),
'不能无条件写入新数组',
)
})