This commit is contained in:
2026-09-15 14:18:23 +08:00
parent fb41713930
commit a311ec41c3
13 changed files with 88 additions and 31 deletions
+2
View File
@@ -28,6 +28,8 @@
## 履约硬边界
- 托盘管理单张打印:`pages/PalletsView.vue` 每行“打印”经 `api/pallets.ts` 的 `getPalletLabelPdf` 下载当前托盘一页 PDF;使用该行仓库 ID 与托盘 ID,权限复用托盘列表。Backend `warehouse-overseas-service` 的 `PalletLabelPrintService` 读取公共托盘并调用现有 Print `INBOUND_MARK_V1`,不在前端生成标签、改码或维护第二套模板。
- API 为 `api/dispatches.ts`、`api/outbounds.ts`、`api/deliveryOrders.ts`;Backend `lastleg-service/dispatch/`、`delivery/`、`dropship/`。一件代发独立出库,从服务端库存位置选择并携带幂等键;普通出库及换标单经头程排车转出库,按托盘快照绑定头程箱唛,PDA 按托盘作业,详情展示箱唛与已拣数量;排车转派送按稳定 `dispatchNo` 进入服务端命令,可在 `PENDING_DEPARTURE`、`PICKING`、`READY_TO_DEPART` 状态执行并须在 PDA 发车前生成一张待出库单及按目的 FBA 仓拆分的派送单
- API 为 `api/exceptionOrders.ts`、`api/customerVisits.ts`;Backend `lastleg-service/exceptionorder/`、`customervisit/`。异常处理只消费服务端 `allowedActions/version`;完成时至少上传一份异常证据。派送退回先从服务端原出库拣货事实选择入仓单和可退箱数,不允许自由填写 SKU/数量;退回海外仓时必须选择服务端校验的目标托盘和库位,库存恢复、异常完成与派送 `RETURNED` 由服务端原子推进。回访附件和派送完成/状态均走海外真实 API
@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { listSharedReceivingOverseasWarehouseOptions } from '@/modules/iam'
import { overseasHttp } from '@/shared/api/overseasHttp'
import { createPallet, getPallet, listPallets, listReceivingOverseasWarehouses, updatePallet } from './pallets'
import { createPallet, getPallet, getPalletLabelPdf, listPallets, listReceivingOverseasWarehouses, updatePallet } from './pallets'
vi.mock('@/modules/iam', () => ({ listSharedReceivingOverseasWarehouseOptions: vi.fn() }))
vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn(), post: vi.fn(), put: vi.fn() } }))
@@ -9,6 +9,19 @@ vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn(), post
describe('overseas pallet API', () => {
beforeEach(() => { vi.clearAllMocks() })
it('downloads only the selected pallet through the overseas gateway', async () => {
const pdf = new Blob(['%PDF-label'], { type: 'application/pdf' })
vi.mocked(overseasHttp.get).mockResolvedValueOnce(pdf)
await expect(getPalletLabelPdf('200', '9007199254740993')).resolves.toBe(pdf)
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/warehouse-admin/200/pallets/9007199254740993/label', { responseType: 'blob', timeout: 60_000 })
})
it('surfaces a JSON business failure instead of saving it as a PDF', async () => {
const error = new Blob([JSON.stringify({ code: 'A0422', message: '托盘编码不支持打印', requestId: 'r1' })], { type: 'application/json' })
vi.mocked(overseasHttp.get).mockResolvedValueOnce(error)
await expect(getPalletLabelPdf('200', '9')).rejects.toMatchObject({ message: '托盘编码不支持打印', code: 'A0422', requestId: 'r1' })
})
it('uses the public warehouse scoped pallet CRUD contract', async () => {
const page = { records: [], page: 1, size: 20, total: 0, pages: 0 }
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', data: page })
@@ -78,6 +78,16 @@ export async function getPallet(warehouseId: string, palletId: string) {
return unwrapApiResponse(response)
}
export async function getPalletLabelPdf(warehouseId: string, palletId: string) {
const blob = await overseasHttp.get<Blob, Blob>(
`/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}/label`,
{ responseType: 'blob', timeout: 60_000 },
)
if (blob.type.includes('application/json')) unwrapApiResponse(JSON.parse(await blob.text()) as ApiEnvelope<unknown>)
if (!blob.size || !blob.type.includes('application/pdf')) throw new Error('打印服务未返回有效 PDF,请重试')
return blob
}
export async function createPallet(warehouseId: string, payload: SavePalletPayload) {
const response = await overseasHttp.post<ApiEnvelope<string>, ApiEnvelope<string>>(
`/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets`, payload,
@@ -161,9 +161,9 @@ async function chooseContainer(wrapper: Awaited<ReturnType<typeof mountView>>) {
describe('LastMileOrdersView pending order and container conversion', () => {
it.each([
[[], ['查看'], false],
[['inbound-order:create'], ['查看', '填写入库计划'], false],
[['inbound-order:create'], ['查看', '入库计划'], false],
[['last-mile-order:edit'], ['查看', '暂扣订单', '人工修改节点'], false],
[['inbound-order:create', 'last-mile-order:edit'], ['查看', '填写入库计划', ''], true],
[['inbound-order:create', 'last-mile-order:edit'], ['查看', '入库计划', ''], true],
])('only collapses row actions above three for %j', async (allowed, labels, overflow) => {
canAccess.mockImplementation(code => allowed.some(action => code === 'platform:overseas-warehouse:' + action))
vi.mocked(listLastMileOrders).mockResolvedValue({ records: [{ id: 'order-1', customerId, orderNo: 'LM-1', currentStatus: 'PENDING_ASSIGNMENT', orderType: 'FIRST_LEG' }], page: 1, size: 10, total: 1, pages: 1 })
@@ -845,7 +845,7 @@ onMounted(() => { void load() })
</section>
<section v-else-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-last-mile-orders" row-key="id" empty-text="暂无尾程订单" @selection-change="onSelectionChange" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-actions><el-button v-if="access.can(permission.lastMileOrderExport)" plain :loading="exportLoading" @click="exportOrders"><Download :size="16" />导出订单</el-button><el-button v-if="access.can(permission.lastMileOrderTransfer)" plain :disabled="!selectedRows.length || actionLoading" @click="openTransfer">转仓</el-button><el-button v-if="access.can(permission.inboundOrderCreate)" type="primary" plain :disabled="selectedRows.length !== 1 || actionLoading" @click="openInboundPlan()">生成入库计划</el-button><el-button v-if="statusTab === '待分配' && access.can(permission.inboundOrderCreate)" type="primary" :disabled="actionLoading" @click="openContainerConversion">按柜批量生成</el-button></template>
<template #actions="{ row }"><div class="row-actions density-actions"><el-button class="density-action" data-tone="neutral" size="small" @click="openDetail(row)">查看</el-button><el-button class="density-action" data-tone="warning" v-if="access.can(permission.warehouseTransferApprove) && row.orderTypeCode === 'WAREHOUSE_TRANSFER' && row.currentStatusCode === 'PENDING_REVIEW'" size="small" @click="openTransferReview(row)">审核</el-button><el-button class="density-action" data-tone="neutral" v-if="access.can(permission.inboundOrderCreate)" :disabled="row.detained" size="small" @click="openInboundPlan(row)">填写入库计划</el-button><template v-if="rowActionCount(row) <= 3 && access.can(permission.lastMileOrderTransfer)"><el-button class="density-action" data-tone="warning" size="small" @click="openHold(row)">{{ row.detained ? '解除暂扣' : '暂扣订单' }}</el-button><el-button class="density-action" data-tone="neutral" size="small" @click="openManualNodes(row)">人工修改节点</el-button></template><el-dropdown v-if="rowActionCount(row) > 3" trigger="click"><el-button class="density-action" :aria-label="`更多操作 · ${row.orderNo || row.dispatchNo || '当前记录'}`" title="更多操作" data-tone="neutral" size="small" ><MoreHorizontal aria-hidden="true" :size="15" /></el-button><template #dropdown><el-dropdown-menu><el-dropdown-item v-if="access.can(permission.lastMileOrderTransfer)" @click="openHold(row)">{{ row.detained ? '解除暂扣' : '暂扣订单' }}</el-dropdown-item><el-dropdown-item v-if="access.can(permission.lastMileOrderTransfer)" @click="openManualNodes(row)">人工修改节点</el-dropdown-item></el-dropdown-menu></template></el-dropdown></div></template>
<template #actions="{ row }"><div class="row-actions density-actions"><el-button class="density-action" data-tone="neutral" size="small" @click="openDetail(row)">查看</el-button><el-button class="density-action" data-tone="warning" v-if="access.can(permission.warehouseTransferApprove) && row.orderTypeCode === 'WAREHOUSE_TRANSFER' && row.currentStatusCode === 'PENDING_REVIEW'" size="small" @click="openTransferReview(row)">审核</el-button><el-button class="density-action" data-tone="neutral" v-if="access.can(permission.inboundOrderCreate)" :disabled="row.detained" size="small" @click="openInboundPlan(row)">入库计划</el-button><template v-if="rowActionCount(row) <= 3 && access.can(permission.lastMileOrderTransfer)"><el-button class="density-action" data-tone="warning" size="small" @click="openHold(row)">{{ row.detained ? '解除暂扣' : '暂扣订单' }}</el-button><el-button class="density-action" data-tone="neutral" size="small" @click="openManualNodes(row)">人工修改节点</el-button></template><el-dropdown v-if="rowActionCount(row) > 3" trigger="click"><el-button class="density-action" :aria-label="`更多操作 · ${row.orderNo || row.dispatchNo || '当前记录'}`" title="更多操作" data-tone="neutral" size="small" ><MoreHorizontal aria-hidden="true" :size="15" /></el-button><template #dropdown><el-dropdown-menu><el-dropdown-item v-if="access.can(permission.lastMileOrderTransfer)" @click="openHold(row)">{{ row.detained ? '解除暂扣' : '暂扣订单' }}</el-dropdown-item><el-dropdown-item v-if="access.can(permission.lastMileOrderTransfer)" @click="openManualNodes(row)">人工修改节点</el-dropdown-item></el-dropdown-menu></template></el-dropdown></div></template>
<template #orderNo="{ row }"><OrderReferenceText :value="String(row.orderNo || '')" copyable /></template>
<template #pagination-left>共 {{ total }} 条尾程订单 · 已选 {{ selectedRows.length }} 条</template>
</DataTable></section>
@@ -4,13 +4,13 @@ import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vites
import { listAllPlatformWarehouses } from '@/modules/warehouses'
import AppDialog from '@/shared/components/AppDialog.vue'
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
import { createPallet, getPallet, getPalletSummary, listPallets, listReceivingOverseasWarehouses, updatePallet } from '../api/pallets'
import { createPallet, getPallet, getPalletLabelPdf, getPalletSummary, listPallets, listReceivingOverseasWarehouses, updatePallet } from '../api/pallets'
import PalletsView from './PalletsView.vue'
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() }))
vi.mock('../api/pallets', () => ({ createPallet: vi.fn(), getPallet: vi.fn(), getPalletSummary: vi.fn(), listPallets: vi.fn(), listReceivingOverseasWarehouses: vi.fn(), updatePallet: vi.fn() }))
vi.mock('../api/pallets', () => ({ createPallet: vi.fn(), getPallet: vi.fn(), getPalletLabelPdf: vi.fn(), getPalletSummary: vi.fn(), listPallets: vi.fn(), listReceivingOverseasWarehouses: vi.fn(), updatePallet: vi.fn() }))
class ResizeObserverStub implements ResizeObserver { observe() {} unobserve() {} disconnect() {} }
function memoryStorage(): Storage { const values = new Map<string, string>(); return { get length() { return values.size }, clear: () => values.clear(), getItem: (key) => values.get(key) ?? null, key: (index) => [...values.keys()][index] ?? null, removeItem: (key) => values.delete(key), setItem: (key, value) => values.set(key, value) } }
@@ -28,6 +28,7 @@ describe('PalletsView', () => {
vi.mocked(getPalletSummary).mockReset().mockResolvedValue({ totalCount: 1, idleCount: 1, occupiedCount: 0, scrappedCount: 0 })
vi.mocked(getPallet).mockReset().mockResolvedValue(pallet)
vi.mocked(createPallet).mockReset()
vi.mocked(getPalletLabelPdf).mockReset()
vi.mocked(updatePallet).mockReset().mockResolvedValue(null)
})
@@ -47,6 +48,31 @@ describe('PalletsView', () => {
expect(wrapper.get('[aria-label="海外托盘数量概览"]').text()).toContain('已报废52')
})
it('prints one pallet from the all-warehouse list and allows retry after failure', async () => {
const pdf = new Blob(['%PDF-label'], { type: 'application/pdf' })
vi.mocked(getPalletLabelPdf).mockRejectedValueOnce(new Error('打印服务暂不可用')).mockResolvedValueOnce(pdf)
const createUrl = vi.fn(() => 'blob:pallet-label')
vi.stubGlobal('URL', Object.assign(URL, { createObjectURL: createUrl, revokeObjectURL: vi.fn() }))
const downloads: string[] = []
const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) { downloads.push(this.download) })
try {
const wrapper = mount(PalletsView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
await flushPromises()
const button = wrapper.findAll('button').filter((item) => item.text() === '打印').at(-1)!
await button.trigger('click')
await flushPromises()
expect(createUrl).not.toHaveBeenCalled()
expect(button.attributes('disabled')).toBeUndefined()
await button.trigger('click')
await flushPromises()
expect(getPalletLabelPdf).toHaveBeenNthCalledWith(2, '200', '9007199254740993')
expect(createUrl).toHaveBeenCalledWith(pdf)
expect(downloads).toEqual(['PLT-1-托盘码.pdf'])
expect(createPallet).not.toHaveBeenCalled()
expect(updatePallet).not.toHaveBeenCalled()
} finally { click.mockRestore() }
})
it('reloads all warehouses when the filters are reset', async () => {
const wrapper = mount(PalletsView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
await flushPromises()
@@ -15,7 +15,7 @@ import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { createPallet, getPallet, getPalletSummary, listPallets, listReceivingOverseasWarehouses, updatePallet, type PalletDto, type PalletSummaryDto, type ReceivingOverseasWarehouseDto } from '../api/pallets'
import { createPallet, getPallet, getPalletLabelPdf, getPalletSummary, listPallets, listReceivingOverseasWarehouses, updatePallet, type PalletDto, type PalletSummaryDto, type ReceivingOverseasWarehouseDto } from '../api/pallets'
import { overseasWarehousePermissions as permission } from '../permissions'
import type { PalletListItem } from '../types'
@@ -27,6 +27,7 @@ const rows = ref<PalletListItem[]>([])
const loading = ref(false)
const contextLoading = ref(false)
const saving = ref(false)
const printingId = ref('')
const loadError = ref<ApiErrorDetails | null>(null)
const mutationError = ref<ApiErrorDetails | null>(null)
const loadErrorKind = ref<'context' | 'list'>('context')
@@ -120,6 +121,21 @@ async function save() {
}
function search(model: QueryModel) { page.value = 1; void load(model) }
async function printPallet(source: object) {
if (printingId.value) return
const row = source as PalletListItem
printingId.value = row.id; mutationError.value = null
try {
const blob = await getPalletLabelPdf(row.warehouseId, row.id)
const url = URL.createObjectURL(blob)
try {
const link = document.createElement('a')
link.href = url; link.download = `${row.code}-托盘码.pdf`; link.click()
} finally { window.setTimeout(() => URL.revokeObjectURL(url), 0) }
ElMessage.success('已生成 1 张托盘标签 PDF')
} catch (error) { mutationError.value = getApiErrorDetails(error, '托盘标签打印失败,请重试') }
finally { printingId.value = '' }
}
function resetSearch(model: QueryModel) { page.value = 1; void load(model) }
function openDetail(source: object) { const row = source as PalletListItem; void router.push({ name: 'overseas-pallet-detail', params: { palletId: row.id }, query: { warehouseId: row.warehouseId } }) }
async function initialize() { await loadWarehouses(); if (!loadError.value) await load() }
@@ -134,7 +150,7 @@ onMounted(initialize)
<template v-if="loadError" #state><LoadErrorState title="托盘数据加载失败" :message="loadError.message" :loading="loading || contextLoading" @retry="retryLoad"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
<section v-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-pallets" row-key="id" empty-text="暂无托盘" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-actions><el-button v-if="access.can(permission.palletCreate)" type="primary" @click="openCreate"><Plus :size="16" />新增托盘</el-button></template>
<template #actions="{ row }"><div class="density-actions"><el-button class="density-action" data-tone="neutral" size="small" @click="openDetail(row)">查看</el-button><el-button class="density-action" data-tone="neutral" v-if="access.can(permission.palletEdit)" size="small" @click="openEdit(row)">编辑</el-button></div></template>
<template #actions="{ row }"><div class="density-actions"><el-button class="density-action" data-tone="neutral" size="small" @click="openDetail(row)">查看</el-button><el-button v-if="access.can(permission.palletList)" class="density-action" data-tone="neutral" size="small" :loading="printingId === row.id" :disabled="Boolean(printingId) && printingId !== row.id" @click="printPallet(row)">打印</el-button><el-button class="density-action" data-tone="neutral" v-if="access.can(permission.palletEdit)" size="small" @click="openEdit(row)">编辑</el-button></div></template>
<template #pagination-left>共 {{ total }} 条托盘</template>
</DataTable></section>
</ListPageLayout>