feat: support dropship pallet loading

This commit is contained in:
2026-09-08 17:24:49 +08:00
parent 2dc357e55b
commit 7ba81007a8
21 changed files with 300 additions and 52 deletions
@@ -19,7 +19,8 @@ describe('InboundWorkflowWorkspace', () => {
expect(source).toContain('label="测量审核"')
expect(source).toContain('label="费用信息"')
expect(source).toContain('label="状态历史"')
expect(source).toContain('label="其他记录"')
expect(source).toContain("detail.summary.fulfillmentType === 'ONE_CLICK_DROPSHIP' ? '清点组托与其他记录' : '其他记录'")
expect(source).toContain('listPlatformInboundDropshipPallets(props.customerId, props.inboundId)')
})
it('opens measurement editing and review in prototype-sized centered dialogs', () => {
@@ -6,7 +6,7 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import { getPlatformInbound } from '../core/api'
import { getPlatformInbound, listPlatformInboundDropshipPallets } from '../core/api'
import InboundHistoryPanel from '../core/InboundHistoryPanel.vue'
import InboundSummaryPanel from '../core/InboundSummaryPanel.vue'
import type { InboundOrderDetail } from '../core/types'
@@ -37,6 +37,7 @@ let disposed = false
const title = computed(() => detail.value?.summary.inboundCode || '入仓码待补充')
const canViewMeasurement = computed(() => access.can('platform:firstleg:inbound:measure:view'))
const canViewDropshipPallets = computed(() => access.can('platform:firstleg:pda:view'))
const statusLabel = computed(() => inboundStatusLabel(detail.value?.summary.processStatus, inboundProcessStatusLabels, '未知状态'))
async function loadDetail() {
@@ -51,6 +52,9 @@ async function loadDetail() {
loadError.value = null
try {
const response = await getPlatformInbound(props.customerId, props.inboundId)
if (response.summary.fulfillmentType === 'ONE_CLICK_DROPSHIP' && canViewDropshipPallets.value) {
response.dropshipPallets = await listPlatformInboundDropshipPallets(props.customerId, props.inboundId)
}
if (disposed || serial !== requestSerial) return
detail.value = response
} catch (error) {
@@ -174,12 +178,13 @@ onBeforeUnmount(() => {
:sections="['history']"
/>
</el-tab-pane>
<el-tab-pane label="其他记录" name="records" lazy>
<el-tab-pane :label="detail.summary.fulfillmentType === 'ONE_CLICK_DROPSHIP' ? '清点组托与其他记录' : '其他记录'" name="records" lazy>
<InboundHistoryPanel
:pallet-locations="detail.palletLocations"
:dropship-pallets="detail.dropshipPallets"
:customer-id="customerId"
:inbound-id="inboundId"
:can-view-dropship="canViewDropshipPallets"
:files="detail.files"
:internal-notes="detail.internalNotes"
:status-history="[]"
@@ -0,0 +1,21 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const source = readFileSync(resolve(process.cwd(), 'src/modules/inbounds/core/InboundHistoryPanel.vue'), 'utf8')
describe('InboundHistoryPanel dropship pallet result', () => {
it('shows the frozen pallet, carton and discrepancy facts', () => {
for (const marker of [
'一件代发清点与组托结果', 'pallet.palletNo', 'pallet.status', 'pallet.cartonCount',
'pallet.skuCount', 'pallet.weightKg', 'pallet.volumeCbm', 'pallet.discrepancyCartons', 'pallet.sealedAt',
'item.inboundCode', 'item.skuCode', 'item.cargoName', 'item.skuQuantity', 'item.cartonCount', 'item.cartonMarks',
]) expect(source).toContain(marker)
expect(source).toContain("SEALED: '已封托'")
})
it('keeps the existing scoped pallet-label action available', () => {
expect(source).toContain('downloadDropshipPalletLabel(props.customerId, props.inboundId, pallet.palletId)')
expect(source).toContain('打印托盘唛')
})
})
@@ -9,6 +9,7 @@ const props = withDefaults(defineProps<{
dropshipPallets?: InboundDropshipPallet[]
customerId?: string
inboundId?: string
canViewDropship?: boolean
files: InboundFileSummary[]
internalNotes: InboundInternalNote[]
statusHistory: InboundStatusHistory[]
@@ -16,6 +17,7 @@ const props = withDefaults(defineProps<{
sections?: Array<'dropship' | 'allocations' | 'files' | 'notes' | 'history' | 'remark'>
}>(), {
dropshipPallets: () => [],
canViewDropship: true,
sections: () => ['dropship', 'allocations', 'files', 'notes', 'history', 'remark'],
})
@@ -30,6 +32,9 @@ function date(value: string | null) {
const parsed = new Date(value)
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString('zh-CN', { hour12: false })
}
function dropshipPalletStatus(value: string) {
return ({ OPEN: '清点组托中', SEALED: '已封托' } as Record<string, string>)[value] || value || '状态未知'
}
async function printPallet(pallet: InboundDropshipPallet) {
if (!props.customerId || !props.inboundId || !pallet.palletNo) return
try {
@@ -44,9 +49,35 @@ async function printPallet(pallet: InboundDropshipPallet) {
<template>
<div class="history-panel">
<section v-if="shows('dropship')" class="history-section">
<div class="section-heading"><h3>一件代发绑定托盘</h3><span>{{ dropshipPallets.length }} 个托盘</span></div>
<div v-if="dropshipPallets.length" class="simple-list"><div v-for="pallet in dropshipPallets" :key="pallet.palletId"><strong>{{ value(pallet.palletNo) }} · {{ pallet.cartonCount }} 箱 <el-button v-if="pallet.palletNo" link type="primary" @click="printPallet(pallet)">打印托盘唛</el-button></strong><span v-for="item in pallet.items" :key="`${pallet.palletId}-${item.inboundDetailId}`">{{ item.inboundCode }} · SKU {{ item.skuCode }} · {{ item.cartonCount }} 箱</span></div></div>
<p v-else class="muted-empty">暂无一件代发托盘绑定</p>
<div class="section-heading"><h3>一件代发清点与组托结果</h3><span>{{ dropshipPallets.length }} 个托盘</span></div>
<el-alert v-if="!canViewDropship" title="当前账号缺少一件代发 PDA 查看权限,无法读取清点组托结果。" type="warning" :closable="false" show-icon />
<div v-else-if="dropshipPallets.length" class="dropship-pallet-list">
<article v-for="pallet in dropshipPallets" :key="pallet.palletId" class="dropship-pallet-card">
<header>
<div><strong>{{ value(pallet.palletNo) }}</strong><el-tag size="small" :type="pallet.status === 'SEALED' ? 'success' : 'warning'" effect="plain">{{ dropshipPalletStatus(pallet.status) }}</el-tag></div>
<el-button v-if="pallet.palletNo" link type="primary" @click="printPallet(pallet)">打印托盘唛</el-button>
</header>
<dl class="dropship-pallet-summary">
<div><dt>箱数</dt><dd>{{ pallet.cartonCount }}</dd></div>
<div><dt>SKU 数</dt><dd>{{ pallet.skuCount }}</dd></div>
<div><dt>毛重</dt><dd>{{ number(pallet.weightKg) }} kg</dd></div>
<div><dt>体积</dt><dd>{{ number(pallet.volumeCbm) }} m³</dd></div>
<div><dt>差异箱数</dt><dd><el-tag size="small" :type="pallet.discrepancyCartons ? 'warning' : 'success'">{{ pallet.discrepancyCartons }}</el-tag></dd></div>
<div><dt>封托时间</dt><dd>{{ date(pallet.sealedAt) }}</dd></div>
</dl>
<div class="dropship-item-table">
<div class="dropship-item-row dropship-item-row--header"><span>入仓码</span><span>SKU / 货物</span><span>商品数量</span><span>箱数</span><span>箱码</span></div>
<div v-for="item in pallet.items" :key="`${pallet.palletId}-${item.inboundDetailId}`" class="dropship-item-row">
<span>{{ value(item.inboundCode) }}</span>
<span><strong>{{ value(item.skuCode) }}</strong><small>{{ value(item.cargoName) }}</small></span>
<span>{{ item.skuQuantity }}</span>
<span>{{ item.cartonCount }}</span>
<span>{{ item.cartonMarks.length ? item.cartonMarks.join('、') : '—' }}</span>
</div>
</div>
</article>
</div>
<p v-else class="muted-empty">暂无一件代发清点与组托结果</p>
</section>
<section v-if="shows('allocations')" class="history-section">
<div class="section-heading"><h3>托盘与库位</h3><span>{{ palletLocations.length }} 条分配</span></div>
@@ -93,6 +124,25 @@ async function printPallet(pallet: InboundDropshipPallet) {
.allocation-location { overflow-wrap: anywhere; color: var(--cc-primary); text-align: right; }
.simple-list > div { padding: 10px 12px; border-bottom: 1px solid #edf0f4; font-size: 12px; }
.simple-list strong, .simple-list span { overflow-wrap: anywhere; }
.dropship-pallet-list { display: grid; gap: 12px; }
.dropship-pallet-card { min-width: 0; overflow: hidden; border: 1px solid var(--cc-border); border-radius: 6px; background: #fff; }
.dropship-pallet-card > header { display: flex; min-height: 42px; align-items: center; justify-content: space-between; gap: 12px; padding: 8px 12px; border-bottom: 1px solid #edf0f4; background: #f8fafc; }
.dropship-pallet-card > header > div { display: flex; min-width: 0; align-items: center; gap: 8px; }
.dropship-pallet-card > header strong { overflow-wrap: anywhere; color: #172033; font-size: 13px; }
.dropship-pallet-summary { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); margin: 0; border-bottom: 1px solid #edf0f4; }
.dropship-pallet-summary > div { min-width: 0; padding: 8px 12px; border-right: 1px solid #edf0f4; }
.dropship-pallet-summary > div:last-child { border-right: 0; }
.dropship-pallet-summary dt { color: var(--cc-muted); font-size: 11px; }
.dropship-pallet-summary dd { margin: 3px 0 0; overflow-wrap: anywhere; color: var(--cc-text); font-size: 12px; font-weight: 650; }
.dropship-item-table { min-width: 680px; }
.dropship-pallet-card { overflow-x: auto; }
.dropship-item-row { display: grid; grid-template-columns: minmax(140px, 1fr) minmax(180px, 1.2fr) 100px 70px minmax(220px, 1.5fr); min-width: 800px; border-bottom: 1px solid #edf0f4; }
.dropship-item-row:last-child { border-bottom: 0; }
.dropship-item-row > span { min-width: 0; padding: 8px 12px; overflow-wrap: anywhere; color: var(--cc-text); font-size: 12px; }
.dropship-item-row > span + span { border-left: 1px solid #edf0f4; }
.dropship-item-row small { display: block; margin-top: 3px; color: var(--cc-muted); font-size: 11px; }
.dropship-item-row--header { background: #f8fafc; }
.dropship-item-row--header > span { color: var(--cc-muted); font-size: 11px; font-weight: 650; }
.muted-empty { margin: 0; padding: 18px 12px; border: 1px dashed var(--cc-border); border-radius: 6px; background: #f8fafc; color: var(--cc-muted); font-size: 12px; text-align: center; }
.timeline { display: grid; gap: 0; margin: 0; padding: 0; list-style: none; }
.timeline li { display: grid; grid-template-columns: 16px 1fr; gap: 10px; min-width: 0; padding-bottom: 16px; }
@@ -101,5 +151,6 @@ async function printPallet(pallet: InboundDropshipPallet) {
.timeline strong, .timeline span, .timeline small { display: block; overflow-wrap: anywhere; }
.timeline span, .timeline small { margin-top: 3px; color: var(--cc-muted); font-size: 12px; line-height: 1.5; }
.remark-section p { margin: 0; padding: 12px; border-radius: 6px; background: #f8fafc; color: var(--cc-text); font-size: 13px; line-height: 1.6; white-space: pre-wrap; }
@media (max-width: 720px) { .dropship-pallet-summary { grid-template-columns: 1fr; } .dropship-pallet-summary > div { border-right: 0; border-bottom: 1px solid #edf0f4; } .dropship-pallet-summary > div:last-child { border-bottom: 0; } }
@media (max-width: 480px) { .allocation-row { grid-template-columns: 1fr auto; } .allocation-location { grid-column: 1 / -1; text-align: left; } }
</style>
+15 -1
View File
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { downloadInboundCartonLabels, downloadPlatformInboundHoldEvidence, getPlatformInbound, getPlatformInboundHold, holdPlatformInbound, listPlatformInbounds, releasePlatformInbound, uploadPlatformInboundHoldEvidence } from './api'
import { downloadInboundCartonLabels, downloadPlatformInboundHoldEvidence, getPlatformInbound, getPlatformInboundHold, holdPlatformInbound, listPlatformInboundDropshipPallets, listPlatformInbounds, releasePlatformInbound, uploadPlatformInboundHoldEvidence } from './api'
vi.mock('@/shared/api/http', () => ({
ApiResponseError: class ApiResponseError extends Error {
@@ -73,6 +73,20 @@ describe('platform inbound query API', () => {
expect(http.get).toHaveBeenCalledWith('/platform/inbounds/19001', { params: { tenantId: '10001' } })
})
it('loads and normalizes the inbound-scoped dropship pallet result', async () => {
vi.mocked(http.get).mockResolvedValue({ code: '200', data: [{
palletId: 9007199254740993n, palletNo: 'PLT-001', status: 'SEALED', cartonCount: '2', skuCount: '1', version: '3',
weightKg: '20.5', volumeCbm: '0.88', discrepancyCartons: '1', sealedAt: '2026-09-08T10:00:00',
items: [{ inboundId: 19001, inboundCode: 'RK001', inboundDetailId: 9007199254740994n, skuCode: 'SKU001', cargoName: '服装', cartonCount: '2', skuQuantity: '19', cartonMarks: ['BOX-001', 'BOX-002'] }],
}] })
await expect(listPlatformInboundDropshipPallets('10001', '19001')).resolves.toMatchObject([{
palletId: '9007199254740993', weightKg: 20.5, volumeCbm: 0.88, discrepancyCartons: 1,
items: [{ inboundId: '19001', inboundDetailId: '9007199254740994', cartonCount: 2, skuQuantity: 19, cartonMarks: ['BOX-001', 'BOX-002'] }],
}])
expect(http.get).toHaveBeenCalledWith('/platform/v1/first-leg/pda-dropship/inbound-orders/19001/pallets', { params: { accountId: '10001' } })
})
it('passes the implemented order, process, measurement and fee review filters without renaming them', async () => {
vi.mocked(http.get).mockResolvedValue({
code: '200', message: 'success', data: { records: [], page: 1, size: 20, total: 0, pages: 0 },
+33 -5
View File
@@ -11,6 +11,7 @@ import type {
InboundPageResult,
InboundPalletLocation,
InboundStatusHistory,
InboundDropshipPallet,
} from './types'
const basePath = '/platform/inbounds'
@@ -102,11 +103,7 @@ function normalizeDetail(raw: InboundOrderDetail): InboundOrderDetail {
return {
...raw,
summary: normalizeInboundSummary(raw.summary),
dropshipPallets: (raw.dropshipPallets || []).map((pallet) => ({
...pallet,
palletId: id(pallet.palletId) || '',
items: (pallet.items || []).map((item) => ({ ...item, inboundId: id(item.inboundId) || '', inboundDetailId: id(item.inboundDetailId) || '' })),
})),
dropshipPallets: [],
palletLocations: (raw.palletLocations || []).map(normalizePallet),
files: (raw.files || []).map(normalizeFile),
internalNotes: (raw.internalNotes || []).map(normalizeNote),
@@ -114,6 +111,29 @@ function normalizeDetail(raw: InboundOrderDetail): InboundOrderDetail {
}
}
function normalizeDropshipPallet(raw: InboundDropshipPallet): InboundDropshipPallet {
return {
...raw,
palletId: id(raw.palletId) || '',
palletNo: raw.palletNo || null,
cartonCount: integerValue(raw.cartonCount) || 0,
skuCount: integerValue(raw.skuCount) || 0,
version: integerValue(raw.version) || 0,
weightKg: numberValue(raw.weightKg),
volumeCbm: numberValue(raw.volumeCbm),
discrepancyCartons: integerValue(raw.discrepancyCartons) || 0,
sealedAt: raw.sealedAt || null,
items: (raw.items || []).map((item) => ({
...item,
inboundId: id(item.inboundId) || '',
inboundDetailId: id(item.inboundDetailId) || '',
cartonCount: integerValue(item.cartonCount) || 0,
skuQuantity: integerValue(item.skuQuantity) || 0,
cartonMarks: item.cartonMarks || [],
})),
}
}
function normalizeHoldDetail(raw: InboundHoldDetail): InboundHoldDetail {
return {
...raw,
@@ -161,6 +181,14 @@ export async function getPlatformInbound(tenantId: string, inboundId: string) {
return normalizeDetail(unwrapApiResponse(response))
}
export async function listPlatformInboundDropshipPallets(accountId: string, inboundId: string) {
const response = await http.get<ApiEnvelope<InboundDropshipPallet[]>, ApiEnvelope<InboundDropshipPallet[]>>(
`/platform/v1/first-leg/pda-dropship/inbound-orders/${encodeURIComponent(inboundId)}/pallets`,
{ params: { accountId } },
)
return (unwrapApiResponse(response) || []).map(normalizeDropshipPallet)
}
export async function getPlatformInboundHold(tenantId: string, inboundId: string) {
const response = await http.get<ApiEnvelope<InboundHoldDetail>, ApiEnvelope<InboundHoldDetail>>(
`${basePath}/${encodeURIComponent(inboundId)}/hold`,
+18 -3
View File
@@ -36,6 +36,7 @@ export interface InboundOrderSummary {
deliveryWarehouseId: string | null
deliveryWarehouseName: string | null
businessType: string | null
fulfillmentType: 'STANDARD' | 'ONE_CLICK_DROPSHIP' | string | null
channelCode: string | null
channelName: string | null
taxMode: string | null
@@ -161,12 +162,26 @@ export interface InboundHoldEvidenceUpload {
export interface InboundDropshipPallet {
palletId: string
palletNo: string | null
palletStatus: string
status: 'OPEN' | 'SEALED'
cartonCount: number
skuCount: number
version: number
items: Array<{ inboundId: string; inboundCode: string; inboundDetailId: string; skuCode: string; cargoName: string | null; cartonCount: number; cartonMarks: string[] }>
completedAt: string | null
weightKg: number | null
volumeCbm: number | null
discrepancyCartons: number
sealedAt: string | null
items: InboundDropshipSkuItem[]
}
export interface InboundDropshipSkuItem {
inboundId: string
inboundCode: string
inboundDetailId: string
skuCode: string
cargoName: string | null
cartonCount: number
skuQuantity: number
cartonMarks: string[]
}
export interface InboundPageResult {
@@ -43,7 +43,7 @@ const summary: InboundOrderSummary = {
orderStatusSnapshot: 'APPROVED', measureReviewStatus: 'PENDING', feeStatus: 'UNPRICED', feeReviewStatus: null,
notificationStatus: null, version: 1, expectedArrivalAt: '2026-08-04T10:00:00', customerId: '10001', customerName: '示例客户',
companyId: '10001', companyName: '示例企业', warehouseId: '20001', warehouseName: '义乌收货仓', deliveryWarehouseId: null,
deliveryWarehouseName: null, businessType: 'FBA', channelCode: 'SEA', channelName: '海运', taxMode: 'DDP', containerMode: null,
deliveryWarehouseName: null, businessType: 'FBA', fulfillmentType: 'STANDARD', channelCode: 'SEA', channelName: '海运', taxMode: 'DDP', containerMode: null,
customsInspection: false, destinationType: 'FBA', destinationWarehouseId: '30001', destinationCode: 'ONT8', destinationName: 'ONT8',
holdFlag: false, currentHoldReason: null, totalPackageCount: 6, actualGrossWeightKg: 120, actualNetWeightKg: 110,
actualVolumeCbm: 1.2, volumeWeightKg: 200, chargeableWeightKg: 200, chargeableVolumeCbm: null, palletCount: 1, locationCodes: 'A-01',