feat(consignment): 一个寄卖单号锚定全流程 + 统一状态机 + 卖家侧确认加好友/提交赠送截图

问题:一件货身上挂着三套互不相通的状态(提报单审核态、仓库库存态、工单赠送态),
my-items 同时返回两个数组、卖家端分两个列表渲染,同一件货显示两行两个号;
且卖家端只映射了前两套状态,"已出库"之后流程就断了,卖家不知道该干什么。

- 提报单号 SUB… 作为唯一「寄卖单号」从发布锚定到结算(零迁移);
  listWorkerConsignments 改成一件货一条记录,库存/工单信息挂上去,
  客服手工入库的现货(无提报单)才回退用库存号当锚点
- 新增 domain/consignment-item-stage.ts:把三套底层状态推导成卖家看得懂的一个阶段
  (待审核/已通过待匹配/已匹配-待加好友/冷却中/待赠送/已赠送待审核/已完成/驳回/作废/取消)
  + 一句"下一步做什么" + 能力位(谁能确认加好友、谁该传截图),列表筛选与 KPI 也改由它驱动
- 卖家侧新增动作(以前只有 publish + my-items,卖家只能干看):
  POST /worker/consignment/items/:submissionId/{confirm-friend,gift-evidence}
  POST /worker/consignment/inventories/:inventoryId/{confirm-friend,gift-evidence}(手工入库现货)
  复用既有工单链路 confirmWorkOrderFriendAdded / submitWorkerOrderAcceptance,
  只加归属校验(不是本人履约的订单 403),顺带白拿"冷却没结束不许提交凭证""必须带图"
- 客服端用同一个号:库存列表/详情补 sourceSubmissionNo(LEFT JOIN 提报单),
  履约/指派写进工单的寄售信息也带上;关键字搜索支持按寄卖单号搜(计数 SQL 同步补 join)

