diff --git a/src/modules/ocean-bookings/PROTOTYPE_MATRIX.md b/src/modules/ocean-bookings/PROTOTYPE_MATRIX.md index 31b16ab7..e6aaeda8 100644 --- a/src/modules/ocean-bookings/PROTOTYPE_MATRIX.md +++ b/src/modules/ocean-bookings/PROTOTYPE_MATRIX.md @@ -6,10 +6,14 @@ | 页面/路由 | 操作 | 参考承载与布局 | 当前实现 | 页面核对 | |---|---|---|---|---| -| `/ocean-bookings/new`、`/ocean-bookings/:bookingId` | 草稿新增、维护 | 独立页面;左主资料、右三方并排,下方放舱资料 | 独立页面;按产品稿重排视觉层级,保留仓库、渠道、预计到港、多柜型和附件;港代码与港名成组、委实成对输入,新增保存留在详情 | 已核对新增保存、初始步骤、控件状态与仓库渠道联动;完整长字段展示待人工确认 | +| `/ocean-bookings/new`、`/ocean-bookings/:bookingId` | 草稿新增、维护 | 独立页面;左主资料、右三方并排,下方放舱资料 | 按用户红框移除客户选择、港名、预计到港、箱种、容量、限重、备注及包装单位选择;仓库和渠道保留在默认收起的“作业配置”,放舱缺配置时自动展开;委实成对输入,新增保存留在详情 | 前批已核对新增保存与控件状态;本次精简及折叠区待 TEST 页面核对,完整长字段展示待人工确认 | | `/ocean-bookings/:bookingId` | 已放舱补实 | 客服补录实际货量 | 运输及预报只读,实值可按编辑权限补录,原因必填并版本审计 | 已核对放舱、原因校验、补实保存和历史;最终 TEST #698 回验实值保存后立即回显,无需刷新 | | `/ocean-bookings/:bookingId` | 托运单、提单确认件 | 两个作业入口,A4单据预览 | 两张业务卡片底部右侧的蓝色主按钮,分别直达现有PDF预览;读已保存委/实数据,缺失显示“—” | 弹框开关/重开、PDF 生成与数据映射已核对;内嵌 PDF 空白,显示未通过 | 人工按 1920×1080、100% 缩放核对三方长名称/地址、两类货量、保存失败与版本冲突、冻结/无编辑权限,以及两类 PDF 预览的打开、关闭和重开。自动检查结果在交付中单独报告,不能代替人工截图。 +新增订舱默认运费条款 `FREIGHT PREPAID`、运输条款 `CY-CY`、委值 `500 / 15000 / 68`,实值留空;包装按 `CARTONS` 保存,新柜需求为整箱 FCL,容量和限重沿平台柜型规格计算。默认值只用于新增,不覆盖已保存资料。预计到港取消放舱必填,已有计划时间仍按原值保留。 + +同日流程断开修复经 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 结构同步与页面验收”为准。 diff --git a/src/modules/ocean-bookings/pages/OceanBookingDetailView.release.test.ts b/src/modules/ocean-bookings/pages/OceanBookingDetailView.release.test.ts index 3acb2e28..4b73ab74 100644 --- a/src/modules/ocean-bookings/pages/OceanBookingDetailView.release.test.ts +++ b/src/modules/ocean-bookings/pages/OceanBookingDetailView.release.test.ts @@ -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 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()) 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(), 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 - 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,21 @@ 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(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 +161,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 +228,28 @@ 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('allows release without an estimated arrival while preserving other requirements', async () => { + const saved = booking() + saved.estimatedArrivalAt = null + saved.oceanBooking.estimatedArrivalAt = null + vi.mocked(getOceanBooking).mockResolvedValue(saved) + vi.mocked(releaseOceanBooking).mockResolvedValue({ ...saved, status: 'RELEASED', 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(releaseOceanBooking).toHaveBeenCalledWith('123', { expectedVersion: 3 }) + 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() }) diff --git a/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue b/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue index 3c5a70b5..19216880 100644 --- a/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue +++ b/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue @@ -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({ 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([]) const printOpen = ref(false) const printType = ref('SHIPPING_INSTRUCTION') const previewSignatures = ref({}) @@ -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 { @@ -388,13 +373,16 @@ function releaseValidationError() { const requiredFields: Array<[unknown, string]> = [ [form.data.warehouseId, '装柜仓库'], [form.data.channelCode, '物流渠道'], [form.data.billOfLadingNo, '提单号'], [form.data.vesselName, '船名'], [form.data.voyageNo, '航次'], - [form.data.estimatedSailingAt, '预计开船时间'], [form.data.estimatedArrivalAt, '预计到港时间'], + [form.data.estimatedSailingAt, '预计开船时间'], [form.data.placeOfDelivery, '交货地点'], [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,18 +480,14 @@ onMounted(() => { void load(); void loadOptions() })
重新加载
- - -
{{ channelsError }}重新加载
该仓库暂无有效物流渠道,请更换仓库或联系管理员。

港口航线

-
-
+ + - @@ -544,7 +528,6 @@ onMounted(() => { void load(); void loadOptions() })
-
@@ -572,10 +555,10 @@ onMounted(() => { void load(); void loadOptions() })
-

柜型需求与备注

+

柜型需求

- + - - -
操作柜型箱种数量容量限重剩余可关联
操作柜型数量剩余可关联
@@ -585,20 +568,24 @@ onMounted(() => { void load(); void loadOptions() }) {{ requirement.capacityCbm }} m³{{ requirement.maxWeightKg }} kg {{ requirement.remainingQuantity ?? requirement.quantity }}
新增柜型需求{{ form.data.containerRequirements.length }} / 20 项
-
+ + + + +
{{ channelsError }}重新加载
该仓库暂无有效物流渠道,请更换仓库或联系管理员。
+
+
+
打印托运单
@@ -635,7 +622,6 @@ onMounted(() => { void load(); void loadOptions() }) {{ form.data.vesselName || '—' }} {{ form.data.voyageNo || '—' }} {{ form.data.estimatedSailingAt || '—' }} - {{ form.data.estimatedArrivalAt || '—' }}
@@ -759,7 +745,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 +784,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); }