From a311ec41c34d4ec6c0c926a1cb1ac89469381ca1 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 15 Sep 2026 14:18:23 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/terminalDrayages.test.ts | 7 ++--- .../container-loading/api/terminalDrayages.ts | 5 +--- .../pages/ContainersView.test.ts | 4 +-- .../pages/ContainersView.vue | 14 ++++----- src/modules/container-loading/types.ts | 4 +-- src/modules/overseas-warehouse/AGENTS.md | 2 ++ .../overseas-warehouse/api/pallets.test.ts | 15 +++++++++- src/modules/overseas-warehouse/api/pallets.ts | 10 +++++++ .../pages/LastMileOrdersView.behavior.test.ts | 4 +-- .../pages/LastMileOrdersView.vue | 2 +- .../pages/PalletsView.test.ts | 30 +++++++++++++++++-- .../overseas-warehouse/pages/PalletsView.vue | 20 +++++++++++-- src/styles/adminDensityTokens.css | 2 +- 13 files changed, 88 insertions(+), 31 deletions(-) diff --git a/src/modules/container-loading/api/terminalDrayages.test.ts b/src/modules/container-loading/api/terminalDrayages.test.ts index 4856d849..041c9e50 100644 --- a/src/modules/container-loading/api/terminalDrayages.test.ts +++ b/src/modules/container-loading/api/terminalDrayages.test.ts @@ -7,7 +7,6 @@ import { getTerminalDrayageHistory, listTerminalDrayages, recordTerminalDrayageEmptyReturn, - recordTerminalDrayagePickup, scheduleTerminalDrayagePickup, selectTerminalDrayageFleet, } from './terminalDrayages' @@ -54,12 +53,10 @@ describe('terminal drayages API', () => { vi.mocked(http.post).mockResolvedValue({ data: view }) await selectTerminalDrayageFleet('101', { expectedVersion: 3, fleetSupplierId: '501', fleetSupplierName: '港运车队' }) await scheduleTerminalDrayagePickup('101', { expectedVersion: 4, pickupAppointmentAt: '2026-09-07T08:00:00' }) - await recordTerminalDrayagePickup('101', { expectedVersion: 5, actualAt: '2026-09-07T09:00:00', remark: '实际提柜' }) - await recordTerminalDrayageEmptyReturn('101', { expectedVersion: 6, actualAt: '2026-09-10T09:00:00', remark: '实际还柜' }) + await recordTerminalDrayageEmptyReturn('101', { expectedVersion: 5, actualAt: '2026-09-10T09:00:00', remark: '实际还柜' }) expect(http.post).toHaveBeenNthCalledWith(1, '/platform/v1/terminal-drayages/101/fleet', expect.objectContaining({ expectedVersion: 3, fleetSupplierId: '501' }), expect.objectContaining({ headers: { 'Idempotency-Key': expect.stringContaining('terminal-drayage-fleet-') } })) expect(http.post).toHaveBeenNthCalledWith(2, '/platform/v1/terminal-drayages/101/pickup-appointment', expect.objectContaining({ expectedVersion: 4 }), expect.any(Object)) - expect(http.post).toHaveBeenNthCalledWith(3, '/platform/v1/terminal-drayages/101/pickup', expect.objectContaining({ expectedVersion: 5, remark: '实际提柜' }), expect.objectContaining({ headers: { 'Idempotency-Key': expect.stringContaining('terminal-drayage-pickup-') } })) - expect(http.post).toHaveBeenNthCalledWith(4, '/platform/v1/terminal-drayages/101/empty-return', expect.objectContaining({ expectedVersion: 6, remark: '实际还柜' }), expect.objectContaining({ headers: { 'Idempotency-Key': expect.stringContaining('terminal-drayage-empty-return-') } })) + expect(http.post).toHaveBeenNthCalledWith(3, '/platform/v1/terminal-drayages/101/empty-return', expect.objectContaining({ expectedVersion: 5, remark: '实际还柜' }), expect.objectContaining({ headers: { 'Idempotency-Key': expect.stringContaining('terminal-drayage-empty-return-') } })) }) it('loads operation history from the container business id', async () => { diff --git a/src/modules/container-loading/api/terminalDrayages.ts b/src/modules/container-loading/api/terminalDrayages.ts index bf176078..78555ad7 100644 --- a/src/modules/container-loading/api/terminalDrayages.ts +++ b/src/modules/container-loading/api/terminalDrayages.ts @@ -1,7 +1,7 @@ import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' export type TerminalDrayageStatus = 'WAITING_FOR_ARRIVAL' | 'ARRIVED' | 'PICKUP_SCHEDULED' | 'PICKED_UP' | 'UNLOADED' | 'EMPTY_RETURNED' -export type TerminalDrayageAction = 'SELECT_FLEET' | 'SCHEDULE_PICKUP' | 'RECORD_PICKUP' | 'RECORD_EMPTY_RETURN' | 'SEND_PICKUP_EMAIL' +export type TerminalDrayageAction = 'SELECT_FLEET' | 'SCHEDULE_PICKUP' | 'RECORD_EMPTY_RETURN' | 'SEND_PICKUP_EMAIL' export interface TerminalDrayageEmail { id: string @@ -123,9 +123,6 @@ export function selectTerminalDrayageFleet(containerBookingId: string, body: { e export function scheduleTerminalDrayagePickup(containerBookingId: string, body: { expectedVersion: number; pickupAppointmentAt: string; reason?: string }) { return command(containerBookingId, 'pickup-appointment', body) } -export function recordTerminalDrayagePickup(containerBookingId: string, body: { expectedVersion: number; actualAt: string; remark?: string }) { - return command(containerBookingId, 'pickup', body) -} export function recordTerminalDrayageEmptyReturn(containerBookingId: string, body: { expectedVersion: number; actualAt: string; remark?: string }) { return command(containerBookingId, 'empty-return', body) } diff --git a/src/modules/container-loading/pages/ContainersView.test.ts b/src/modules/container-loading/pages/ContainersView.test.ts index da10b405..34afd62c 100644 --- a/src/modules/container-loading/pages/ContainersView.test.ts +++ b/src/modules/container-loading/pages/ContainersView.test.ts @@ -25,7 +25,7 @@ vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn().mockResolvedValue const accessCan = vi.hoisted(() => vi.fn()) vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: accessCan }) })) vi.mock('../api/platformContainerLoading', () => ({ listContainers: vi.fn(), getContainer: vi.fn(), getContainerOverview: vi.fn(), getContainerClearance: vi.fn(), uploadContainerClearance: vi.fn(), completeContainerClearance: vi.fn(), uploadContainerDocument: vi.fn(), downloadContainerDocument: vi.fn(), downloadContainerPdaPhoto: vi.fn(), markContainerException: vi.fn(), cancelContainerException: vi.fn(), createContainerSimulation: vi.fn(), linkContainerOceanBooking: vi.fn(), unlinkContainerOceanBooking: vi.fn(), updateContainer: vi.fn() })) -vi.mock('../api/terminalDrayages', () => ({ sendTerminalDrayageEmail: vi.fn(), getTerminalDrayageEmails: vi.fn(), retryTerminalDrayageEmail: vi.fn(), listTerminalDrayages: vi.fn(), getTerminalDrayage: vi.fn(), getTerminalDrayageHistory: vi.fn(), selectTerminalDrayageFleet: vi.fn(), scheduleTerminalDrayagePickup: vi.fn(), recordTerminalDrayagePickup: vi.fn(), recordTerminalDrayageEmptyReturn: vi.fn() })) +vi.mock('../api/terminalDrayages', () => ({ sendTerminalDrayageEmail: vi.fn(), getTerminalDrayageEmails: vi.fn(), retryTerminalDrayageEmail: vi.fn(), listTerminalDrayages: vi.fn(), getTerminalDrayage: vi.fn(), getTerminalDrayageHistory: vi.fn(), selectTerminalDrayageFleet: vi.fn(), scheduleTerminalDrayagePickup: vi.fn(), recordTerminalDrayageEmptyReturn: vi.fn() })) const drayage = { containerBookingId: '9001', containerNo: 'MSCU-1', oceanBookingId: '101', status: 'UNLOADED' as const, version: 3, @@ -71,7 +71,7 @@ describe('ContainersView', () => { vi.mocked(getTerminalDrayageHistory).mockResolvedValue([]) vi.mocked(selectTerminalDrayageFleet).mockResolvedValue({ ...drayage, fleetSupplierId: '502', fleetSupplierName: '新车队', version: 4, allowedActions: [...drayage.allowedActions] }) vi.mocked(recordTerminalDrayageEmptyReturn).mockReset().mockResolvedValue({ ...drayage, status: 'EMPTY_RETURNED', actualEmptyReturnAt: '2026-09-11T13:00:00', version: 4, emptyReturnWarning: { status: 'RESOLVED', deadlineAt: '2026-09-10T10:00:00', overdueHours: 0, overdueDays: 0 }, allowedActions: ['SELECT_FLEET'] }) - vi.mocked(listContainers).mockResolvedValue({ records: [{ id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', sourceType: 'SIMULATION', allowedActions: ['EDIT_CONTAINER', 'SELECT_FLEET', 'SCHEDULE_PICKUP', 'RECORD_PICKUP', 'RECORD_EMPTY_RETURN'], terminalDrayage: drayage, oceanBookingId: null, warehouseId: '8', warehouseName: '义乌仓', channelGroupCode: 'MATSON_CLX', channelCode: null, channelName: null, containerType: '40HQ', planStatus: 'WAIT_LOADING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 19500, terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null }], page: 1, size: 20, total: 1, pages: 1 }) + vi.mocked(listContainers).mockResolvedValue({ records: [{ id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', sourceType: 'SIMULATION', allowedActions: ['EDIT_CONTAINER', 'SELECT_FLEET', 'SCHEDULE_PICKUP', 'RECORD_EMPTY_RETURN'], terminalDrayage: drayage, oceanBookingId: null, warehouseId: '8', warehouseName: '义乌仓', channelGroupCode: 'MATSON_CLX', channelCode: null, channelName: null, containerType: '40HQ', planStatus: 'WAIT_LOADING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 19500, terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null }], page: 1, size: 20, total: 1, pages: 1 }) vi.mocked(getContainer).mockResolvedValue({ id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', sourceType: 'SIMULATION', allowedActions: ['EDIT_CONTAINER'], oceanBookingId: null, warehouseId: '8', warehouseName: '义乌仓', channelGroupCode: 'MATSON_CLX', channelCode: null, channelName: null, containerType: '40HQ', planStatus: 'WAIT_LOADING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 19500, terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null }) vi.mocked(getContainerOverview).mockResolvedValue({ container: { id: '9001', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', status: 'WAIT_LOADING', warehouseId: '8', warehouseName: '义乌仓', voyageNo: null, vesselName: null, version: 0 }, loadingPlanId: null, pda: null, documents: [], exception: { active: false, reason: null, markedBy: null, markedByName: null, markedAt: null }, exceptionHistory: [], timeline: [] }) }) diff --git a/src/modules/container-loading/pages/ContainersView.vue b/src/modules/container-loading/pages/ContainersView.vue index 7dc39e5b..391c9479 100644 --- a/src/modules/container-loading/pages/ContainersView.vue +++ b/src/modules/container-loading/pages/ContainersView.vue @@ -31,7 +31,6 @@ import { getTerminalDrayage, getTerminalDrayageHistory, recordTerminalDrayageEmptyReturn, - recordTerminalDrayagePickup, scheduleTerminalDrayagePickup, selectTerminalDrayageFleet, type TerminalDrayageAction, @@ -188,7 +187,6 @@ const canCreate = computed(() => access.can('platform:firstleg:container:create' const canOverseasDocumentEdit = computed(() => access.can('platform:firstleg:terminal-drayage:document:edit')) const canFleetEdit = computed(() => access.can('platform:firstleg:terminal-drayage:fleet:edit')) const canPickupAppointmentEdit = computed(() => access.can('platform:firstleg:terminal-drayage:pickup-appointment:edit')) -const canPickupConfirm = computed(() => access.can('platform:firstleg:terminal-drayage:pickup:confirm')) const canEmptyReturnConfirm = computed(() => access.can('platform:firstleg:terminal-drayage:empty-return:confirm')) const canSmartLoading = computed(() => access.can('platform:firstleg:smart-loading:list')) const canViewPreload = computed(() => access.can('platform:firstleg:preload:list')) @@ -281,10 +279,10 @@ function documentStatusText(status: ContainerDocumentStatus | null | undefined) function formatTime(value: string | null | undefined) { return value?.replace('T', ' ').slice(0, 16) || '—' } function drayageFor(containerBookingId: string) { return drayages.value[containerBookingId] } function drayageStatusText(status?: string | null) { return ({ WAITING_FOR_ARRIVAL: '待到港', ARRIVED: '已到港', PICKUP_SCHEDULED: '已约提柜', PICKED_UP: '已提柜', UNLOADED: '已卸柜', EMPTY_RETURNED: '已还柜' } as Record)[status || ''] || status || '—' } -function drayageActionText(action: TerminalDrayageAction) { return ({ SELECT_FLEET: '选择码头车队', SCHEDULE_PICKUP: '预约提柜', RECORD_PICKUP: '确认实际提柜', RECORD_EMPTY_RETURN: '确认还柜', SEND_PICKUP_EMAIL: '发送提柜邮件' } as Record)[action] } +function drayageActionText(action: TerminalDrayageAction) { return ({ SELECT_FLEET: '选择码头车队', SCHEDULE_PICKUP: '预约提柜', RECORD_EMPTY_RETURN: '确认还柜', SEND_PICKUP_EMAIL: '发送提柜邮件' } as Record)[action] } function hasAction(row: ContainerWorkbenchRow, action: ContainerWorkbenchAction) { return Boolean(row.allowedActions?.includes(action)) } function canDrayage(row: ContainerWorkbenchRow, action: TerminalDrayageAction) { - const permission = action === 'SELECT_FLEET' || action === 'SEND_PICKUP_EMAIL' ? canFleetEdit.value : action === 'SCHEDULE_PICKUP' ? canPickupAppointmentEdit.value : action === 'RECORD_PICKUP' ? canPickupConfirm.value : canEmptyReturnConfirm.value + const permission = action === 'SELECT_FLEET' || action === 'SEND_PICKUP_EMAIL' ? canFleetEdit.value : action === 'SCHEDULE_PICKUP' ? canPickupAppointmentEdit.value : canEmptyReturnConfirm.value return Boolean(isOverseas.value && permission && hasAction(row, action) && drayageFor(row.id)?.allowedActions.includes(action)) } function drayageWarningText(row: ContainerBookingResource) { @@ -559,9 +557,7 @@ async function saveDrayageAction() { const reason = drayageReason.value.trim() || undefined const next = drayageAction.value === 'SCHEDULE_PICKUP' ? await scheduleTerminalDrayagePickup(row.id, { expectedVersion: current.version, pickupAppointmentAt: drayageActionAt.value, reason }) - : drayageAction.value === 'RECORD_PICKUP' - ? await recordTerminalDrayagePickup(row.id, { expectedVersion: current.version, actualAt: drayageActionAt.value, remark: reason }) - : await recordTerminalDrayageEmptyReturn(row.id, { expectedVersion: current.version, actualAt: drayageActionAt.value, remark: reason }) + : await recordTerminalDrayageEmptyReturn(row.id, { expectedVersion: current.version, actualAt: drayageActionAt.value, remark: reason }) replaceDrayage(next); drayageActionOpen.value = false ElMessage.success(`${drayageActionText(drayageAction.value)}成功`) await loadRows() @@ -698,7 +694,7 @@ onMounted(async () => { await Promise.all([loadCustomers(), loadRows()]) })
- + @@ -749,7 +745,7 @@ onMounted(async () => { await Promise.all([loadCustomers(), loadRows()]) }) - + diff --git a/src/modules/container-loading/types.ts b/src/modules/container-loading/types.ts index b8d786c1..566dd08e 100644 --- a/src/modules/container-loading/types.ts +++ b/src/modules/container-loading/types.ts @@ -208,7 +208,7 @@ export interface ContainerQuery { export type ContainerWorkbenchKind = 'DOMESTIC' | 'OVERSEAS' export type ContainerWorkbenchScope = 'ACTIVE' | 'HISTORY' -export type ContainerWorkbenchAction = 'EDIT_CONTAINER' | 'MANAGE_CUSTOMS' | 'CONFIRM_OUTBOUND' | 'MANAGE_DESTINATION_DOCUMENTS' | 'SELECT_FLEET' | 'SCHEDULE_PICKUP' | 'RECORD_PICKUP' | 'RECORD_EMPTY_RETURN' | 'SEND_PICKUP_EMAIL' +export type ContainerWorkbenchAction = 'EDIT_CONTAINER' | 'MANAGE_CUSTOMS' | 'CONFIRM_OUTBOUND' | 'MANAGE_DESTINATION_DOCUMENTS' | 'SELECT_FLEET' | 'SCHEDULE_PICKUP' | 'RECORD_EMPTY_RETURN' | 'SEND_PICKUP_EMAIL' export interface ContainerWorkbenchStatusSummary { loadingStatus?: string | null @@ -244,7 +244,7 @@ export interface ContainerWorkbenchTerminalDrayage { actualEmptyReturnAt: string | null unloadingCompletedAt: string | null emptyReturnWarning: { status: 'NOT_APPLICABLE' | 'PENDING' | 'OVERDUE' | 'RESOLVED'; deadlineAt: string | null; overdueHours: number; overdueDays: number } - allowedActions: ReadonlyArray<'SELECT_FLEET' | 'SCHEDULE_PICKUP' | 'RECORD_PICKUP' | 'RECORD_EMPTY_RETURN' | 'SEND_PICKUP_EMAIL'> + allowedActions: ReadonlyArray<'SELECT_FLEET' | 'SCHEDULE_PICKUP' | 'RECORD_EMPTY_RETURN' | 'SEND_PICKUP_EMAIL'> updatedAt: string | null } diff --git a/src/modules/overseas-warehouse/AGENTS.md b/src/modules/overseas-warehouse/AGENTS.md index f58cb140..a3d6d78a 100644 --- a/src/modules/overseas-warehouse/AGENTS.md +++ b/src/modules/overseas-warehouse/AGENTS.md @@ -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 diff --git a/src/modules/overseas-warehouse/api/pallets.test.ts b/src/modules/overseas-warehouse/api/pallets.test.ts index 22522801..26bb06b4 100644 --- a/src/modules/overseas-warehouse/api/pallets.test.ts +++ b/src/modules/overseas-warehouse/api/pallets.test.ts @@ -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 }) diff --git a/src/modules/overseas-warehouse/api/pallets.ts b/src/modules/overseas-warehouse/api/pallets.ts index 37b1c135..abc0218a 100644 --- a/src/modules/overseas-warehouse/api/pallets.ts +++ b/src/modules/overseas-warehouse/api/pallets.ts @@ -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( + `/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) + 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>( `/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets`, payload, diff --git a/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts b/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts index 81c3b0b7..6c076d5e 100644 --- a/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts +++ b/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts @@ -161,9 +161,9 @@ async function chooseContainer(wrapper: Awaited>) { 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 }) diff --git a/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue b/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue index 35bf3090..9d8e0e32 100644 --- a/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue +++ b/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue @@ -845,7 +845,7 @@ onMounted(() => { void load() })
- +
diff --git a/src/modules/overseas-warehouse/pages/PalletsView.test.ts b/src/modules/overseas-warehouse/pages/PalletsView.test.ts index 403ba86b..36039717 100644 --- a/src/modules/overseas-warehouse/pages/PalletsView.test.ts +++ b/src/modules/overseas-warehouse/pages/PalletsView.test.ts @@ -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(); 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() diff --git a/src/modules/overseas-warehouse/pages/PalletsView.vue b/src/modules/overseas-warehouse/pages/PalletsView.vue index 048058dc..f175abfd 100644 --- a/src/modules/overseas-warehouse/pages/PalletsView.vue +++ b/src/modules/overseas-warehouse/pages/PalletsView.vue @@ -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([]) const loading = ref(false) const contextLoading = ref(false) const saving = ref(false) +const printingId = ref('') const loadError = ref(null) const mutationError = ref(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)
- +
diff --git a/src/styles/adminDensityTokens.css b/src/styles/adminDensityTokens.css index c8dcfd06..94efcaef 100644 --- a/src/styles/adminDensityTokens.css +++ b/src/styles/adminDensityTokens.css @@ -27,7 +27,7 @@ --admin-table-border: #e5e7eb; --admin-row-hover: #f3f7fb; --admin-row-stripe: #fcfcfd; - --admin-action-neutral: #52525b; + --admin-action-neutral: #1777ff; --admin-action-neutral-border: #dfe3ee; --admin-action-warning: #c76500; --admin-action-warning-border: #fed7aa;