From 41924a438c0897f84141a989d94b7fedc302bd69 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 9 Sep 2026 13:33:34 +0800 Subject: [PATCH 1/7] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../container-loading/pages/ContainersView.test.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/modules/container-loading/pages/ContainersView.test.ts b/src/modules/container-loading/pages/ContainersView.test.ts index 9675af1d..b61f729e 100644 --- a/src/modules/container-loading/pages/ContainersView.test.ts +++ b/src/modules/container-loading/pages/ContainersView.test.ts @@ -88,7 +88,14 @@ describe('ContainersView', () => { expect(wrapper.text()).not.toContain('报空预警') }) - it('shows one direct empty-return action only when permission and server actions allow it', async () => { + it.each([3, 0])('submits direct empty return with server version %s, including unload without pickup', async (version) => { + const original = await listContainers({ page: 1, size: 20 }, 'OVERSEAS') + vi.mocked(listContainers).mockClear().mockResolvedValue({ ...original, records: original.records.map((row) => ({ + ...row, + terminalDrayage: { ...drayage, version, actualPickupAt: version ? drayage.actualPickupAt : null, + fleetSupplierId: version ? drayage.fleetSupplierId : null, allowedActions: ['RECORD_EMPTY_RETURN'], + emptyReturnWarning: { status: 'PENDING', deadlineAt: drayage.estimatedEmptyReturnAt, overdueHours: 0, overdueDays: 0 } }, + })) }) const wrapper = await mountOverseasRoute() expect(listContainers).toHaveBeenCalledWith(expect.objectContaining({ scope: 'ACTIVE' }), 'OVERSEAS') const buttons = wrapper.findAll('button').filter((item) => item.text().includes('确认还柜')) @@ -105,7 +112,7 @@ describe('ContainersView', () => { confirm.click() await flushPromises() expect(recordTerminalDrayageEmptyReturn).toHaveBeenCalledTimes(1) - expect(recordTerminalDrayageEmptyReturn).toHaveBeenCalledWith('9001', { expectedVersion: 3, actualAt: '2026-09-11T13:00:00', remark: '还柜凭证已核对' }) + expect(recordTerminalDrayageEmptyReturn).toHaveBeenCalledWith('9001', { expectedVersion: version, actualAt: '2026-09-11T13:00:00', remark: '还柜凭证已核对' }) expect(wrapper.text()).toContain('已还柜') expect(listContainers).toHaveBeenCalledTimes(callsBefore + 1) expect(wrapper.findAll('.row-actions button').some((button) => button.text() === '确认还柜')).toBe(false) From 08a1f1e5e27a346de5374f2bdc2f1d2999dc84de Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 9 Sep 2026 13:51:14 +0800 Subject: [PATCH 2/7] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/ContainersView.test.ts | 26 +++++++++++++++++++ .../pages/ContainersView.vue | 5 +++- .../api/deliveryOrders.test.ts | 14 +++++++++- .../overseas-warehouse/api/deliveryOrders.ts | 10 ++++++- 4 files changed, 52 insertions(+), 3 deletions(-) diff --git a/src/modules/container-loading/pages/ContainersView.test.ts b/src/modules/container-loading/pages/ContainersView.test.ts index b61f729e..cb36f1ae 100644 --- a/src/modules/container-loading/pages/ContainersView.test.ts +++ b/src/modules/container-loading/pages/ContainersView.test.ts @@ -485,6 +485,32 @@ describe('ContainersView', () => { expect(source).toContain("void router.replace({ name: 'domestic-container-workbench' })") }) + it.each(['EDITING', 'LOADING', 'COMPLETED'] as const)('keeps released-booking linking reachable alongside the %s process action', async (planStatus) => { + const page = await listContainers({ page: 1, size: 20 }, 'DOMESTIC') + vi.mocked(listContainers).mockResolvedValue({ ...page, records: page.records.map((row) => ({ + ...row, planStatus, containerNo: null, allowedActions: ['EDIT_CONTAINER'], + })) }) + const router = createTestRouter() + await router.push('/'); await router.isReady() + const wrapper = mount(ContainersView, { attachTo: document.body, global: { plugins: [ElementPlus, router] } }) + try { + await flushPromises() + const actions = wrapper.find('.el-table__body .row-actions') + expect(actions.findAll('button').map((button) => button.text())).toEqual(['查看', '编辑运输信息', ({ EDITING: '智能装柜', LOADING: '预装清单', COMPLETED: '装柜清单' })[planStatus], '']) + await actions.find('button[aria-label="更多货柜操作"]').trigger('click') + await flushPromises() + const link = Array.from(document.body.querySelectorAll('.el-dropdown-menu__item')) + .filter((item) => item.textContent?.includes('关联已放舱订舱')).at(-1)! + expect(link).toBeDefined() + link.click() + await flushPromises() + expect(wrapper.findAllComponents(AppDialog).find((dialog) => dialog.props('title') === '关联已放舱订舱')?.props('modelValue')).toBe(true) + expect(listOceanBookingLinkCandidates).toHaveBeenCalledWith({ warehouseId: '8', channelCode: 'ZBYHK', containerType: '40HC', page: 1, size: 200 }) + } finally { + wrapper.unmount() + } + }) + it('normalizes an optional booking-link container number', async () => { vi.mocked(linkContainerOceanBooking).mockResolvedValue({} as never) const router = createTestRouter() diff --git a/src/modules/container-loading/pages/ContainersView.vue b/src/modules/container-loading/pages/ContainersView.vue index c8fc56c1..853b3e89 100644 --- a/src/modules/container-loading/pages/ContainersView.vue +++ b/src/modules/container-loading/pages/ContainersView.vue @@ -210,7 +210,10 @@ const allColumns: DataTableColumn[] = [ ] const domesticColumnKeys = new Set(['actions', 'containerNo', 'planNo', 'planStatus', 'exceptionActive', 'cabinetNo', 'oceanBooking', 'warehouseName', 'billOfLadingNo', 'route', 'containerType', 'capacity', 'vessel', 'shippingCompanyName', 'loadingAt', 'estimatedSailingAt']) const overseasColumnKeys = new Set(['actions', 'emptyReturnWarning', 'containerNo', 'cabinetNo', 'destinationClearanceDocumentStatus', 'deliveryOrderDocumentStatus', 'pickupCode', 'terminalTruckingCompany', 'pickupAppointmentAt', 'actualPickupAt', 'estimatedEmptyReturnAt', 'actualEmptyReturnAt', 'billOfLadingNo', 'route', 'vessel', 'actualSailingAt', 'estimatedPortArrivalAt', 'actualPortArrivalAt']) -const columns = computed(() => allColumns.filter((column) => (isDomestic.value ? domesticColumnKeys : overseasColumnKeys).has(String(column.key)))) +const columns = computed(() => allColumns + .filter((column) => (isDomestic.value ? domesticColumnKeys : overseasColumnKeys).has(String(column.key))) + // 国内同时展示查看、运输信息、装柜流程和更多菜单,须为四个入口预留空间。 + .map((column) => isDomestic.value && column.key === 'actions' ? { ...column, width: 380, minWidth: 380 } : column)) const drayageHistoryColumns: DataTableColumn[] = [ { key: 'action', label: '动作', width: 130, formatter: (row: TerminalDrayageHistory) => drayageActionText(row.action) }, { key: 'status', label: '状态变化', minWidth: 180, formatter: (row: TerminalDrayageHistory) => `${drayageStatusText(row.fromStatus)} → ${drayageStatusText(row.toStatus)}` }, diff --git a/src/modules/overseas-warehouse/api/deliveryOrders.test.ts b/src/modules/overseas-warehouse/api/deliveryOrders.test.ts index 3d073756..2aa59f70 100644 --- a/src/modules/overseas-warehouse/api/deliveryOrders.test.ts +++ b/src/modules/overseas-warehouse/api/deliveryOrders.test.ts @@ -18,11 +18,23 @@ describe('delivery order API', () => { }) it('loads an encoded detail in its explicit customer scope', async () => { - vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', data: { order: { deliveryNo: 'DEL/1' }, skus: [], fees: [], operations: [] } }) + vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', data: { order: { deliveryNo: 'DEL/1', version: '0' }, skus: [], fees: [], operations: [] } }) await getDeliveryOrder('DEL/1', '1001') expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/delivery-orders/DEL%2F1', { params: { customerId: '1001', page: 1, size: 200 } }) }) + it.each(['0', '7', '9007199254740991'])('reads Java Long version %s for status editing', async version => { + vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', data: { order: { version, id: '2097515131537313794' } } }) + const detail = await getDeliveryOrder('DEL/1', '1001') + expect(detail.order.version).toBe(Number(version)) + expect(detail.order.id).toBe('2097515131537313794') + }) + + it.each([null, undefined, '', ' ', '-1', '1.5', '9007199254740992', -1])('rejects invalid version %s', async version => { + vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', data: { order: { version } } }) + await expect(getDeliveryOrder('DEL/1', '1001')).rejects.toThrow('派送单缺少有效版本') + }) + it('loads return options and marks a return in the explicit customer scope', async () => { vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', data: [{ inboundOrderId: '9', inboundCode: 'IN-1' }] }) await listDeliveryReturnInboundOptions('DEL/1', '1001') diff --git a/src/modules/overseas-warehouse/api/deliveryOrders.ts b/src/modules/overseas-warehouse/api/deliveryOrders.ts index 4e2bd314..9aa54e71 100644 --- a/src/modules/overseas-warehouse/api/deliveryOrders.ts +++ b/src/modules/overseas-warehouse/api/deliveryOrders.ts @@ -97,7 +97,15 @@ export async function getDeliveryOrder(deliveryNo: string, customerId: string) { const response = await overseasHttp.get, ApiEnvelope>( `/platform/v1/last-leg/delivery-orders/${encodeURIComponent(deliveryNo)}`, { params: { customerId, page: 1, size: 200 } }, ) - return unwrapApiResponse(response) + const detail = unwrapApiResponse(response) + // Java Long values are serialized as decimal strings, including the version. + const rawVersion: unknown = detail.order.version + const version = typeof rawVersion === 'string' && /^(0|[1-9]\d*)$/.test(rawVersion) + ? Number(rawVersion) : rawVersion + if (typeof version !== 'number' || !Number.isSafeInteger(version) || version < 0) { + throw new Error('派送单缺少有效版本,请刷新后重试') + } + return { ...detail, order: { ...detail.order, version } } } export async function listDeliveryReturnInboundOptions(deliveryNo: string, customerId: string) { From f62a3175f76d10bdbfb8775ab5356f2b30cc29bb Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 9 Sep 2026 13:54:43 +0800 Subject: [PATCH 3/7] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../container-loading/api/terminalDrayages.ts | 32 ++++++++++++++++++- .../pages/ContainersView.vue | 6 ++-- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/src/modules/container-loading/api/terminalDrayages.ts b/src/modules/container-loading/api/terminalDrayages.ts index 82b200f8..bf176078 100644 --- a/src/modules/container-loading/api/terminalDrayages.ts +++ b/src/modules/container-loading/api/terminalDrayages.ts @@ -1,7 +1,37 @@ 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' +export type TerminalDrayageAction = 'SELECT_FLEET' | 'SCHEDULE_PICKUP' | 'RECORD_PICKUP' | 'RECORD_EMPTY_RETURN' | 'SEND_PICKUP_EMAIL' + +export interface TerminalDrayageEmail { + id: string + fleetName: string + recipient: string + kind: 'REQUEST_PICKUP' | 'CANCEL_PICKUP' + subject: string + body: string + status: 'PENDING' | 'SENDING' | 'SENT' | 'FAILED' + attempts: number + failureReason: string | null + createdAt: string + sentAt: string | null +} + +export async function getTerminalDrayageEmails(containerId: string) { + type Mailbox = { deliveryEnabled: boolean; records: TerminalDrayageEmail[] } + const response = await http.get, ApiEnvelope>(`${path}/${encodeURIComponent(containerId)}/emails`) + const result = unwrapApiResponse(response) + return { ...result, records: result.records.map((mail) => ({ ...mail, id: String(mail.id) })) } +} + +export function sendTerminalDrayageEmail(containerId: string, expectedVersion: number) { + return command(containerId, 'pickup-email', { expectedVersion }) +} + +export async function retryTerminalDrayageEmail(containerId: string, mailId: string) { + const response = await http.post, ApiEnvelope>(`${path}/${encodeURIComponent(containerId)}/emails/${encodeURIComponent(mailId)}/retry`) + unwrapApiResponse(response) +} export type EmptyReturnWarningStatus = 'NOT_APPLICABLE' | 'PENDING' | 'OVERDUE' | 'RESOLVED' export interface EmptyReturnWarning { diff --git a/src/modules/container-loading/pages/ContainersView.vue b/src/modules/container-loading/pages/ContainersView.vue index 853b3e89..aabe3fa5 100644 --- a/src/modules/container-loading/pages/ContainersView.vue +++ b/src/modules/container-loading/pages/ContainersView.vue @@ -179,7 +179,7 @@ const queryFields = computed(() => [ { key: 'sailingRange', type: 'daterange', label: '预计开船', width: 260, props: { 'aria-label': '预计开船日期范围' } }, ]) const allColumns: DataTableColumn[] = [ - { key: 'actions', label: '操作', width: 310, fixed: 'left', slotName: 'actions' }, + { key: 'actions', label: '操作', width: 140, fixed: 'left', slotName: 'actions' }, { key: 'emptyReturnWarning', label: '报空预警', width: 120, slotName: 'emptyReturnWarning' }, { key: 'containerNo', label: '集装箱号', minWidth: 150, slotName: 'containerNo' }, { key: 'planNo', prop: 'planNo', label: '装柜清单号', minWidth: 165, formatter: (row: ContainerBookingResource) => row.planNo || '—' }, @@ -213,7 +213,7 @@ const overseasColumnKeys = new Set(['actions', 'emptyReturnWarning', 'containerN const columns = computed(() => allColumns .filter((column) => (isDomestic.value ? domesticColumnKeys : overseasColumnKeys).has(String(column.key))) // 国内同时展示查看、运输信息、装柜流程和更多菜单,须为四个入口预留空间。 - .map((column) => isDomestic.value && column.key === 'actions' ? { ...column, width: 380, minWidth: 380 } : column)) + .map((column) => isDomestic.value && column.key === 'actions' ? { ...column, width: 290 } : column)) const drayageHistoryColumns: DataTableColumn[] = [ { key: 'action', label: '动作', width: 130, formatter: (row: TerminalDrayageHistory) => drayageActionText(row.action) }, { key: 'status', label: '状态变化', minWidth: 180, formatter: (row: TerminalDrayageHistory) => `${drayageStatusText(row.fromStatus)} → ${drayageStatusText(row.toStatus)}` }, @@ -727,7 +727,7 @@ onMounted(async () => {
- + From a2515119b7db8ed0e8e02d1aecb34cb0c0b11681 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 9 Sep 2026 14:32:04 +0800 Subject: [PATCH 4/7] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 4 +- .../api/terminalDrayages.test.ts | 15 +++ .../pages/ContainersView.test.ts | 59 ++++++++---- .../pages/ContainersView.vue | 94 +++++++++++++++++-- src/modules/container-loading/types.ts | 4 +- .../pages/OceanBookingDetailView.vue | 4 +- .../pages/OceanBookingsView.test.ts | 15 +++ .../pages/OceanBookingsView.vue | 4 +- 8 files changed, 166 insertions(+), 33 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e2831fde..9f97338c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,9 +53,9 @@ | 货柜双工作台、排柜、预装/装柜清单、真实柜号后补与整柜报关 | 国内 `modules/container-loading/pages/DomesticContainersView.vue`、海外 `OverseasContainersView.vue`,共享详情实现 `ContainerDetailView.vue`;预装详情 `modules/loading-lists/pages/PreloadPlanDetailView.vue`,资料/PDA 证据 `components/ContainerOverviewPanel.vue`,报关 `components/ContainerCustomsPanel.vue` | 国内“送柜报关”只消费 `/containers/domestic` 与服务端 `allowedActions`,承载模拟柜、订舱关联、智能装柜、装柜清单页签、出口报关和国内出仓;创建柜次时自动生成唯一预装计划,预装详情先调用 `/loading-plans/{planId}/lifecycle`,完成后深链进入国内货柜详情的装柜清单页签,不再注册独立正式清单导航和路由。订舱关联允许真实集装箱号为空,但 PDA 最终完成前必须补齐;全买单柜继续使用 `complete-all-buy`。海外“清关提柜”只消费 `/containers/overseas`,承载船节点、目的港清关、DO/PU、码头车队、提柜和还空;还空截止事实由服务端基于卸柜完成时间加 72 小时计算,前端不重算。两端统一使用 `containerBookingId`,列表范围由服务端 `scope=ACTIVE/HISTORY` 分页,禁止浏览器筛选或本地推导写资格。后端为 `LoadingListService`、`firstleg/customs/container/`、`firstleg/terminaldrayage/`,文件归 `file/` | | 合柜报关 / 同柜不可拆组 | `modules/unified-orders/api/coLoadGroups.ts`、`pages/UnifiedOrdersView.vue`;装柜消费 `modules/container-loading/pages/SmartLoadingWorkbenchView.vue` | 前端只提交订单 ID;Backend `firstleg/loading/coload/` 展开并冻结每票全部可用入仓片段。成员数是入仓片段数,不等于订单票数;只要求同国内仓、同渠道,允许跨客户及不同目的仓港;分配、移除、取消均整组处理 | | 全局智能装柜工作台、入仓码候选与箱数分配 | `modules/container-loading/components/SmartLoadingDialog.vue` 内嵌 `pages/GlobalSmartLoadingView.vue`,柜次页 `SmartLoadingWorkbenchView.vue`,以及 `smartLoadingChannelGroups.ts`、`smartLoadingItems.ts`、`api/platformContainerLoading.ts`;全局页当前不注册独立路由 | Backend `firstleg/` 的 `/inbound-candidates`、`/containers`、`/inbound-assignments`、`/plans/{planId}/pending-review-assignments`、`/group-assignments`、`/unpicked-items`。`orderStatuses` 最多四项且未传表示全部未完成状态;候选状态多选覆盖待审核、待测量、待分柜、装柜中,状态只用于筛选展示,按钮资格以服务端 `canAssign` 为准;待审核/待测量可提前占位但不得提前确认预装或启动 PDA。计划箱数编辑上限、已用和未用展示只消费服务端 `totalPackageCount/remainingPackageCount`,不得从候选页或本地规格重新拼算。人工加入允许跨渠道,智能推荐仍按柜次渠道。柜次容量和限重取服务端字段;同柜组作为单卡整组分页、分配和移除,任一成员已拣时整组保留 | -| 国内货柜履约订舱资料、打印、关联候选与出仓前船期 | `modules/ocean-bookings/` 的 `components/OceanBookingDocumentsPanel.vue`、`OceanBookingPrintDialog.vue`,以及 `modules/container-loading/`、`modules/port-schedules/` | 订舱草稿按当前版本维护资料,放舱后资料与表单只读;打印只对 `RELEASED` 开放。`platformOceanBookings.ts` 的 `/link-candidates` 只消费服务端分页,`containerCount` 是柜型需求 `quantity` 总和;Backend `firstleg/oceanbooking/`、`file/`、`print/`、`firstleg/portschedule/` | +| 国内货柜履约订舱资料、打印、关联候选与出仓前船期 | `modules/ocean-bookings/` 的 `components/OceanBookingDocumentsPanel.vue`、`OceanBookingPrintDialog.vue`,以及 `modules/container-loading/`、`modules/port-schedules/` | 订舱草稿按当前版本维护资料,放舱后资料与表单只读;打印对草稿与已放舱订舱均开放,按当前已保存资料生成,未填写字段显示“—”。`platformOceanBookings.ts` 的 `/link-candidates` 只消费服务端分页,`containerCount` 是柜型需求 `quantity` 总和;Backend `firstleg/oceanbooking/`、`file/`、`print/`、`firstleg/portschedule/` | | 订舱级拖车预约 | `modules/trucking/api/platformTrucking.ts` 的 `/candidates`、`components/TruckAppointmentForm.vue`、`pages/TruckAppointmentCreateView.vue`、`TruckAppointmentsView.vue` | 表单只选择服务端返回的 `RELEASED` 订舱与拖车供应商,不选择或提交客户、柜次、装柜计划或真实集装箱号;提单号和仓库由订舱冻结事实带入。一个订舱最多一张非终态拖车单,重复创建转入现有单继续编辑;PDA 完成不自动推进拖车。附件为平台作用域且不重复提单文件;Backend `TruckAppointmentService`、`truck-appointments-v1.yaml` | -| 目的港码头拖车与还空预警 | 海外清关提柜工作台/详情 `modules/container-loading/pages/OverseasContainersView.vue`、`OverseasContainerDetailView.vue`,API `modules/container-loading/api/terminalDrayages.ts` | 按 `containerBookingId` 读取服务端状态、`allowedActions` 和历史,不能复用订舱级拖车表单或自行推进状态;实际到港后选择 `ACTIVE + TRUCKING` 车队,再预约提柜、确认实际提柜与实际还柜。还空截止、超时天/小时只展示服务端基于权威卸柜完成时间加 72 小时计算的结果;Backend `firstleg/terminaldrayage/`、`terminal-drayage-v1.yaml` | +| 目的港码头拖车与还空预警 | 海外清关提柜工作台/详情 `modules/container-loading/pages/OverseasContainersView.vue`、`OverseasContainerDetailView.vue`,API `modules/container-loading/api/terminalDrayages.ts` | 按 `containerBookingId` 读取服务端状态、`allowedActions` 和历史,不能复用订舱级拖车表单或自行推进状态;实际到港后选择 `ACTIVE + TRUCKING` 车队,再预约提柜、确认实际提柜与实际还柜。还空截止、超时天/小时只展示服务端基于权威卸柜完成时间加 72 小时计算的结果;Backend `firstleg/terminaldrayage/`、`terminal-drayage-v1.yaml` 列表消费 `SEND_PICKUP_EMAIL`,邮件记录通过 `terminalDrayages.ts` 查询与重试;发送及换队成功只提示已入队,发送权限复用码头车队编辑权限。 | | 历史报关协同与国内出仓 | `modules/customs/`、`modules/outbound-dispatch/` | `modules/customs/` 是历史报关案件/清单/代理提交与回执入口;装柜完成后的整柜退税/买单资料从货柜详情进入,不在旧模块复制;Backend `firstleg/outbound/dispatch/`、`firstleg/customs/`、`file/` | | 主数据、下载、退避任务重试、审计 | `modules/master-data/`、`modules/downloads/pages/DownloadCenterView.vue`、`modules/audit/` | File 导出任务返回 `attemptCount/maxAttempts/nextRetryAt`;仅失败或已进入自动退避的等待任务显示重试,等待任务重试用于立即执行当前投递,不创建新任务;联查 `warehouse/`、`file/`、`audit-service/` | | 海外接柜、入库、库存与库内服务 | `modules/overseas-warehouse/` | `gateway-overseas/`、`lastleg-service/`、`warehouse-overseas-service/`;与排车、出库、派送、异常退回和一件代发共同组成海外仓全流程 | diff --git a/src/modules/container-loading/api/terminalDrayages.test.ts b/src/modules/container-loading/api/terminalDrayages.test.ts index 524bf6e6..4856d849 100644 --- a/src/modules/container-loading/api/terminalDrayages.test.ts +++ b/src/modules/container-loading/api/terminalDrayages.test.ts @@ -1,6 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { http } from '@/shared/api/http' import { + sendTerminalDrayageEmail, + getTerminalDrayageEmails, + retryTerminalDrayageEmail, getTerminalDrayageHistory, listTerminalDrayages, recordTerminalDrayageEmptyReturn, @@ -26,6 +29,18 @@ const view = { describe('terminal drayages API', () => { beforeEach(() => vi.clearAllMocks()) + it('queues pickup email with version and idempotency and scopes retries to the container', async () => { + vi.mocked(http.post).mockResolvedValueOnce({ data: view }) + await sendTerminalDrayageEmail('9007199254740993', 3) + expect(http.post).toHaveBeenCalledWith('/platform/v1/terminal-drayages/9007199254740993/pickup-email', + { expectedVersion: 3 }, { headers: { 'Idempotency-Key': expect.any(String) } }) + vi.mocked(http.get).mockResolvedValueOnce({ data: { deliveryEnabled: false, records: [{ id: '9007199254740999', status: 'FAILED' }] } }) + expect((await getTerminalDrayageEmails('9007199254740993')).records[0].id).toBe('9007199254740999') + vi.mocked(http.post).mockResolvedValueOnce({ data: null }) + await retryTerminalDrayageEmail('9007199254740993', '9007199254740999') + expect(http.post).toHaveBeenLastCalledWith('/platform/v1/terminal-drayages/9007199254740993/emails/9007199254740999/retry') + }) + it('sends repeated container ids and preserves long identifiers as strings', async () => { vi.mocked(http.get).mockResolvedValueOnce({ data: [view] }) const result = await listTerminalDrayages(['9007199254740993', '9007199254740994']) diff --git a/src/modules/container-loading/pages/ContainersView.test.ts b/src/modules/container-loading/pages/ContainersView.test.ts index cb36f1ae..161e5db9 100644 --- a/src/modules/container-loading/pages/ContainersView.test.ts +++ b/src/modules/container-loading/pages/ContainersView.test.ts @@ -11,7 +11,7 @@ import { listPlatformPartners } from '@/modules/master-data' import { listSuppliers } from '@/modules/suppliers' import { getOceanBooking, listOceanBookingLinkCandidates, listOceanBookings } from '@/modules/ocean-bookings' import { getContainer, getContainerOverview, linkContainerOceanBooking, listContainers, updateContainer } from '../api/platformContainerLoading' -import { getTerminalDrayage, getTerminalDrayageHistory, recordTerminalDrayageEmptyReturn, selectTerminalDrayageFleet } from '../api/terminalDrayages' +import { getTerminalDrayage, getTerminalDrayageHistory, recordTerminalDrayageEmptyReturn, selectTerminalDrayageFleet, sendTerminalDrayageEmail, getTerminalDrayageEmails } from '../api/terminalDrayages' import AppDialog from '@/shared/components/AppDialog.vue' import { containerLoadingRoutes } from '../routes' @@ -23,7 +23,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', () => ({ 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(), recordTerminalDrayagePickup: vi.fn(), recordTerminalDrayageEmptyReturn: vi.fn() })) const drayage = { containerBookingId: '9001', containerNo: 'MSCU-1', oceanBookingId: '101', status: 'UNLOADED' as const, version: 3, @@ -88,6 +88,31 @@ describe('ContainersView', () => { expect(wrapper.text()).not.toContain('报空预警') }) + it('sends pickup mail from the list once while pending and shows disabled delivery in history', async () => { + const original = await listContainers({ page: 1, size: 20 }, 'OVERSEAS') + vi.mocked(listContainers).mockResolvedValue({ ...original, records: original.records.map((row) => ({ + ...row, allowedActions: ['SEND_PICKUP_EMAIL'], terminalDrayage: { ...drayage, allowedActions: ['SEND_PICKUP_EMAIL'] }, + })) }) + let resolveSend!: (value: Awaited>) => void + vi.mocked(sendTerminalDrayageEmail).mockReset().mockReturnValue(new Promise((resolve) => { resolveSend = resolve })) + vi.mocked(getTerminalDrayageEmails).mockResolvedValue({ deliveryEnabled: false, records: [] }) + const wrapper = await mountOverseasRoute() + const button = wrapper.findAll('.row-actions button').find((item) => item.text().includes('发送邮件'))! + expect(button.exists()).toBe(true) + await button.trigger('click'); await button.trigger('click') + expect(sendTerminalDrayageEmail).toHaveBeenCalledTimes(1) + expect(sendTerminalDrayageEmail).toHaveBeenCalledWith('9001', 3) + resolveSend({ ...drayage, version: 4, allowedActions: ['SEND_PICKUP_EMAIL'] }) + await flushPromises() + await wrapper.find('.el-table__body button[aria-label="更多货柜操作"]').trigger('click') + await flushPromises() + Array.from(document.body.querySelectorAll('.el-dropdown-menu__item')).filter((item) => item.textContent?.includes('邮件记录')).at(-1)!.click() + await flushPromises() + expect(getTerminalDrayageEmails).toHaveBeenCalledWith('9001') + expect(document.body.textContent).toContain('发信尚未启用') + wrapper.unmount() + }) + it.each([3, 0])('submits direct empty return with server version %s, including unload without pickup', async (version) => { const original = await listContainers({ page: 1, size: 20 }, 'OVERSEAS') vi.mocked(listContainers).mockClear().mockResolvedValue({ ...original, records: original.records.map((row) => ({ @@ -233,14 +258,14 @@ describe('ContainersView', () => { await flushPromises() expect(listAllPlatformWarehouses).toHaveBeenCalledTimes(1) - await wrapper.findAll('button').find((button) => button.text().includes('编辑运输信息'))!.trigger('click') + await wrapper.findAll('button').find((button) => button.text().trim() === '编辑')!.trigger('click') await flushPromises() expect(listAllPlatformWarehouses).toHaveBeenCalledTimes(1) expect(listPlatformPartners).toHaveBeenCalledTimes(1) expect(listSuppliers).toHaveBeenCalledTimes(1) expect(listOceanBookings).toHaveBeenCalledTimes(1) wrapper.findAllComponents(AppDialog).find((dialog) => dialog.props('title') === '编辑')!.vm.$emit('update:modelValue', false) - await wrapper.findAll('button').find((button) => button.text().includes('编辑运输信息'))!.trigger('click') + await wrapper.findAll('button').find((button) => button.text().trim() === '编辑')!.trigger('click') await flushPromises() expect(listPlatformPartners).toHaveBeenCalledTimes(1) expect(listSuppliers).toHaveBeenCalledTimes(1) @@ -315,11 +340,11 @@ describe('ContainersView', () => { const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } }) await flushPromises() - expect(wrapper.text()).toContain('智能装柜') + expect(wrapper.text()).toContain('装柜') expect(wrapper.text()).toContain('预装清单') expect(wrapper.text()).toContain('装柜清单') - await wrapper.findAll('button').find((button) => button.text().trim() === '智能装柜')!.trigger('click') + await wrapper.findAll('button').find((button) => button.text().trim() === '装柜')!.trigger('click') await wrapper.findAll('button').find((button) => button.text().includes('预装清单'))!.trigger('click') await wrapper.findAll('button').find((button) => button.text().includes('装柜清单'))!.trigger('click') expect(push).toHaveBeenNthCalledWith(1, { name: 'smart-loading-workbench', params: { containerId: '9001' } }) @@ -343,7 +368,7 @@ describe('ContainersView', () => { await flushPromises() expect(wrapper.find('button[aria-label="查看 CB-1"]').exists()).toBe(true) - expect(wrapper.find('button[aria-label="更多货柜操作"]').exists()).toBe(true) + expect(wrapper.find('.el-table__body button[aria-label="更多货柜操作"]').exists()).toBe(true) expect(wrapper.findAll('button').some((button) => button.text().trim() === '编辑')).toBe(false) }) @@ -355,7 +380,7 @@ describe('ContainersView', () => { await flushPromises() expect(wrapper.find('button[aria-label="查看 CB-1"]').exists()).toBe(false) - expect(wrapper.find('button[aria-label="更多货柜操作"]').exists()).toBe(false) + expect(wrapper.find('.el-table__body button[aria-label="更多货柜操作"]').exists()).toBe(false) expect(wrapper.findAll('button').some((button) => button.text().trim() === '编辑')).toBe(false) }) @@ -449,6 +474,8 @@ describe('ContainersView', () => { drayageActionAt: string saveDrayageAction: () => Promise } + const beforeFleet = await listContainers({ page: 1, size: 20 }, 'OVERSEAS') + vi.mocked(listContainers).mockResolvedValueOnce({ ...beforeFleet, records: beforeFleet.records.map((row) => ({ ...row, terminalDrayage: { ...drayage, version: 4, fleetSupplierId: '502', allowedActions: [...drayage.allowedActions] } })) }) await vm.selectRowFleet(vm.rows[0], '502') expect(selectTerminalDrayageFleet).toHaveBeenCalledWith('9001', { expectedVersion: 3, fleetSupplierId: '502', fleetSupplierName: '新车队' }) @@ -496,7 +523,7 @@ describe('ContainersView', () => { try { await flushPromises() const actions = wrapper.find('.el-table__body .row-actions') - expect(actions.findAll('button').map((button) => button.text())).toEqual(['查看', '编辑运输信息', ({ EDITING: '智能装柜', LOADING: '预装清单', COMPLETED: '装柜清单' })[planStatus], '']) + expect(actions.findAll('button').map((button) => button.text())).toEqual(['查看', '编辑', ({ EDITING: '装柜', LOADING: '预装清单', COMPLETED: '装柜清单' })[planStatus], '']) await actions.find('button[aria-label="更多货柜操作"]').trigger('click') await flushPromises() const link = Array.from(document.body.querySelectorAll('.el-dropdown-menu__item')) @@ -610,7 +637,7 @@ describe('ContainersView', () => { await router.push('/'); await router.isReady() const wrapper = mount(ContainersView, { attachTo: document.body, global: { plugins: [ElementPlus, router] } }) await flushPromises() - await wrapper.find('button[aria-label="更多货柜操作"]').trigger('click') + await wrapper.find('.el-table__body button[aria-label="更多货柜操作"]').trigger('click') await flushPromises() Array.from(document.body.querySelectorAll('.el-dropdown-menu__item')) .filter((item) => item.textContent?.includes('关联已放舱订舱')).at(-1)!.click() @@ -637,7 +664,7 @@ describe('ContainersView', () => { await router.push('/'); await router.isReady() const wrapper = mount(ContainersView, { attachTo: document.body, global: { plugins: [ElementPlus, router] } }) await flushPromises() - await wrapper.find('button[aria-label="更多货柜操作"]').trigger('click') + await wrapper.find('.el-table__body button[aria-label="更多货柜操作"]').trigger('click') await flushPromises() const linkAction = Array.from(document.body.querySelectorAll('.el-dropdown-menu__item')) .filter((item) => item.textContent?.includes('关联已放舱订舱')).at(-1)! @@ -666,7 +693,7 @@ describe('ContainersView', () => { const wrapper = mount(ContainersView, { attachTo: document.body, global: { plugins: [ElementPlus, router] } }) await flushPromises() - await wrapper.find('button[aria-label="更多货柜操作"]').trigger('click') + await wrapper.find('.el-table__body button[aria-label="更多货柜操作"]').trigger('click') await flushPromises() Array.from(document.body.querySelectorAll('.el-dropdown-menu__item')) .filter((item) => item.textContent?.includes('关联已放舱订舱')).at(-1)!.click() @@ -675,7 +702,7 @@ describe('ContainersView', () => { .find((dialog) => dialog.props('title') === '关联已放舱订舱')!.vm.$emit('update:modelValue', false) await flushPromises() - await wrapper.find('button[aria-label="更多货柜操作"]').trigger('click') + await wrapper.find('.el-table__body button[aria-label="更多货柜操作"]').trigger('click') await flushPromises() Array.from(document.body.querySelectorAll('.el-dropdown-menu__item')) .filter((item) => item.textContent?.includes('关联已放舱订舱')).at(-1)!.click() @@ -700,7 +727,7 @@ describe('ContainersView', () => { const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } }) await flushPromises() - await wrapper.findAll('button').find((button) => button.text().includes('编辑运输信息'))!.trigger('click') + await wrapper.findAll('button').find((button) => button.text().trim() === '编辑')!.trigger('click') await flushPromises() await wrapper.findAll('button').find((button) => button.text().trim() === '确定')!.trigger('click') await flushPromises() @@ -720,7 +747,7 @@ describe('ContainersView', () => { const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } }) await flushPromises() - await wrapper.findAll('button').find((button) => button.text().includes('编辑运输信息'))!.trigger('click') + await wrapper.findAll('button').find((button) => button.text().trim() === '编辑')!.trigger('click') await flushPromises() const truckingSelect = wrapper.findAllComponents({ name: 'ElSelect' }).find((select) => select.props('placeholder') === '请选择拖车供应商') truckingSelect!.vm.$emit('update:modelValue', '') @@ -742,7 +769,7 @@ describe('ContainersView', () => { const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } }) await flushPromises() - await wrapper.findAll('button').find((button) => button.text().includes('编辑运输信息'))!.trigger('click') + await wrapper.findAll('button').find((button) => button.text().trim() === '编辑')!.trigger('click') await flushPromises() const truckingSelect = wrapper.findAllComponents({ name: 'ElSelect' }).find((select) => select.props('placeholder') === '请选择拖车供应商') expect(truckingSelect?.props('modelValue')).toBe('') diff --git a/src/modules/container-loading/pages/ContainersView.vue b/src/modules/container-loading/pages/ContainersView.vue index aabe3fa5..a73165f7 100644 --- a/src/modules/container-loading/pages/ContainersView.vue +++ b/src/modules/container-loading/pages/ContainersView.vue @@ -1,8 +1,8 @@ + + + + diff --git a/src/modules/container-loading/index.ts b/src/modules/container-loading/index.ts index d8c0bdbe..da4036f4 100644 --- a/src/modules/container-loading/index.ts +++ b/src/modules/container-loading/index.ts @@ -1,3 +1,4 @@ export * from './api/platformContainerLoading' export * from './types' export { default as SmartLoadingDialog } from './components/SmartLoadingDialog.vue' +export { default as ContainerSimulationCreate } from './components/ContainerSimulationCreate.vue' diff --git a/src/modules/container-loading/pages/ContainerDetailView.test.ts b/src/modules/container-loading/pages/ContainerDetailView.test.ts index 0783ae76..846ee964 100644 --- a/src/modules/container-loading/pages/ContainerDetailView.test.ts +++ b/src/modules/container-loading/pages/ContainerDetailView.test.ts @@ -87,7 +87,6 @@ describe('ContainerDetailView customs integration', () => { expect(lastLegFeesSource).toContain("canEdit.value && access.can('platform:supplier:list') ? listContainerFeeSupplierOptions(null) : Promise.resolve([])") expect(lastLegFeesSource).toContain("sourceType: 'OVERSEAS_CONTAINER_FEE', businessType: 'OVERSEAS_CONTAINER'") expect(lastLegFeesSource).toContain("form.settlementMode === 'DIRECT_PAID'") - expect(lastLegFeesSource).toContain('uploadContainerFeePaymentVoucher(form.customerId, file.raw)') expect(lastLegFeesSource).toContain("access.can('platform:finance:fee-source:edit')") expect(lastLegFeesSource).toContain("access.can('platform:finance:fee-source:review')") expect(source).toContain("if (!overview.value?.loadingPlanId) return '当前货柜尚未关联装柜计划,暂无头程计费归属。'") diff --git a/src/modules/container-loading/pages/ContainersView.test.ts b/src/modules/container-loading/pages/ContainersView.test.ts index 161e5db9..2433a23c 100644 --- a/src/modules/container-loading/pages/ContainersView.test.ts +++ b/src/modules/container-loading/pages/ContainersView.test.ts @@ -609,13 +609,15 @@ describe('ContainersView', () => { it('creates an internal simulation without asking for real booking transport facts', () => { const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8') + const simulationSource = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/components/ContainerSimulationCreate.vue'), 'utf8') + expect(source).toContain('ContainerSimulationCreate') expect(source).not.toContain('新增货柜') - expect(source).toContain('新增模拟柜') - expect(source).toContain('createContainerSimulation({ ...simulationForm })') - expect(source).toContain("channelCode: '', warehouseId: '', containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000") + expect(simulationSource).toContain('新增模拟柜') + expect(simulationSource).toContain('createContainerSimulation({ ...simulationForm })') + expect(simulationSource).toContain("channelCode: '', warehouseId: '', containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000") expect(source).toContain('CONTAINER_SPECIFICATIONS') - expect(source).toContain('selectSimulationContainerType') - expect(source).toContain('服务端会生成稳定的柜次号') + expect(simulationSource).toContain('selectSimulationContainerType') + expect(simulationSource).toContain('服务端会生成稳定的柜次号') expect(source).toContain(':model-value="form.cabinetNo" disabled placeholder="系统生成"') expect(source).not.toContain('v-model="form.cabinetNo"') expect(source).not.toContain('cabinetNo: form.cabinetNo.trim()') diff --git a/src/modules/container-loading/pages/ContainersView.vue b/src/modules/container-loading/pages/ContainersView.vue index a73165f7..df69f727 100644 --- a/src/modules/container-loading/pages/ContainersView.vue +++ b/src/modules/container-loading/pages/ContainersView.vue @@ -1,14 +1,15 @@