diff --git a/AGENTS.md b/AGENTS.md index 9615b979..9e958a4a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,9 +54,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/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/components/ContainerLastLegFeesPanel.test.ts b/src/modules/container-loading/components/ContainerLastLegFeesPanel.test.ts new file mode 100644 index 00000000..f00836f6 --- /dev/null +++ b/src/modules/container-loading/components/ContainerLastLegFeesPanel.test.ts @@ -0,0 +1,87 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import ElementPlus from 'element-plus' +import ContainerLastLegFeesPanel from './ContainerLastLegFeesPanel.vue' + +const mocks = vi.hoisted(() => ({ upload: vi.fn(), metadata: vi.fn(), download: vi.fn(), create: vi.fn(), fees: vi.fn() })) +vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) })) +vi.mock('@/modules/finance', () => ({ getFinancePaymentVoucher: mocks.metadata, downloadFinancePaymentVoucher: mocks.download })) +vi.mock('@/modules/loading-lists', () => ({ uploadContainerFeePaymentVoucher: mocks.upload, listContainerFeeSupplierOptions: async () => [] })) +vi.mock('../api/overseasContainerFees', () => ({ + listOverseasContainerFees: mocks.fees, listOverseasContainerCustomers: async () => [{ customerId: '11', customerName: '客户甲' }], + createOverseasContainerFee: mocks.create, adjustOverseasContainerFee: vi.fn(), reviewOverseasContainerFee: vi.fn(), submitOverseasContainerFee: vi.fn(), voidOverseasContainerFee: vi.fn(), +})) + +async function setup(readonly = false) { + const wrapper = mount(ContainerLastLegFeesPanel, { props: { containerBookingId: '99', containerNo: 'CONT-99', readonly }, global: { plugins: [ElementPlus], stubs: { + AppDialog: { props: ['modelValue', 'title', 'confirmDisabled'], template: '

{{ title }}

' }, + } } }) + await flushPromises() + return wrapper +} +type FormState = { form: { customerId: string; settlementMode: string }; dialogOpen: boolean } +async function upload(wrapper: Awaited>, name: string) { + const input = wrapper.get('input[type="file"]') + Object.defineProperty(input.element, 'files', { configurable: true, value: [new File(['image'], name, { type: 'image/png' })] }) + await input.trigger('change') + await flushPromises() +} +describe('尾程付款凭证', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.fees.mockResolvedValue([]) + vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }) + URL.createObjectURL = vi.fn(() => 'blob:voucher') + URL.revokeObjectURL = vi.fn() + mocks.metadata.mockResolvedValue({ fileId: '91', fileName: '付款.png', contentType: 'image/png' }) + mocks.download.mockResolvedValue(new Blob(['image'])) + }) + + it('显示上传文件名,允许再次上传替换和移除', async () => { + const wrapper = await setup() + await wrapper.get('button').trigger('click') + const state = (wrapper.vm.$ as unknown as { setupState: FormState }).setupState + state.form.settlementMode = 'DIRECT_PAID' + await flushPromises() + mocks.upload.mockResolvedValueOnce({ fileId: '91', fileName: '付款.png' }).mockResolvedValueOnce({ fileId: '92', fileName: '新凭证.png' }) + await upload(wrapper, '付款.png') + expect(mocks.upload).toHaveBeenCalledWith('11', expect.any(File)) + expect(wrapper.text()).toContain('付款.png') + await upload(wrapper, '新凭证.png') + expect(wrapper.text()).toContain('新凭证.png') + expect(wrapper.text()).not.toContain('付款.png') + await wrapper.findAll('button').find(b => b.text() === '移除')!.trigger('click') + expect(wrapper.text()).not.toContain('新凭证.png') + wrapper.unmount() + }) + + it('上传中禁止保存,客户切换后忽略旧客户上传结果', async () => { + const wrapper = await setup() + await wrapper.get('button').trigger('click') + const state = (wrapper.vm.$ as unknown as { setupState: FormState }).setupState + state.form.settlementMode = 'DIRECT_PAID' + await flushPromises() + let complete!: (file: { fileId: string; fileName: string }) => void + mocks.upload.mockReturnValue(new Promise(resolve => { complete = resolve })) + await upload(wrapper, '旧客户.png') + expect(wrapper.findAll('button').find(b => b.text() === '保存')!.attributes('disabled')).toBeDefined() + state.form.customerId = '12' + complete({ fileId: '91', fileName: '旧客户.png' }) + await flushPromises() + expect(wrapper.text()).not.toContain('旧客户.png') + wrapper.unmount() + }) + + it('只读列表能查看已保存的待审核凭证,并在关闭时释放预览资源', async () => { + mocks.fees.mockResolvedValue([{ sourceFeeId: '81', customerId: '11', amount: 10, reviewStatus: 'PENDING', paymentVoucherFileIds: ['91'] }]) + const wrapper = await setup(true) + await wrapper.findAll('button').find(b => b.text() === '付款凭证 1')!.trigger('click') + await flushPromises() + expect(mocks.metadata).toHaveBeenCalledWith('11', '91') + expect(mocks.download).toHaveBeenCalledWith('11', '91') + expect(wrapper.get('img').attributes('src')).toBe('blob:voucher') + expect(wrapper.get('a[download]').attributes('download')).toBe('付款.png') + wrapper.unmount() + expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:voucher') + }) +}) diff --git a/src/modules/container-loading/components/ContainerLastLegFeesPanel.vue b/src/modules/container-loading/components/ContainerLastLegFeesPanel.vue index 2d9450c2..be5191b4 100644 --- a/src/modules/container-loading/components/ContainerLastLegFeesPanel.vue +++ b/src/modules/container-loading/components/ContainerLastLegFeesPanel.vue @@ -1,6 +1,7 @@ + + + + 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 0325a22f..2982e732 100644 --- a/src/modules/container-loading/pages/ContainersView.test.ts +++ b/src/modules/container-loading/pages/ContainersView.test.ts @@ -12,7 +12,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' @@ -24,7 +24,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, @@ -89,7 +89,39 @@ describe('ContainersView', () => { expect(wrapper.text()).not.toContain('报空预警') }) - it('shows one direct empty-return action only when permission and server actions allow it', async () => { + 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) => ({ + ...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('确认还柜')) @@ -106,7 +138,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) @@ -227,14 +259,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) @@ -309,11 +341,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' } }) @@ -337,7 +369,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) }) @@ -349,7 +381,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) }) @@ -443,6 +475,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: '新车队' }) @@ -524,6 +558,32 @@ describe('ContainersView', () => { wrapper.unmount() }) + 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() @@ -595,13 +655,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()') @@ -623,7 +685,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,6 +699,26 @@ describe('ContainersView', () => { wrapper.unmount() }) + it.each([undefined, null, '', '客户一'])('renders optional booking customer %s without leaking a missing value', async (customerName) => { + vi.mocked(listOceanBookingLinkCandidates).mockResolvedValueOnce({ records: [{ + oceanBookingId: '315', bookingNo: 'OB-OPTIONAL-CUSTOMER', requirementId: '45315', + containerType: '40HC', remainingQuantity: 1, customerName, warehouseName: '义乌仓', channelName: 'EXX海卡', + }], page: 1, size: 200, total: 1, pages: 1 } as never) + const router = createTestRouter() + 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 flushPromises() + Array.from(document.body.querySelectorAll('.el-dropdown-menu__item')) + .filter((item) => item.textContent?.includes('关联已放舱订舱')).at(-1)!.click() + await flushPromises() + const label = `OB-OPTIONAL-CUSTOMER · 40HC × 1 · ${customerName || '未关联客户'} · 义乌仓 · EXX海卡` + expect(document.body.textContent).toContain(label) + expect((wrapper.vm as unknown as { linkCandidates: Array<{ label: string }> }).linkCandidates[0]?.label).toBe(label) + wrapper.unmount() + }) + it('keeps a three-field match selectable when only capacity snapshots differ and blocks duplicate waves', async () => { let resolveCandidates!: (value: Awaited>) => void vi.mocked(listContainers).mockResolvedValue({ records: [{ @@ -650,7 +732,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)! @@ -679,7 +761,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() @@ -688,7 +770,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() @@ -713,7 +795,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() @@ -733,7 +815,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', '') @@ -755,7 +837,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 ce8f98dc..642b8a90 100644 --- a/src/modules/container-loading/pages/ContainersView.vue +++ b/src/modules/container-loading/pages/ContainersView.vue @@ -1,14 +1,15 @@