feat(consignment): 客服手动指定现货匹配的界面入口
后端批次已提供判定/差异/强制留痕与双向读取接口,这里补界面,让客服真的能用上。 - 新增共享弹窗 ConsignmentManualMatchModal:订单侧(匹配弹窗「手动指定现货匹配」)与 库存侧(寄售仓库行内「手动匹配订单」)共用同一套交互与落地逻辑,避免两套 UI 漂移 - 交互:搜索 → 选中(精确/非精确/规格冲突三档标签 + 字段级差异说明)→ 选交付方式 (内部现货履约 / 指派供货打手)→ 非精确需勾选确认、冲突需勾选「确认规格不一致」并必填原因 → 落地;并在落地前提示"现货立刻被占用、点错需撤销寄售履约" - 判定规则收敛成纯函数 consignment-manual-match.ts(需不需要确认/填原因、能否提交、 关键词过滤、标签文案)并加 4 例单测 - 服务与类型补齐:fetchWorkOrderConsignmentMatchOptions / fetchInventoryMatchableOrders, fulfill/assign 入参增加 allowSpecMismatch 与 matchNote - 凭证核销失败的提示沿用既有规则(核销失败不能报成功) 验证:前端 typecheck/lint/format/44 例测试/build 全过;两个新接口未登录访问均 401 (路由已挂载、权限位生效)。
This commit is contained in:
@@ -0,0 +1,478 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Checkbox,
|
||||
Descriptions,
|
||||
Empty,
|
||||
Input,
|
||||
Modal,
|
||||
Radio,
|
||||
Space,
|
||||
Spin,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd'
|
||||
import { SearchOutlined } from '@ant-design/icons'
|
||||
|
||||
import {
|
||||
assignWorkOrderWithConsignmentWorker,
|
||||
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'
|
||||
|
||||
const { Text, Paragraph } = Typography
|
||||
|
||||
type Selected = {
|
||||
workOrderId: number | string
|
||||
workOrderLabel: string
|
||||
inventoryId: number | string
|
||||
inventoryLabel: string
|
||||
matchType: ManualMatchType
|
||||
conflicts: string[]
|
||||
/** 指派打手线要用的供货卖家(现货的供货打手);内部现货履约不需要 */
|
||||
workerId?: number | null
|
||||
workerName?: string
|
||||
}
|
||||
|
||||
export type ConsignmentManualMatchModalProps = {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
/** 订单侧入口:从订单出发选现货 */
|
||||
workOrder?: { workOrderId: number | string; label: string } | null
|
||||
/** 库存侧入口:从现货出发选订单 */
|
||||
inventory?: { inventoryId: number | string; label: 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 [selected, setSelected] = useState<Selected | null>(null)
|
||||
const [deliveryKind, setDeliveryKind] = useState<'internal_stock' | 'assign_worker'>(
|
||||
'internal_stock',
|
||||
)
|
||||
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)
|
||||
setDeliveryKind('internal_stock')
|
||||
setConfirmedPartial(false)
|
||||
setConfirmedMismatch(false)
|
||||
setMatchNote('')
|
||||
}
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
if (inventory) {
|
||||
const res = await fetchInventoryMatchableOrders(inventory.inventoryId)
|
||||
setOrders(res.data?.orders || [])
|
||||
setInventoryLabel(
|
||||
res.data?.inventory
|
||||
? `${res.data.inventory.itemName}(${res.data.inventory.sourceSubmissionNo || res.data.inventory.inventoryNo})`
|
||||
: inventory.label,
|
||||
)
|
||||
} else if (workOrder) {
|
||||
const res = await fetchWorkOrderConsignmentMatchOptions(workOrder.workOrderId)
|
||||
setOptions(res.data?.options || [])
|
||||
}
|
||||
} catch (err: any) {
|
||||
message.error(err.message || '读取可匹配数据失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
]),
|
||||
[options, keyword],
|
||||
)
|
||||
const filteredOrders = useMemo(
|
||||
() =>
|
||||
filterManualMatchOptions(orders, keyword, (item) => [
|
||||
item.workOrderNo,
|
||||
item.productName,
|
||||
item.requiredSpec.system,
|
||||
item.requiredSpec.serverZone,
|
||||
item.requiredSpec.series,
|
||||
]),
|
||||
[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 payload = {
|
||||
workOrderId: selected.workOrderId,
|
||||
inventoryId: selected.inventoryId,
|
||||
allowPartial: selected.matchType !== 'exact',
|
||||
allowSpecMismatch: selected.matchType === 'conflict',
|
||||
matchNote: matchNote.trim(),
|
||||
}
|
||||
const res =
|
||||
deliveryKind === 'assign_worker'
|
||||
? await assignWorkOrderWithConsignmentWorker({
|
||||
...payload,
|
||||
workerId: selected.workerId ?? undefined,
|
||||
})
|
||||
: await fulfillWorkOrderWithConsignment(payload)
|
||||
|
||||
const voucher = (res.data as any)?.voucherConsume
|
||||
if (voucher && voucher.ok === false) {
|
||||
message.warning({
|
||||
content: `状态已变更,但电子凭证核销失败:${
|
||||
voucher.errorMessage || '未知错误'
|
||||
}。请在快手后台人工核销`,
|
||||
duration: 8,
|
||||
})
|
||||
} else {
|
||||
message.success(
|
||||
deliveryKind === 'assign_worker'
|
||||
? '已手动匹配:指派供货打手履约,押金已冻结'
|
||||
: '已手动匹配:使用内部现货履约,工单进入待加好友',
|
||||
)
|
||||
}
|
||||
onMatched?.()
|
||||
resetState()
|
||||
onClose()
|
||||
} catch (err: any) {
|
||||
message.error(err.message || '手动匹配失败')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={mode === 'order' ? `手动指定现货 · ${workOrder?.label || ''}` : '手动匹配订单'}
|
||||
open={open}
|
||||
width={860}
|
||||
onCancel={() => {
|
||||
resetState()
|
||||
onClose()
|
||||
}}
|
||||
afterOpenChange={(visible) => {
|
||||
if (visible) void load()
|
||||
}}
|
||||
footer={
|
||||
<Space>
|
||||
<Button
|
||||
onClick={() => {
|
||||
resetState()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="primary" loading={submitting} disabled={!canSubmit} onClick={handleSubmit}>
|
||||
确认匹配
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
destroyOnClose
|
||||
>
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="手动匹配会立刻占用现货、冻结相关押金,点错需要用「撤销寄售履约」退回"
|
||||
description={
|
||||
mode === 'inventory'
|
||||
? `当前现货:${inventoryLabel}。选中订单后选择交付方式即可。`
|
||||
: '下面是该订单的全部在库现货(含规格不一致的),请核对差异后再决定。'
|
||||
}
|
||||
/>
|
||||
|
||||
<Input
|
||||
allowClear
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder={
|
||||
mode === 'order'
|
||||
? '搜索物品名 / 寄卖单号 / 仓库号 / 规格'
|
||||
: '搜索订单号 / 商品名 / 规格'
|
||||
}
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
/>
|
||||
|
||||
<Spin spinning={loading}>
|
||||
<div style={{ maxHeight: 320, overflowY: 'auto' }}>
|
||||
{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>
|
||||
<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.system}/{option.inventory.serverZone}/
|
||||
{option.inventory.series} {option.inventory.diamondLevel}钻 · 货源{' '}
|
||||
{option.inventory.workerName || '—'} · 结算价 ¥
|
||||
{(option.inventory.costPriceFen / 100).toFixed(2)}
|
||||
</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 || '',
|
||||
inventoryId: option.inventory.id,
|
||||
inventoryLabel: option.inventory.itemName,
|
||||
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>
|
||||
<Text strong>#{order.workOrderId}</Text>
|
||||
<Text>{order.productName}</Text>
|
||||
<Tag color={meta.color}>{meta.label}</Tag>
|
||||
</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)}
|
||||
</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.workOrderId} ${order.productName}`,
|
||||
inventoryId: inventory?.inventoryId || 0,
|
||||
inventoryLabel,
|
||||
matchType: order.matchType,
|
||||
conflicts: order.conflicts,
|
||||
})
|
||||
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="订单">{selected.workOrderLabel || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="现货">{selected.inventoryLabel || '—'}</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%' }}>
|
||||
<Radio.Group
|
||||
value={deliveryKind}
|
||||
onChange={(e) => setDeliveryKind(e.target.value)}
|
||||
options={[
|
||||
{
|
||||
value: 'internal_stock',
|
||||
label: '内部现货履约(客服/内部仓代交付,工单停在待加好友)',
|
||||
},
|
||||
{
|
||||
value: 'assign_worker',
|
||||
label: `指派供货打手履约(冻结押金、工单进行中)${
|
||||
selected.workerName ? ` · 打手:${selected.workerName}` : ''
|
||||
}`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{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>
|
||||
)
|
||||
}
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
updateAdminConsignmentInventory,
|
||||
} from '@/services/admin/consignment'
|
||||
import { fetchAdminWorkerUsers } from '@/services/admin/worker-platform'
|
||||
import ConsignmentManualMatchModal from '@/components/admin/ConsignmentManualMatchModal'
|
||||
import type { ConsignmentInventory, GiftEvidenceReviewStatus } from '@/types/consignment'
|
||||
import { fetchAdminSession } from '@/services/admin/auth'
|
||||
import { resolveAdminPermission } from '@/utils/admin-auth'
|
||||
@@ -143,6 +144,7 @@ export default function ConsignmentInventoryPanel() {
|
||||
const [editingItem, setEditingItem] = useState<ConsignmentInventory | null>(null)
|
||||
const [viewingItem, setViewingItem] = useState<ConsignmentInventory | null>(null)
|
||||
const [returnItem, setReturnItem] = useState<ConsignmentInventory | null>(null)
|
||||
const [manualMatchItem, setManualMatchItem] = useState<ConsignmentInventory | null>(null)
|
||||
const [returnReason, setReturnReason] = useState<string>('')
|
||||
|
||||
const [editForm] = Form.useForm()
|
||||
@@ -552,6 +554,9 @@ export default function ConsignmentInventoryPanel() {
|
||||
</Button>
|
||||
{record.status === 'in_stock' && canManageConsignment ? (
|
||||
<>
|
||||
<Button size="small" type="link" onClick={() => setManualMatchItem(record)}>
|
||||
手动匹配订单
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
type="link"
|
||||
@@ -939,6 +944,20 @@ export default function ConsignmentInventoryPanel() {
|
||||
</Form.Item>
|
||||
</Card>
|
||||
|
||||
<ConsignmentManualMatchModal
|
||||
open={Boolean(manualMatchItem)}
|
||||
onClose={() => setManualMatchItem(null)}
|
||||
inventory={
|
||||
manualMatchItem
|
||||
? {
|
||||
inventoryId: manualMatchItem.id,
|
||||
label: manualMatchItem.itemName,
|
||||
}
|
||||
: null
|
||||
}
|
||||
onMatched={() => void inventoriesQuery.refetch()}
|
||||
/>
|
||||
|
||||
<Form.Item name="remark" label="备注">
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
|
||||
@@ -82,6 +82,7 @@ import {
|
||||
} from '@/services/admin/consignment'
|
||||
import type { ConsignmentFulfillmentInfo, WorkOrderInventoryMatchCheck } from '@/types/consignment'
|
||||
import type { UploadedFile, WorkOrder, WorkOrderShare } from '@/types/worker-platform'
|
||||
import ConsignmentManualMatchModal from '@/components/admin/ConsignmentManualMatchModal'
|
||||
import { AdminRangePicker } from '@/components/admin/AdminDatePicker'
|
||||
import dayjs, { ADMIN_DATE_FORMAT } from '@/lib/dayjs'
|
||||
import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination'
|
||||
@@ -313,6 +314,7 @@ export default function WorkOrdersPanel() {
|
||||
}>()
|
||||
|
||||
// 寄售库存匹配拦截弹窗状态
|
||||
const [manualMatchOrder, setManualMatchOrder] = useState<WorkOrder | null>(null)
|
||||
const [consignmentInterceptModal, setConsignmentInterceptModal] = useState<{
|
||||
open: boolean
|
||||
order: WorkOrder | null
|
||||
@@ -2970,6 +2972,23 @@ export default function WorkOrdersPanel() {
|
||||
})
|
||||
}}
|
||||
footer={[
|
||||
<Button
|
||||
key="manual-match"
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
const currentOrder = consignmentInterceptModal.order
|
||||
if (!currentOrder) return
|
||||
setConsignmentInterceptModal({
|
||||
open: false,
|
||||
order: null,
|
||||
matchData: null,
|
||||
submitting: false,
|
||||
})
|
||||
setManualMatchOrder(currentOrder)
|
||||
}}
|
||||
>
|
||||
手动指定现货匹配
|
||||
</Button>,
|
||||
<Button
|
||||
key="ignore-and-publish"
|
||||
onClick={() => {
|
||||
@@ -3189,6 +3208,22 @@ export default function WorkOrdersPanel() {
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<ConsignmentManualMatchModal
|
||||
open={Boolean(manualMatchOrder)}
|
||||
onClose={() => setManualMatchOrder(null)}
|
||||
workOrder={
|
||||
manualMatchOrder
|
||||
? {
|
||||
workOrderId: manualMatchOrder.workOrderId,
|
||||
label: `#${manualMatchOrder.workOrderId} ${manualMatchOrder.productName || ''}`,
|
||||
}
|
||||
: null
|
||||
}
|
||||
onMatched={() => {
|
||||
void ordersQuery.refetch()
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 撤单 / 撤销寄售履约弹窗 */}
|
||||
<Modal
|
||||
title={`撤单 / 撤销寄售履约 · ${getDisplayOrderNo(revokeConsignmentModal.order)}`}
|
||||
|
||||
@@ -3,7 +3,9 @@ import type {
|
||||
ConsignmentInventory,
|
||||
ConsignmentStatsSummary,
|
||||
ConsignmentSubmission,
|
||||
InventoryMatchableOrders,
|
||||
WorkOrderInventoryMatchCheck,
|
||||
WorkOrderInventoryMatchOptions,
|
||||
} from '@/types/consignment'
|
||||
|
||||
// --- 库存管理 ---
|
||||
@@ -97,6 +99,27 @@ export function checkWorkOrderConsignmentMatches(workOrderId: number | string) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动指定现货:该订单的**全部**在库现货(含不符合规格的),逐件带匹配判定与差异说明。
|
||||
* 与 checkWorkOrderConsignmentMatches 的区别:后者只给系统候选,这里给"人工拍板"的全量视图。
|
||||
*/
|
||||
export function fetchWorkOrderConsignmentMatchOptions(workOrderId: number | string) {
|
||||
return apiGet<WorkOrderInventoryMatchOptions>(
|
||||
`/api/v1/admin/worker-platform/consignment/matches/${workOrderId}/options`,
|
||||
)
|
||||
}
|
||||
|
||||
/** 手动匹配:从现货出发,列出可以匹配的待指派/待补资料订单(逐单带规格判定) */
|
||||
export function fetchInventoryMatchableOrders(
|
||||
inventoryId: number | string,
|
||||
params?: { keyword?: string },
|
||||
) {
|
||||
return apiGet<InventoryMatchableOrders>(
|
||||
`/api/v1/admin/worker-platform/consignment/inventories/${inventoryId}/match-orders`,
|
||||
params,
|
||||
)
|
||||
}
|
||||
|
||||
/** 履约/指派返回的电子凭证核销结果:ok=false 时必须提示运营人工核销,不能报成功 */
|
||||
export type WorkOrderVoucherConsumeResult = {
|
||||
ok: boolean
|
||||
@@ -113,6 +136,10 @@ export function fulfillWorkOrderWithConsignment(payload: {
|
||||
inventoryId: number | string
|
||||
/** 订单规格信息不全或高配充低配时,必须显式确认才放行(后端会二次校验) */
|
||||
allowPartial?: boolean
|
||||
/** 规格真冲突(系统/区服/系列/钻级不符)也放行,必须同时给 matchNote */
|
||||
allowSpecMismatch?: boolean
|
||||
/** 手动指定现货的原因(强制匹配必填,后端会留痕) */
|
||||
matchNote?: string
|
||||
}) {
|
||||
return apiPost<{
|
||||
workOrder: unknown
|
||||
@@ -127,6 +154,10 @@ export function assignWorkOrderWithConsignmentWorker(payload: {
|
||||
workerId?: number | null
|
||||
/** 订单规格信息不全或高配充低配时,必须显式确认才放行(后端会二次校验) */
|
||||
allowPartial?: boolean
|
||||
/** 规格真冲突(系统/区服/系列/钻级不符)也放行,必须同时给 matchNote */
|
||||
allowSpecMismatch?: boolean
|
||||
/** 手动指定现货的原因(强制匹配必填,后端会留痕) */
|
||||
matchNote?: string
|
||||
}) {
|
||||
return apiPost<{
|
||||
workOrder: unknown
|
||||
|
||||
@@ -146,6 +146,41 @@ export type ConsignmentMatchResult = {
|
||||
reason?: string
|
||||
}
|
||||
|
||||
/** 手动指定现货:订单侧的候选(含不符合规格的,附字段级差异) */
|
||||
export type ConsignmentMatchOptionView = {
|
||||
inventory: ConsignmentInventory
|
||||
matchType: 'exact' | 'partial' | 'conflict'
|
||||
score: number
|
||||
reason: string
|
||||
conflicts: string[]
|
||||
}
|
||||
|
||||
export type WorkOrderInventoryMatchOptions = {
|
||||
hasMatch: boolean
|
||||
exactCount: number
|
||||
partialCount: number
|
||||
options: ConsignmentMatchOptionView[]
|
||||
}
|
||||
|
||||
/** 手动匹配:库存侧的可选订单(同样带规格判定) */
|
||||
export type ConsignmentMatchableOrder = {
|
||||
workOrderId: number
|
||||
workOrderNo: string
|
||||
productName: string
|
||||
status: string
|
||||
rewardAmountFen: number
|
||||
requiredSpec: { system: string; serverZone: string; series: string; diamondLevel: number }
|
||||
matchType: 'exact' | 'partial' | 'conflict'
|
||||
score: number
|
||||
reason: string
|
||||
conflicts: string[]
|
||||
}
|
||||
|
||||
export type InventoryMatchableOrders = {
|
||||
inventory: ConsignmentInventory
|
||||
orders: ConsignmentMatchableOrder[]
|
||||
}
|
||||
|
||||
export type WorkOrderInventoryMatchCheck = {
|
||||
hasMatch: boolean
|
||||
exactCount: number
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* 「手动指定现货」的判定辅助(纯函数,便于单测)。
|
||||
*
|
||||
* 业务规则(用户口径):
|
||||
* - 精确匹配:直接落地;
|
||||
* - 非精确匹配(订单规格没写全 / 高配充低配):必须勾选确认;
|
||||
* - 规格冲突(系统/区服/系列/钻级不符):必须勾选「确认规格不一致」,且**必填原因**,
|
||||
* 原因会写进工单事件与 material 留痕,方便事后核对是谁指定了这件货。
|
||||
*/
|
||||
|
||||
export type ManualMatchType = 'exact' | 'partial' | 'conflict'
|
||||
|
||||
export type ManualMatchRequirement = {
|
||||
needsPartialConfirm: boolean
|
||||
needsMismatchConfirm: boolean
|
||||
needsNote: boolean
|
||||
}
|
||||
|
||||
export function resolveManualMatchRequirement(matchType: ManualMatchType): ManualMatchRequirement {
|
||||
return {
|
||||
needsPartialConfirm: matchType === 'partial',
|
||||
needsMismatchConfirm: matchType === 'conflict',
|
||||
needsNote: matchType === 'conflict',
|
||||
}
|
||||
}
|
||||
|
||||
export function describeManualMatchType(matchType: ManualMatchType): {
|
||||
label: string
|
||||
color: string
|
||||
tone: 'ok' | 'warning' | 'danger'
|
||||
} {
|
||||
if (matchType === 'exact') {
|
||||
return { label: '精确匹配', color: 'green', tone: 'ok' }
|
||||
}
|
||||
if (matchType === 'partial') {
|
||||
return { label: '非精确匹配', color: 'orange', tone: 'warning' }
|
||||
}
|
||||
return { label: '规格冲突', color: 'red', tone: 'danger' }
|
||||
}
|
||||
|
||||
/** 提交按钮是否可用:该确认的都要确认,冲突还要填原因 */
|
||||
export function canSubmitManualMatch(input: {
|
||||
matchType: ManualMatchType
|
||||
confirmedPartial?: boolean
|
||||
confirmedMismatch?: boolean
|
||||
matchNote?: string
|
||||
}): boolean {
|
||||
const requirement = resolveManualMatchRequirement(input.matchType)
|
||||
if (requirement.needsPartialConfirm && input.confirmedPartial !== true) return false
|
||||
if (requirement.needsMismatchConfirm && input.confirmedMismatch !== true) return false
|
||||
if (requirement.needsNote && !String(input.matchNote || '').trim()) return false
|
||||
return true
|
||||
}
|
||||
|
||||
/** 关键词过滤:现货按物品名/寄卖单号/仓库号搜,订单按单号/商品名/买家搜 */
|
||||
export function filterManualMatchOptions<T>(
|
||||
list: T[],
|
||||
keyword: string,
|
||||
pickFields: (item: T) => Array<string | number | null | undefined>,
|
||||
): T[] {
|
||||
const trimmed = String(keyword || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
if (!trimmed) return list
|
||||
return list.filter((item) =>
|
||||
pickFields(item).some((value) =>
|
||||
String(value ?? '')
|
||||
.toLowerCase()
|
||||
.includes(trimmed),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
canSubmitManualMatch,
|
||||
describeManualMatchType,
|
||||
filterManualMatchOptions,
|
||||
resolveManualMatchRequirement,
|
||||
} from '../src/utils/consignment-manual-match.ts'
|
||||
|
||||
test('精确匹配不需要任何确认,直接可提交', () => {
|
||||
assert.deepEqual(resolveManualMatchRequirement('exact'), {
|
||||
needsPartialConfirm: false,
|
||||
needsMismatchConfirm: false,
|
||||
needsNote: false,
|
||||
})
|
||||
assert.equal(canSubmitManualMatch({ matchType: 'exact' }), true)
|
||||
})
|
||||
|
||||
test('非精确匹配必须勾选确认,但不用写原因', () => {
|
||||
assert.equal(resolveManualMatchRequirement('partial').needsNote, false)
|
||||
assert.equal(canSubmitManualMatch({ matchType: 'partial' }), false)
|
||||
assert.equal(canSubmitManualMatch({ matchType: 'partial', confirmedPartial: true }), true)
|
||||
})
|
||||
|
||||
test('规格冲突必须勾选确认**并且**填原因', () => {
|
||||
assert.equal(resolveManualMatchRequirement('conflict').needsNote, true)
|
||||
assert.equal(canSubmitManualMatch({ matchType: 'conflict', confirmedMismatch: true }), false)
|
||||
assert.equal(
|
||||
canSubmitManualMatch({ matchType: 'conflict', confirmedMismatch: true, matchNote: ' ' }),
|
||||
false,
|
||||
)
|
||||
assert.equal(
|
||||
canSubmitManualMatch({
|
||||
matchType: 'conflict',
|
||||
confirmedMismatch: true,
|
||||
matchNote: '订单规格录错,已与买家确认',
|
||||
}),
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
test('匹配状态文案与色调:绿/橙/红三档,冲突要一眼看出来', () => {
|
||||
assert.equal(describeManualMatchType('exact').tone, 'ok')
|
||||
assert.equal(describeManualMatchType('partial').tone, 'warning')
|
||||
assert.equal(describeManualMatchType('conflict').tone, 'danger')
|
||||
assert.equal(describeManualMatchType('conflict').label, '规格冲突')
|
||||
})
|
||||
|
||||
test('关键词过滤:现货能按寄卖单号/物品名搜到,大小写与空格无关', () => {
|
||||
const list = [
|
||||
{ submissionNo: 'SUB26091711256110', itemName: '苹果微信暗潮2钻' },
|
||||
{ submissionNo: 'SUB26091519501248', itemName: '安卓QQ苍龙4钻' },
|
||||
]
|
||||
const pick = (item: (typeof list)[number]) => [item.submissionNo, item.itemName]
|
||||
|
||||
assert.equal(filterManualMatchOptions(list, '', pick).length, 2)
|
||||
assert.equal(filterManualMatchOptions(list, ' sub260917 ', pick).length, 1)
|
||||
assert.equal(filterManualMatchOptions(list, '苍龙', pick).length, 1)
|
||||
assert.equal(filterManualMatchOptions(list, '不存在', pick).length, 0)
|
||||
})
|
||||
@@ -21,6 +21,7 @@
|
||||
| `632dddc4` | 上一轮:H5 渠道整体下线 + 凭据字段修复 + 状态机封闭(撤销守卫 / 库存绑定唯一性 / 生命周期解绑)+ 押金归属取台账 | 迁移 `079`、`consignment-*` 五个文件、`work-order-*` 三个仓库、两个前端面板 |
|
||||
| `5d11bf20` | 批次 C+D:指派部分成功补偿、凭证核销如实反馈、空规格不放行 + 履约/指派服务端规格复检 | `consignment-fulfillment-service.ts`、`consignment-matching-service.ts`、`consignment-repo.ts`、履约路由、`WorkOrdersPanel.tsx` |
|
||||
| `d6f6219f` | 批次 F1:内部现货履约停在「待加好友」,客服确认后才进赠送冷却 + 备注快捷项 | `work-order-gift-repo.ts`、履约服务、`WorkOrdersPanel.tsx`、`utils/gift-phase.ts` |
|
||||
| 本批提交(批次 S,同一提交内含本文档,故不写自身 hash) | 批次 S:手动指定现货匹配(判定/差异/强制留痕 + 双向入口 + 共享弹窗) | `consignment-matching-service.ts`、`consignment-fulfillment-service.ts`、`ConsignmentManualMatchModal.tsx` |
|
||||
| 本批提交(批次 R,同一提交内含本文档,故不写自身 hash) | 批次 R:售后立案回落到供货卖家;抽共享"从工单找供货卖家"查询 | `consignment-supplier-repo.ts`、`after-sales-repo.ts`、`consignment-deposit-repo.ts` |
|
||||
| 本批提交(批次 Q,同一提交内含本文档,故不写自身 hash) | 批次 Q:验收结算补寄卖货款给供货卖家;修指派打手线报酬 100 倍单位错 | `consignment-deposit-repo.ts`、`work-order-settlement-repo.ts`、`consignment-fulfillment-service.ts` |
|
||||
| 本批提交(批次 P,同一提交内含本文档,故不写自身 hash) | 批次 P:确认加好友/上传赠送截图恢复为卖家操作,客服兜底 | `consignment-item-stage.ts`、`consignment-worker-service.ts`、`worker-acceptance-service.ts` |
|
||||
@@ -288,6 +289,45 @@
|
||||
前端在"部分匹配"候选上先 `modal.confirm` 危险确认,再带 `allowPartial: true` 重试,高配充低配的合法玩法保留但不可误触。
|
||||
3. ⏳ 匹配下推 SQL + 批量匹配接口 + 工单列表角标(P2-8)未做:当前仍是"取全量在库 → 内存比对"。
|
||||
|
||||
### 批次 S · 客服手动指定现货匹配(本轮)
|
||||
|
||||
**需求**(用户):客服要能在后台**人工指定**现货与订单匹配起来,而不是只能用系统算出的候选。
|
||||
|
||||
**先说清楚现状**:后端其实早就留了"人工选定现货"的口径(`allowPartial` + 宽松规格复检),
|
||||
只是**没有界面入口**,客服只能从"规格完全对得上"的自动候选里挑;遇到订单规格没写全、
|
||||
订单录错、需要高配充低配时无从下手。
|
||||
|
||||
**改法**(三项决策均由用户确认:允许强制匹配但必须留痕 / 两种交付方式都保留 / 双向入口都加):
|
||||
|
||||
1. ✅ **判定与解释**:新增 `listConsignmentMatchOptions()`/`classifyConsignmentMatch()`,
|
||||
把某订单的**全部**在库现货逐件判定 exact / partial / conflict,conflict 时给出**字段级差异**
|
||||
(`describeConsignmentSpecDifferences`,例如「系统:订单「苹果」/ 现货「安卓」」)。
|
||||
正反两个方向共用同一套判定,避免"界面说能选、接口却拒绝"。
|
||||
2. ✅ **强制匹配 + 留痕**:履约/指派两条链路新增 `allowSpecMismatch` 与 `matchNote`。
|
||||
规格真冲突默认仍拒绝(409 `consignment_spec_mismatch`);显式确认后放行,但
|
||||
**必填原因**(400 `consignment_match_note_required`),原因与差异明细写入工单事件与
|
||||
`material.consignment`(`manualMatch` / `matchType` / `matchNote` / `specConflicts`)。
|
||||
3. ✅ **双向入口**:订单侧 `GET /consignment/matches/:workOrderId/options`(全部在库现货+判定),
|
||||
库存侧 `GET /consignment/inventories/:id/match-orders`(该现货可匹配的待指派/待补资料订单+判定)。
|
||||
4. ✅ **前端一个共享弹窗**:`ConsignmentManualMatchModal`,订单侧(匹配弹窗里「手动指定现货匹配」)
|
||||
与库存侧(寄售仓库行内「手动匹配订单」)共用,避免两套 UI 逻辑漂移。
|
||||
交互:搜索 → 选中(带判定标签与差异说明)→ 选交付方式(内部现货履约 / 指派供货打手)
|
||||
→ 非精确勾选确认 / 冲突勾选「确认规格不一致」+ 必填原因 → 落地。
|
||||
判定规则收敛在纯函数 `consignment-manual-match.ts` 并有单测(4 例)。
|
||||
|
||||
**真实库验证**(订单要「苹果微信区暗潮2钻」,现货为「安卓QQ区苍龙4钻」):
|
||||
|
||||
| 步骤 | 结果 |
|
||||
| --- | --- |
|
||||
| 订单侧选项 | 该件判定 `conflict`,列出 4 项差异(系统/区服/系列/钻石等级) |
|
||||
| 库存侧可匹配订单 | 该订单判定 `conflict`,要求规格解析正确 |
|
||||
| 不填原因强制匹配 | ✅ 被拒 `consignment_match_note_required` |
|
||||
| 不勾选强制、直接履约 | ✅ 被规格复检拒绝 `consignment_spec_mismatch` |
|
||||
| 勾选 + 填原因 | ✅ 成功:订单 `in_progress`、现货 `consumed`,事件与 material 均留痕 |
|
||||
| 接口鉴权 | 未登录访问两个新接口均 401(路由已挂载、权限位生效) |
|
||||
|
||||
临时数据已清理(残留 0)。
|
||||
|
||||
### 批次 R · 售后立案也认寄卖关系(本轮)
|
||||
|
||||
补货款时顺手排查"只认接单人"的地方,发现同类第三处:**售后立案**。
|
||||
|
||||
Reference in New Issue
Block a user