feat: add desktop dropship pallet printing

This commit is contained in:
2026-09-14 17:52:00 +08:00
parent 22c755b02a
commit 7e1b9b4cee
9 changed files with 199 additions and 3 deletions
@@ -0,0 +1,23 @@
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/DropshipPalletPrintPanel.vue'), 'utf8')
describe('DropshipPalletPrintPanel', () => {
it('provides desktop batch printing and single-pallet reprinting', () => {
expect(source).toContain('一件代发托盘打印')
expect(source).toContain('打印全部托盘')
expect(source).toContain('单托补打')
expect(source).toContain('downloadAllDropshipPalletLabels')
expect(source).toContain('downloadDropshipPalletLabel')
expect(source).toContain('label="操作" width="120" fixed="left"')
})
it('explains the computer-first printing flow and covers loading, empty and error states', () => {
expect(source).toContain('在电脑端打印并贴到空托盘,再交由 PDA 扫码组托')
expect(source).toContain('v-loading="loading"')
expect(source).toContain('测量审核通过并生成托盘码后,可在这里打印')
expect(source).toContain('托盘数据暂时无法加载')
})
})
@@ -0,0 +1,126 @@
<script setup lang="ts">
import { onBeforeUnmount, ref, watch } from 'vue'
import { Printer, RefreshCw } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { downloadAllDropshipPalletLabels, downloadDropshipPalletLabel, listPlatformInboundDropshipPallets } from './api'
import type { InboundDropshipPallet } from './types'
const props = defineProps<{
customerId: string
inboundId: string
inboundCode?: string | null
}>()
const pallets = ref<InboundDropshipPallet[]>([])
const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const printing = ref('')
let requestSerial = 0
let disposed = false
function statusLabel(status: InboundDropshipPallet['status']) {
return status === 'SEALED' ? '已封托' : '待封托'
}
function download(blob: Blob, fileName: string) {
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = fileName
link.click()
window.setTimeout(() => URL.revokeObjectURL(url), 0)
}
async function loadPallets() {
const serial = ++requestSerial
loading.value = true
loadError.value = null
try {
const response = await listPlatformInboundDropshipPallets(props.customerId, props.inboundId)
if (!disposed && serial === requestSerial) pallets.value = response
} catch (error) {
if (!disposed && serial === requestSerial) {
pallets.value = []
loadError.value = getApiErrorDetails(error, '一件代发托盘加载失败')
}
} finally {
if (!disposed && serial === requestSerial) loading.value = false
}
}
async function printAll() {
if (!pallets.value.length || printing.value) return
printing.value = 'all'
try {
const blob = await downloadAllDropshipPalletLabels(props.customerId, props.inboundId)
download(blob, `${props.inboundCode || '一件代发'}-全部托盘.pdf`)
ElMessage.success(`已生成 ${pallets.value.length} 个托盘的打印文件`)
} catch (error) {
ElMessage.error(getApiErrorDetails(error, '全部托盘打印文件生成失败').message)
} finally {
printing.value = ''
}
}
async function printOne(pallet: InboundDropshipPallet) {
if (!pallet.palletNo || printing.value) return
printing.value = pallet.palletId
try {
const blob = await downloadDropshipPalletLabel(props.customerId, props.inboundId, pallet.palletId)
download(blob, `${pallet.palletNo}-托盘.pdf`)
ElMessage.success(`${pallet.palletNo} 打印文件已生成`)
} catch (error) {
ElMessage.error(getApiErrorDetails(error, '单托打印文件生成失败').message)
} finally {
printing.value = ''
}
}
watch(() => [props.customerId, props.inboundId], loadPallets, { immediate: true })
onBeforeUnmount(() => { disposed = true; requestSerial += 1 })
</script>
<template>
<section class="dropship-print-panel" aria-labelledby="dropship-print-title">
<header class="print-heading">
<div>
<h3 id="dropship-print-title">一件代发托盘打印</h3>
<p>测量审核生成托盘码后,在电脑端打印并贴到空托盘,再交由 PDA 扫码组托。</p>
</div>
<div class="print-actions">
<el-button :disabled="loading || Boolean(printing)" @click="loadPallets"><RefreshCw :size="15" />刷新</el-button>
<el-button type="primary" :disabled="!pallets.length" :loading="printing === 'all'" @click="printAll"><Printer :size="15" />打印全部托盘</el-button>
</div>
</header>
<LoadErrorState v-if="loadError" title="托盘数据暂时无法加载" :message="loadError.message" @retry="loadPallets">
<template #details><ApiErrorMeta :details="loadError" /></template>
</LoadErrorState>
<el-table v-else-if="pallets.length" v-loading="loading" :data="pallets" border stripe empty-text="暂无托盘码" aria-label="一件代发托盘打印列表">
<el-table-column label="操作" width="120" fixed="left">
<template #default="{ row }">
<el-button link type="primary" :loading="printing === row.palletId" :disabled="Boolean(printing) && printing !== row.palletId" @click="printOne(row as InboundDropshipPallet)">单托补打</el-button>
</template>
</el-table-column>
<el-table-column prop="palletNo" label="托盘码" min-width="210" />
<el-table-column label="状态" width="110"><template #default="{ row }"><el-tag size="small" :type="row.status === 'SEALED' ? 'success' : 'warning'" effect="plain">{{ statusLabel(row.status) }}</el-tag></template></el-table-column>
<el-table-column prop="cartonCount" label="已归托箱数" width="120" align="right" />
<el-table-column prop="skuCount" label="SKU 数" width="100" align="right" />
</el-table>
<el-empty v-else-if="!loading" description="测量审核通过并生成托盘码后,可在这里打印" :image-size="64" />
<div v-else class="loading-state"><el-skeleton :rows="4" animated /></div>
</section>
</template>
<style scoped>
.dropship-print-panel { min-width: 0; }
.print-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; margin-bottom: 14px; }
.print-heading h3 { margin: 0; color: #172033; font-size: 15px; }
.print-heading p { margin: 5px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 18px; }
.print-actions { display: flex; flex-shrink: 0; gap: 8px; }
.print-actions .el-button { margin-left: 0; }
.loading-state { padding: 12px 0; }
</style>
+16 -1
View File
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { downloadInboundCartonLabels, downloadInboundCartonLabelsBatch, downloadPlatformInboundHoldEvidence, getPlatformInbound, getPlatformInboundHold, holdPlatformInbound, listPlatformInboundDropshipPallets, listPlatformInbounds, releasePlatformInbound, uploadPlatformInboundHoldEvidence } from './api'
import { downloadAllDropshipPalletLabels, downloadDropshipPalletLabel, downloadInboundCartonLabels, downloadInboundCartonLabelsBatch, downloadPlatformInboundHoldEvidence, getPlatformInbound, getPlatformInboundHold, holdPlatformInbound, listPlatformInboundDropshipPallets, listPlatformInbounds, releasePlatformInbound, uploadPlatformInboundHoldEvidence } from './api'
vi.mock('@/shared/api/http', () => ({
ApiResponseError: class ApiResponseError extends Error {
@@ -87,6 +87,21 @@ describe('platform inbound query API', () => {
expect(http.get).toHaveBeenCalledWith('/platform/v1/first-leg/pda-dropship/inbound-orders/19001/pallets', { params: { accountId: '10001' } })
})
it('downloads one or all inbound-scoped dropship pallet documents for desktop printing', async () => {
const pdf = new Blob(['pdf'], { type: 'application/pdf' })
vi.mocked(http.get).mockResolvedValue(pdf)
await expect(downloadDropshipPalletLabel('10001', '19001', '21001')).resolves.toBe(pdf)
await expect(downloadAllDropshipPalletLabels('10001', '19001')).resolves.toBe(pdf)
expect(http.get).toHaveBeenNthCalledWith(1,
'/platform/v1/first-leg/pda-dropship/inbound-orders/19001/pallets/21001/print',
{ params: { accountId: '10001' }, responseType: 'blob', timeout: 60_000 })
expect(http.get).toHaveBeenNthCalledWith(2,
'/platform/v1/first-leg/pda-dropship/inbound-orders/19001/pallets/print',
{ params: { accountId: '10001' }, responseType: 'blob', timeout: 60_000 })
})
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 },
+7
View File
@@ -291,3 +291,10 @@ export async function downloadDropshipPalletLabel(tenantId: string, inboundId: s
{ params: { accountId: tenantId }, responseType: 'blob', timeout: 60_000 },
)
}
export async function downloadAllDropshipPalletLabels(tenantId: string, inboundId: string) {
return http.get<Blob, Blob>(
`/platform/v1/first-leg/pda-dropship/inbound-orders/${encodeURIComponent(inboundId)}/pallets/print`,
{ params: { accountId: tenantId }, responseType: 'blob', timeout: 60_000 },
)
}
+1
View File
@@ -1,5 +1,6 @@
export { getInboundMeasurement, saveInboundMeasurementDraft, updateMeasurementCalculationStrategy, updateMeasurementOuterDimensions } from './measurement/api'
export { createInboundHoldIdempotencyKey, downloadInboundCartonLabelsBatch, downloadPlatformInboundHoldEvidence, getPlatformInboundHold, holdPlatformInbound, releasePlatformInbound, uploadPlatformInboundHoldEvidence } from './core/api'
export { default as DropshipPalletPrintPanel } from './core/DropshipPalletPrintPanel.vue'
export { default as InboundMeasurementPanel } from './measurement/InboundMeasurementPanel.vue'
export { default as InboundMeasurementDialog } from './measurement/InboundMeasurementDialog.vue'
export { default as MeasurementEvidenceThumbnail } from './measurement/components/MeasurementEvidenceThumbnail.vue'
@@ -125,6 +125,7 @@ export interface UnifiedOrderResource {
currentHoldReason: string | null
companyName?: string | null
businessType?: string | null
fulfillmentType?: string | null
pickupMethod?: string | null
requestedPickupAt: string | null
pickupAt?: string | null
@@ -7,6 +7,14 @@ const source = readFileSync(resolve(process.cwd(), 'src/modules/unified-orders/p
const routeSource = readFileSync(resolve(process.cwd(), 'src/modules/unified-orders/routes.ts'), 'utf8').replace(/\r\n/g, '\n')
describe('UnifiedOrdersView contract', () => {
it('exposes desktop pallet printing only for one-click dropship orders', () => {
expect(source).toContain("detail.value?.summary.fulfillmentType === 'ONE_CLICK_DROPSHIP'")
expect(source).toContain("{ key: 'dropship-print', label: '托盘打印', icon: '🖨️' }")
expect(source).toContain("activeDetailTab === 'dropship-print'")
expect(source).toContain('<DropshipPalletPrintPanel')
expect(source).toContain("can('platform:firstleg:inbound:list')")
})
it('loads the reference unit price and lets the backend enforce the combined final lock', () => {
const openDialogSource = source.slice(source.indexOf('async function openFeeConfirmDialog()'), source.indexOf('async function executeConfirmAndSendFee()'))
const sendFeeSource = source.slice(source.indexOf('async function executeConfirmAndSendFee()'), source.indexOf('function handleOrderSelectionChange'))
@@ -78,6 +78,7 @@ import {
InboundFeePanel,
InboundMeasurementDialog,
MeasurementEvidenceThumbnail,
DropshipPalletPrintPanel,
sendInboundFee,
type InboundFeeSnapshot,
type InboundMeasurementDetail,
@@ -324,15 +325,17 @@ const canReleaseInbound = computed(() => Boolean(
&& holdDetail.value?.canRelease,
))
const detailTabs = [
const isDropshipOrder = computed(() => detail.value?.summary.fulfillmentType === 'ONE_CLICK_DROPSHIP')
const detailTabs = computed(() => [
{ key: 'basic', label: '基本信息', icon: '📋' },
{ key: 'cargo', label: '货品信息', icon: '📦' },
{ key: 'fees', label: '费用信息', icon: '💰' },
{ key: 'history', label: '状态历史', icon: '📝' },
{ key: 'measurement', label: '测量数据', icon: '📐' },
...(isDropshipOrder.value ? [{ key: 'dropship-print', label: '托盘打印', icon: '🖨️' }] : []),
{ key: 'other', label: '其他', icon: '🗂️' },
{ key: 'fulfillment', label: '拆分履约', icon: '🧩' },
]
])
const customerMeasurementColumns = [
{ key: 'itemNo', label: '#' }, { key: 'lengthCm', label: '箱规长(cm)' }, { key: 'widthCm', label: '箱规宽(cm)' },
@@ -2396,6 +2399,17 @@ onBeforeUnmount(() => {
</section>
</template>
<template v-else-if="activeDetailTab === 'dropship-print'">
<el-empty v-if="!normalizedInboundId" description="订单尚未生成入库单,暂无托盘码可打印" :image-size="64" />
<el-alert v-else-if="!can('platform:firstleg:inbound:list')" title="当前账号缺少入库单查看权限,无法打印托盘码" type="warning" :closable="false" show-icon />
<DropshipPalletPrintPanel
v-else
:customer-id="String(detail.summary.tenantId)"
:inbound-id="normalizedInboundId"
:inbound-code="detail.summary.inboundCode"
/>
</template>
<template v-else-if="activeDetailTab === 'measurement'">
<div class="panel-title">
<p>{{ detail.summary.measurement?.completedAt ? `测量完成于 ${formatDate(detail.summary.measurement.completedAt)}` : '尚未完成测量' }}</p>