真实库端到端验证:确认加好友 → gift_phase=friend_countdown / friend_added_at 落库 /
gift_available_at 正好 +72h;冷却期内提交截图被拒(work_order_gift_cooldown_active);
越权操作被拒(consignment_item_not_found);按寄卖单号搜索命中正确。临时数据已清理。
This commit is contained in:
yml2213
2026-09-17 10:57:57 +08:00
parent 99752dce9e
commit 44de11a3a7
19 changed files with 1574 additions and 204 deletions
@@ -92,6 +92,14 @@ const BUYER_COLORS: Record<string, string> = {
客服B: 'magenta',
}
/**
* 寄卖单号 = 来源提报单号(卖家提报时生成的提报单号就是唯一寄卖单号)。
* 客服手工入库的现货没有来源提报,回退显示内部仓库号,保证客服对单口径一致。
*/
function resolveConsignmentNo(record: ConsignmentInventory) {
return record.sourceSubmissionNo || record.inventoryNo
}
export default function ConsignmentInventoryPanel() {
const { message } = App.useApp()
@@ -297,7 +305,7 @@ export default function ConsignmentInventoryPanel() {
async function handleArchive(record: ConsignmentInventory) {
Modal.confirm({
title: '确认下架作废该物品?',
content: `下架后该物品 [${record.itemName} - ${record.inventoryNo}] 将不再参与匹配。`,
content: `下架后该物品 [${record.itemName} - 寄卖单号 ${resolveConsignmentNo(record)}] 将不再参与匹配。`,
okText: '确认作废',
okType: 'danger',
cancelText: '取消',
@@ -342,16 +350,28 @@ export default function ConsignmentInventoryPanel() {
const columns: TableColumnsType<ConsignmentInventory> = [
{
title: '物品/库存编号',
title: '物品/寄卖单号',
key: 'item',
width: 220,
width: 240,
render: (_, record) => (
<div>
<div style={{ fontWeight: 600, fontSize: 14 }}>{record.itemName}</div>
<Space size={4}>
<Text type="secondary" copyable style={{ fontSize: 12 }}>
{record.inventoryNo}
<Space size={4} align="center">
<Text type="secondary" style={{ fontSize: 12 }}>
寄卖单号
</Text>
<Text copyable style={{ fontSize: 12, fontWeight: 600 }}>
{resolveConsignmentNo(record)}
</Text>
</Space>
{record.sourceSubmissionNo ? (
<div>
<Text type="secondary" style={{ fontSize: 11, color: '#bfbfbf' }}>
仓库号 {record.inventoryNo}
</Text>
</div>
) : null}
<div style={{ marginTop: 2 }}>
{record.sourceType === 'worker_portal' ? (
<Tag color="purple" style={{ fontSize: 11, padding: '0 4px' }}>
打手端提报
@@ -365,7 +385,7 @@ export default function ConsignmentInventoryPanel() {
人工入库
</Tag>
)}
</Space>
</div>
</div>
),
},
@@ -1176,7 +1196,7 @@ export default function ConsignmentInventoryPanel() {
{/* 查看详情与凭据弹窗 */}
<Modal
title={`物品详情 [${viewingItem?.inventoryNo}]`}
title={`物品详情 [${viewingItem ? resolveConsignmentNo(viewingItem) : ''}]`}
open={Boolean(viewingItem)}
width={680}
footer={[
@@ -1191,8 +1211,20 @@ export default function ConsignmentInventoryPanel() {
<Descriptions.Item label="物品名称" span={2}>
<strong>{viewingItem.itemName}</strong>
</Descriptions.Item>
<Descriptions.Item label="库存编号">
<Text copyable>{viewingItem.inventoryNo}</Text>
<Descriptions.Item label="寄卖单号">
<Text copyable strong>
{resolveConsignmentNo(viewingItem)}
</Text>
{viewingItem.sourceSubmissionNo ? null : (
<Text type="secondary" style={{ fontSize: 12 }}>
(客服手工入库,无来源提报单号)
</Text>
)}
</Descriptions.Item>
<Descriptions.Item label="仓库号">
<Text type="secondary" copyable>
{viewingItem.inventoryNo}
</Text>
</Descriptions.Item>
<Descriptions.Item label="当前状态">
{viewingItem.status === 'in_stock' ? (
@@ -1289,7 +1321,8 @@ export default function ConsignmentInventoryPanel() {
<Text>
确认将该物品{' '}
<strong>
[{returnItem?.itemName} - {returnItem?.inventoryNo}]
[{returnItem?.itemName} - 寄卖单号{' '}
{returnItem ? resolveConsignmentNo(returnItem) : ''}]
</strong>{' '}
退回复库吗?
</Text>
@@ -80,7 +80,7 @@ import {
revokeWorkOrderConsignmentFulfillment,
type WorkOrderVoucherConsumeResult,
} from '@/services/admin/consignment'
import type { WorkOrderInventoryMatchCheck } from '@/types/consignment'
import type { ConsignmentFulfillmentInfo, WorkOrderInventoryMatchCheck } from '@/types/consignment'
import type { UploadedFile, WorkOrder, WorkOrderShare } from '@/types/worker-platform'
import { AdminRangePicker } from '@/components/admin/AdminDatePicker'
import dayjs, { ADMIN_DATE_FORMAT } from '@/lib/dayjs'
@@ -174,6 +174,20 @@ function isInternalConsignmentOrder(row: WorkOrder) {
return Boolean(consignment.inventoryId) && !row.worker
}
/** 工单 `material.consignment`(寄售履约信息);没有寄售履约信息时返回 null。 */
function getConsignmentFulfillment(row: WorkOrder): ConsignmentFulfillmentInfo | null {
const consignment = asRecord((row.material as Record<string, unknown> | undefined)?.consignment)
return Object.keys(consignment).length > 0 ? (consignment as ConsignmentFulfillmentInfo) : null
}
/**
* 寄卖单号 = 来源提报单号(提报单号即寄卖单号)。
* 历史老单的 consignment 没有 sourceSubmissionNo,回退到内部仓库号 inventoryNo。
*/
function resolveConsignmentNo(info: ConsignmentFulfillmentInfo) {
return info.sourceSubmissionNo || info.inventoryNo || ''
}
/**
* 只有「未验收且未取消」的工单可以撤销寄售履约。
* 已验收工单撤销会造成同一件现货二次出货、并重新冻结已退还的押金,后端一律拒绝。
@@ -330,6 +344,9 @@ export default function WorkOrdersPanel() {
submitting: false,
})
// 详情抽屉里的寄售履约信息(有寄售履约才展示寄卖单号)
const detailConsignment = detailOrder ? getConsignmentFulfillment(detailOrder) : null
// 仅单独筛选「待验收」时提供批量验收入口(多选状态下不启用勾选,避免误操作)
const isPendingAcceptanceOnly = statuses.length === 1 && statuses[0] === 'pending_acceptance'
const selectedWorkOrderStatuses = statuses.filter(
@@ -1210,6 +1227,7 @@ export default function WorkOrdersPanel() {
const source = asRecord(row.material?.source)
const shopName = String(source.shopName || '').trim()
const provider = String(source.provider || '').trim()
const consignment = getConsignmentFulfillment(row)
return (
<div className="cell-stack">
<ProductNameEditableCell
@@ -1255,10 +1273,10 @@ export default function WorkOrdersPanel() {
{shopName ? `店铺:${shopName}` : ''}
</Typography.Text>
) : null}
{(row.material as any)?.consignment ? (
{consignment ? (
<Tag color="purple" style={{ marginTop: 2, width: 'fit-content' }}>
寄售履约: {(row.material as any).consignment.inventoryNo} (成本: ¥
{(((row.material as any).consignment.costPriceFen || 0) / 100).toFixed(2)})
寄卖单号: {resolveConsignmentNo(consignment) || '-'} (成本: ¥
{((Number(consignment.costPriceFen) || 0) / 100).toFixed(2)})
</Tag>
) : null}
</div>
@@ -1869,6 +1887,13 @@ export default function WorkOrdersPanel() {
<Descriptions.Item label="分类">
{detailOrder.categoryName || '-'}
</Descriptions.Item>
{detailConsignment ? (
<Descriptions.Item label="寄卖单号">
<Typography.Text copyable={{ text: resolveConsignmentNo(detailConsignment) }}>
{resolveConsignmentNo(detailConsignment) || '-'}
</Typography.Text>
</Descriptions.Item>
) : null}
<Descriptions.Item label="状态">
<Tag color={resolveStatusColor(detailOrder.status)}>
{formatStatus(detailOrder.status)}
@@ -2999,9 +3024,14 @@ export default function WorkOrdersPanel() {
) : (
<Tag color="gold">【部分匹配 / 高配充低配】</Tag>
)}
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{inv.inventoryNo}
<Typography.Text style={{ fontSize: 12, fontWeight: 500 }}>
寄卖单号 {inv.sourceSubmissionNo || inv.inventoryNo}
</Typography.Text>
{inv.sourceSubmissionNo ? (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
仓库号 {inv.inventoryNo}
</Typography.Text>
) : null}
</Space>
<div style={{ marginTop: 4 }}>