Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a041650ba1 | ||
|
|
22e545fef9 | ||
|
|
3ae9004b8b | ||
|
|
c3f7dcc6f1 | ||
|
|
a86b19604d |
@@ -8,11 +8,11 @@ const event = (type: ScheduleEvent['type'], index = 0): ScheduleEvent => ({ key:
|
||||
const render = (events: ScheduleEvent[] = [], initialDate = '2026-10-19') => mount(AppointmentCalendarPanel, { props: { initialDate, events },
|
||||
global: { stubs: { ElButton: { template: '<button><slot /></button>' } } } })
|
||||
function reportedDispatch() {
|
||||
const row: TripCalendarEntry = { sourceType: 'DISPATCH', dispatchNo: 'PC-CALENDAR-001', containerNumbers: ['TSTU1234567', 'TSTU7654321'], status: 'PLANNED', originWarehouseId: '1', originWarehouseCode: 'LAX', originWarehouseName: '洛杉矶仓', originCountryCode: 'US', originTimezone: 'America/Los_Angeles', originWarehouseStatus: 'ACTIVE', plannedDepartureAt: '2026-10-22T16:00:00Z', expectedDriverEndAt: null, departedAt: null, completedAt: null, stops: [{ name: '洛杉矶仓' }, { name: 'ONT8' }], incompleteReasons: [] }
|
||||
const row: TripCalendarEntry = { sourceType: 'DISPATCH', dispatchNo: 'PC-CALENDAR-001', containerNumbers: ['TSTU1234567', 'TSTU7654321'], cabinetNumbers: ['D1821', 'D1822'], status: 'PLANNED', originWarehouseId: '1', originWarehouseCode: 'LAX', originWarehouseName: '洛杉矶仓', originCountryCode: 'US', originTimezone: 'America/Los_Angeles', originWarehouseStatus: 'ACTIVE', plannedDepartureAt: '2026-10-22T16:00:00Z', expectedDriverEndAt: null, departedAt: null, completedAt: null, stops: [{ name: '洛杉矶仓' }, { name: 'ONT8' }], incompleteReasons: [] }
|
||||
return projectTripCalendar({ records: [row, row], unresolvedRecords: [] }, { from: '2026-10-01', to: '2026-11-01' }).events
|
||||
}
|
||||
describe('four-type appointment calendar', () => {
|
||||
it.each(['TSTU1234567', 'tstu1234567', 'TSTU7654321', ' tstu7654321 '])('finds the one shared dispatch by associated container %s on its origin-local date, not the Beijing date', async keyword => {
|
||||
it.each(['TSTU1234567', 'tstu1234567', 'TSTU7654321', ' tstu7654321 ', 'D1821', ' d1821 ', 'D1822', '1821'])('finds the one shared dispatch by associated container %s on its origin-local date, not the Beijing date', async keyword => {
|
||||
const wrapper = render(reportedDispatch(), '2026-10-23')
|
||||
const search = wrapper.get('[aria-label="搜索当前日历"]')
|
||||
expect(search.attributes('placeholder')).toBe('柜号、排车单号、仓库')
|
||||
@@ -26,6 +26,7 @@ describe('four-type appointment calendar', () => {
|
||||
expect(wrapper.get('.detail-event').text()).toContain('PC-CALENDAR-001')
|
||||
expect(wrapper.get('.detail-event').text()).toContain('09:00')
|
||||
expect(wrapper.get('.detail-event').text()).toContain('TSTU1234567、TSTU7654321')
|
||||
expect(wrapper.get('.detail-event').text()).toContain('D1821、D1822')
|
||||
expect(wrapper.get('.detail-event').text()).toContain('2026-10-22 09:00')
|
||||
expect(wrapper.emitted('range')).toBeUndefined()
|
||||
wrapper.unmount()
|
||||
|
||||
@@ -17,7 +17,7 @@ vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn() }, ElMessageBox:
|
||||
const warehouse: WarehouseArrival = { sourceContainerBookingId: '1', containerNo: 'TEST-CONT', warehouseName: '美国仓', warehouseCode: 'US-W', plannedAt: null, actualAt: null, version: 0, planCount: 0, canEdit: true }
|
||||
const row: ScheduleEvent = { key: 'warehouse-1', type: 'warehouse', date: '2026-10-21', time: '', title: 'TEST-CONT 计划到仓', fields: [], warehouse, canEdit: true }
|
||||
function dispatchEntry(overrides: Partial<TripCalendarEntry> = {}): TripCalendarEntry {
|
||||
return { sourceType: 'DISPATCH', dispatchNo: 'PC-CALENDAR-001', containerNumbers: ['TSTU1234567', 'TSTU7654321'], status: 'PLANNED', originWarehouseId: '1', originWarehouseCode: 'LAX', originWarehouseName: '洛杉矶仓', originCountryCode: 'US', originTimezone: 'America/Los_Angeles', originWarehouseStatus: 'ACTIVE', plannedDepartureAt: '2026-10-22T16:00:00Z', expectedDriverEndAt: null, departedAt: null, completedAt: null, stops: [{ name: '洛杉矶仓' }, { name: 'ONT8' }], incompleteReasons: [], ...overrides }
|
||||
return { sourceType: 'DISPATCH', dispatchNo: 'PC-CALENDAR-001', containerNumbers: ['TSTU1234567', 'TSTU7654321'], cabinetNumbers: [], status: 'PLANNED', originWarehouseId: '1', originWarehouseCode: 'LAX', originWarehouseName: '洛杉矶仓', originCountryCode: 'US', originTimezone: 'America/Los_Angeles', originWarehouseStatus: 'ACTIVE', plannedDepartureAt: '2026-10-22T16:00:00Z', expectedDriverEndAt: null, departedAt: null, completedAt: null, stops: [{ name: '洛杉矶仓' }, { name: 'ONT8' }], incompleteReasons: [], ...overrides }
|
||||
}
|
||||
export function renderSchedule() {
|
||||
return mount(AppointmentCalendarView, { global: { stubs: {
|
||||
|
||||
@@ -4,15 +4,16 @@ import { http } from '@/shared/api/http'
|
||||
vi.mock('@/shared/api/http', () => ({ http: { get: vi.fn() }, unwrapApiResponse: (value: {data: unknown}) => value.data }))
|
||||
const range = { from: '2026-10-01', to: '2026-11-01' }
|
||||
function trip(dispatchNo: string, plannedDepartureAt: string, originTimezone = 'America/Los_Angeles'): TripCalendarEntry {
|
||||
return { sourceType: 'DISPATCH', dispatchNo, containerNumbers: [], status: 'PLANNED', originWarehouseId: '1', originWarehouseCode: 'US-W', originWarehouseName: 'US warehouse', originCountryCode: 'US', originTimezone, originWarehouseStatus: 'ACTIVE', plannedDepartureAt, expectedDriverEndAt: null, departedAt: null, completedAt: null, stops: [{name:'US warehouse'}, {name:'FBA'}], incompleteReasons: [] }
|
||||
return { sourceType: 'DISPATCH', dispatchNo, containerNumbers: [], cabinetNumbers: [], status: 'PLANNED', originWarehouseId: '1', originWarehouseCode: 'US-W', originWarehouseName: 'US warehouse', originCountryCode: 'US', originTimezone, originWarehouseStatus: 'ACTIVE', plannedDepartureAt, expectedDriverEndAt: null, departedAt: null, completedAt: null, stops: [{name:'US warehouse'}, {name:'FBA'}], incompleteReasons: [] }
|
||||
}
|
||||
describe('location-based trip calendar', () => {
|
||||
it('places the reported UTC plan on October 22 at 09:00 in Los Angeles and exposes every container in one event', () => {
|
||||
const row = { ...trip('PC-CALENDAR-001', '2026-10-22T16:00:00Z'), containerNumbers: ['TSTU1234567', 'TSTU7654321'] }
|
||||
const row = { ...trip('PC-CALENDAR-001', '2026-10-22T16:00:00Z'), containerNumbers: ['TSTU1234567', 'TSTU7654321'], cabinetNumbers: ['D1821', 'D1822'] }
|
||||
const projected = projectTripCalendar({ records: [row, row], unresolvedRecords: [] }, range)
|
||||
expect(projected.events).toHaveLength(1)
|
||||
expect(projected.events[0]).toMatchObject({ key: 'dispatch-PC-CALENDAR-001', date: '2026-10-22', time: '09:00', sourcePath: '/overseas-warehouse/dispatches/PC-CALENDAR-001' })
|
||||
expect(projected.events[0]!.fields).toContainEqual({ label: '关联柜号', value: 'TSTU1234567、TSTU7654321' })
|
||||
expect(projected.events[0]!.fields).toContainEqual({ label: '关联柜次号', value: 'D1821、D1822' })
|
||||
expect(projected.events[0]!.fields).toContainEqual({ label: '计划出发(起运仓当地)', value: '2026-10-22 09:00' })
|
||||
expect(projected.events[0]!.fields).toContainEqual({ label: '计划出发(UTC)', value: '2026-10-22T16:00:00Z' })
|
||||
const beijing = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' })
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface TripCalendarEntry {
|
||||
sourceType: 'DISPATCH'
|
||||
dispatchNo: string
|
||||
containerNumbers: string[]
|
||||
cabinetNumbers: string[]
|
||||
status: string
|
||||
originWarehouseId: string | null
|
||||
originWarehouseCode: string | null
|
||||
@@ -81,6 +82,7 @@ export function projectTripCalendar(result: TripCalendarResult, range: { from: s
|
||||
fields: [
|
||||
{ label: '运输状态', value: labels[row.status] || '待核实' },
|
||||
{ label: '关联柜号', value: row.containerNumbers.join('、') },
|
||||
{ label: '关联柜次号', value: row.cabinetNumbers.join('、') },
|
||||
{ label: '起运仓', value: row.originWarehouseName || row.originWarehouseCode },
|
||||
{ label: '地点时区', value: row.originTimezone },
|
||||
{ label: '计划出发(起运仓当地)', value: planned ? `${planned.date} ${planned.time}` : null },
|
||||
|
||||
@@ -2,14 +2,22 @@
|
||||
|
||||
业务依据为用户提供的《订舱信息页面说明.docx》及页面截图;本轮确认:放舱后仅补实货量、客服手工录入、保留现有提单确认件,正式提单从外部附件接收。样式使用 `docs/Admin高密度界面规范.md` 的详情与基本信息、蓝白主题及既有公共密度。用户后续提供现页与产品稿对比,确认现页缺少层次、重点且间距过大;订舱页以该产品稿为局部视觉依据:紧凑页头、蓝色圆点连线流程、编号区块、细线小标题、浅底三方卡片及右下主打印按钮。可编辑输入、下拉和文本域使用白底、清晰边框与文字,悬停/聚焦使用蓝色反馈;灰底和禁止光标仅用于真实禁用状态,校验错误仍保留组件错误色。
|
||||
|
||||
流程条根据已保存订舱、当前未保存修改、放舱状态及实货量推进:当前步骤蓝底,已完成节点勾选,待处理灰色。第一步的“已保存”不代替放舱完整性校验;打印节点仅在本次页面成功生成对应数据的 PDF 预览后显示“已预览”,失败或只点击按钮不推进,也不声称实际打印完成。预览标记随本次页面保留,刷新后按服务端保存/放舱/实值恢复进度,打印仍可重新预览;单据消费的数据改变后,该单据重新进入待预览。
|
||||
流程条根据已保存订舱、当前未保存修改、放舱状态及实货量推进:当前步骤蓝底,已通过阶段勾选,待处理灰色。第一步的“已保存”不代替放舱完整性校验;打印节点仅在本次页面成功生成对应数据的 PDF 预览后显示“已预览”,失败或只点击按钮不产生预览事实,也不声称实际打印完成。预览标记随本次页面保留;刷新后按服务端保存/放舱/实值恢复进度,已放舱时托运单阶段保持通过,提示“可重新预览 / 打印”,避免前面灰色、后面完成的断开。放舱前托运单数据改变须重新预览;提单确认件始终要求已保存实值和对应 PDF 成功预览后才完成。
|
||||
|
||||
| 页面/路由 | 操作 | 参考承载与布局 | 当前实现 | 人工结论 |
|
||||
| 页面/路由 | 操作 | 参考承载与布局 | 当前实现 | 页面核对 |
|
||||
|---|---|---|---|---|
|
||||
| `/ocean-bookings/new`、`/ocean-bookings/:bookingId` | 草稿新增、维护 | 独立页面;左主资料、右三方并排,下方放舱资料 | 独立页面;按产品稿重排视觉层级,保留仓库、渠道、预计到港、多柜型和附件;港代码与港名成组、委实成对输入,新增保存留在详情 | 待人工截图确认 |
|
||||
| `/ocean-bookings/:bookingId` | 已放舱补实 | 客服补录实际货量 | 运输及预报只读,实值可按编辑权限补录,原因必填并版本审计 | 待人工截图确认 |
|
||||
| `/ocean-bookings/:bookingId` | 托运单、提单确认件 | 两个作业入口,A4单据预览 | 两张业务卡片底部右侧的蓝色主按钮,分别直达现有PDF预览;读已保存委/实数据,缺失显示“—” | 待人工截图确认 |
|
||||
| `/ocean-bookings/new`、`/ocean-bookings/:bookingId` | 草稿新增、维护 | 独立页面;左主资料、右三方并排,下方放舱资料 | 移除客户选择、港名、箱种、容量、限重、备注及包装单位选择;预计到港恢复在预计开船旁,并在放舱确认摘要展示;仓库和渠道保留在默认收起的“作业配置”,放舱缺配置时自动展开;委实成对输入,新增保存留在详情 | TEST #700 已核对此前精简;本次预计到港恢复待 TEST 页面核对,完整长字段展示待人工确认 |
|
||||
| `/ocean-bookings/:bookingId` | 已放舱补实 | 客服补录实际货量 | 运输及预报只读,实值可按编辑权限补录,原因必填并版本审计 | 已核对放舱、原因校验、补实保存和历史;最终 TEST #698 回验实值保存后立即回显,无需刷新 |
|
||||
| `/ocean-bookings/:bookingId` | 托运单、提单确认件 | 两个作业入口,A4单据预览 | 两张业务卡片底部右侧的蓝色主按钮,分别直达现有PDF预览;读已保存委/实数据,缺失显示“—” | 弹框开关/重开、PDF 生成与数据映射已核对;内嵌 PDF 空白,显示未通过 |
|
||||
|
||||
人工按 1920×1080、100% 缩放核对三方长名称/地址、两类货量、保存失败与版本冲突、冻结/无编辑权限,以及两类 PDF 预览的打开、关闭和重开。自动检查结果在交付中单独报告,不能代替人工截图。
|
||||
|
||||
2026-10-22 用户明确授权 AI 操作 TEST 页面后,已实际核对新增页:1920×1080 下初始仅第 1 步高亮、后续待处理;白底编辑控件、蓝色聚焦边框与真实灰底禁用状态可区分;船公司下拉及仓库—渠道联动可用。截图保存在本机 `C:/Users/BY-OY/.codex/booking-ui-acceptance-20261022/booking-new-progress.png`。保存未通过:独立测试请求返回 S0500,服务日志与只读结构核查确认 TEST 缺少订舱货物/关系方字段且冻结触发器仍为旧版。完整保存、放舱、补实和两类 PDF 预览仍待数据库结构同步后验收,不将本次局部页面核对记为完整流程通过。环境事实以部署主契约“订舱页面 TEST 验收阻断”为准。
|
||||
新增订舱默认运费条款 `FREIGHT PREPAID`、运输条款 `CY-CY`、委值 `500 / 15000 / 68`,实值留空;包装按 `CARTONS` 保存,新柜需求为整箱 FCL,容量和限重沿平台柜型规格计算。默认值只用于新增,不覆盖已保存资料。预计到港草稿可留空,放舱前必填且不得早于预计开船;放舱后仍遵守运输资料冻结规则。
|
||||
|
||||
用户后续确认预计到港供日历和入库操作使用,恢复该字段及前后端放舱门禁。代码核对:关联柜次时 `ContainerRepository.linkReleasedBooking` 传入订舱预计到港;日历在无实际到港时读柜级预计到港;海外 `ContainerWarehouseArrivalService` 在无人工或计划冻结日期时按到港当地日期加 2 天解析默认到仓,入库计划使用该解析结果,不能把订舱预计到港直接当成计划到仓日期。
|
||||
|
||||
该轮经 Admin #700、Firstleg #512 发布,运行容器 healthy;前端 28 项、后端订舱 26 项定向测试及前端类型检查通过。真实 TEST 新增页以默认值保存为 `OB202610220013`,服务端读回条款和委值一致,实值及预计到港未填写;本轮没有再次推进放舱,取消预计到港必填由前后端定向测试覆盖。页面证据 `ui-booking-simplified-new.png`、`ui-booking-simplified-saved.png` 和 `simplified-form-verification.json` 位于同一证据目录。
|
||||
|
||||
同日流程断开修复经 TEST #699 发布:刷新已放舱测试单后,第 1~3 步保持通过、连接线连续,第 4 步仍为当前待预览;托运单提示“可重新预览 / 打印”。两份定向测试 24 项通过;截图为同一证据目录的 `ui-workflow-continuous.png`。本次未修改后端或测试业务数据,未重新验收内嵌 PDF 显示。
|
||||
|
||||
2026-10-22 用户明确授权 AI 操作 TEST 页面并单独授权结构 SQL 后,已接续同一测试订舱 `OB202610220011` 完成保存、确认放舱、手工补实及两类 PDF 请求的页面核对,委实分离、版本与原因历史正确。发现并修复数字控件过期禁用辅助标记和保存后的输入缓存;两份定向测试 23 项通过,最终版本经 TEST #698 发布并回验:修正件数为 481 后无需刷新,输入显示与服务器版本 5 一致,毛重和体积保持,委值仍为 500。截图及脱敏证据在本机 `C:/Users/BY-OY/.codex/booking-ui-acceptance-20261022/`;两类 PDF 可下载且外部渲染正常,但内置浏览器的内嵌显示为空白,不能记为完整打印显示通过。环境事实以部署主契约“订舱 TEST 结构同步与页面验收”为准。
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import ElementPlus, { ElSelect } from 'element-plus'
|
||||
import ElementPlus, { ElDatePicker } from 'element-plus'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
import { listPlatformPartners } from '@/modules/master-data'
|
||||
import { listPlatformWarehouses } from '@/modules/warehouses'
|
||||
import {
|
||||
@@ -28,8 +27,6 @@ vi.mock('vue-router', () => ({
|
||||
const deniedPermissions = vi.hoisted(() => new Set<string>())
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => !deniedPermissions.has(permission) }) }))
|
||||
vi.mock('@/core/workspace-tabs/workspaceTabs', () => ({ useWorkspaceTabsStore: () => ({ removeByIds: vi.fn(), bumpCacheEpoch: vi.fn() }) }))
|
||||
vi.mock('@/modules/iam', async importOriginal => ({
|
||||
...await importOriginal<typeof import('@/modules/iam')>(), listPlatformTenants: vi.fn() }))
|
||||
vi.mock('@/modules/master-data', () => ({ listPlatformPartners: vi.fn() }))
|
||||
vi.mock('@/modules/warehouses', () => ({ listPlatformWarehouses: vi.fn() }))
|
||||
vi.mock('../api/platformOceanBookings', () => ({
|
||||
@@ -75,7 +72,6 @@ type ViewModel = {
|
||||
hasUnsavedActualChanges: boolean
|
||||
printDisabledReason: string
|
||||
save: () => Promise<void>
|
||||
selectCustomer: (value: unknown) => void
|
||||
canManageDocuments: boolean
|
||||
detail: OceanBookingDetail | null
|
||||
actionError: { code?: string; requestId?: string; traceId?: string; message: string } | null
|
||||
@@ -105,7 +101,6 @@ describe('OceanBookingDetailView one-click release', () => {
|
||||
vi.clearAllMocks()
|
||||
deniedPermissions.clear()
|
||||
vi.mocked(getOceanBooking).mockResolvedValue(booking())
|
||||
vi.mocked(listPlatformTenants).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 })
|
||||
vi.mocked(listPlatformPartners).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 })
|
||||
vi.mocked(listPlatformWarehouses).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 })
|
||||
vi.mocked(listWarehouseLogisticsChannels).mockResolvedValue([{ channelCode: 'MATSON_CLX_TRUCK', channelName: '美森正班海卡' }])
|
||||
@@ -129,25 +124,22 @@ describe('OceanBookingDetailView one-click release', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('finds a customer outside the initial page and saves its actual ID and name in a new booking', async () => {
|
||||
it('starts new bookings with the agreed declared defaults and no removed inputs', async () => {
|
||||
routeState.name = 'ocean-booking-create'; routeState.params.bookingId = 'new'
|
||||
const customer = { id: '9007199254740999', tenantName: '柏悦物流', status: 'ACTIVE' } as PlatformTenantResource
|
||||
vi.mocked(listPlatformTenants).mockImplementation(async query => ({
|
||||
records: query.keyword ? [customer] : [], page: 1, size: 50, total: query.keyword ? 1 : 350, pages: query.keyword ? 1 : 7,
|
||||
}))
|
||||
vi.mocked(createOceanBooking).mockResolvedValue({ ...booking(), customerId: String(customer.id), customerName: customer.tenantName })
|
||||
vi.mocked(createOceanBooking).mockResolvedValue(booking())
|
||||
const { wrapper, vm } = await mountView()
|
||||
const selector = wrapper.findAllComponents(ElSelect).find(item => item.props('placeholder') === '单客户可选择,多客户可留空')!
|
||||
expect(selector.props('remote')).toBe(true)
|
||||
selector.vm.$emit('end-reached', 'bottom'); await flushPromises()
|
||||
expect(listPlatformTenants).toHaveBeenLastCalledWith({ page: 2, size: 50, status: 'ACTIVE' })
|
||||
await selector.props('remoteMethod')?.('柏悦'); await flushPromises()
|
||||
expect(listPlatformTenants).toHaveBeenLastCalledWith({ page: 1, size: 50, status: 'ACTIVE', keyword: '柏悦' })
|
||||
selector.vm.$emit('update:modelValue', customer.id); await flushPromises()
|
||||
expect(vm.form.data.customerId).toBe(customer.id)
|
||||
expect(vm.form.data.customerName).toBe('柏悦物流')
|
||||
expect(vm.form.data).toMatchObject({ freightTerm: 'FREIGHT PREPAID', serviceTerm: 'CY-CY', packageUnit: 'CARTONS', declaredPackageCount: 500, declaredGrossWeightKg: 15000, declaredVolumeCbm: 68, actualPackageCount: null, actualGrossWeightKg: null, actualVolumeCbm: null })
|
||||
expect(wrapper.get('[aria-label="预报件数"]').element).toHaveProperty('value', '500')
|
||||
expect(wrapper.get('[aria-label="预报毛重"]').element).toHaveProperty('value', '15000.000')
|
||||
expect(wrapper.get('[aria-label="预报体积"]').element).toHaveProperty('value', '68.000')
|
||||
const labels = wrapper.findAll('.el-form-item__label').map(item => item.text())
|
||||
for (const removed of ['客户(选填)', '包装单位', '备注']) expect(labels).not.toContain(removed)
|
||||
expect(labels).toContain('预计到港')
|
||||
expect(wrapper.find('[aria-label="起运港名称"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[aria-label="目的港名称"]').exists()).toBe(false)
|
||||
expect(wrapper.findAll('.container-table th').map(item => item.text())).toEqual(['操作', '柜型', '数量'])
|
||||
await vm.save()
|
||||
expect(createOceanBooking).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ customerId: customer.id, customerName: '柏悦物流' }) }))
|
||||
expect(createOceanBooking).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ declaredPackageCount: 500, declaredGrossWeightKg: 15000, declaredVolumeCbm: 68, actualPackageCount: null }) }))
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
@@ -170,8 +162,13 @@ describe('OceanBookingDetailView one-click release', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('shows the matching fixed logistics group instead of the underlying price-book channel', async () => {
|
||||
const { wrapper } = await mountView()
|
||||
it('keeps warehouse and channel in a collapsed operation section with their saved bindings', async () => {
|
||||
const { wrapper, vm } = await mountView()
|
||||
const header = wrapper.get('.booking-operation-config .el-collapse-item__header')
|
||||
expect(header.attributes('aria-expanded')).toBe('false')
|
||||
await header.trigger('click')
|
||||
expect(header.attributes('aria-expanded')).toBe('true')
|
||||
expect(vm.form.data).toMatchObject({ warehouseId: '8', channelCode: 'MATSON_CLX' })
|
||||
expect(wrapper.text()).toContain('美森正班')
|
||||
expect(wrapper.text()).not.toContain('美森正班海卡')
|
||||
wrapper.unmount()
|
||||
@@ -232,16 +229,56 @@ describe('OceanBookingDetailView one-click release', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('clears an existing customer before saving and releasing with the returned version', async () => {
|
||||
const saved = booking('DRAFT', 4)
|
||||
saved.customerId = null; saved.customerName = null; saved.oceanBooking.customerId = null; saved.oceanBooking.customerName = null
|
||||
vi.mocked(updateOceanBookingDraft).mockResolvedValue(saved)
|
||||
vi.mocked(releaseOceanBooking).mockResolvedValue({ ...saved, status: 'RELEASED', version: 5 })
|
||||
it('blocks release without estimated arrival while still allowing a partial draft save', async () => {
|
||||
const saved = booking()
|
||||
saved.estimatedArrivalAt = null
|
||||
saved.oceanBooking.estimatedArrivalAt = null
|
||||
vi.mocked(getOceanBooking).mockResolvedValue(saved)
|
||||
vi.mocked(updateOceanBookingDraft).mockResolvedValue({ ...saved, version: 4 })
|
||||
const { vm, wrapper } = await mountView()
|
||||
vm.selectCustomer('')
|
||||
await vm.submitRelease()
|
||||
expect(updateOceanBookingDraft).toHaveBeenCalledWith('123', expect.objectContaining({ data: expect.objectContaining({ customerId: null, customerName: null }) }))
|
||||
expect(releaseOceanBooking).toHaveBeenCalledWith('123', { expectedVersion: 4 })
|
||||
expect(vm.actionError?.message).toBe('放舱前请填写预计到港时间')
|
||||
expect(releaseOceanBooking).not.toHaveBeenCalled()
|
||||
await vm.save()
|
||||
expect(updateOceanBookingDraft).toHaveBeenCalledWith('123', expect.objectContaining({ data: expect.objectContaining({ estimatedArrivalAt: null }) }))
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('loads and saves estimated arrival from the date input and includes it in release confirmation', async () => {
|
||||
const arrival = '2026-09-25T10:30:00'
|
||||
vi.mocked(updateOceanBookingDraft).mockResolvedValue({ ...booking('DRAFT', 4), estimatedArrivalAt: arrival })
|
||||
const { vm, wrapper } = await mountView()
|
||||
const picker = wrapper.findAllComponents(ElDatePicker).find(item => item.props('placeholder') === '请选择预计到港时间')!
|
||||
expect(picker.props('modelValue')).toBe('2026-09-20T08:00:00')
|
||||
picker.vm.$emit('update:modelValue', arrival)
|
||||
await flushPromises()
|
||||
await vm.save()
|
||||
expect(updateOceanBookingDraft).toHaveBeenCalledWith('123', expect.objectContaining({ data: expect.objectContaining({ estimatedArrivalAt: arrival }) }))
|
||||
await wrapper.findAll('button').find(button => button.text() === '放舱')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.get('.release-summary').text()).toContain(arrival)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('blocks an arrival before the sailing date', async () => {
|
||||
const { vm, wrapper } = await mountView()
|
||||
vm.form.data.estimatedArrivalAt = '2026-08-31T08:00:00'
|
||||
await vm.submitRelease()
|
||||
expect(vm.actionError?.message).toBe('预计到港时间不能早于预计开船时间')
|
||||
expect(releaseOceanBooking).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('opens operation configuration when release is missing a warehouse', async () => {
|
||||
const saved = booking()
|
||||
saved.warehouseId = null
|
||||
vi.mocked(getOceanBooking).mockResolvedValue(saved)
|
||||
const { vm, wrapper } = await mountView()
|
||||
await vm.submitRelease()
|
||||
await flushPromises()
|
||||
expect(vm.actionError?.message).toBe('放舱前请填写装柜仓库')
|
||||
expect(wrapper.get('.booking-operation-config .el-collapse-item__header').attributes('aria-expanded')).toBe('true')
|
||||
expect(releaseOceanBooking).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
||||
import PageToolbar from '@/shared/components/PageToolbar.vue'
|
||||
import { CONTAINER_SPECIFICATIONS } from '@/shared/domain/containerSpecifications'
|
||||
import { listPlatformTenants, useActiveCustomerOptions } from '@/modules/iam'
|
||||
import { listPlatformPartners, type PlatformPartnerResource } from '@/modules/master-data'
|
||||
import { listPlatformWarehouses, type PlatformWarehouseResource } from '@/modules/warehouses'
|
||||
import { CarrierScheduleQueryPanel } from '@/modules/port-schedules'
|
||||
@@ -57,10 +56,6 @@ const partyTypes = [
|
||||
{ value: 'NOTIFY_PARTY', label: '通知人' },
|
||||
] as const
|
||||
const containerTypeOptions = CONTAINER_SPECIFICATIONS
|
||||
const containerKindOptions = [
|
||||
{ value: 'FCL', label: '整箱 FCL' },
|
||||
{ value: 'LCL', label: '拼箱 LCL' },
|
||||
]
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const access = useAccessPreviewStore()
|
||||
@@ -87,9 +82,6 @@ const channelsError = ref('')
|
||||
const selectedChannelName = ref('')
|
||||
let channelRequestSerial = 0
|
||||
let bookingAgentRequestSerial = 0
|
||||
const selectableCustomers = computed(() => customers.value.filter((customer) => (
|
||||
customer.status === 'ACTIVE' || String(customer.id) === String(form.data.customerId)
|
||||
)))
|
||||
const shippingCompanies = computed(() => partners.value.filter((partner) => partner.types.includes('CARRIER')))
|
||||
const channelGroups = computed(() => {
|
||||
const activeCodes = new Set(channels.value.map((channel) => channel.channelCode))
|
||||
@@ -103,7 +95,7 @@ function blankParties(): OceanBookingParty[] {
|
||||
function blankData(): OceanBookingDraftData {
|
||||
return {
|
||||
freightTerm: 'FREIGHT PREPAID', serviceTerm: 'CY-CY', packageUnit: 'CARTONS',
|
||||
cargoDescription: '', marksAndNos: '', declaredPackageCount: null, declaredGrossWeightKg: null, declaredVolumeCbm: null,
|
||||
cargoDescription: '', marksAndNos: '', declaredPackageCount: 500, declaredGrossWeightKg: 15000, declaredVolumeCbm: 68,
|
||||
actualPackageCount: null, actualGrossWeightKg: null, actualVolumeCbm: null,
|
||||
customerId: null, customerName: null, channelCode: '', warehouseId: '', parties: blankParties(),
|
||||
billOfLadingNo: '', vesselName: '', voyageNo: '', estimatedSailingAt: null, estimatedArrivalAt: null,
|
||||
@@ -112,7 +104,6 @@ function blankData(): OceanBookingDraftData {
|
||||
}
|
||||
}
|
||||
const form = reactive<OceanBookingSaveRequest>({ expectedVersion: 0, data: blankData() })
|
||||
const { customers, loading: customersLoading, error: customersError, search: loadCustomers, endReached: customerEndReached, retry: retryCustomers } = useActiveCustomerOptions(() => form.data.customerId, listPlatformTenants)
|
||||
const savedDraftSignature = ref('')
|
||||
const rules: FormRules = {}
|
||||
const editable = computed(() => isNew.value || detail.value?.status === 'DRAFT')
|
||||
@@ -128,6 +119,7 @@ const actualReason = ref('')
|
||||
const cargoWritable = computed(() => formWritable.value || (detail.value?.status === 'RELEASED' && access.can('platform:firstleg:ocean-booking:edit')))
|
||||
const workflowBusy = computed(() => saving.value || actionSaving.value || actualSaving.value)
|
||||
const releaseDialogOpen = ref(false)
|
||||
const operationSections = ref<string[]>([])
|
||||
const printOpen = ref(false)
|
||||
const printType = ref<OceanBookingPrintType>('SHIPPING_INSTRUCTION')
|
||||
const previewSignatures = ref<OceanBookingPreviewSignatures>({})
|
||||
@@ -143,8 +135,7 @@ async function loadOptions() {
|
||||
optionsLoading.value = true
|
||||
optionsError.value = ''
|
||||
try {
|
||||
const [, partnerResult, warehouseResult] = await Promise.all([
|
||||
loadCustomers(),
|
||||
const [partnerResult, warehouseResult] = await Promise.all([
|
||||
listPlatformPartners({ page: 1, size: 200, status: 'ACTIVE' }),
|
||||
listPlatformWarehouses({ page: 1, size: 200, warehouseType: 'DOMESTIC', status: 'ACTIVE' }),
|
||||
])
|
||||
@@ -161,12 +152,6 @@ async function loadOptions() {
|
||||
optionsLoading.value = false
|
||||
}
|
||||
}
|
||||
function selectCustomer(value: unknown) {
|
||||
const customerId = value == null || value === '' ? null : String(value)
|
||||
form.data.customerId = customerId
|
||||
const customer = customerId == null ? null : customers.value.find((item) => String(item.id) === customerId)
|
||||
form.data.customerName = customer?.tenantName || null
|
||||
}
|
||||
function draftPayload(): OceanBookingDraftData {
|
||||
const customerId = form.data.customerId || null
|
||||
return {
|
||||
@@ -393,8 +378,11 @@ function releaseValidationError() {
|
||||
[form.data.shippingCompanyId, '船公司'], [form.data.bookingAgentId, '订舱代理'],
|
||||
]
|
||||
const missing = requiredFields.find(([value]) => !String(value || '').trim())
|
||||
if (missing) return `放舱前请填写${missing[1]}`
|
||||
if (form.data.estimatedArrivalAt! < form.data.estimatedSailingAt!) return '预计到港时间不能早于预计开船时间'
|
||||
if (missing) {
|
||||
if (missing[1] === '装柜仓库' || missing[1] === '物流渠道') operationSections.value = ['operation']
|
||||
return `放舱前请填写${missing[1]}`
|
||||
}
|
||||
if (form.data.estimatedArrivalAt && form.data.estimatedSailingAt && form.data.estimatedArrivalAt < form.data.estimatedSailingAt) return '预计到港时间不能早于预计开船时间'
|
||||
if (form.data.parties.some((party) => !party.partyName.trim())) return '放舱前请填写发货人、收货人和通知人名称'
|
||||
if (!detail.value?.documents.some((document) => document.valid && ['BOOKING_CONFIRMATION', 'BILL_OF_LADING'].includes(document.documentType))) return '放舱前请上传有效的订舱确认文件或提单文件'
|
||||
return containerRequirementError(form.data.containerRequirements)
|
||||
@@ -492,16 +480,13 @@ onMounted(() => { void load(); void loadOptions() })
|
||||
<el-form-item label="订舱号"><el-input :model-value="detail?.bookingNo || '保存后生成'" disabled /></el-form-item>
|
||||
<el-form-item label="船公司"><el-select :model-value="form.data.shippingCompanyId" filterable clearable placeholder="请选择船公司" :loading="optionsLoading" style="width:100%" @update:model-value="selectShippingCompany(String($event || ''))"><el-option v-if="form.data.shippingCompanyId && !shippingCompanies.some((partner) => String(partner.id) === String(form.data.shippingCompanyId))" :label="form.data.shippingCompanyName || '船公司名称缺失'" :value="String(form.data.shippingCompanyId)" /><el-option v-for="partner in shippingCompanies" :key="String(partner.id)" :label="partner.partnerName" :value="String(partner.id)" /></el-select></el-form-item>
|
||||
<el-form-item label="订舱代理"><el-select :model-value="form.data.bookingAgentId" filterable clearable :disabled="!editable || !form.data.carrierCode || bookingAgentsLoading || !!bookingAgentsError" :placeholder="form.data.carrierCode ? '请选择订舱代理' : '请先选择船公司'" :loading="bookingAgentsLoading" no-data-text="当前船公司暂无可用订舱代理" style="width:100%" @update:model-value="selectBookingAgent(String($event || ''))"><el-option v-if="!editable && form.data.bookingAgentId && !bookingAgents.some((supplier) => supplier.supplierId === String(form.data.bookingAgentId))" :label="form.data.bookingAgentName || '订舱代理名称缺失'" :value="String(form.data.bookingAgentId)" /><el-option v-for="supplier in bookingAgents" :key="supplier.supplierId" :label="supplier.name" :value="supplier.supplierId" /></el-select><div v-if="bookingAgentsError" class="field-error"><ApiErrorMeta :details="bookingAgentsError" /><el-button link type="primary" :loading="bookingAgentsLoading" @click="form.data.carrierCode && loadBookingAgents(form.data.carrierCode)">重新加载</el-button></div></el-form-item>
|
||||
<el-form-item label="客户(选填)"><el-select :model-value="form.data.customerId" filterable clearable placeholder="单客户可选择,多客户可留空" no-data-text="暂无可选客户" :loading="customersLoading" remote :remote-method="loadCustomers" @end-reached="customerEndReached" style="width:100%" @update:model-value="selectCustomer"><el-option v-if="form.data.customerId && !selectableCustomers.some((customer) => String(customer.id) === String(form.data.customerId))" :label="form.data.customerName || '客户名称缺失'" :value="String(form.data.customerId)" /><el-option v-for="customer in selectableCustomers" :key="String(customer.id)" :label="customer.tenantName" :value="String(customer.id)" /></el-select><LoadErrorState v-if="customersError" title="客户选项加载失败" :message="customersError.message" :loading="customersLoading" compact @retry="retryCustomers"><template #details><ApiErrorMeta :details="customersError" /></template></LoadErrorState></el-form-item>
|
||||
<el-form-item label="装柜仓库" prop="warehouseId"><el-select :model-value="form.data.warehouseId" filterable placeholder="请选择装柜仓库" :loading="optionsLoading" style="width:100%" @update:model-value="selectWarehouse"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="warehouse.warehouseName" :value="String(warehouse.id)" /></el-select></el-form-item>
|
||||
<el-form-item label="物流渠道" prop="channelCode"><el-select :model-value="form.data.channelCode" filterable :disabled="!formWritable || !form.data.warehouseId || channelsLoading || !!channelsError || channelUnavailable" :loading="channelsLoading" :placeholder="form.data.warehouseId ? '请选择物流渠道' : '请先选择装柜仓库'" no-data-text="该仓库暂无有效物流渠道" style="width:100%" @update:model-value="selectChannel(String($event || ''))"><el-option v-if="form.data.channelCode && !channelGroups.some((channel) => channel.code === form.data.channelCode)" :label="selectedChannelName || form.data.channelCode" :value="form.data.channelCode" /><el-option v-for="channel in channelGroups" :key="channel.code" :label="channel.name" :value="channel.code" /></el-select><div v-if="channelsError" class="field-error"><span>{{ channelsError }}</span><el-button link type="primary" :loading="channelsLoading" @click="form.data.warehouseId && loadChannels(form.data.warehouseId)">重新加载</el-button></div><div v-else-if="channelUnavailable" class="field-hint">该仓库暂无有效物流渠道,请更换仓库或联系管理员。</div></el-form-item>
|
||||
</div>
|
||||
</section>
|
||||
<section class="booking-group" aria-label="航线船期">
|
||||
<h3 class="booking-group__title"><span>港口航线</span></h3>
|
||||
<div class="booking-group__body booking-group__body--fields">
|
||||
<el-form-item label="起运港"><div class="booking-port-fields"><el-input v-model="form.data.departurePortCode" placeholder="港代码" aria-label="起运港代码" /><el-input v-model="form.data.departurePortName" placeholder="港口名称" aria-label="起运港名称" /></div></el-form-item>
|
||||
<el-form-item label="目的港"><div class="booking-port-fields"><el-input v-model="form.data.destinationPortCode" placeholder="港代码" aria-label="目的港代码" /><el-input v-model="form.data.destinationPortName" placeholder="港口名称" aria-label="目的港名称" /></div></el-form-item>
|
||||
<el-form-item label="起运港"><el-input v-model="form.data.departurePortCode" placeholder="港代码" aria-label="起运港代码" /></el-form-item>
|
||||
<el-form-item label="目的港"><el-input v-model="form.data.destinationPortCode" placeholder="港代码" aria-label="目的港代码" /></el-form-item>
|
||||
<el-form-item label="预计开船" prop="estimatedSailingAt"><el-date-picker v-model="form.data.estimatedSailingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计开船时间" style="width:100%" /></el-form-item>
|
||||
<el-form-item label="预计到港" prop="estimatedArrivalAt"><el-date-picker v-model="form.data.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计到港时间" style="width:100%" /></el-form-item>
|
||||
<el-form-item class="booking-field--wide" label="交货地点"><el-input v-model="form.data.placeOfDelivery" placeholder="请输入最终交货地点" /></el-form-item>
|
||||
@@ -544,7 +529,6 @@ onMounted(() => { void load(); void loadOptions() })
|
||||
</div>
|
||||
</div>
|
||||
<div class="booking-group__body--fields">
|
||||
<el-form-item label="包装单位"><el-select v-model="form.data.packageUnit" :disabled="!formWritable" placeholder="请选择包装单位"><el-option label="箱 CARTONS" value="CARTONS" /><el-option label="件 PCS" value="PCS" /><el-option label="托 PALLETS" value="PALLETS" /></el-select></el-form-item>
|
||||
<el-form-item label="唛头和号码"><el-input v-model="form.data.marksAndNos" :disabled="!formWritable" maxlength="1000" /></el-form-item>
|
||||
<el-form-item class="booking-field--wide" label="品名及托运要求"><el-input v-model="form.data.cargoDescription" :disabled="!formWritable" type="textarea" :rows="2" maxlength="1000" placeholder="填写实际品名及免用箱等托运要求" /></el-form-item>
|
||||
</div>
|
||||
@@ -572,10 +556,10 @@ onMounted(() => { void load(); void loadOptions() })
|
||||
|
||||
<el-form :model="form.data" label-position="top" :disabled="!formWritable" class="density-form booking-requirements">
|
||||
<section class="booking-group" aria-label="柜型需求">
|
||||
<h3 class="booking-group__title"><span>柜型需求与备注</span></h3><div class="booking-group__body">
|
||||
<h3 class="booking-group__title"><span>柜型需求</span></h3><div class="booking-group__body">
|
||||
<div class="container-table-wrap" aria-label="柜型需求">
|
||||
<div class="workbench-evidence"><table class="container-table density-table">
|
||||
<thead><tr><th scope="col">操作</th><th scope="col">柜型</th><th scope="col">箱种</th><th scope="col">数量</th><th scope="col">容量</th><th scope="col">限重</th><th v-if="detail?.status === 'RELEASED'" scope="col">剩余可关联</th></tr></thead>
|
||||
<thead><tr><th scope="col">操作</th><th scope="col">柜型</th><th scope="col">数量</th><th v-if="detail?.status === 'RELEASED'" scope="col">剩余可关联</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="(requirement, index) in form.data.containerRequirements" :key="requirement.requirementId || index">
|
||||
<td class="container-table__action">
|
||||
@@ -585,20 +569,24 @@ onMounted(() => { void load(); void loadOptions() })
|
||||
<span v-else aria-hidden="true">—</span>
|
||||
</td>
|
||||
<td><el-select :model-value="requirement.containerType" :disabled="!formWritable" placeholder="请选择柜型" @update:model-value="selectContainerRequirementType(requirement, String($event || ''))"><el-option v-for="item in containerTypeOptions" :key="item.type" :label="item.type" :value="item.type" /></el-select></td>
|
||||
<td><el-select v-model="requirement.containerKind" :disabled="!formWritable" placeholder="请选择箱种"><el-option v-for="item in containerKindOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></td>
|
||||
<td><el-input-number v-model="requirement.quantity" :disabled="!formWritable" :min="1" :max="99" controls-position="right" placeholder="请输入柜量" /></td>
|
||||
<td class="container-table__specification"><span>{{ requirement.capacityCbm }} m³</span></td>
|
||||
<td class="container-table__specification"><span>{{ requirement.maxWeightKg }} kg</span></td>
|
||||
<td v-if="detail?.status === 'RELEASED'"><span>{{ requirement.remainingQuantity ?? requirement.quantity }}</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
<div v-if="formWritable" class="container-actions"><el-button class="add-container density-action" size="small" :disabled="form.data.containerRequirements.length >= 20" @click="addRequirement">新增柜型需求</el-button><span>{{ form.data.containerRequirements.length }} / 20 项</span></div>
|
||||
<el-form-item label="备注"><el-input v-model="form.data.remark" type="textarea" :rows="3" /></el-form-item>
|
||||
</div>
|
||||
</section>
|
||||
</el-form>
|
||||
<el-collapse v-model="operationSections" class="booking-operation-config">
|
||||
<el-collapse-item title="作业配置" name="operation">
|
||||
<el-form :model="form.data" label-position="top" :disabled="!formWritable" class="density-form booking-group__body--fields">
|
||||
<el-form-item label="装柜仓库" prop="warehouseId"><el-select :model-value="form.data.warehouseId" filterable placeholder="请选择装柜仓库" :loading="optionsLoading" style="width:100%" @update:model-value="selectWarehouse"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="warehouse.warehouseName" :value="String(warehouse.id)" /></el-select></el-form-item>
|
||||
<el-form-item label="物流渠道" prop="channelCode"><el-select :model-value="form.data.channelCode" filterable :disabled="!formWritable || !form.data.warehouseId || channelsLoading || !!channelsError || channelUnavailable" :loading="channelsLoading" :placeholder="form.data.warehouseId ? '请选择物流渠道' : '请先选择装柜仓库'" no-data-text="该仓库暂无有效物流渠道" style="width:100%" @update:model-value="selectChannel(String($event || ''))"><el-option v-if="form.data.channelCode && !channelGroups.some((channel) => channel.code === form.data.channelCode)" :label="selectedChannelName || form.data.channelCode" :value="form.data.channelCode" /><el-option v-for="channel in channelGroups" :key="channel.code" :label="channel.name" :value="channel.code" /></el-select><div v-if="channelsError" class="field-error"><span>{{ channelsError }}</span><el-button link type="primary" :loading="channelsLoading" @click="form.data.warehouseId && loadChannels(form.data.warehouseId)">重新加载</el-button></div><div v-else-if="channelUnavailable" class="field-hint">该仓库暂无有效物流渠道,请更换仓库或联系管理员。</div></el-form-item>
|
||||
</el-form>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
<footer class="booking-section__footer">
|
||||
<el-tooltip :content="printDisabledReason || '按已保存预报货量打印托运单'" placement="top"><span><el-button type="primary" :disabled="!!printDisabledReason || workflowBusy" @click="openPrint('SHIPPING_INSTRUCTION')"><Printer :size="16" />打印托运单</el-button></span></el-tooltip>
|
||||
</footer>
|
||||
@@ -759,7 +747,6 @@ onMounted(() => { void load(); void loadOptions() })
|
||||
.booking-group__title::after { flex: 1; height: 1px; background: var(--workbench-border); content: ''; }
|
||||
.booking-group__body--fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: var(--admin-space-5); row-gap: var(--admin-space-3); }
|
||||
.booking-field--wide { grid-column: 1 / -1; }
|
||||
.booking-port-fields { display: grid; grid-template-columns: minmax(76px, .65fr) minmax(0, 1fr); gap: var(--admin-space-2); width: 100%; }
|
||||
.booking-core__main > .booking-group { margin-top: var(--admin-space-6); }
|
||||
.booking-parties { padding-left: var(--admin-space-7); border-left: 1px solid var(--workbench-border); }
|
||||
.party-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--admin-space-4); align-items: start; }
|
||||
@@ -799,17 +786,16 @@ onMounted(() => { void load(); void loadOptions() })
|
||||
.cargo-metric__separator { color: var(--workbench-muted); text-align: center; }
|
||||
.cargo-metric__unit { color: var(--workbench-muted); font-size: var(--admin-font-caption); }
|
||||
.cargo-metric__inputs :deep(.el-input__inner) { text-align: left; }
|
||||
.booking-operation-config { margin-top: var(--admin-space-4); }
|
||||
.booking-requirements { margin-top: var(--admin-space-6); }
|
||||
.container-table-wrap { max-width: 100%; overflow-x: auto; margin-bottom: var(--admin-space-4); }
|
||||
.container-table { width: 100%; min-width: 780px; table-layout: fixed; }
|
||||
.container-table { width: 100%; min-width: 420px; table-layout: fixed; }
|
||||
.container-table th:first-child, .container-table td:first-child { width: 80px; }
|
||||
.container-table th:nth-child(2), .container-table td:nth-child(2) { width: 180px; }
|
||||
.container-table th:nth-child(3), .container-table td:nth-child(3) { width: 90px; }
|
||||
.container-table th:nth-child(4), .container-table td:nth-child(4) { width: 110px; }
|
||||
.container-table th:nth-child(5), .container-table td:nth-child(5), .container-table th:nth-child(6), .container-table td:nth-child(6) { width: 100px; }
|
||||
.container-table td :deep(.el-select), .container-table td :deep(.el-input) { width: 100%; min-width: 0; }
|
||||
.container-table__action { text-align: center !important; }
|
||||
.container-table__specification { color: var(--workbench-muted); white-space: nowrap; }
|
||||
.container-actions { display: flex; align-items: center; gap: var(--admin-space-4); margin-bottom: var(--admin-space-5); color: var(--workbench-muted); font-size: var(--admin-font-caption); }
|
||||
.add-container { margin: 0; }
|
||||
.booking-section__footer { display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: var(--admin-space-3); margin-top: var(--admin-space-6); padding-top: var(--admin-space-5); border-top: 1px dashed var(--workbench-border); }
|
||||
|
||||
@@ -45,6 +45,8 @@ describe('ocean booking workflow milestones', () => {
|
||||
const saved = detail()
|
||||
saved.status = 'RELEASED'
|
||||
let steps = oceanBookingWorkflow(saved, false, false, {})
|
||||
expect(steps.map(step => step.state)).toEqual(['complete', 'complete', 'complete', 'current'])
|
||||
expect(steps[1]).toMatchObject({ state: 'complete', status: '可重新预览 / 打印' })
|
||||
expect(steps[2]).toMatchObject({ state: 'complete', status: '已放舱' })
|
||||
expect(steps[3]).toMatchObject({ state: 'current', status: '待补齐实值' })
|
||||
saved.actualPackageCount = 480
|
||||
@@ -72,6 +74,14 @@ describe('ocean booking workflow milestones', () => {
|
||||
expect(steps[3]).toMatchObject({ state: 'current', status: '可预览 / 打印' })
|
||||
saved.declaredPackageCount = 510
|
||||
steps = oceanBookingWorkflow(saved, false, false, previews)
|
||||
expect(steps[1]?.state).toBe('pending')
|
||||
expect(steps[1]).toMatchObject({ state: 'complete', status: '可重新预览 / 打印' })
|
||||
})
|
||||
|
||||
it('requires a fresh preview when draft print data changes before release', () => {
|
||||
const saved = detail()
|
||||
const previews = { SHIPPING_INSTRUCTION: oceanBookingPrintSignature(saved, 'SHIPPING_INSTRUCTION') }
|
||||
saved.declaredPackageCount = 510
|
||||
expect(oceanBookingWorkflow(saved, false, false, previews).map(step => step.state))
|
||||
.toEqual(['complete', 'current', 'pending', 'pending'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -36,6 +36,9 @@ export function oceanBookingWorkflow(
|
||||
.every(value => value != null && Number(value) > 0))
|
||||
const previewed = (type: OceanBookingPrintType) => Boolean(detail && previews[type] === oceanBookingPrintSignature(detail, type))
|
||||
const instructionPreviewed = saved && previewed('SHIPPING_INSTRUCTION')
|
||||
// 已放舱代表已进入后续作业;刷新清除预览标记时,不让先前阶段倒退。
|
||||
// 阶段通过不代表有打印记录,仍用状态文案区分本次是否预览。
|
||||
const instructionPassed = released || instructionPreviewed
|
||||
const confirmationPreviewed = saved && released && actualComplete && !actualDirty && previewed('BILL_OF_LADING_CONFIRMATION')
|
||||
const current = !saved ? 0 : !released ? instructionPreviewed ? 2 : 1 : confirmationPreviewed ? -1 : 3
|
||||
const step = (index: number, label: string, complete: boolean, status: string): OceanBookingWorkflowStep => ({
|
||||
@@ -43,7 +46,7 @@ export function oceanBookingWorkflow(
|
||||
})
|
||||
return [
|
||||
step(0, '填写并保存订舱信息', saved, saved ? '已保存' : detail ? '修改待保存' : '待填写并保存'),
|
||||
step(1, '托运单预览 / 打印', instructionPreviewed, instructionPreviewed ? '已预览' : saved ? '可预览 / 打印' : '待保存资料'),
|
||||
step(1, '托运单预览 / 打印', instructionPassed, instructionPreviewed ? '已预览' : released ? '可重新预览 / 打印' : saved ? '可预览 / 打印' : '待保存资料'),
|
||||
step(2, '补齐资料并确认放舱', released, released ? '已放舱' : saved ? '待确认放舱' : '待补齐资料'),
|
||||
step(3, '补实并预览提单确认件', confirmationPreviewed, !released ? '待放舱' : actualDirty ? '实值待保存' : !actualComplete ? '待补齐实值' : confirmationPreviewed ? '已预览' : '可预览 / 打印'),
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user