diff --git a/apps/frontend/src/components/admin/ConsignmentManualMatchModal.tsx b/apps/frontend/src/components/admin/ConsignmentManualMatchModal.tsx new file mode 100644 index 00000000..ebf5d81a --- /dev/null +++ b/apps/frontend/src/components/admin/ConsignmentManualMatchModal.tsx @@ -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([]) + const [orders, setOrders] = useState([]) + const [inventoryLabel, setInventoryLabel] = useState('') + const [selected, setSelected] = useState(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 ( + { + resetState() + onClose() + }} + afterOpenChange={(visible) => { + if (visible) void load() + }} + footer={ + + + + + } + destroyOnClose + > + + + + } + placeholder={ + mode === 'order' + ? '搜索物品名 / 寄卖单号 / 仓库号 / 规格' + : '搜索订单号 / 商品名 / 规格' + } + value={keyword} + onChange={(e) => setKeyword(e.target.value)} + /> + + +
+ {mode === 'order' ? ( + filteredOptions.length === 0 ? ( + + ) : ( + filteredOptions.map((option) => { + const meta = describeManualMatchType(option.matchType) + const isSelected = + selected?.inventoryId != null && + String(selected.inventoryId) === String(option.inventory.id) + return ( +
+ + + + {option.inventory.itemName} + {meta.label} + + + 寄卖单号 {option.inventory.sourceSubmissionNo || '—'} ·{' '} + {option.inventory.system}/{option.inventory.serverZone}/ + {option.inventory.series} {option.inventory.diamondLevel}钻 · 货源{' '} + {option.inventory.workerName || '—'} · 结算价 ¥ + {(option.inventory.costPriceFen / 100).toFixed(2)} + + {option.conflicts.length > 0 ? ( + + 差异:{option.conflicts.join(';')} + + ) : ( + + {option.reason || '规格相符'} + + )} + + + +
+ ) + }) + ) + ) : filteredOrders.length === 0 ? ( + + ) : ( + filteredOrders.map((order) => { + const meta = describeManualMatchType(order.matchType) + const isSelected = + selected?.workOrderId != null && + String(selected.workOrderId) === String(order.workOrderId) + return ( +
+ + + + #{order.workOrderId} + {order.productName} + {meta.label} + + + 要求:{order.requiredSpec.system || '未指定'}/ + {order.requiredSpec.serverZone || '未指定'}/ + {order.requiredSpec.series || '未指定'}{' '} + {order.requiredSpec.diamondLevel + ? `${order.requiredSpec.diamondLevel}钻` + : ''}{' '} + · 报酬 ¥{(order.rewardAmountFen / 100).toFixed(2)} + + {order.conflicts.length > 0 ? ( + + 差异:{order.conflicts.join(';')} + + ) : ( + + {order.reason || '规格相符'} + + )} + + + +
+ ) + }) + )} +
+
+ + {selected ? ( +
+ + {selected.workOrderLabel || '—'} + {selected.inventoryLabel || '—'} + + + {describeManualMatchType(selected.matchType).label} + + {selected.conflicts.length > 0 ? ( + + {selected.conflicts.join(';')} + + ) : null} + + + + + setDeliveryKind(e.target.value)} + options={[ + { + value: 'internal_stock', + label: '内部现货履约(客服/内部仓代交付,工单停在待加好友)', + }, + { + value: 'assign_worker', + label: `指派供货打手履约(冻结押金、工单进行中)${ + selected.workerName ? ` · 打手:${selected.workerName}` : '' + }`, + }, + ]} + /> + + {requirement.needsPartialConfirm ? ( + setConfirmedPartial(e.target.checked)} + > + 允许非精确匹配(订单规格未写全或高配充低配) + + ) : null} + + {requirement.needsMismatchConfirm ? ( + <> + + setConfirmedMismatch(e.target.checked)} + > + 确认规格不一致,仍要手动匹配 + + + ) : null} + + {requirement.needsNote ? ( + setMatchNote(e.target.value)} + /> + ) : null} + + + 确认后现货立即被占用:内部现货履约进入「待加好友」,指派打手会冻结履约押金。 + 如需退回请使用「撤销寄售履约」。 + + +
+ ) : null} +
+
+ ) +} diff --git a/apps/frontend/src/pages/admin/panels/ConsignmentInventoryPanel.tsx b/apps/frontend/src/pages/admin/panels/ConsignmentInventoryPanel.tsx index 1838fe3c..67752bbe 100644 --- a/apps/frontend/src/pages/admin/panels/ConsignmentInventoryPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/ConsignmentInventoryPanel.tsx @@ -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(null) const [viewingItem, setViewingItem] = useState(null) const [returnItem, setReturnItem] = useState(null) + const [manualMatchItem, setManualMatchItem] = useState(null) const [returnReason, setReturnReason] = useState('') const [editForm] = Form.useForm() @@ -552,6 +554,9 @@ export default function ConsignmentInventoryPanel() { {record.status === 'in_stock' && canManageConsignment ? ( <> + ,