feat(consignment): 寄售仓库在库列表增加通过审核入库时间并在手动匹配中补全时间展示

- 寄售仓库 (ConsignmentInventoryPanel):表格中增加「通过审核入库时间」列并格式化展示,详情弹窗规范化展示入库时间、出库时间与提报时间
- 手动匹配弹窗 (ConsignmentManualMatchModal):候选订单列表展示工单创建时间,顶部横幅与已选概要展示现货入库时间及订单创建时间;从订单匹配现货时展示现货入库时间
- 内部匹配接口与工单拦截:后端 listInventoryMatchableOrders 返回工单创建时间及平台单号;发布拦截弹窗中增加展示现货入库时间
- 验证方式:运行前后端单元测试全部通过,前端 build 校验通过
This commit is contained in:
yml2213
2026-10-08 18:26:53 +08:00
parent 5466783017
commit f5614a1e2b
8 changed files with 162 additions and 8 deletions
@@ -243,6 +243,8 @@ export async function listInventoryMatchableOrders(
orders: Array<{
workOrderId: number
workOrderNo: string
platformOrderId?: string
createdAt?: string
productName: string
status: string
rewardAmountFen: number
@@ -293,6 +295,8 @@ export async function listInventoryMatchableOrders(
return {
workOrderId: Number(order.id),
workOrderNo: order.work_order_no || '',
platformOrderId: order.platform_order_id || '',
createdAt: order.created_at || '',
productName: order.product_name || '',
status: String(order.status || ''),
rewardAmountFen: Number(order.reward_amount || 0),
@@ -103,3 +103,20 @@ test('手动匹配的两个读取接口只读,不改状态', async () => {
assert.ok(routes.includes('listWorkOrderInventoryMatchOptions('))
assert.ok(routes.includes('listInventoryMatchableOrders('))
})
test('库存侧「手动匹配订单」候选列表必须包含创建时间与平台单号', async () => {
const { readFile } = await import('node:fs/promises')
const src = await readFile(
new URL('./consignment-fulfillment-service.ts', import.meta.url),
'utf8',
)
const fnBody = src.slice(src.indexOf('export async function listInventoryMatchableOrders'))
assert.ok(
fnBody.includes('createdAt: order.created_at'),
'listInventoryMatchableOrders 必须透传 order.created_at',
)
assert.ok(
fnBody.includes('platformOrderId: order.platform_order_id'),
'listInventoryMatchableOrders 必须透传 order.platform_order_id',
)
})
@@ -28,6 +28,7 @@ import {
resolveManualMatchRequirement,
type ManualMatchType,
} from '@/utils/consignment-manual-match'
import { formatDateTime } from '@/utils/date-time'
import { notifyVoucherConsume } from '@/utils/voucher-consume'
const { Text, Paragraph } = Typography
@@ -35,8 +36,10 @@ const { Text, Paragraph } = Typography
type Selected = {
workOrderId: number | string
workOrderLabel: string
workOrderCreatedAt?: string
inventoryId: number | string
inventoryLabel: string
inventoryInboundAt?: string
matchType: ManualMatchType
conflicts: string[]
/** 供货打手(用于回显供货来源) */
@@ -48,9 +51,9 @@ export type ConsignmentManualMatchModalProps = {
open: boolean
onClose: () => void
/** 订单侧入口:从订单出发选现货 */
workOrder?: { workOrderId: number | string; label: string } | null
workOrder?: { workOrderId: number | string; label: string; createdAt?: string } | null
/** 库存侧入口:从现货出发选订单 */
inventory?: { inventoryId: number | string; label: string } | null
inventory?: { inventoryId: number | string; label: string; inboundAt?: string } | null
onMatched?: () => void
}
@@ -71,6 +74,7 @@ export default function ConsignmentManualMatchModal({
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
@@ -89,6 +93,7 @@ export default function ConsignmentManualMatchModal({
setConfirmedPartial(false)
setConfirmedMismatch(false)
setMatchNote('')
setInventoryInboundAt('')
}
const load = async () => {
@@ -106,6 +111,7 @@ export default function ConsignmentManualMatchModal({
? `${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 || '',
@@ -251,7 +257,9 @@ export default function ConsignmentManualMatchModal({
description={
mode === 'order'
? '下面是该订单的全部在库现货(含规格不一致的),核对差异后直接内部履约。'
: `当前现货:${inventoryLabel || inventory?.label || ''}。下面是可匹配的订单列表,核对后直接内部履约。`
: `当前现货:${inventoryLabel || inventory?.label || ''}${
inventoryInboundAt ? `(审核入库:${formatDateTime(inventoryInboundAt)})` : ''
}。下面是可匹配的订单列表,核对后直接内部履约。`
}
/>
@@ -312,6 +320,15 @@ export default function ConsignmentManualMatchModal({
: ''}{' '}
· 货源 {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' }}>
@@ -330,8 +347,11 @@ export default function ConsignmentManualMatchModal({
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,
@@ -390,6 +410,9 @@ export default function ConsignmentManualMatchModal({
? `${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' }}>
@@ -408,8 +431,10 @@ export default function ConsignmentManualMatchModal({
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,
@@ -433,8 +458,26 @@ export default function ConsignmentManualMatchModal({
{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="订单">
<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}>
@@ -502,6 +502,24 @@ export default function ConsignmentInventoryPanel() {
)
},
},
{
title: '通过审核入库时间',
key: 'inboundAt',
width: 160,
render: (_, record) => {
const timeStr = formatDateTime(record.inboundAt || record.createdAt)
return (
<div>
<Text style={{ fontSize: 12 }}>{timeStr}</Text>
{record.consumedAt ? (
<div style={{ fontSize: 11, color: '#8c8c8c', marginTop: 2 }}>
出库: {formatDateTime(record.consumedAt)}
</div>
) : null}
</div>
)
},
},
{
title: '允许匹配',
key: 'allowMatching',
@@ -1032,7 +1050,19 @@ export default function ConsignmentInventoryPanel() {
<Descriptions.Item label="收购批次">
{viewingItem.inboundBatch || '-'}
</Descriptions.Item>
<Descriptions.Item label="入库时间">{viewingItem.inboundAt || '-'}</Descriptions.Item>
<Descriptions.Item label="通过审核入库时间">
{formatDateTime(viewingItem.inboundAt || viewingItem.createdAt)}
</Descriptions.Item>
{viewingItem.consumedAt ? (
<Descriptions.Item label="履约出库时间">
{formatDateTime(viewingItem.consumedAt)}
</Descriptions.Item>
) : null}
{viewingItem.sourceType === 'worker_portal' && viewingItem.createdAt ? (
<Descriptions.Item label="打手提报时间">
{formatDateTime(viewingItem.createdAt)}
</Descriptions.Item>
) : null}
<Descriptions.Item label="卖家微信号">
<Text copyable>{viewingItem.sellerContact || '-'}</Text>
</Descriptions.Item>
@@ -1225,6 +1255,7 @@ export default function ConsignmentInventoryPanel() {
? {
inventoryId: manualMatchItem.id,
label: manualMatchItem.itemName,
inboundAt: manualMatchItem.inboundAt || manualMatchItem.createdAt,
}
: null
}
@@ -327,8 +327,17 @@ export default function ConsignmentSubmissionsPanel() {
{
title: '提报时间',
dataIndex: 'createdAt',
width: 150,
render: (v) => <Text style={{ fontSize: 12 }}>{v}</Text>,
width: 160,
render: (v, record) => (
<div>
<Text style={{ fontSize: 12 }}>{formatDateTime(v)}</Text>
{record.reviewedAt ? (
<div style={{ fontSize: 11, color: '#8c8c8c', marginTop: 2 }}>
审核: {formatDateTime(record.reviewedAt)}
</div>
) : null}
</div>
),
},
{
title: '操作',
@@ -921,6 +930,11 @@ export default function ConsignmentSubmissionsPanel() {
<Descriptions.Item label="审核人">
{viewingSubmission.reviewerName || '-'}
</Descriptions.Item>
{viewingSubmission.reviewedAt ? (
<Descriptions.Item label="审核时间">
{formatDateTime(viewingSubmission.reviewedAt)}
</Descriptions.Item>
) : null}
<Descriptions.Item label="审核备注" span={2}>
{viewingSubmission.reviewNote || '-'}
</Descriptions.Item>
@@ -3426,6 +3426,7 @@ export default function WorkOrdersPanel() {
) : null}
{inv.shopName ? <span>店铺: {inv.shopName}</span> : null}
{inv.inboundBatch ? <span>批次: {inv.inboundBatch}</span> : null}
<span>入库时间: {formatAdminDateTime(inv.inboundAt || inv.createdAt)}</span>
</Space>
</div>
@@ -3489,6 +3490,7 @@ export default function WorkOrdersPanel() {
? {
workOrderId: manualMatchOrder.workOrderId,
label: `#${manualMatchOrder.workOrderId} ${manualMatchOrder.productName || ''}`,
createdAt: manualMatchOrder.createdAt,
}
: null
}
+2
View File
@@ -191,6 +191,8 @@ export type WorkOrderInventoryMatchOptions = {
export type ConsignmentMatchableOrder = {
workOrderId: number
workOrderNo: string
platformOrderId?: string
createdAt?: string
productName: string
status: string
rewardAmountFen: number
@@ -114,4 +114,45 @@ test('发布拦截弹窗:只走内部现货履约,文案停在待加好友
assert.ok(source.includes('进入待加好友'), '拦截弹窗必须写明工单进入待加好友')
assert.ok(!source.includes('自动进冷静期'), '拦截弹窗不得再写自动进冷静期')
assert.ok(!source.includes('进入赠送冷静期'), '成功提示不得再写成进入赠送冷静期')
assert.ok(source.includes('入库时间:'), '拦截弹窗现货展示必须包含入库时间')
})
test('寄售仓库库存面板:必须包含「通过审核入库时间」列,且详情弹窗正确展示时间', () => {
const source = readFileSync(
new URL('../src/pages/admin/panels/ConsignmentInventoryPanel.tsx', import.meta.url).pathname,
'utf8',
)
assert.ok(source.includes("title: '通过审核入库时间'"), '库存表格必须有「通过审核入库时间」列')
assert.ok(
source.includes('formatDateTime(record.inboundAt || record.createdAt)'),
'表格必须格式化入库时间',
)
assert.ok(source.includes('label="通过审核入库时间"'), '详情弹窗必须包含「通过审核入库时间」项')
assert.ok(
source.includes('inboundAt: manualMatchItem.inboundAt || manualMatchItem.createdAt'),
'打开手动匹配弹窗时必须透传现货的入库时间',
)
})
test('手动匹配弹窗:候选订单必须展示创建时间,现货展示审核入库时间', () => {
const source = readFileSync(
new URL('../src/components/admin/ConsignmentManualMatchModal.tsx', import.meta.url).pathname,
'utf8',
)
assert.ok(
source.includes('创建时间 {formatDateTime(order.createdAt)}'),
'候选订单卡片必须展示工单创建时间',
)
assert.ok(
source.includes('审核入库:${formatDateTime(inventoryInboundAt)}'),
'弹窗现货提示栏必须展示审核入库时间',
)
assert.ok(
source.includes('审核入库于 {formatDateTime(selected.inventoryInboundAt)}'),
'已选现货概要必须展示审核入库时间',
)
assert.ok(
source.includes('创建于 {formatDateTime(selected.workOrderCreatedAt)}'),
'已选订单概要必须展示创建时间',
)
})