feat(consignment): 拆分自动/手动匹配口径并优化内部匹配列表
This commit is contained in:
@@ -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) ||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user