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
+1
View File
@@ -51,6 +51,7 @@
| 客户原始申报资料编辑与渠道变更 | `modules/unified-orders/components/OrderDeclarationEditorDialog.vue`、`api/unifiedOrders.ts`、`pages/UnifiedOrdersView.vue` | 使用订单版本和货物 `itemNo` 整体提交,不允许增删货物行;只编辑客户申报快照,不覆盖仓库/PDA 实测、测量审核或计费事实。渠道变化需二次确认并由 Backend `order/` 联查 `firstleg/` 的分柜、同柜、装柜与重新计价门禁;国内收货仓或中转仓变化必须重新下单 |
| 入库扣货、解除与凭证历史 | `modules/unified-orders/pages/UnifiedOrdersView.vue`、`modules/inbounds/core/api.ts`、`types.ts` | 页面只消费服务端 `holdFlag/currentHoldReason/canHold/canRelease/actionBlockReason/history`;扣货与解除提交当前入库版本、幂等键、必填原因和可选凭证,凭证下载只走 Firstleg 授权入口。Backend `firstleg/` 的 `InboundHoldService`、`InboundHoldGuard`、`InboundHoldEvidenceService`,文件归 `file/` |
| 入库、测量、称重库位状态、客户箱规策略与计价主数据 | 测量弹框/工作区 `modules/inbounds/measurement/InboundMeasurementDialog.vue`、`InboundMeasurementPanel.vue`,称重 `modules/inbounds/weight/components/WeightOcrPanel.vue`;主数据 `modules/fba-pricing/`、`modules/dimension-config/`、`modules/customers/components/CustomerBillingRulesPanel.vue`、`modules/fee-items/` | 客户固定四档箱规,默认计算方式为 `ADD/MULTIPLY`;测量行可选实际尺寸、箱规尺寸策略或客户默认尺寸系数,保存后以 Backend `firstleg/` 返回的外箱尺寸、体积重和计费基数为准,不在页面独立重算。费用确认的实际箱数、毛重和体积优先汇总当前 `actualMeasurementRows`,不得用可能滞后的工作区顶层合计覆盖明细。称重页在每张图片卡片只展示服务端 `locationCode/slotStatus/sharedOverflow`,不渲染或自行推导整票预留引导;删除图片须提示槽位仍归本票保留,不得按图片数组下标推导托盘或库位 |
| 一件代发托盘打印 | `modules/unified-orders/pages/UnifiedOrdersView.vue` 的“托盘打印”页签、`modules/inbounds/core/DropshipPalletPrintPanel.vue`、`core/api.ts` | 仅一件代发订单展示;测量审核预生成托盘码后由 Admin 电脑端打印,支持本票全部托盘合并打印和单托补打。PDA 只扫码组托,不承担 PDF 下载或打印机连接 |
| 入库单扣货、解除、凭证与历史 | `modules/inbounds/pages/InboundOrdersView.vue`、详情工作区、`modules/inbounds/core/api.ts` | 列表展示服务端 `holdFlag/currentHoldReason` 并支持筛选;详情只按 `canHold/canRelease/actionBlockReason` 开放操作。扣货与解除提交当前 `version`、原因和独立幂等键,可选凭证先按明确客户与入库单上传;历史文件只调用专用下载入口,不把上传成功但未提交命令的文件冒充扣货证据 |
| 客户专属价格簿、柜级阶梯运费与受控调价 | 列表/上传 `modules/fba-pricing/carrier-rates/pages/CarrierRatesView.vue`,版本详情/仓库映射/确认 `modules/fba-pricing/carrier-rates/pages/CarrierRateDetailView.vue`,路由 `modules/fba-pricing/routes.ts`;`modules/inbounds/fee/`、`modules/unified-orders/components/InboundFeeDetailManager.vue` | 列表与上传后按 `versionId` 进入 `fba-carrier-rate-detail` 独立详情页,价格行只读;回归入口 `modules/fba-pricing/carrier-rates/pages/CarrierRatesView.test.ts`。价格簿页面显式选择公共报价或具体客户;列表、上传、确认只作用于该 `customerId`,不得混合版本。计价优先消费客户专属 ACTIVE 版本,无专属版本才回退公共 ACTIVE;柜级阶梯金额和各客户分摊只展示服务端结果。调整计费量只能在服务端返回的上下 10% 范围内并提交当前费用版本、原因与二次确认;私人仓尾程派送费使用稳定 `PRIVATE_LAST_MILE_DELIVERY` 代码且只对 `PRIVATE` 目的地开放;一件代发须在预装确认前完成试算,计价后保持服务端 `WAIT_PALLETIZE` 状态 |
| 财务、客户资金账户与柜级公共费用分摊 | `modules/finance/`,继续读该目录 `AGENTS.md`;客户账户 `pages/FinanceCustomerAccountsView.vue`,柜费 `modules/loading-lists/components/ContainerFeesPanel.vue`、`api/containerFees.ts` | `finance/`、`firstleg/`、`file/`、`docs/财务管理/`;实际、冻结、可用余额、授信与流水只消费 Finance 权威结果,充值审核和手工充扣携带版本与幂等键。跨客户公共柜费只按服务端柜内客户/订单事实分摊,金额必须守恒,创建与审核、驳回、调整、作废均整组原子处理 |
@@ -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>