feat: add pallet label printing
This commit is contained in:
@@ -11,7 +11,7 @@ import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import { CarrierScheduleQueryPanel } from '@/modules/port-schedules'
|
||||
import { ContainerFeesPanel, getConfirmedLoadingList, getPreloadPlan, LoadingListDetailDrawer, type LoadingDetail } from '@/modules/loading-lists'
|
||||
import { ContainerFeesPanel, getConfirmedLoadingList, getPreloadPlan, LoadingListDetailDrawer, PalletLabelPrintDialog, type LoadingDetail } from '@/modules/loading-lists'
|
||||
import {
|
||||
confirmContainerOutboundDispatch,
|
||||
getContainerOutboundDispatch,
|
||||
@@ -496,7 +496,7 @@ onMounted(async () => { await load(); if (isOverseas.value && route.query.action
|
||||
</el-tabs>
|
||||
<LoadErrorState v-if="overviewError" compact title="履约摘要加载失败" :message="overviewError.message" :show-retry="false" />
|
||||
<section v-show="activeTab === 'overview'" class="detail-section surface-panel" aria-labelledby="container-information-heading">
|
||||
<h2 id="container-information-heading">货柜信息</h2>
|
||||
<header class="detail-section__heading"><h2 id="container-information-heading">货柜信息</h2><PalletLabelPrintDialog v-if="overview?.loadingPlanId && detail.warehouseId" :plan-id="overview.loadingPlanId" :warehouse-id="detail.warehouseId" :disabled="!detail.containerNo || detail.planStatus === 'EDITING'" /></header>
|
||||
<div class="detail-fields"><dl><div><dt>真实订舱</dt><dd><span>{{ detail.oceanBookingId ? (detail.billOfLadingNo || '已关联') : '未关联' }}</span><el-button v-if="canLinkOceanBooking" link type="primary" @click="openOceanBookingLink"><Link2 :size="14" />关联已放舱订舱</el-button></dd></div><div><dt>异常状态</dt><dd>{{ detail.exceptionActive ? '异常' : '正常' }}</dd></div><div><dt>目的仓</dt><dd>{{ detail.destinationWarehouseName || detail.destinationWarehouseCode || '—' }}</dd></div><div><dt>拖车公司</dt><dd>{{ detail.terminalTruckingCompany || '—' }}</dd></div></dl></div>
|
||||
<ContainerOverviewPanel v-if="isDomestic && overview" :overview="overview" :sections="['exception']" :can-manage-documents="canEdit" :can-manage-customs="canManageCustoms" :can-manage-exception="canEdit" @manage-exception="openException" />
|
||||
</section>
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
getPreloadPlan,
|
||||
listConfirmedLoadingLists,
|
||||
listPreloadPlans,
|
||||
preparePalletLabels,
|
||||
printPalletLabels,
|
||||
updatePreloadTransport,
|
||||
updateLoadingItemWarehouseRemark,
|
||||
} from './platformLoadingLists'
|
||||
@@ -158,4 +160,18 @@ describe('platform loading lists API', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('prepares and prints pallet labels by FBA warehouse', async () => {
|
||||
const post = vi.spyOn(http, 'post')
|
||||
.mockResolvedValueOnce({ code: '200', message: 'ok', data: { planId: 10, planNo: 'LP-1', containerNo: 'MSCU6639870', totalCount: 2, groups: [{ destinationWarehouseCode: 'ONT8', destinationWarehouseName: 'ONT8', totalCount: 2, labels: [{ labelId: 1, itemId: 2, accountId: 3, inboundId: 4, inboundCode: 'RK-1', containerNo: 'MSCU6639870', destinationWarehouseCode: 'ONT8', destinationWarehouseName: 'ONT8', palletIndex: 1, palletCode: 'MSCU6639870-ONT8-001' }] }] } })
|
||||
.mockResolvedValueOnce(new Blob(['pdf'], { type: 'application/pdf' }))
|
||||
|
||||
const manifest = await preparePalletLabels('10', '20')
|
||||
const pdf = await printPalletLabels('10', '20', 'ONT8', 1)
|
||||
|
||||
expect(manifest).toMatchObject({ planId: '10', groups: [{ labels: [{ labelId: '1', itemId: '2', accountId: '3', inboundId: '4' }] }] })
|
||||
expect(pdf.type).toBe('application/pdf')
|
||||
expect(post).toHaveBeenNthCalledWith(1, '/platform/v1/first-leg/loading-plans/10/pallet-labels/prepare', null, { params: { warehouseId: '20' } })
|
||||
expect(post).toHaveBeenNthCalledWith(2, '/platform/v1/first-leg/loading-plans/10/pallet-labels/print', { destinationWarehouseCode: 'ONT8', count: 1 }, { params: { warehouseId: '20' }, responseType: 'blob', timeout: 60_000 })
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -256,6 +256,34 @@ function normalizeHistory(raw: LoadingHistory): LoadingHistory {
|
||||
}
|
||||
}
|
||||
|
||||
export interface PalletLabel {
|
||||
labelId: string
|
||||
itemId: string
|
||||
accountId: string
|
||||
inboundId: string
|
||||
inboundCode: string
|
||||
containerNo: string
|
||||
destinationWarehouseCode: string
|
||||
destinationWarehouseName: string | null
|
||||
palletIndex: number
|
||||
palletCode: string
|
||||
}
|
||||
|
||||
export interface PalletLabelGroup {
|
||||
destinationWarehouseCode: string
|
||||
destinationWarehouseName: string | null
|
||||
totalCount: number
|
||||
labels: PalletLabel[]
|
||||
}
|
||||
|
||||
export interface PalletLabelManifest {
|
||||
planId: string
|
||||
planNo: string
|
||||
containerNo: string
|
||||
totalCount: number
|
||||
groups: PalletLabelGroup[]
|
||||
}
|
||||
|
||||
function normalizePage<T extends LoadingListSummary>(raw: RawPage<T>, normalize: (row: T) => T): LoadingListPage<T> {
|
||||
return {
|
||||
records: (raw.records || []).map(normalize),
|
||||
@@ -353,3 +381,26 @@ export async function getConfirmedLoadingList(planId: string, warehouseId: strin
|
||||
histories: (result.histories || []).map(normalizeHistory),
|
||||
}
|
||||
}
|
||||
|
||||
export async function preparePalletLabels(planId: string, warehouseId: string) {
|
||||
const response = await http.post<ApiEnvelope<PalletLabelManifest>, ApiEnvelope<PalletLabelManifest>>(
|
||||
`${basePath}/loading-plans/${encodeURIComponent(planId)}/pallet-labels/prepare`, null, { params: { warehouseId } },
|
||||
)
|
||||
const result = unwrapApiResponse(response)
|
||||
return {
|
||||
...result,
|
||||
planId: requiredId(result.planId),
|
||||
groups: (result.groups || []).map((group) => ({
|
||||
...group,
|
||||
labels: (group.labels || []).map((label) => ({ ...label, labelId: requiredId(label.labelId), itemId: requiredId(label.itemId), accountId: requiredId(label.accountId), inboundId: requiredId(label.inboundId) })),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export function printPalletLabels(planId: string, warehouseId: string, destinationWarehouseCode?: string, count?: number) {
|
||||
return http.post<Blob, Blob>(
|
||||
`${basePath}/loading-plans/${encodeURIComponent(planId)}/pallet-labels/print`,
|
||||
{ destinationWarehouseCode: destinationWarehouseCode || null, count: count || null },
|
||||
{ params: { warehouseId }, responseType: 'blob', timeout: 60_000 },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.v
|
||||
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import PalletLabelPrintDialog from './PalletLabelPrintDialog.vue'
|
||||
import { loadingListStatusLabel } from '../status'
|
||||
import type { ApiErrorDetails } from '@/shared/api/http'
|
||||
import type { LoadingDetail, LoadingHistory, LoadingItem, LoadingListMode, LoadingListSummary, PreloadSummary } from '../api/platformLoadingLists'
|
||||
@@ -151,6 +152,7 @@ function formatNumber(value?: number | null, digits = 3) {
|
||||
<p>{{ mode === 'preload' ? '核对运输信息、确认预装并维护逐条仓库备注' : '查看已完成装柜明细和柜次费用' }}</p>
|
||||
</div>
|
||||
<div v-if="detail && summary" class="detail-page-header__actions">
|
||||
<PalletLabelPrintDialog v-if="summary.warehouseId" :plan-id="summary.planId" :warehouse-id="summary.warehouseId" :disabled="!summary.containerNo || summary.status === 'EDITING'" />
|
||||
<template v-if="canEditItemRemark">
|
||||
<el-button v-if="canEditTransport" @click="emit('edit')"><Pencil :size="16" />{{ summary.containerNo ? '纠正集装箱号 / 航次' : '补录集装箱号 / 航次' }}</el-button>
|
||||
<el-button type="primary" :loading="confirmSaving" :disabled="!preloadSummary?.readiness.canConfirm" @click="emit('confirmPreload')"><Check :size="16" />确认预装</el-button>
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { Printer } from '@lucide/vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { preparePalletLabels, printPalletLabels, type PalletLabelManifest } from '../api/platformLoadingLists'
|
||||
|
||||
const props = defineProps<{ planId: string; warehouseId: string; disabled?: boolean }>()
|
||||
const open = ref(false)
|
||||
const loading = ref(false)
|
||||
const printing = ref(false)
|
||||
const error = ref<ApiErrorDetails | null>(null)
|
||||
const manifest = ref<PalletLabelManifest | null>(null)
|
||||
const scope = ref<'ALL' | 'WAREHOUSE'>('ALL')
|
||||
const warehouseCode = ref('')
|
||||
const count = ref(1)
|
||||
const selectedGroup = computed(() => manifest.value?.groups.find((group) => group.destinationWarehouseCode === warehouseCode.value) || null)
|
||||
const disabledConfirm = computed(() => !manifest.value?.totalCount || (scope.value === 'WAREHOUSE' && (!selectedGroup.value || count.value < 1 || count.value > selectedGroup.value.totalCount)))
|
||||
|
||||
async function show() {
|
||||
open.value = true; loading.value = true; error.value = null; manifest.value = null
|
||||
try {
|
||||
manifest.value = await preparePalletLabels(props.planId, props.warehouseId)
|
||||
warehouseCode.value = manifest.value.groups[0]?.destinationWarehouseCode || ''
|
||||
count.value = manifest.value.groups[0]?.totalCount || 1
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, '托盘码清单加载失败') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
function onWarehouseChange() { count.value = selectedGroup.value?.totalCount || 1 }
|
||||
async function print() {
|
||||
if (!manifest.value) return
|
||||
printing.value = true; error.value = null
|
||||
try {
|
||||
const blob = await printPalletLabels(props.planId, props.warehouseId,
|
||||
scope.value === 'WAREHOUSE' ? warehouseCode.value : undefined,
|
||||
scope.value === 'WAREHOUSE' ? count.value : undefined)
|
||||
const url = URL.createObjectURL(blob); const anchor = document.createElement('a')
|
||||
anchor.href = url; anchor.download = `${manifest.value.containerNo}-托盘码.pdf`; anchor.click(); URL.revokeObjectURL(url)
|
||||
ElMessage.success(`已生成 ${scope.value === 'ALL' ? manifest.value.totalCount : count.value} 张托盘码`)
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, '托盘码打印失败') }
|
||||
finally { printing.value = false }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-button :disabled="disabled" @click="show"><Printer :size="16" />打印托盘码</el-button>
|
||||
<AppDialog v-model="open" title="散货托盘码" width="min(760px, calc(100vw - 28px))" confirm-text="生成 PDF" :loading="loading" :confirm-loading="printing" :confirm-disabled="disabledConfirm" @confirm="print">
|
||||
<LoadErrorState v-if="error" compact title="托盘码操作失败" :message="error.message" :show-retry="false" />
|
||||
<template v-if="manifest">
|
||||
<el-alert type="info" :closable="false" :title="`编号规则:${manifest.containerNo}-FBA仓编码-三位序号;一件代发托盘不在此清单。`" />
|
||||
<el-radio-group v-model="scope" class="print-scope"><el-radio value="ALL">全部打印({{ manifest.totalCount }} 张)</el-radio><el-radio value="WAREHOUSE">按 FBA 仓补打</el-radio></el-radio-group>
|
||||
<div v-if="scope === 'WAREHOUSE'" class="print-options"><el-select v-model="warehouseCode" placeholder="选择 FBA 仓" @change="onWarehouseChange"><el-option v-for="group in manifest.groups" :key="group.destinationWarehouseCode" :label="`${group.destinationWarehouseCode} · ${group.totalCount} 张`" :value="group.destinationWarehouseCode" /></el-select><el-input-number v-model="count" :min="1" :max="selectedGroup?.totalCount || 1" /><span>张(从该仓第 1 张起)</span></div>
|
||||
<el-table :data="manifest.groups" border max-height="300"><el-table-column label="FBA 仓编码" prop="destinationWarehouseCode" width="170" /><el-table-column label="FBA 仓名称" prop="destinationWarehouseName" min-width="180"><template #default="{ row }">{{ row.destinationWarehouseName || '—' }}</template></el-table-column><el-table-column label="托盘数" prop="totalCount" width="100" align="right" /><el-table-column label="编号范围" min-width="230"><template #default="{ row }">{{ row.labels[0]?.palletCode || '—' }}<template v-if="row.labels.length > 1"> ~ {{ row.labels[row.labels.length - 1]?.palletCode }}</template></template></el-table-column></el-table>
|
||||
</template>
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.print-scope { display:flex; margin:18px 0 12px; }
|
||||
.print-options { display:flex; align-items:center; gap:12px; margin-bottom:16px; }
|
||||
.print-options .el-select { width:240px; }
|
||||
</style>
|
||||
@@ -10,3 +10,4 @@ export { loadingListRoutes } from './routes'
|
||||
export { loadingListStatusLabel } from './status'
|
||||
export { default as ContainerFeesPanel } from './components/ContainerFeesPanel.vue'
|
||||
export { default as LoadingListDetailDrawer } from './components/LoadingListDetailDrawer.vue'
|
||||
export { default as PalletLabelPrintDialog } from './components/PalletLabelPrintDialog.vue'
|
||||
|
||||
Reference in New Issue
Block a user