Files
order_site/apps/frontend/src/components/admin/ConsignmentManualMatchModal.tsx
T
yml2213 cb064d7cd5 fix(worker-platform): 工单号前缀改 WK 并在检索侧容错 O/0,彻底解决「按工单号搜不到」
## 线上定位到的真正根因

客服搜 `WO26101213031939` 搜不到,但用同单的平台号 `2628501763808879` 搜得到。
到生产库核对后确认:

- 库里真实值 `work_order_no = WO26101213031939`(hex 574f… = 字母 O),状态 pending_acceptance;
- 用字母 O 的 SQL 分支 → 1 行;用数字 0(`W026101213031939`)→ 0 行;
- 全库 127,906 单**全部**以 `WO` 开头、`W0` 开头 0 单、字母 `o` 只出现在第 2 位。

即:前缀里的字母 O 被输成数字 0(`WO` / `W0` 在界面字体里几乎无法区分),
落进通配符分支后必然为空 —— 与前面几轮的「筛选把单藏起来」是两个独立成因,
这也解释了为什么改筛选逻辑一直没解决。

## 修法

1. 生成端换前缀:generateWorkOrderNo 产出 `WK…`(K 与 0 不会看混),从源头消掉歧义;
2. 检索端同时兼容三种写法(历史 WO 号无需迁移):
   - isWorkOrderNoLookupKeyword 放宽为 `^W[oOkK0]…`;
   - 新增 buildWorkOrderNoKeywordVariants:W0→[W0, WO, WK]、WO→[WO, W0]、WK→[WK],
     SQL 分支对每个候选下发「等值 + 前缀」条件(仍是索引条件,不会退化成全表扫);
   - 前端 isExplicitOrderNoSearch 同步(否则面板不清理筛选、实时补丁护栏也不生效);
3. 顺手加一层归一化:NFKC(全角→半角)+ 去零宽字符,前后端同一口径
   (normalizeLookupKeyword / normalizeSearchText),覆盖「粘贴带不可见字符」这类同族问题;
4. 寄卖匹配弹窗的占位提示更新为 WK... / WO...。

## 验证

- 后端 npm run check 全绿:692 tests(689 pass / 3 skip),新增
  work-order-keyword-lookup.test.ts 直接断言 SQL 参数与「不得出现前置通配符」;
- 前端 npm run check 全绿:139 tests;
- 既有用例 worker-platform-repo.test.ts 的期望值同步为新形态(两条候选条件)。
2026-10-12 16:51:08 +08:00

