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 @@