优化
This commit is contained in:
@@ -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/`;与排车、出库、派送、异常退回和一件代发共同组成海外仓全流程 |
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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<ReturnType<typeof sendTerminalDrayageEmail>>) => 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<HTMLElement>('.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<void>
|
||||
}
|
||||
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<HTMLElement>('.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<HTMLElement>('.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<HTMLElement>('.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<HTMLElement>('.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<HTMLElement>('.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('')
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Boxes, Check, Eye, FileText, FileUp, Link2, ListChecks, MoreHorizontal, Pencil, Plus, Sparkles } from '@lucide/vue'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { Boxes, Check, Eye, FileText, FileUp, Link2, ListChecks, Mail, MoreHorizontal, Pencil, Plus, Sparkles } from '@lucide/vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
|
||||
@@ -20,6 +20,10 @@ import { CONTAINER_SPECIFICATIONS, applyContainerSpecification, containerSpecifi
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { createContainerSimulation, getContainer, linkContainerOceanBooking, listContainers, updateContainer } from '../api/platformContainerLoading'
|
||||
import {
|
||||
getTerminalDrayageEmails,
|
||||
sendTerminalDrayageEmail,
|
||||
retryTerminalDrayageEmail,
|
||||
type TerminalDrayageEmail,
|
||||
getTerminalDrayage,
|
||||
getTerminalDrayageHistory,
|
||||
recordTerminalDrayageEmptyReturn,
|
||||
@@ -85,7 +89,7 @@ const unresolvedTruckingSupplierName = ref<string | null>(null)
|
||||
const drayages = ref<Record<string, TerminalDrayageView>>({})
|
||||
const drayageSavingIds = ref<string[]>([])
|
||||
const drayageActionOpen = ref(false)
|
||||
const drayageAction = ref<Exclude<TerminalDrayageAction, 'SELECT_FLEET'>>('SCHEDULE_PICKUP')
|
||||
const drayageAction = ref<Exclude<TerminalDrayageAction, 'SELECT_FLEET' | 'SEND_PICKUP_EMAIL'>>('SCHEDULE_PICKUP')
|
||||
const drayageTarget = ref<ContainerBookingResource | null>(null)
|
||||
const drayageActionAt = ref('')
|
||||
const drayageReason = ref('')
|
||||
@@ -95,6 +99,66 @@ const drayageHistoryOpen = ref(false)
|
||||
const drayageHistory = ref<TerminalDrayageHistory[]>([])
|
||||
const drayageHistoryLoading = ref(false)
|
||||
const drayageHistoryError = ref<ApiErrorDetails | null>(null)
|
||||
const emailHistoryOpen = ref(false)
|
||||
const emailTarget = ref<ContainerBookingResource | null>(null)
|
||||
const emails = ref<TerminalDrayageEmail[]>([])
|
||||
const emailsLoading = ref(false)
|
||||
const emailsError = ref<ApiErrorDetails | null>(null)
|
||||
const emailDeliveryEnabled = ref(false)
|
||||
const retryingEmail = ref('')
|
||||
let emailSequence = 0
|
||||
const emailColumns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'emailActions' },
|
||||
{ key: 'kind', label: '类型', width: 100, formatter: (row: TerminalDrayageEmail) => row.kind === 'CANCEL_PICKUP' ? '取消提柜' : '请求提柜' },
|
||||
{ key: 'fleetName', label: '车队', minWidth: 130 },
|
||||
{ key: 'recipient', label: '收件邮箱', minWidth: 210 },
|
||||
{ key: 'status', label: '发送状态', width: 100, formatter: (row: TerminalDrayageEmail) => ({ PENDING: '待发送', SENDING: '发送中', SENT: '已发送', FAILED: '失败待核对' })[row.status] },
|
||||
{ key: 'content', label: '邮件内容', minWidth: 270, slotName: 'emailContent' },
|
||||
{ key: 'failureReason', label: '失败说明', minWidth: 200 },
|
||||
{ key: 'createdAt', label: '创建时间', width: 165, formatter: (row: TerminalDrayageEmail) => formatTime(row.createdAt) },
|
||||
{ key: 'sentAt', label: '发送时间', width: 165, formatter: (row: TerminalDrayageEmail) => formatTime(row.sentAt) },
|
||||
]
|
||||
|
||||
async function sendPickupEmail(row: ContainerWorkbenchRow) {
|
||||
const current = drayageFor(row.id)
|
||||
if (!current || !canDrayage(row, 'SEND_PICKUP_EMAIL') || drayageSavingIds.value.includes(row.id)) return
|
||||
drayageSavingIds.value.push(row.id)
|
||||
try {
|
||||
replaceDrayage(await sendTerminalDrayageEmail(row.id, current.version))
|
||||
ElMessage.success('提柜邮件已加入发送队列,可在邮件记录中查看结果')
|
||||
await loadRows()
|
||||
} catch (error) {
|
||||
ElMessage.error(getApiErrorDetails(error, '提交提柜邮件失败').message)
|
||||
await loadRows()
|
||||
} finally { drayageSavingIds.value = drayageSavingIds.value.filter((id) => id !== row.id) }
|
||||
}
|
||||
|
||||
async function openEmailHistory(row: ContainerBookingResource) {
|
||||
const sequence = ++emailSequence
|
||||
emailTarget.value = row; emailHistoryOpen.value = true; emailsLoading.value = true
|
||||
emailsError.value = null; emails.value = []
|
||||
try {
|
||||
const result = await getTerminalDrayageEmails(row.id)
|
||||
if (sequence !== emailSequence) return
|
||||
emails.value = result.records; emailDeliveryEnabled.value = result.deliveryEnabled
|
||||
} catch (error) {
|
||||
if (sequence === emailSequence) emailsError.value = getApiErrorDetails(error, '邮件记录加载失败')
|
||||
} finally { if (sequence === emailSequence) emailsLoading.value = false }
|
||||
}
|
||||
|
||||
async function retryEmail(mail: TerminalDrayageEmail) {
|
||||
const target = emailTarget.value
|
||||
if (!target || !canFleetEdit.value || retryingEmail.value || mail.status !== 'FAILED') return
|
||||
retryingEmail.value = mail.id
|
||||
try {
|
||||
await ElMessageBox.confirm('发送失败时邮件仍可能已送达。请先核对车队未收到,再确认重试;同柜后续邮件会等待本邮件发送成功。', '重试邮件', { confirmButtonText: '确认重试', cancelButtonText: '取消', type: 'warning' })
|
||||
await retryTerminalDrayageEmail(target.id, mail.id)
|
||||
ElMessage.success('邮件已重新加入发送队列')
|
||||
await openEmailHistory(target)
|
||||
} catch (error) {
|
||||
if (error !== 'cancel' && error !== 'close') ElMessage.error(getApiErrorDetails(error, '重试邮件失败').message)
|
||||
} finally { retryingEmail.value = '' }
|
||||
}
|
||||
let listSequence = 0
|
||||
let detailSequence = 0
|
||||
let linkSequence = 0
|
||||
@@ -230,10 +294,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<string, string>)[status || ''] || status || '—' }
|
||||
function drayageActionText(action: TerminalDrayageAction) { return ({ SELECT_FLEET: '选择码头车队', SCHEDULE_PICKUP: '预约提柜', RECORD_PICKUP: '确认实际提柜', RECORD_EMPTY_RETURN: '确认还柜' } as Record<TerminalDrayageAction, string>)[action] }
|
||||
function drayageActionText(action: TerminalDrayageAction) { return ({ SELECT_FLEET: '选择码头车队', SCHEDULE_PICKUP: '预约提柜', RECORD_PICKUP: '确认实际提柜', RECORD_EMPTY_RETURN: '确认还柜', SEND_PICKUP_EMAIL: '发送提柜邮件' } as Record<TerminalDrayageAction, string>)[action] }
|
||||
function hasAction(row: ContainerWorkbenchRow, action: ContainerWorkbenchAction) { return Boolean(row.allowedActions?.includes(action)) }
|
||||
function canDrayage(row: ContainerWorkbenchRow, action: TerminalDrayageAction) {
|
||||
const permission = action === 'SELECT_FLEET' ? 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 : action === 'RECORD_PICKUP' ? canPickupConfirm.value : canEmptyReturnConfirm.value
|
||||
return Boolean(isOverseas.value && permission && hasAction(row, action) && drayageFor(row.id)?.allowedActions.includes(action))
|
||||
}
|
||||
function drayageWarningText(row: ContainerBookingResource) {
|
||||
@@ -545,7 +609,8 @@ async function selectRowFleet(source: object, supplierIdValue: unknown) {
|
||||
drayageSavingIds.value = [...drayageSavingIds.value, row.id]
|
||||
try {
|
||||
replaceDrayage(await selectTerminalDrayageFleet(row.id, { expectedVersion: current.version, fleetSupplierId: supplier.id, fleetSupplierName: supplier.name }))
|
||||
ElMessage.success('码头车队已更新')
|
||||
ElMessage.success(current.fleetSupplierId && current.fleetSupplierId !== supplier.id ? '码头车队已更新,取消及提柜邮件已加入发送队列' : '码头车队已更新')
|
||||
await loadRows()
|
||||
} catch (error) {
|
||||
const details = getApiErrorDetails(error, '码头车队更新失败')
|
||||
if (details.code === 'A0409') {
|
||||
@@ -554,7 +619,7 @@ async function selectRowFleet(source: object, supplierIdValue: unknown) {
|
||||
} else ElMessage.error(details.message)
|
||||
} finally { drayageSavingIds.value = drayageSavingIds.value.filter((id) => id !== row.id) }
|
||||
}
|
||||
function openDrayageAction(row: ContainerBookingResource, action: Exclude<TerminalDrayageAction, 'SELECT_FLEET'>) {
|
||||
function openDrayageAction(row: ContainerBookingResource, action: Exclude<TerminalDrayageAction, 'SELECT_FLEET' | 'SEND_PICKUP_EMAIL'>) {
|
||||
if (!canDrayage(row, action)) return
|
||||
drayageTarget.value = row; drayageAction.value = action; drayageActionAt.value = localDateTimeValue(); drayageReason.value = ''
|
||||
drayageActionError.value = null; drayageActionOpen.value = true
|
||||
@@ -619,8 +684,9 @@ function handleMoreAction(command: string, source: object) {
|
||||
const row = source as ContainerBookingResource
|
||||
if (command === 'link') { void openLink(row); return }
|
||||
if (command === 'overview') { openDetail(row); return }
|
||||
if (command === 'email-history') { void openEmailHistory(row); return }
|
||||
if (command === 'drayage-history') { void openDrayageHistory(row); return }
|
||||
if (command.startsWith('drayage:')) { openDrayageAction(row, command.slice('drayage:'.length) as Exclude<TerminalDrayageAction, 'SELECT_FLEET'>); return }
|
||||
if (command.startsWith('drayage:')) { openDrayageAction(row, command.slice('drayage:'.length) as Exclude<TerminalDrayageAction, 'SELECT_FLEET' | 'SEND_PICKUP_EMAIL'>); return }
|
||||
if (command === 'documents' && isOverseas.value && hasAction(row as ContainerWorkbenchRow, 'MANAGE_DESTINATION_DOCUMENTS')) void router.push({ name: 'overseas-container-detail', params: { containerId: row.id }, query: { action: command } })
|
||||
}
|
||||
async function openEdit(source: object) {
|
||||
@@ -727,7 +793,7 @@ onMounted(async () => {
|
||||
<section v-if="!loadError" id="container-workbench-results" class="surface-panel table-panel">
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" stripe preference-key="container-loading-table" empty-text="暂无货柜">
|
||||
<template #toolbar-actions><el-button v-if="isDomestic && canCreate" type="primary" @click="openSimulation"><Plus :size="16" />新增模拟柜</el-button></template>
|
||||
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canView" size="small" type="primary" plain :aria-label="containerViewLabel(row as ContainerBookingResource)" @click="openDetail(row)"><Eye :size="15" />查看</el-button><template v-if="isDomestic"><el-button v-if="canEdit && hasAction(row as ContainerWorkbenchRow, 'EDIT_CONTAINER')" size="small" @click="openEdit(row)"><Pencil :size="15" />编辑</el-button><el-button v-if="row.planStatus === 'EDITING' && canSmartLoading" size="small" type="primary" @click="goContainerProcess(row)"><Sparkles :size="15" />装柜</el-button><el-button v-else-if="row.planStatus === 'LOADING' && canViewPreload" size="small" type="primary" plain @click="goContainerProcess(row)"><ListChecks :size="15" />预装清单</el-button><el-button v-else-if="row.planStatus === 'COMPLETED' && canViewLoadingList" size="small" type="primary" plain @click="goContainerProcess(row)"><ListChecks :size="15" />装柜清单</el-button></template><el-button v-if="canDrayage(row as ContainerWorkbenchRow, 'RECORD_EMPTY_RETURN')" size="small" type="primary" @click="openDrayageAction(row as ContainerBookingResource, 'RECORD_EMPTY_RETURN')">确认还柜</el-button><el-dropdown v-if="canView" trigger="click" @command="handleMoreAction($event, row)"><el-button size="small" plain aria-label="更多货柜操作" title="更多货柜操作"><MoreHorizontal :size="16" /></el-button><template #dropdown><el-dropdown-menu><el-dropdown-item command="overview"><FileText :size="15" />轨迹与日志</el-dropdown-item><template v-if="isOverseas"><el-dropdown-item command="drayage-history"><FileText :size="15" />码头拖车记录</el-dropdown-item><el-dropdown-item v-if="canDrayage(row as ContainerWorkbenchRow, 'SCHEDULE_PICKUP')" command="drayage:SCHEDULE_PICKUP">预约提柜</el-dropdown-item><el-dropdown-item v-if="canDrayage(row as ContainerWorkbenchRow, 'RECORD_PICKUP')" command="drayage:RECORD_PICKUP">确认实际提柜</el-dropdown-item><el-dropdown-item v-if="canOverseasDocumentEdit && hasAction(row as ContainerWorkbenchRow, 'MANAGE_DESTINATION_DOCUMENTS')" command="documents"><FileUp :size="15" />清关 / DO / PU</el-dropdown-item></template><el-dropdown-item v-if="isDomestic && canEdit && hasAction(row as ContainerWorkbenchRow, 'EDIT_CONTAINER') && row.sourceType === 'SIMULATION' && !row.oceanBookingId" command="link"><Link2 :size="15" />关联已放舱订舱</el-dropdown-item></el-dropdown-menu></template></el-dropdown></div></template>
|
||||
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canView" size="small" type="primary" plain :aria-label="containerViewLabel(row as ContainerBookingResource)" @click="openDetail(row)"><Eye :size="15" />查看</el-button><template v-if="isDomestic"><el-button v-if="canEdit && hasAction(row as ContainerWorkbenchRow, 'EDIT_CONTAINER')" size="small" @click="openEdit(row)"><Pencil :size="15" />编辑</el-button><el-button v-if="row.planStatus === 'EDITING' && canSmartLoading" size="small" type="primary" @click="goContainerProcess(row)"><Sparkles :size="15" />装柜</el-button><el-button v-else-if="row.planStatus === 'LOADING' && canViewPreload" size="small" type="primary" plain @click="goContainerProcess(row)"><ListChecks :size="15" />预装清单</el-button><el-button v-else-if="row.planStatus === 'COMPLETED' && canViewLoadingList" size="small" type="primary" plain @click="goContainerProcess(row)"><ListChecks :size="15" />装柜清单</el-button></template><el-button v-if="canDrayage(row as ContainerWorkbenchRow, 'RECORD_EMPTY_RETURN')" size="small" type="primary" @click="openDrayageAction(row as ContainerBookingResource, 'RECORD_EMPTY_RETURN')">确认还柜</el-button><el-button v-if="canDrayage(row as ContainerWorkbenchRow, 'SEND_PICKUP_EMAIL')" size="small" type="primary" :loading="drayageSavingIds.includes(row.id)" @click="sendPickupEmail(row as ContainerWorkbenchRow)"><Mail :size="15" />发送邮件</el-button><el-dropdown v-if="canView" trigger="click" @command="handleMoreAction($event, row)"><el-button size="small" plain aria-label="更多货柜操作" title="更多货柜操作"><MoreHorizontal :size="16" /></el-button><template #dropdown><el-dropdown-menu><el-dropdown-item command="overview"><FileText :size="15" />轨迹与日志</el-dropdown-item><template v-if="isOverseas"><el-dropdown-item command="email-history"><Mail :size="15" />邮件记录</el-dropdown-item><el-dropdown-item command="drayage-history"><FileText :size="15" />码头拖车记录</el-dropdown-item><el-dropdown-item v-if="canDrayage(row as ContainerWorkbenchRow, 'SCHEDULE_PICKUP')" command="drayage:SCHEDULE_PICKUP">预约提柜</el-dropdown-item><el-dropdown-item v-if="canDrayage(row as ContainerWorkbenchRow, 'RECORD_PICKUP')" command="drayage:RECORD_PICKUP">确认实际提柜</el-dropdown-item><el-dropdown-item v-if="canOverseasDocumentEdit && hasAction(row as ContainerWorkbenchRow, 'MANAGE_DESTINATION_DOCUMENTS')" command="documents"><FileUp :size="15" />清关 / DO / PU</el-dropdown-item></template><el-dropdown-item v-if="isDomestic && canEdit && hasAction(row as ContainerWorkbenchRow, 'EDIT_CONTAINER') && row.sourceType === 'SIMULATION' && !row.oceanBookingId" command="link"><Link2 :size="15" />关联已放舱订舱</el-dropdown-item></el-dropdown-menu></template></el-dropdown></div></template>
|
||||
<template #emptyReturnWarning="{ row }"><el-tag :type="drayageWarningType(row as ContainerBookingResource)" effect="light" :title="drayageWarningTitle(row as ContainerBookingResource)">{{ drayageWarningText(row as ContainerBookingResource) }}</el-tag></template>
|
||||
<template #cabinet="{ row }"><strong>{{ row.cabinetNo }}</strong><small>系统生成</small></template>
|
||||
<template #oceanBooking="{ row }"><span v-if="row.oceanBookingId"><strong>{{ row.billOfLadingNo || '已关联订舱' }}</strong><small>{{ row.containerNo ? `集装箱号:${row.containerNo}` : '集装箱号:待补录' }}</small></span><span v-else>未关联</span></template>
|
||||
@@ -798,6 +864,16 @@ onMounted(async () => {
|
||||
<LoadErrorState v-if="drayageActionError" compact :title="`${drayageActionText(drayageAction)}失败`" :message="drayageActionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="drayageActionError" /></template></LoadErrorState>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog v-model="emailHistoryOpen" title="车队邮件记录" width="min(1100px, calc(100vw - 28px))" :show-footer="false">
|
||||
<p>集装箱号:{{ emailTarget?.containerNo || '—' }} · 显示最近 100 封邮件</p>
|
||||
<el-alert v-if="!emailsLoading && !emailsError && !emailDeliveryEnabled" title="发信尚未启用,邮件保留在待发送队列中" type="warning" :closable="false" />
|
||||
<LoadErrorState v-if="emailsError" compact title="邮件记录加载失败" :message="emailsError.message" @retry="emailTarget && openEmailHistory(emailTarget)"><template #details><ApiErrorMeta :details="emailsError" /></template></LoadErrorState>
|
||||
<DataTable v-else :data="emails" :columns="emailColumns" :loading="emailsLoading" column-settings-mode="hide" empty-text="暂无邮件记录">
|
||||
<template #toolbar-actions><el-button :loading="emailsLoading" @click="emailTarget && openEmailHistory(emailTarget)">刷新记录</el-button></template>
|
||||
<template #emailActions="{ row }"><el-button v-if="canFleetEdit && row.status === 'FAILED'" size="small" :loading="retryingEmail === row.id" :disabled="Boolean(retryingEmail)" @click="retryEmail(row as TerminalDrayageEmail)">重试</el-button><span v-else>—</span></template>
|
||||
<template #emailContent="{ row }"><details><summary>{{ row.subject }}</summary><p style="white-space: pre-wrap">{{ row.body }}</p></details></template>
|
||||
</DataTable>
|
||||
</AppDialog>
|
||||
<AppDialog v-model="drayageHistoryOpen" title="码头拖车记录" width="min(980px, calc(100vw - 28px))" :show-footer="false">
|
||||
<LoadErrorState v-if="drayageHistoryError" compact title="码头拖车记录加载失败" :message="drayageHistoryError.message" :loading="drayageHistoryLoading" @retry="drayageTarget && openDrayageHistory(drayageTarget)"><template #details><ApiErrorMeta :details="drayageHistoryError" /></template></LoadErrorState>
|
||||
<template v-else><el-descriptions v-if="drayageTarget && drayageFor(drayageTarget.id)" :column="4" border class="drayage-summary"><el-descriptions-item label="当前状态">{{ drayageStatusText(drayageFor(drayageTarget.id)?.status) }}</el-descriptions-item><el-descriptions-item label="码头车队">{{ drayageFor(drayageTarget.id)?.fleetSupplierName || '—' }}</el-descriptions-item><el-descriptions-item label="预计还柜">{{ formatTime(drayageFor(drayageTarget.id)?.estimatedEmptyReturnAt) }}</el-descriptions-item><el-descriptions-item label="实际还柜">{{ formatTime(drayageFor(drayageTarget.id)?.actualEmptyReturnAt) }}</el-descriptions-item></el-descriptions><DataTable :data="drayageHistory" :columns="drayageHistoryColumns" :loading="drayageHistoryLoading" stripe column-settings-mode="hide" empty-text="暂无码头拖车操作记录" /></template>
|
||||
|
||||
@@ -206,7 +206,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'
|
||||
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 interface ContainerWorkbenchStatusSummary {
|
||||
loadingStatus?: string | null
|
||||
@@ -242,7 +242,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'>
|
||||
allowedActions: ReadonlyArray<'SELECT_FLEET' | 'SCHEDULE_PICKUP' | 'RECORD_PICKUP' | 'RECORD_EMPTY_RETURN' | 'SEND_PICKUP_EMAIL'>
|
||||
updatedAt: string | null
|
||||
}
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ const canSave = computed(() => editable.value && access.can(isNew.value ? 'platf
|
||||
const formWritable = computed(() => canSave.value)
|
||||
const canDelete = computed(() => detail.value?.status === 'DRAFT' && access.can('platform:firstleg:ocean-booking:delete'))
|
||||
const canRelease = computed(() => detail.value?.status === 'DRAFT' && access.can('platform:firstleg:ocean-booking:release'))
|
||||
const canPrint = computed(() => detail.value?.status === 'RELEASED' && access.can('platform:firstleg:ocean-booking:list'))
|
||||
const canPrint = computed(() => !!detail.value && access.can('platform:firstleg:ocean-booking:list'))
|
||||
const canManageDocuments = computed(() => detail.value?.status === 'DRAFT' && access.can('platform:firstleg:ocean-booking:edit'))
|
||||
const documentManageReason = computed(() => !access.can('platform:firstleg:ocean-booking:edit') ? '暂无订舱资料维护权限' : '')
|
||||
const workflowBusy = computed(() => saving.value || actionSaving.value)
|
||||
@@ -385,7 +385,7 @@ onMounted(() => { void load(); void loadOptions() })
|
||||
<div class="section-title">
|
||||
<div><h2>订舱信息</h2><p v-if="detail?.status === 'DRAFT'">草稿可反复保存;放舱后资料同步至关联货柜并锁定。</p><p v-else-if="detail">已放舱资料只读。</p></div>
|
||||
<div class="actions">
|
||||
<el-tooltip :content="canPrint ? '选择出口货物托运单或提单确认件' : detail?.status === 'DRAFT' ? '订舱放舱后方可打印' : '暂无订舱单据打印权限'" placement="top"><span><el-button v-if="detail" plain :disabled="!canPrint || workflowBusy" @click="printOpen = true"><Printer :size="16" />打印单据</el-button></span></el-tooltip>
|
||||
<el-tooltip :content="canPrint ? '按当前已保存资料打印出口货物托运单或提单确认件' : '暂无订舱单据打印权限'" placement="top"><span><el-button v-if="detail" plain :disabled="!canPrint || workflowBusy" @click="printOpen = true"><Printer :size="16" />打印单据</el-button></span></el-tooltip>
|
||||
<el-button v-if="canDelete" type="danger" plain :loading="actionSaving" :disabled="workflowBusy" @click="removeDraft"><Trash2 :size="16" />删除草稿</el-button>
|
||||
<el-button v-if="canRelease" type="primary" plain :loading="actionSaving" :disabled="workflowBusy" @click="openReleaseDialog">放舱</el-button>
|
||||
<el-button v-if="canSave" type="primary" :loading="saving" :disabled="workflowBusy" @click="save"><Save :size="16" />保存草稿</el-button>
|
||||
|
||||
@@ -40,6 +40,21 @@ describe('OceanBookingsView', () => {
|
||||
expect(source).toContain("access.can('platform:firstleg:ocean-booking:list')")
|
||||
})
|
||||
|
||||
it.each(['DRAFT', 'RELEASED'] as const)('opens printing for %s with incomplete booking data', async (status) => {
|
||||
vi.mocked(listOceanBookings).mockResolvedValueOnce({ records: [{
|
||||
id: '183', bookingNo: 'OB-183', status, containerCount: 1,
|
||||
billOfLadingNo: null, shipperName: null, consigneeName: null,
|
||||
}], page: 1, size: 20, total: 1, pages: 1 } as never)
|
||||
const wrapper = mount(OceanBookingsView, { global: { plugins: [ElementPlus], stubs: { OceanBookingPrintDialog: true } } })
|
||||
await (wrapper.vm as unknown as { load: () => Promise<void> }).load()
|
||||
await flushPromises()
|
||||
const button = wrapper.findAll('button').find((item) => item.text().includes('打印单据'))!
|
||||
expect(button.attributes('disabled')).toBeUndefined()
|
||||
await button.trigger('click')
|
||||
expect(wrapper.findComponent({ name: 'OceanBookingPrintDialog' }).attributes('modelvalue')).toBe('true')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('uses only the frozen DRAFT and RELEASED status options', () => {
|
||||
expect(source).toContain('options: oceanBookingStatusOptions')
|
||||
expect(source).toContain(':status="oceanBookingStatusLabel(row.status)"')
|
||||
|
||||
@@ -62,7 +62,7 @@ async function load() { loading.value = true; error.value = null; try { const ke
|
||||
function open(id: string) { void router.push(`/ocean-bookings/${id}`) }
|
||||
function createBooking() { void router.push('/ocean-bookings/new') }
|
||||
function openPrint(row: Record<string, unknown>) { printBooking.value = { id: String(row.id), bookingNo: typeof row.bookingNo === 'string' ? row.bookingNo : null }; printOpen.value = true }
|
||||
function printHint(row: Record<string, unknown>) { return !canPrint.value ? '暂无订舱单据打印权限' : row.status !== 'RELEASED' ? '订舱放舱后方可打印' : '选择出口货物托运单或提单确认件' }
|
||||
function printHint() { return !canPrint.value ? '暂无订舱单据打印权限' : '按当前已保存资料打印出口货物托运单或提单确认件' }
|
||||
function search() { if (page.value === 1) void load(); else page.value = 1 }
|
||||
watch([page, pageSize], () => void load()); onMounted(() => void loadCustomers())
|
||||
onActivated(() => void load())
|
||||
@@ -75,7 +75,7 @@ onActivated(() => void load())
|
||||
<section v-if="!error" class="surface-panel table-panel">
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" stripe preference-key="ocean-bookings" aria-label="订舱列表" row-key="id" empty-text="暂无符合筛选条件的订舱">
|
||||
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="createBooking"><Plus :size="16" />新增订舱</el-button></template>
|
||||
<template #actions="{ row }"><div class="table-actions"><el-button size="small" type="primary" plain @click="open(row.id)"><Pencil v-if="row.status === 'DRAFT'" :size="15" /><Eye v-else :size="15" />{{ row.status === 'DRAFT' ? '编辑' : '查看' }}</el-button><el-tooltip :content="printHint(row)" placement="top"><span><el-button size="small" plain :disabled="!canPrint || row.status !== 'RELEASED'" @click="openPrint(row)"><Printer :size="15" />打印单据</el-button></span></el-tooltip></div></template>
|
||||
<template #actions="{ row }"><div class="table-actions"><el-button size="small" type="primary" plain @click="open(row.id)"><Pencil v-if="row.status === 'DRAFT'" :size="15" /><Eye v-else :size="15" />{{ row.status === 'DRAFT' ? '编辑' : '查看' }}</el-button><el-tooltip :content="printHint()" placement="top"><span><el-button size="small" plain :disabled="!canPrint" @click="openPrint(row)"><Printer :size="15" />打印单据</el-button></span></el-tooltip></div></template>
|
||||
<template #vessel="{ row }">{{ row.vesselName || '—' }}<span v-if="row.voyageNo"> / {{ row.voyageNo }}</span></template>
|
||||
<template #etd="{ row }">{{ formatDateTime(row.etd) }}</template>
|
||||
<template #eta="{ row }">{{ formatDateTime(row.eta) }}</template>
|
||||
|
||||
Reference in New Issue
Block a user