556 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState } from 'react'
import {
Alert,
Button,
Checkbox,
Descriptions,
Empty,
Input,
Modal,
Space,
Spin,
Tag,
Typography,
message,
} from 'antd'
import { SearchOutlined, TeamOutlined } from '@ant-design/icons'
import {
fetchInventoryMatchableOrders,
fetchWorkOrderConsignmentMatchOptions,
fulfillWorkOrderWithConsignment,
} from '@/services/admin/consignment'
import type { ConsignmentMatchOptionView, ConsignmentMatchableOrder } from '@/types/consignment'
import {
canSubmitManualMatch,
describeManualMatchType,
filterManualMatchOptions,
resolveManualMatchRequirement,
type ManualMatchType,
} from '@/utils/consignment-manual-match'
import { formatDateTime } from '@/utils/date-time'
import { notifyVoucherConsume } from '@/utils/voucher-consume'
const { Text, Paragraph } = Typography
type Selected = {
workOrderId: number | string
workOrderLabel: string
workOrderCreatedAt?: string
inventoryId: number | string
inventoryLabel: string
inventoryInboundAt?: string
matchType: ManualMatchType
conflicts: string[]
/** 供货打手(用于回显供货来源) */
workerId?: number | null
workerName?: string
}
export type ConsignmentManualMatchModalProps = {
open: boolean
onClose: () => void
/** 订单侧入口:从订单出发选现货 */
workOrder?: { workOrderId: number | string; label: string; createdAt?: string } | null
/** 库存侧入口:从现货出发选订单 */
inventory?: { inventoryId: number | string; label: string; inboundAt?: string } | null
onMatched?: () => void
}
/**
* 「手动指定现货」弹窗:订单侧与库存侧共用一套交互与落地逻辑。
*
* 统一走内部现货直接履约(原子占库、免外派佣金、不冻结重复押金、跳过打手资料直接进入待加好友)。
*/
export default function ConsignmentManualMatchModal({
open,
onClose,
workOrder,
inventory,
onMatched,
}: ConsignmentManualMatchModalProps) {
const [keyword, setKeyword] = useState('')
const [loading, setLoading] = useState(false)
const [options, setOptions] = useState<ConsignmentMatchOptionView[]>([])
const [orders, setOrders] = useState<ConsignmentMatchableOrder[]>([])
const [inventoryLabel, setInventoryLabel] = useState('')
const [inventoryInboundAt, setInventoryInboundAt] = useState('')
const [inventoryWorker, setInventoryWorker] = useState<{
workerId: number | null
workerName: string
} | null>(null)
const [selected, setSelected] = useState<Selected | null>(null)
const [confirmedPartial, setConfirmedPartial] = useState(false)
const [confirmedMismatch, setConfirmedMismatch] = useState(false)
const [matchNote, setMatchNote] = useState('')
const [submitting, setSubmitting] = useState(false)
const mode: 'order' | 'inventory' = inventory ? 'inventory' : 'order'
const resetState = () => {
setKeyword('')
setSelected(null)
setConfirmedPartial(false)
setConfirmedMismatch(false)
setMatchNote('')
setInventoryInboundAt('')
}
const load = async () => {
setLoading(true)
try {
if (inventory) {
// 关键字与分页透传后端:库存侧默认只拉 50 条时,第 51 单以后永远搜不到
const res = await fetchInventoryMatchableOrders(inventory.inventoryId, {
keyword: keyword.trim() || undefined,
pageSize: 100,
})
setOrders(res.data?.orders || [])
setInventoryLabel(
res.data?.inventory
? `${res.data.inventory.itemName}(${res.data.inventory.sourceSubmissionNo || res.data.inventory.inventoryNo})`
: inventory.label,
)
setInventoryInboundAt(res.data?.inventory?.inboundAt || inventory.inboundAt || '')
setInventoryWorker({
workerId: res.data?.inventory?.workerId ?? null,
workerName: res.data?.inventory?.workerName || '',
})
} else if (workOrder) {
const res = await fetchWorkOrderConsignmentMatchOptions(workOrder.workOrderId)
setOptions(res.data?.options || [])
}
} catch (err: any) {
message.error(err.message || '读取可匹配数据失败')
} finally {
setLoading(false)
}
}
// 库存侧:关键字防抖透传后端搜索(本地过滤只作用在已拉取的 100 条内)
useEffect(() => {
if (!open || !inventory) return
const timer = setTimeout(() => {
void load()
}, 300)
return () => clearTimeout(timer)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [keyword])
const filteredOptions = useMemo(
() =>
filterManualMatchOptions(options, keyword, (item) => [
item.inventory.itemName,
item.inventory.sourceSubmissionNo,
item.inventory.inventoryNo,
item.inventory.system,
item.inventory.serverZone,
item.inventory.series,
item.inventory.workerName,
item.inventory.buyerName,
item.inventory.sellerGameId,
item.inventory.id,
]),
[options, keyword],
)
const filteredOrders = useMemo(
() =>
filterManualMatchOptions(orders, keyword, (item) => [
item.workOrderNo,
item.workOrderId,
item.productName,
item.requiredSpec.system,
item.requiredSpec.serverZone,
item.requiredSpec.series,
item.status,
]),
[orders, keyword],
)
const requirement = resolveManualMatchRequirement(selected?.matchType || 'exact')
const canSubmit =
Boolean(selected) &&
canSubmitManualMatch({
matchType: selected?.matchType || 'exact',
confirmedPartial,
confirmedMismatch,
matchNote,
})
async function handleSubmit() {
if (!selected) return
setSubmitting(true)
try {
const res = await fulfillWorkOrderWithConsignment({
workOrderId: selected.workOrderId,
inventoryId: selected.inventoryId,
allowPartial: selected.matchType !== 'exact' || confirmedPartial,
allowSpecMismatch: selected.matchType === 'conflict' || confirmedMismatch,
matchNote: matchNote.trim(),
})
// 核销失败时共享工具会弹警告(状态已变更但凭证没消耗),此时不能再弹"成功"掩盖
const voucher = (res.data as any)?.voucherConsume
notifyVoucherConsume(voucher)
if (!voucher || voucher.ok) {
message.success('已使用内部寄售现货直接履约,已进入待加好友阶段')
}
onMatched?.()
resetState()
onClose()
} catch (err: any) {
// 列表数据过期(并发占用 / 打开弹窗后订单规格被改):刷新列表按最新判定重来,
// 否则运营会对着已失效的候选反复重试、循环撞同一个错
const code = String(err?.errorCode || '')
if (
code === 'consignment_inventory_race_condition' ||
code === 'consignment_inventory_unavailable'
) {
message.error('该现货刚被其他操作占用或已下架,已刷新列表,请重新选择')
setSelected(null)
void load()
return
}
if (
code === 'consignment_spec_mismatch' ||
code === 'consignment_spec_partial_requires_confirm'
) {
message.error('订单或现货的规格已变化(列表数据过期),已刷新,请按最新判定重新确认')
setSelected(null)
void load()
return
}
message.error(err.message || '内部现货履约失败')
} finally {
setSubmitting(false)
}
}
return (
<Modal
title={mode === 'order' ? `手动指定现货 · ${workOrder?.label || ''}` : '手动匹配订单'}
open={open}
width={860}
okText="确认内部现货履约"
okButtonProps={{
disabled: !canSubmit,
loading: submitting,
}}
onOk={() => void handleSubmit()}
onCancel={() => {
resetState()
onClose()
}}
afterOpenChange={(visible) => {
if (visible) {
void load()
} else {
resetState()
}
}}
>
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Alert
type="info"
showIcon
message="手动匹配将直接选用内部寄售现货履约(原子占库、免外派佣金、不冻结重复押金、自动跳过打手资料)。"
description={
mode === 'order'
? '下面是该订单的全部在库现货(含规格不一致的),核对差异后直接内部履约。'
: `当前现货:${inventoryLabel || inventory?.label || ''}${
inventoryInboundAt ? `(审核入库:${formatDateTime(inventoryInboundAt)})` : ''
}。下面是可匹配的订单列表,核对后直接内部履约。`
}
/>
<Input
placeholder={
mode === 'order'
? '搜索物品名 / 寄卖单号 (SUB...) / 仓库号 (INV...) / 规格 / 供货打手'
: '搜索工单号 (WK... / WO...) / 平台单号 / 标题 / 规格'
}
prefix={<SearchOutlined />}
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
allowClear
/>
<Spin spinning={loading}>
<div style={{ maxHeight: 320, overflowY: 'auto', paddingRight: 4 }}>
{mode === 'order' ? (
filteredOptions.length === 0 ? (
<Empty description="没有可匹配的现货" />
) : (
filteredOptions.map((option) => {
const meta = describeManualMatchType(option.matchType)
const isSelected =
selected?.inventoryId != null &&
String(selected.inventoryId) === String(option.inventory.id)
return (
<div
key={option.inventory.id}
style={{
border: isSelected ? '1px solid #fa8c16' : '1px solid #f0f0f0',
borderRadius: 8,
padding: 10,
marginBottom: 8,
background: isSelected ? '#fff7e6' : '#fff',
}}
>
<Space
style={{ width: '100%', justifyContent: 'space-between' }}
align="start"
>
<Space direction="vertical" size={2}>
<Space size={6} wrap>
<Tag color="cyan">
{option.inventory.sourceSubmissionNo || option.inventory.inventoryNo}
</Tag>
<Text strong>{option.inventory.itemName}</Text>
<Tag color={meta.color}>{meta.label}</Tag>
</Space>
<Text type="secondary" style={{ fontSize: 12 }}>
寄卖单号{' '}
{option.inventory.sourceSubmissionNo || option.inventory.inventoryNo} ·{' '}
{option.inventory.system || '未指定'}/
{option.inventory.serverZone || '未指定'}/
{option.inventory.series || '未指定'}{' '}
{option.inventory.diamondLevel
? `${option.inventory.diamondLevel}钻`
: ''}{' '}
· 货源 {option.inventory.workerName || '未关联打手'} · 结算价 ¥
{(Number(option.inventory.costPriceFen || 0) / 100).toFixed(2)}
{option.inventory.inboundAt || option.inventory.createdAt ? (
<>
{' '}
· 审核入库{' '}
{formatDateTime(
option.inventory.inboundAt || option.inventory.createdAt,
)}
</>
) : null}
</Text>
{option.conflicts.length > 0 ? (
<Text style={{ fontSize: 12, color: '#cf1322' }}>
差异:{option.conflicts.join(';')}
</Text>
) : (
<Text type="secondary" style={{ fontSize: 12 }}>
{option.reason || '规格完全一致'}
</Text>
)}
</Space>
<Button
size="small"
type={isSelected ? 'primary' : 'default'}
onClick={() => {
setSelected({
workOrderId: workOrder?.workOrderId || 0,
workOrderLabel: workOrder?.label || '',
workOrderCreatedAt: workOrder?.createdAt,
inventoryId: option.inventory.id,
inventoryLabel: option.inventory.itemName,
inventoryInboundAt:
option.inventory.inboundAt || option.inventory.createdAt,
matchType: option.matchType,
conflicts: option.conflicts,
workerId: option.inventory.workerId,
workerName: option.inventory.workerName,
})
setConfirmedPartial(false)
setConfirmedMismatch(false)
setMatchNote('')
}}
>
{isSelected ? '已选' : '选它'}
</Button>
</Space>
</div>
)
})
)
) : filteredOrders.length === 0 ? (
<Empty description="没有待匹配的订单" />
) : (
filteredOrders.map((order) => {
const meta = describeManualMatchType(order.matchType)
const isSelected =
selected?.workOrderId != null &&
String(selected.workOrderId) === String(order.workOrderId)
return (
<div
key={order.workOrderId}
style={{
border: isSelected ? '1px solid #fa8c16' : '1px solid #f0f0f0',
borderRadius: 8,
padding: 10,
marginBottom: 8,
background: isSelected ? '#fff7e6' : '#fff',
}}
>
<Space style={{ width: '100%', justifyContent: 'space-between' }} align="start">
<Space direction="vertical" size={2}>
<Space size={6} wrap>
<Tag color="purple">{order.workOrderNo || `#${order.workOrderId}`}</Tag>
<Text strong>{order.productName}</Text>
<Tag color={meta.color}>{meta.label}</Tag>
{order.status === 'open' ? (
<Tag color="blue">大厅待抢</Tag>
) : order.status === 'unassigned' ? (
<Tag color="orange">待指派</Tag>
) : order.status === 'pending_material' ? (
<Tag color="gold">待补资料</Tag>
) : null}
</Space>
<Text type="secondary" style={{ fontSize: 12 }}>
要求:{order.requiredSpec.system || '未指定'}/
{order.requiredSpec.serverZone || '未指定'}/
{order.requiredSpec.series || '未指定'}{' '}
{order.requiredSpec.diamondLevel
? `${order.requiredSpec.diamondLevel}钻`
: ''}{' '}
· 报酬 ¥{(order.rewardAmountFen / 100).toFixed(2)}
{order.createdAt ? (
<> · 创建时间 {formatDateTime(order.createdAt)}</>
) : null}
</Text>
{order.conflicts.length > 0 ? (
<Text style={{ fontSize: 12, color: '#cf1322' }}>
差异:{order.conflicts.join(';')}
</Text>
) : (
<Text type="secondary" style={{ fontSize: 12 }}>
{order.reason || '规格相符'}
</Text>
)}
</Space>
<Button
size="small"
type={isSelected ? 'primary' : 'default'}
onClick={() => {
setSelected({
workOrderId: order.workOrderId,
workOrderLabel: `${order.workOrderNo || `#${order.workOrderId}`} · ${order.productName}`,
workOrderCreatedAt: order.createdAt,
inventoryId: inventory?.inventoryId || 0,
inventoryLabel,
inventoryInboundAt,
matchType: order.matchType,
conflicts: order.conflicts,
workerId: inventoryWorker?.workerId ?? null,
workerName: inventoryWorker?.workerName || '',
})
setConfirmedPartial(false)
setConfirmedMismatch(false)
setMatchNote('')
}}
>
{isSelected ? '已选' : '选它'}
</Button>
</Space>
</div>
)
})
)}
</div>
</Spin>
{selected ? (
<div style={{ border: '1px solid #f0f0f0', borderRadius: 8, padding: 12 }}>
<Descriptions size="small" column={1} style={{ marginBottom: 8 }}>
<Descriptions.Item label="订单">
<Space size={6} wrap>
<span>{selected.workOrderLabel || '—'}</span>
{selected.workOrderCreatedAt ? (
<Text type="secondary" style={{ fontSize: 12 }}>
(创建于 {formatDateTime(selected.workOrderCreatedAt)})
</Text>
) : null}
</Space>
</Descriptions.Item>
<Descriptions.Item label="现货">
<Space size={6} wrap>
<span>{selected.inventoryLabel || '—'}</span>
{selected.inventoryInboundAt ? (
<Text type="secondary" style={{ fontSize: 12 }}>
(审核入库于 {formatDateTime(selected.inventoryInboundAt)})
</Text>
) : null}
</Space>
</Descriptions.Item>
<Descriptions.Item label="供货来源">
{selected.workerId ? (
<Space size={6}>
<Tag icon={<TeamOutlined />} color="blue">
{selected.workerName || `供货打手 #${selected.workerId}`}
</Tag>
<Text type="secondary" style={{ fontSize: 12 }}>
(验收后将自动为该打手发放货款)
</Text>
</Space>
) : (
<Tag color="default">内部库存现货(无打手关联)</Tag>
)}
</Descriptions.Item>
<Descriptions.Item label="匹配判定">
<Tag color={describeManualMatchType(selected.matchType).color}>
{describeManualMatchType(selected.matchType).label}
</Tag>
{selected.conflicts.length > 0 ? (
<Text style={{ fontSize: 12, color: '#cf1322' }}>
{selected.conflicts.join(';')}
</Text>
) : null}
</Descriptions.Item>
</Descriptions>
<Space direction="vertical" size={8} style={{ width: '100%' }}>
{requirement.needsPartialConfirm ? (
<Checkbox
checked={confirmedPartial}
onChange={(e) => setConfirmedPartial(e.target.checked)}
>
允许非精确匹配(订单规格未写全或高配充低配)
</Checkbox>
) : null}
{requirement.needsMismatchConfirm ? (
<>
<Alert
type="error"
showIcon
message="该现货与订单规格不一致,确认要强制匹配?"
description="系统/区服/系列/钻级不符通常是发错货;确实要指定请勾选下方确认并填写原因,操作会留痕。"
/>
<Checkbox
checked={confirmedMismatch}
onChange={(e) => setConfirmedMismatch(e.target.checked)}
>
确认规格不一致,仍要手动匹配
</Checkbox>
</>
) : null}
{requirement.needsNote ? (
<Input.TextArea
rows={2}
maxLength={200}
showCount
placeholder="手动匹配原因(必填,会写入工单留痕,便于事后核对)"
value={matchNote}
onChange={(e) => setMatchNote(e.target.value)}
/>
) : null}
<Paragraph type="secondary" style={{ fontSize: 12, marginBottom: 0 }}>
确认后现货立即被占用并进入「待加好友」,双方加好买家好友后点确认加好友进入赠送冷却期。
如需退回请使用「撤销寄售履约」。
</Paragraph>
</Space>
</div>
) : null}
</Space>
</Modal>
)
}