Compare commits

..
Author SHA1 Message Date
ouyangxq 3dca7d9d60 fix(booking): sync cargo input accessibility with edit state 2026-10-22 14:55:24 +08:00
13 changed files with 85 additions and 367 deletions
+5 -11
View File
@@ -2,18 +2,12 @@
业务依据为用户提供的《订舱信息页面说明.docx》及页面截图;本轮确认:放舱后仅补实货量、客服手工录入、保留现有提单确认件,正式提单从外部附件接收。样式使用 `docs/Admin高密度界面规范.md` 的详情与基本信息、蓝白主题及既有公共密度。用户后续提供现页与产品稿对比,确认现页缺少层次、重点且间距过大;订舱页以该产品稿为局部视觉依据:紧凑页头、蓝色圆点连线流程、编号区块、细线小标题、浅底三方卡片及右下主打印按钮。可编辑输入、下拉和文本域使用白底、清晰边框与文字,悬停/聚焦使用蓝色反馈;灰底和禁止光标仅用于真实禁用状态,校验错误仍保留组件错误色。
流程条根据已保存订舱、当前未保存修改、放舱状态及实货量推进:当前步骤蓝底,已通过阶段勾选,待处理灰色。第一步的“已保存”不代替放舱完整性校验;打印节点仅在本次页面成功生成对应数据的 PDF 预览后显示“已预览”,失败或只点击按钮不产生预览事实,也不声称实际打印完成。预览标记随本次页面保留;刷新后按服务端保存/放舱/实值恢复进度,已放舱时托运单阶段保持通过,提示“可重新预览 / 打印”,避免前面灰色、后面完成的断开。放舱前托运单数据改变须重新预览;提单确认件始终要求已保存实值和对应 PDF 成功预览后才完成。
流程条根据已保存订舱、当前未保存修改、放舱状态及实货量推进:当前步骤蓝底,已完成节点勾选,待处理灰色。第一步的“已保存”不代替放舱完整性校验;打印节点仅在本次页面成功生成对应数据的 PDF 预览后显示“已预览”,失败或只点击按钮不推进,也不声称实际打印完成。预览标记随本次页面保留,刷新后按服务端保存/放舱/实值恢复进度,打印仍可重新预览;单据消费的数据改变后,该单据重新进入待预览。
| 页面/路由 | 操作 | 参考承载与布局 | 当前实现 | 页面核对 |
| 页面/路由 | 操作 | 参考承载与布局 | 当前实现 | 人工结论 |
|---|---|---|---|---|
| `/ocean-bookings/new`、`/ocean-bookings/:bookingId` | 草稿新增、维护 | 独立页面;左主资料、右三方并排,下方放舱资料 | 按用户红框移除客户选择、港名、预计到港、箱种、容量、限重、备注及包装单位选择;仓库和渠道保留在默认收起的“作业配置”,放舱缺配置时自动展开;委实成对输入,新增保存留在详情 | 前批已核对新增保存与控件状态;本次精简及折叠区待 TEST 页面核对,完整长字段展示待人工确认 |
| `/ocean-bookings/:bookingId` | 已放舱补实 | 客服补录实际货量 | 运输及预报只读,实值可按编辑权限补录,原因必填并版本审计 | 已核对放舱、原因校验、补实保存和历史;最终 TEST #698 回验实值保存后立即回显,无需刷新 |
| `/ocean-bookings/:bookingId` | 托运单、提单确认件 | 两个作业入口,A4单据预览 | 两张业务卡片底部右侧的蓝色主按钮,分别直达现有PDF预览;读已保存委/实数据,缺失显示“—” | 弹框开关/重开、PDF 生成与数据映射已核对;内嵌 PDF 空白,显示未通过 |
| `/ocean-bookings/new`、`/ocean-bookings/:bookingId` | 草稿新增、维护 | 独立页面;左主资料、右三方并排,下方放舱资料 | 独立页面;按产品稿重排视觉层级,保留仓库、渠道、预计到港、多柜型和附件;港代码与港名成组、委实成对输入,新增保存留在详情 | 待人工截图确认 |
| `/ocean-bookings/:bookingId` | 已放舱补实 | 客服补录实际货量 | 运输及预报只读,实值可按编辑权限补录,原因必填并版本审计 | 待人工截图确认 |
| `/ocean-bookings/:bookingId` | 托运单、提单确认件 | 两个作业入口,A4单据预览 | 两张业务卡片底部右侧的蓝色主按钮,分别直达现有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 结构同步与页面验收”为准。
@@ -1,7 +1,8 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import ElementPlus from 'element-plus'
import ElementPlus, { ElSelect } 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 {
@@ -27,6 +28,8 @@ 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', () => ({
@@ -72,6 +75,7 @@ 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
@@ -101,6 +105,7 @@ 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: '美森正班海卡' }])
@@ -124,21 +129,25 @@ describe('OceanBookingDetailView one-click release', () => {
wrapper.unmount()
})
it('starts new bookings with the agreed declared defaults and no removed inputs', async () => {
it('finds a customer outside the initial page and saves its actual ID and name in a new booking', async () => {
routeState.name = 'ocean-booking-create'; routeState.params.bookingId = 'new'
vi.mocked(createOceanBooking).mockResolvedValue(booking())
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 })
const { wrapper, vm } = await mountView()
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(['操作', '柜型', '数量'])
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('柏悦物流')
await vm.save()
expect(createOceanBooking).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ declaredPackageCount: 500, declaredGrossWeightKg: 15000, declaredVolumeCbm: 68, actualPackageCount: null }) }))
expect(createOceanBooking).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ customerId: customer.id, customerName: '柏悦物流' }) }))
wrapper.unmount()
})
@@ -161,13 +170,8 @@ describe('OceanBookingDetailView one-click release', () => {
wrapper.unmount()
})
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' })
it('shows the matching fixed logistics group instead of the underlying price-book channel', async () => {
const { wrapper } = await mountView()
expect(wrapper.text()).toContain('美森正班')
expect(wrapper.text()).not.toContain('美森正班海卡')
wrapper.unmount()
@@ -228,28 +232,16 @@ describe('OceanBookingDetailView one-click release', () => {
wrapper.unmount()
})
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 })
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 })
const { vm, wrapper } = await mountView()
vm.selectCustomer('')
await vm.submitRelease()
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()
expect(updateOceanBookingDraft).toHaveBeenCalledWith('123', expect.objectContaining({ data: expect.objectContaining({ customerId: null, customerName: null }) }))
expect(releaseOceanBooking).toHaveBeenCalledWith('123', { expectedVersion: 4 })
wrapper.unmount()
})
@@ -349,9 +341,9 @@ describe('OceanBookingDetailView one-click release', () => {
expect(declaredInput.attributes('disabled')).toBeDefined()
expect(declaredInput.attributes('aria-disabled')).toBe('true')
}
await wrapper.get('input[aria-label="实际件数"]').setValue('480')
await wrapper.get('input[aria-label="实际毛重"]').setValue('14320.125')
await wrapper.get('input[aria-label="实际体积"]').setValue('65.321')
vm.form.data.actualPackageCount = 480
vm.form.data.actualGrossWeightKg = 14320.125
vm.form.data.actualVolumeCbm = 65.321
vm.actualReason = ' 实装后补录 '
await flushPromises()
expect(vm.printDisabledReason).toContain('请先保存')
@@ -362,29 +354,6 @@ describe('OceanBookingDetailView one-click release', () => {
expect(vm.actualReason).toBe('')
expect(vm.hasUnsavedActualChanges).toBe(false)
expect(vm.form.data.declaredPackageCount).toBe(500)
expect((wrapper.get('input[aria-label="实际件数"]').element as HTMLInputElement).value).toBe('480')
expect((wrapper.get('input[aria-label="实际毛重"]').element as HTMLInputElement).value).toBe('14320.125')
expect((wrapper.get('input[aria-label="实际体积"]').element as HTMLInputElement).value).toBe('65.321')
wrapper.unmount()
})
it('shows the saved server values after input and blur without a change event', async () => {
vi.mocked(getOceanBooking).mockResolvedValue(booking('RELEASED'))
vi.mocked(updateOceanBookingActualCargo).mockResolvedValue({ ...booking('RELEASED', 4), actualPackageCount: 480, actualGrossWeightKg: 14320.125, actualVolumeCbm: 65.321 })
const { vm, wrapper } = await mountView()
for (const [label, value] of [['实际件数', '480'], ['实际毛重', '14320.125'], ['实际体积', '65.321']] as const) {
const input = wrapper.get(`input[aria-label="${label}"]`)
const element = input.element as HTMLInputElement
element.value = value
await input.trigger('input')
await input.trigger('blur')
}
vm.actualReason = '补录'
await vm.saveActualCargo()
await flushPromises()
for (const [label, value] of [['实际件数', '480'], ['实际毛重', '14320.125'], ['实际体积', '65.321']] as const) {
expect((wrapper.get(`input[aria-label="${label}"]`).element as HTMLInputElement).value).toBe(value)
}
wrapper.unmount()
})
@@ -17,6 +17,7 @@ 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'
@@ -56,6 +57,10 @@ 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()
@@ -82,6 +87,9 @@ 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))
@@ -95,7 +103,7 @@ function blankParties(): OceanBookingParty[] {
function blankData(): OceanBookingDraftData {
return {
freightTerm: 'FREIGHT PREPAID', serviceTerm: 'CY-CY', packageUnit: 'CARTONS',
cargoDescription: '', marksAndNos: '', declaredPackageCount: 500, declaredGrossWeightKg: 15000, declaredVolumeCbm: 68,
cargoDescription: '', marksAndNos: '', declaredPackageCount: null, declaredGrossWeightKg: null, declaredVolumeCbm: null,
actualPackageCount: null, actualGrossWeightKg: null, actualVolumeCbm: null,
customerId: null, customerName: null, channelCode: '', warehouseId: '', parties: blankParties(),
billOfLadingNo: '', vesselName: '', voyageNo: '', estimatedSailingAt: null, estimatedArrivalAt: null,
@@ -104,6 +112,7 @@ 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')
@@ -119,7 +128,6 @@ 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>({})
@@ -135,7 +143,8 @@ async function loadOptions() {
optionsLoading.value = true
optionsError.value = ''
try {
const [partnerResult, warehouseResult] = await Promise.all([
const [, partnerResult, warehouseResult] = await Promise.all([
loadCustomers(),
listPlatformPartners({ page: 1, size: 200, status: 'ACTIVE' }),
listPlatformWarehouses({ page: 1, size: 200, warehouseType: 'DOMESTIC', status: 'ACTIVE' }),
])
@@ -152,6 +161,12 @@ 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 {
@@ -373,16 +388,13 @@ 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.estimatedSailingAt, '预计开船时间'], [form.data.estimatedArrivalAt, '预计到港时间'],
[form.data.placeOfDelivery, '交货地点'],
[form.data.shippingCompanyId, '船公司'], [form.data.bookingAgentId, '订舱代理'],
]
const missing = requiredFields.find(([value]) => !String(value || '').trim())
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 (missing) return `放舱前请填写${missing[1]}`
if (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)
@@ -480,14 +492,18 @@ 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="起运港"><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="起运港"><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="预计开船" 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>
<el-form-item label="运费条款"><el-input v-model="form.data.freightTerm" maxlength="64" /></el-form-item>
<el-form-item label="运输条款"><el-input v-model="form.data.serviceTerm" maxlength="64" /></el-form-item>
@@ -498,13 +514,13 @@ onMounted(() => { void load(); void loadOptions() })
<h3 class="booking-group__title"><span>货物信息</span></h3>
<el-form label-position="top" class="density-form booking-group__body">
<div class="cargo-metrics" role="group" aria-label="货物预报与实际数量">
<!-- 数字组件仅挂载时写入禁用标记,且可能保留输入缓存;资格切换及实值保存成功后按权威版本重建。 -->
<!-- 数字组件只在挂载时写入 aria-disabled;可编辑资格切换时重建,保持辅助标记与原生禁用状态一致。 -->
<div class="cargo-metric">
<span class="cargo-metric__label">件数(委 / 实)</span>
<div class="cargo-metric__inputs">
<el-input-number :key="'declared-count-' + formWritable" v-model="form.data.declaredPackageCount" :disabled="!formWritable" :min="1" :max="999999999" :precision="0" :controls="false" placeholder="预报量" aria-label="预报件数" />
<span class="cargo-metric__separator" aria-hidden="true">/</span>
<el-input-number :key="'actual-count-' + cargoWritable + '-' + (detail?.version ?? 'new')" v-model="form.data.actualPackageCount" :disabled="!cargoWritable" :min="1" :max="999999999" :precision="0" :controls="false" placeholder="待录实值" aria-label="实际件数" />
<el-input-number :key="'actual-count-' + cargoWritable" v-model="form.data.actualPackageCount" :disabled="!cargoWritable" :min="1" :max="999999999" :precision="0" :controls="false" placeholder="待录实值" aria-label="实际件数" />
<span class="cargo-metric__unit">{{ form.data.packageUnit || '—' }}</span>
</div>
</div>
@@ -513,7 +529,7 @@ onMounted(() => { void load(); void loadOptions() })
<div class="cargo-metric__inputs">
<el-input-number :key="'declared-weight-' + formWritable" v-model="form.data.declaredGrossWeightKg" :disabled="!formWritable" :min="0.001" :max="999999999999.999" :precision="3" :controls="false" placeholder="预报量" aria-label="预报毛重" />
<span class="cargo-metric__separator" aria-hidden="true">/</span>
<el-input-number :key="'actual-weight-' + cargoWritable + '-' + (detail?.version ?? 'new')" v-model="form.data.actualGrossWeightKg" :disabled="!cargoWritable" :min="0.001" :max="999999999999.999" :precision="3" :controls="false" placeholder="待录实值" aria-label="实际毛重" />
<el-input-number :key="'actual-weight-' + cargoWritable" v-model="form.data.actualGrossWeightKg" :disabled="!cargoWritable" :min="0.001" :max="999999999999.999" :precision="3" :controls="false" placeholder="待录实值" aria-label="实际毛重" />
<span class="cargo-metric__unit">KGS</span>
</div>
</div>
@@ -522,12 +538,13 @@ onMounted(() => { void load(); void loadOptions() })
<div class="cargo-metric__inputs">
<el-input-number :key="'declared-volume-' + formWritable" v-model="form.data.declaredVolumeCbm" :disabled="!formWritable" :min="0.001" :max="999999999999.999" :precision="3" :controls="false" placeholder="预报量" aria-label="预报体积" />
<span class="cargo-metric__separator" aria-hidden="true">/</span>
<el-input-number :key="'actual-volume-' + cargoWritable + '-' + (detail?.version ?? 'new')" v-model="form.data.actualVolumeCbm" :disabled="!cargoWritable" :min="0.001" :max="999999999999.999" :precision="3" :controls="false" placeholder="待录实值" aria-label="实际体积" />
<el-input-number :key="'actual-volume-' + cargoWritable" v-model="form.data.actualVolumeCbm" :disabled="!cargoWritable" :min="0.001" :max="999999999999.999" :precision="3" :controls="false" placeholder="待录实值" aria-label="实际体积" />
<span class="cargo-metric__unit">CBM</span>
</div>
</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>
@@ -555,10 +572,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 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 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">
@@ -568,24 +585,20 @@ 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>
@@ -622,6 +635,7 @@ onMounted(() => { void load(); void loadOptions() })
<el-descriptions-item label="船名">{{ form.data.vesselName || '—' }}</el-descriptions-item>
<el-descriptions-item label="航次">{{ form.data.voyageNo || '—' }}</el-descriptions-item>
<el-descriptions-item label="预计开船">{{ form.data.estimatedSailingAt || '—' }}</el-descriptions-item>
<el-descriptions-item label="预计到港">{{ form.data.estimatedArrivalAt || '—' }}</el-descriptions-item>
</el-descriptions></section>
</div>
</AppDialog>
@@ -745,6 +759,7 @@ 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; }
@@ -784,16 +799,17 @@ 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: 420px; table-layout: fixed; }
.container-table { width: 100%; min-width: 780px; 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); }
+1 -11
View File
@@ -45,8 +45,6 @@ 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
@@ -74,14 +72,6 @@ describe('ocean booking workflow milestones', () => {
expect(steps[3]).toMatchObject({ state: 'current', status: '可预览 / 打印' })
saved.declaredPackageCount = 510
steps = oceanBookingWorkflow(saved, false, false, previews)
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'])
expect(steps[1]?.state).toBe('pending')
})
})
+1 -4
View File
@@ -36,9 +36,6 @@ 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 => ({
@@ -46,7 +43,7 @@ export function oceanBookingWorkflow(
})
return [
step(0, '填写并保存订舱信息', saved, saved ? '已保存' : detail ? '修改待保存' : '待填写并保存'),
step(1, '托运单预览 / 打印', instructionPassed, instructionPreviewed ? '已预览' : released ? '可重新预览 / 打印' : saved ? '可预览 / 打印' : '待保存资料'),
step(1, '托运单预览 / 打印', instructionPreviewed, instructionPreviewed ? '已预览' : saved ? '可预览 / 打印' : '待保存资料'),
step(2, '补齐资料并确认放舱', released, released ? '已放舱' : saved ? '待确认放舱' : '待补齐资料'),
step(3, '补实并预览提单确认件', confirmationPreviewed, !released ? '待放舱' : actualDirty ? '实值待保存' : !actualComplete ? '待补齐实值' : confirmationPreviewed ? '已预览' : '可预览 / 打印'),
]
@@ -15,15 +15,6 @@ beforeEach(() => {
vi.mocked(http.put).mockResolvedValue({ data: command('EDIT') })
vi.mocked(http.delete).mockResolvedValue({ data: draft() })
})
it('reads and writes original dispatch appointment allocations with lossless versions', async () => {
const value = { dispatchNo: 'PC/1', recordVersion: '9007199254740993', items: [], edit: { allowed: true, blockCode: null, blockReason: null } }
vi.mocked(http.get).mockResolvedValueOnce({ data: value })
expect(await api.getDispatchAppointmentAllocations('PC/1')).toEqual(value)
expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1/appointment-allocations')
const body = { recordVersion: 0, idempotencyKey: 'allocation-key', dispatchItemIds: ['11'], batchAllocations: [{ planPalletId: '21', batchKey: 'FBA_APPOINTMENT:9', customerId: '101', sourceKey: 'ORDER:1', destinationKey: 'FBA:ACY2', packageCount: 10, recordVersion: 0 }] }
await api.saveDispatchAppointmentAllocations('PC/1', body)
expect(http.put).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1/appointment-allocations', body)
})
describe('native whole-vehicle dispatch API', () => {
it('round-trips this trip maintenance data and its vehicle photo references without altering actual departure', async () => {
@@ -1,5 +1,4 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type { AppointmentAllocations, AppointmentAllocationCommand } from '../dispatchContracts'
import type { MarkDispatchException, DispatchEvidenceKind, DispatchRoute, DispatchRouteCommand } from '../dispatchContracts'
import { requireVersion, requireBatchVersions, type AddCargo, type CandidateQuery, type ConvertDraft, type CreateDispatch, type DispatchQuery, type DraftContainer, type DraftSelection, type LoadingComplete, type ReasonCommand, type ReviewCommand, type UpdateDispatch, type VersionCommand, type DispatchBatchAllocation } from '../dispatchContracts'
import { normalizeCandidateList, normalizeConvertedDraft, normalizeDispatchCommand, normalizeDispatchDetail, normalizeDispatchDraft, normalizeDispatchItems, normalizeDispatchPage, normalizeEvidenceFile, normalizeLoadingPreview, type CandidateListWire, type CommandResultWire, type ConvertedDraftWire, type DispatchDetailWire, type DispatchDraftWire, type DispatchEvidenceFileWire, type DispatchItemsWire, type DispatchPageWire, type LoadingPreviewWire } from '../dispatchWire'
@@ -18,19 +17,6 @@ export async function listDispatches(query: DispatchQuery) {
}
export const getDispatch = (dispatchNo: string) => get<DispatchDetailWire>(path(dispatchNo)).then(normalizeDispatchDetail)
export const getDispatchItems = (no: string) => get<DispatchItemsWire>(`${path(no)}/items`).then(normalizeDispatchItems)
export async function getDispatchAppointmentAllocations(no: string) {
const value = await get<AppointmentAllocations>(`${path(no)}/appointment-allocations`)
requireVersion(value.recordVersion)
value.items.forEach(item => { requireBatchVersions(item.allocations); item.sources.forEach(source => source.options.forEach(option => requireVersion(option.recordVersion))) })
return value
}
export const saveDispatchAppointmentAllocations = (no: string, body: AppointmentAllocationCommand) => {
versioned(body); requireBatchVersions(body.batchAllocations)
return postAppointmentAllocations(no, body)
}
async function postAppointmentAllocations(no: string, body: AppointmentAllocationCommand) {
return normalizeDispatchCommand(unwrapApiResponse(await http.put<ApiEnvelope<CommandResultWire>, ApiEnvelope<CommandResultWire>>(`${path(no)}/appointment-allocations`, body)))
}
export const getDispatchRoute = (no: string) => get<DispatchRoute>(`${path(no)}/route`)
export async function reorderDispatchRoute(no: string, body: DispatchRouteCommand) { return normalizeDispatchCommand(unwrapApiResponse(await http.put<ApiEnvelope<CommandResultWire>, ApiEnvelope<CommandResultWire>>(`${path(no)}/route`, versioned(body)))) }
const evidencePath = (no: string, kind: DispatchEvidenceKind, id?: string) => `${path(no)}/evidence/${kind}${id ? `/${encodeURIComponent(id)}` : ''}`
@@ -1,86 +0,0 @@
import ElementPlus, { ElInputNumber } from 'element-plus'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, expect, it, vi } from 'vitest'
import AppDialog from '@/shared/components/AppDialog.vue'
import DispatchAppointmentAllocationsDialog from './DispatchAppointmentAllocationsDialog.vue'
import * as api from '../api/dispatches'
import type { AppointmentAllocations } from '../dispatchContracts'
import { command } from '../dispatchTestFixtures'
const access = vi.hoisted(() => ({ can: true }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => access.can }) }))
vi.mock('../api/dispatches', () => ({ getDispatchAppointmentAllocations: vi.fn(), saveDispatchAppointmentAllocations: vi.fn() }))
function facts(): AppointmentAllocations {
return { dispatchNo: 'PC/1', recordVersion: '0', edit: { allowed: true, blockCode: null, blockReason: null }, items: ['11', '12'].map((id, index) => ({
dispatchItemId: id, planPalletId: `${index + 21}`, customerId: '101', customerName: '客户甲', palletNo: `PALLET-${index + 1}`, allocations: [],
sources: [{ sourceKey: 'ORDER:1', orderNo: 'WC-1', inboundCode: 'IN-1', destinationKey: 'FBA:ACY2', packageCount: 10, blockReasons: [],
options: [{ batchKey: 'FBA_APPOINTMENT:9', batchNo: 'YY-9', recordVersion: '0', availablePackages: 20, confirmationNo: 'CONF-9', appointmentAt: '2026-10-26T10:00:00', appointmentEndAt: null, timezone: 'America/New_York' }] }],
})) }
}
async function open() {
const wrapper = mount(DispatchAppointmentAllocationsDialog, { props: { modelValue: true, dispatchNo: 'PC/1' }, global: { plugins: [ElementPlus] } })
await flushPromises(); return wrapper
}
beforeEach(() => {
vi.clearAllMocks(); access.can = true
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
vi.mocked(api.getDispatchAppointmentAllocations).mockResolvedValue(facts())
vi.mocked(api.saveDispatchAppointmentAllocations).mockResolvedValue(command('EDIT'))
})
it('links both pallets to the confirmed appointment on the original dispatch with explicit version zero', async () => {
const wrapper = await open()
expect(wrapper.text()).toContain('WC-1'); expect(wrapper.text()).toContain('IN-1'); expect(wrapper.text()).toContain('America/New_York')
expect(wrapper.text()).not.toContain('ORDER:1')
const dialog = wrapper.findComponent(AppDialog)
expect(dialog.props('confirmDisabled')).toBe(false)
dialog.vm.$emit('confirm'); dialog.vm.$emit('confirm'); await flushPromises()
expect(api.saveDispatchAppointmentAllocations).toHaveBeenCalledTimes(1)
expect(api.saveDispatchAppointmentAllocations).toHaveBeenCalledWith('PC/1', expect.objectContaining({ recordVersion: '0', dispatchItemIds: ['11', '12'],
batchAllocations: [expect.objectContaining({ planPalletId: '21', packageCount: 10, recordVersion: '0' }), expect.objectContaining({ planPalletId: '22', packageCount: 10, recordVersion: '0' })] }))
expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
})
it('counts a shared source quota once across pallets and rejects incomplete allocation', async () => {
const value = facts(); value.items.forEach(item => { item.sources[0]!.options[0]!.availablePackages = 15 })
vi.mocked(api.getDispatchAppointmentAllocations).mockResolvedValue(value)
const wrapper = await open(); const dialog = wrapper.findComponent(AppDialog)
expect(dialog.props('confirmDisabled')).toBe(true); expect(wrapper.text()).toContain('本票剩余可排量不足')
wrapper.findAllComponents(ElInputNumber)[0]!.vm.$emit('update:modelValue', 5); await flushPromises()
expect(dialog.props('confirmDisabled')).toBe(true); expect(wrapper.text()).toContain('须完整分配 10 箱')
dialog.vm.$emit('confirm'); await flushPromises(); expect(api.saveDispatchAppointmentAllocations).not.toHaveBeenCalled(); wrapper.unmount()
})
it('preserves edited quantities after rejection and retries with current versions and a new command key', async () => {
const value = facts(); value.items = value.items.slice(0, 1)
value.items[0]!.sources[0]!.options.push({ ...value.items[0]!.sources[0]!.options[0]!, batchKey: 'FBA_APPOINTMENT:10', batchNo: 'YY-10' })
vi.mocked(api.getDispatchAppointmentAllocations).mockResolvedValue(value)
const wrapper = await open(); const inputs = wrapper.findAllComponents(ElInputNumber)
inputs[0]!.vm.$emit('update:modelValue', 6); inputs[1]!.vm.$emit('update:modelValue', 4); await flushPromises()
const latest = structuredClone(value); latest.recordVersion = '2'; latest.items[0]!.sources[0]!.options.forEach(option => { option.recordVersion = '3' })
vi.mocked(api.saveDispatchAppointmentAllocations).mockRejectedValueOnce(new Error('版本已变化'))
vi.mocked(api.getDispatchAppointmentAllocations).mockResolvedValue(latest)
wrapper.findComponent(AppDialog).vm.$emit('confirm'); await flushPromises()
expect(wrapper.findAllComponents(ElInputNumber).map(input => input.props('modelValue'))).toEqual([6, 4])
expect(wrapper.emitted('saved')).toBeUndefined()
wrapper.findComponent(AppDialog).vm.$emit('confirm'); await flushPromises()
const calls = vi.mocked(api.saveDispatchAppointmentAllocations).mock.calls
expect(calls).toHaveLength(2); expect(calls[1]![1].idempotencyKey).not.toBe(calls[0]![1].idempotencyKey)
expect(calls[1]![1]).toMatchObject({ recordVersion: '2', batchAllocations: [{ recordVersion: '3', packageCount: 6 }, { recordVersion: '3', packageCount: 4 }] })
wrapper.unmount()
})
it('retains an unavailable former choice after a failed save until the user clears it', async () => {
const wrapper = await open()
vi.mocked(api.saveDispatchAppointmentAllocations).mockRejectedValueOnce(new Error('预约已变化'))
const latest = facts(); latest.items[0]!.sources[0]!.options = []
vi.mocked(api.getDispatchAppointmentAllocations).mockResolvedValue(latest)
wrapper.findComponent(AppDialog).vm.$emit('confirm'); await flushPromises()
expect(wrapper.text()).toContain('已失效'); expect(wrapper.findAllComponents(ElInputNumber)[0]!.props('modelValue')).toBe(10)
expect(wrapper.findComponent(AppDialog).props('confirmDisabled')).toBe(true); wrapper.unmount()
})
it.each(['permission', 'gate', 'unconfirmed'] as const)('does not save when blocked by %s', async mode => {
const value = facts()
if (mode === 'permission') access.can = false
if (mode === 'gate') value.edit = { allowed: false, blockCode: 'NOT_ELIGIBLE', blockReason: '本车已有真实扫码' }
if (mode === 'unconfirmed') value.items[0]!.sources[0] = { ...value.items[0]!.sources[0]!, options: [], blockReasons: ['请先确认送仓时间'] }
vi.mocked(api.getDispatchAppointmentAllocations).mockResolvedValue(value)
const wrapper = await open(); expect(wrapper.findComponent(AppDialog).props('confirmDisabled')).toBe(true)
wrapper.findComponent(AppDialog).vm.$emit('confirm'); await flushPromises(); expect(api.saveDispatchAppointmentAllocations).not.toHaveBeenCalled(); wrapper.unmount()
})
@@ -1,112 +0,0 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getDispatchAppointmentAllocations, saveDispatchAppointmentAllocations } from '../api/dispatches'
import { DispatchInputError, requireVersion, type AppointmentAllocations, type AppointmentAllocationItem, type AppointmentAllocationSource, type AppointmentAllocationOption, type DispatchBatchAllocation } from '../dispatchContracts'
const open = defineModel<boolean>({ default: false })
const props = defineProps<{ dispatchNo: string }>()
const emit = defineEmits<{ saved: [] }>()
const access = useAccessPreviewStore()
interface Choice extends AppointmentAllocationOption { packages: number; unavailable: boolean }
interface SourceRow { key: string; item: AppointmentAllocationItem; source: AppointmentAllocationSource; choices: Choice[] }
const context = ref<AppointmentAllocations | null>(null), rows = ref<SourceRow[]>([])
const loading = ref(false), saving = ref(false), factsReady = ref(false), error = ref<ApiErrorDetails | null>(null)
let generation = 0
const permitted = computed(() => access.can('platform:overseas-warehouse:dispatch:edit') && context.value?.edit.allowed === true)
const allocationKey = (item: AppointmentAllocationItem, source: AppointmentAllocationSource, batch: string) => JSON.stringify([item.dispatchItemId, source.sourceKey, source.destinationKey, batch])
const quotaKey = (row: SourceRow, batch: string) => JSON.stringify([batch, row.item.customerId, row.source.sourceKey, row.source.destinationKey])
const validation = computed(() => {
const used = new Map<string, number>()
for (const row of rows.value) {
if (row.source.blockReasons.length) return row.source.blockReasons[0]!
if (!Number.isSafeInteger(row.source.packageCount) || (row.source.packageCount ?? 0) <= 0) return '本票箱数尚未核实,请刷新本车货物'
let total = 0
for (const choice of row.choices) {
if (!Number.isSafeInteger(choice.packages) || choice.packages < 0) return '分配箱数须为非负整数'
if (!choice.packages) continue
if (choice.unavailable) return '原选择的预约已失效,请将对应箱数清零后重新选择'
total += choice.packages
const key = quotaKey(row, choice.batchKey), count = (used.get(key) || 0) + choice.packages
if (count > choice.availablePackages) return `预约 ${choice.batchNo} 的本票剩余可排量不足,请调整分配箱数`
used.set(key, count)
}
if (total !== row.source.packageCount) return `托盘 ${row.item.palletNo} 的本票货物须完整分配 ${row.source.packageCount} 箱`
}
return rows.value.length ? '' : '本车暂无可关联正式预约的FBA货物'
})
const canSave = computed(() => factsReady.value && permitted.value && !loading.value && !saving.value && !validation.value)
async function load(preserve = false) {
const current = ++generation, no = props.dispatchNo
const previous = new Map(rows.value.flatMap(row => row.choices.map(choice => [allocationKey(row.item, row.source, choice.batchKey), choice] as const)))
loading.value = true; factsReady.value = false
if (!preserve) { error.value = null; context.value = null; rows.value = [] }
try {
const value = await getDispatchAppointmentAllocations(no)
if (current !== generation) return
if (value.dispatchNo !== no) throw new DispatchInputError('预约分配与当前排车单不一致,请重新打开')
requireVersion(value.recordVersion)
const next: SourceRow[] = []
for (const item of value.items) for (const source of item.sources.filter(source => source.destinationKey.startsWith('FBA:'))) {
const key = JSON.stringify([item.dispatchItemId, source.sourceKey, source.destinationKey])
const choices: Choice[] = source.options.map(option => {
const retained = item.allocations.find(a => a.batchKey === option.batchKey && a.sourceKey === source.sourceKey && a.destinationKey === source.destinationKey)
const old = preserve ? previous.get(allocationKey(item, source, option.batchKey)) : undefined
return { ...option, packages: old?.packages ?? retained?.packageCount ?? (!preserve && !item.allocations.length && source.options.length === 1 ? source.packageCount || 0 : 0), unavailable: false }
})
if (preserve) for (const oldRow of rows.value.filter(row => row.key === key)) for (const old of oldRow.choices)
if (old.packages > 0 && !choices.some(choice => choice.batchKey === old.batchKey)) choices.push({ ...old, unavailable: true })
next.push({ key, item, source, choices })
}
context.value = value; rows.value = next; factsReady.value = true
} catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '本车正式预约读取失败,请刷新重试') }
finally { if (current === generation) loading.value = false }
}
async function save() {
if (!canSave.value || !context.value) return
const current = generation, no = props.dispatchNo
saving.value = true; error.value = null
try {
const batchAllocations: DispatchBatchAllocation[] = rows.value.flatMap(row => row.choices.filter(choice => choice.packages > 0).map(choice => ({
planPalletId: row.item.planPalletId, customerId: row.item.customerId, sourceKey: row.source.sourceKey, destinationKey: row.source.destinationKey,
batchKey: choice.batchKey, recordVersion: requireVersion(choice.recordVersion), packageCount: choice.packages,
})))
await saveDispatchAppointmentAllocations(no, { recordVersion: requireVersion(context.value.recordVersion), idempotencyKey: crypto.randomUUID(),
dispatchItemIds: [...new Set(rows.value.map(row => row.item.dispatchItemId))], batchAllocations })
if (current !== generation) return
ElMessage.success('正式预约已关联,请核对运输时间后重新提交审核')
emit('saved'); open.value = false
} catch (cause) {
if (current === generation) { error.value = getApiErrorDetails(cause, '正式预约关联未完成'); await load(true) }
} finally { saving.value = false }
}
watch([open, () => props.dispatchNo], ([value]) => { if (value) void load(); else { generation++; factsReady.value = false } }, { immediate: true })
onBeforeUnmount(() => { generation++ })
</script>
<template>
<AppDialog v-model="open" class="admin-density-dialog admin-density" :title="`关联正式预约 · ${dispatchNo}`" width="1100px" fullscreenable
confirm-text="保存关联" :loading="loading" :confirm-loading="saving" :confirm-disabled="!canSave" :close-on-press-escape="!saving" @confirm="save">
<LoadErrorState v-if="error" title="正式预约关联未完成" :message="error.message" :loading="loading || saving" retry-text="刷新当前预约" @retry="load(true)"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<el-alert v-if="context && !permitted" :title="context.edit.blockReason || '当前无关联预约资格'" type="warning" :closable="false" show-icon />
<p class="density-dialog-note">按本车每托、每票货物核对已确认的正式预约及箱数。保存后保留原排车,重新提交两级审核。</p>
<div class="appointment-table"><table class="density-table density-table--editable"><thead><tr><th>客户/托盘</th><th>订单号/入仓码</th><th>目的仓</th><th>本票箱数</th><th>正式预约/当地送达时间</th><th>本票可排箱数</th><th>分配箱数</th></tr></thead><tbody>
<template v-for="row in rows" :key="row.key">
<tr v-for="choice in row.choices" :key="choice.batchKey"><td>{{ row.item.customerName || '—' }}<br>{{ row.item.palletNo }}</td><td>{{ row.source.orderNo || '—' }}<br>{{ row.source.inboundCode || '—' }}</td><td>{{ row.source.destinationKey.slice(4) }}</td><td>{{ row.source.packageCount ?? '待核实' }}</td><td>{{ choice.batchNo || '—' }}<span v-if="choice.unavailable">(已失效)</span><br>{{ choice.confirmationNo || '—' }}<br>{{ choice.appointmentAt?.replace('T', ' ') }}<template v-if="choice.appointmentEndAt"> ~ {{ choice.appointmentEndAt.replace('T', ' ') }}</template><br>{{ choice.timezone }}</td><td>{{ choice.availablePackages }}<span v-if="!choice.unavailable">(同票共用)</span></td><td><el-input-number v-model="choice.packages" :min="0" :precision="0" :controls="false" :disabled="loading || saving || !permitted" :aria-label="`${row.item.palletNo} ${choice.batchNo} 分配箱数`" /></td></tr>
<tr v-if="!row.choices.length"><td>{{ row.item.customerName || '—' }}<br>{{ row.item.palletNo }}</td><td>{{ row.source.orderNo || '—' }}<br>{{ row.source.inboundCode || '—' }}</td><td>{{ row.source.destinationKey.slice(4) }}</td><td>{{ row.source.packageCount ?? '待核实' }}</td><td colspan="3">{{ row.source.blockReasons.join(';') || '暂无已确认的正式预约,请先在预约列表确认送仓时间' }}</td></tr>
</template><tr v-if="!loading && !rows.length"><td colspan="7">本车暂无可关联正式预约的FBA货物</td></tr>
</tbody></table></div>
<el-alert v-if="factsReady && permitted && validation" :title="validation" type="warning" :closable="false" show-icon />
</AppDialog>
</template>
<style scoped>
.appointment-table { overflow-x: auto; }
.appointment-table table { min-width: 960px; }
.appointment-table :deep(.el-input-number) { width: 112px; }
</style>
@@ -52,7 +52,6 @@ const vehicleTypes = ['厢式货车', '平板车', '集装箱卡车']
</div>
</el-tab-pane>
<el-tab-pane :label="`关联订单 (${detail.orderDetails.length})`" name="orders">
<slot name="appointments" />
<div class="maintenance-orders"><table class="density-table"><thead><tr><th>客户</th><th>订单号</th><th>入仓码</th><th>重量(kg)</th><th>托数</th><th>目的FBA仓</th><th>体积(m³)</th><th>集装箱号</th></tr></thead><tbody><tr v-for="(order, index) in detail.orderDetails" :key="`${order.sourceKey}-${order.orderNo}-${index}`"><td>{{ order.customerName || '—' }}</td><td>{{ order.orderNo || '—' }}</td><td>{{ order.inboundCode || '—' }}</td><td>{{ order.grossWeightKg ?? '—' }}</td><td>{{ order.palletCount ?? '—' }}</td><td>{{ order.destinationFbaWarehouse || '—' }}</td><td>{{ order.volumeCbm ?? '—' }}</td><td>{{ order.containerNo || '—' }}</td></tr><tr v-if="!detail.orderDetails.length"><td colspan="8">暂无关联订单</td></tr></tbody></table></div>
</el-tab-pane>
</el-tabs>
@@ -11,7 +11,6 @@ const identity = vi.hoisted(() => ({ id: '' }))
vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
vi.mock('./DispatchResourceFields.vue', () => ({ default: { template: '<div />' } }))
vi.mock('./DispatchAppointmentAllocationsDialog.vue', () => ({ default: { name: 'DispatchAppointmentAllocationsDialog', props: ['modelValue', 'dispatchNo'], emits: ['saved', 'update:modelValue'], template: '<div />' } }))
vi.mock('../api/dispatches', () => ({ markDispatchException: vi.fn(), getDispatch: vi.fn(), getDispatchItems: vi.fn(), getDispatchRoute: vi.fn(), reorderDispatchRoute: vi.fn(), removeDispatchItem: vi.fn(), getDispatchLoading: vi.fn(), getDispatchCommand: vi.fn(), listDispatches: vi.fn(), listDispatchCandidates: vi.fn(), completeDispatchLoading: vi.fn(), reviewDispatch: vi.fn(), reviewDispatchLoading: vi.fn(), returnDispatchToReview: vi.fn(), cancelDispatch: vi.fn(), updateDispatch: vi.fn(), addDispatchCargo: vi.fn(), submitDispatchReview: vi.fn() }))
interface State { exception: { exceptionType: string; returnDirection: string; description: string; imageFileIds: string[] }; submit(submitReviewAfterSave?: boolean): Promise<void>; retryError(): Promise<void>; measurements: Array<{ weightKg: number | null; volumeCbm: number | null; deliveryIndex: number | null; palletSpaces: number | null }>; form: { reason: string; decision: string; remark: string; publishLooseboardDemand: boolean }; detail: DispatchDetail }
async function open(action: DispatchAction, allowed = true) {
@@ -20,18 +19,6 @@ async function open(action: DispatchAction, allowed = true) {
return { wrapper, state: wrapper.vm as unknown as State }
}
beforeEach(() => { vi.clearAllMocks(); sessionStorage.clear(); identity.id = crypto.randomUUID(); vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); vi.mocked(api.getDispatchLoading).mockResolvedValue(loadingPreview()) })
it('opens appointment linking from vehicle maintenance and refreshes the original version without losing unsaved inputs', async () => {
const { wrapper, state } = await open('EDIT')
state.form.remark = '尚未保存的车辆备注'
const button = wrapper.findAll('button').find(button => button.text() === '关联正式预约')
expect(button).toBeDefined(); await button!.trigger('click'); await flushPromises()
const child = wrapper.findComponent({ name: 'DispatchAppointmentAllocationsDialog' })
expect(child.props('dispatchNo')).toBe('PC/1')
vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 2 }))
child.vm.$emit('saved'); await flushPromises()
expect(state.detail.recordVersion).toBe(2); expect(state.form.remark).toBe('尚未保存的车辆备注')
expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
})
it('confirms scanned cargo without requesting measurements and preserves native version zero', async () => {
const value = loadingPreview(); value.pallets[0]!.weightKg = 100; value.pallets[0]!.volumeCbm = 2
vi.mocked(api.getDispatchLoading).mockResolvedValue(value)
@@ -17,7 +17,6 @@ import { dispatchExceptionLabels, dispatchReturnDirectionLabels, canDispatchActi
import { isDefiniteDispatchRejection, useDispatchCommandRecovery, type PendingDispatchCommand } from '../dispatchCommandRecovery'
import DispatchVehicleMaintenanceForm from './DispatchVehicleMaintenanceForm.vue'
import DispatchVehicleMaintenanceFees from './DispatchVehicleMaintenanceFees.vue'
import DispatchAppointmentAllocationsDialog from './DispatchAppointmentAllocationsDialog.vue'
import { blankVehicleMaintenance, vehicleMaintenancePayload } from '../dispatchVehicleMaintenance'
import DispatchEvidenceFiles from './DispatchEvidenceFiles.vue'
@@ -33,9 +32,6 @@ const currentOperation = computed(() => pending.value ? pending.value.operation
const route = ref<DispatchRoute | null>(null), stops = ref<DispatchRouteStop[]>([])
const detail = ref<DispatchDetail | null>(null), working = ref(false), error = ref<ApiErrorDetails | null>(null)
const factsStale = ref(false)
const appointmentsOpen = ref(false)
const canLinkAppointments = computed(() => vehicleEditor.value && detail.value?.orderDetails.some(row => Boolean(row.destinationFbaWarehouse)) && canDispatchAction(detail.value?.actions, 'EDIT', access.can))
async function appointmentsSaved() { error.value = null; await refreshFacts(); emit('saved') }
const preview = ref<LoadingPreview | null>(null)
const loading = computed(() => working.value)
const measurements = ref<Array<{ warehousePalletId: string; palletNo: string; palletSpaces: number | null; weightKg: number | null; volumeCbm: number | null; deliveryIndex: number | null }>>([])
@@ -152,7 +148,6 @@ async function submitReview(decision: 'APPROVED' | 'REJECTED') {
await submit()
}
async function submit(submitReviewAfterSave = false) {
if (appointmentsOpen.value) return
if (submitting.value || busy.value || loading.value || uploadingEvidence.value) return
submitting.value = true
error.value = null
@@ -222,7 +217,6 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
<el-alert v-if="dispatchRejectionReason(detail?.reviews)" title="已退回客服处理,请核对原因后重新提交调度审核。" :description="dispatchRejectionReason(detail?.reviews)" type="warning" :closable="false" show-icon />
<LoadErrorState v-if="error && !cargoAction" title="排车操作未完成" :message="error.message" :loading="loading || busy || uploadingEvidence" retry-text="刷新当前资料" @retry="retryError"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<el-alert v-if="detail && !allowed && !pending" :title="detail?.actions[currentAction]?.blockReason || '当前操作未获得允许,请核对权限与作业状态'" type="warning" :closable="false" show-icon />
<div v-if="canLinkAppointments" class="density-actions"><el-button :disabled="loading || busy || submitting" @click="appointmentsOpen = true">关联正式预约</el-button></div>
<el-form v-if="detail && !cargoAction" class="density-form density-dialog-sections" label-position="left" label-width="130px" :disabled="frozen || loading || uploadingEvidence || !allowed">
<section v-if="currentOperation === 'ROUTE' && route" class="density-dialog-section" data-tone="source" aria-label="本车卸货顺序"><h3 class="density-dialog-section__title">卸货顺序</h3>
<table class="density-table"><tbody><tr><th scope="row">起运仓</th><td>{{ route.stops[0]?.name || '—' }}</td><th scope="row">地址</th><td>{{ route.stops[0]?.address || '—' }}</td></tr></tbody></table>
@@ -237,7 +231,6 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
</template>
<template v-else-if="vehicleEditor">
<DispatchVehicleMaintenanceForm :key="currentNo" :detail="detail" v-model:vehicle="vehicle" v-model:driver="driver" v-model:maintenance="form.maintenance" v-model:planned-departure-at="form.plannedDepartureAt" v-model:estimated-arrival-at="form.estimatedArrivalAt" v-model:publish-looseboard-demand="form.publishLooseboardDemand" :disabled="frozen || loading || uploadingEvidence || !allowed">
<template #appointments><div v-if="canLinkAppointments" class="density-actions"><el-button :disabled="loading || busy || submitting" @click="appointmentsOpen = true">关联正式预约</el-button></div></template>
<template #fees><DispatchVehicleMaintenanceFees :dispatch-no="currentNo" :deliveries="detail.documents.deliveries" /></template>
<template #attachments>
<div class="workflow-photo-grid density-dialog-sections" aria-label="车辆司机照片">
@@ -280,7 +273,6 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
</div>
</template>
</AppDialog>
<DispatchAppointmentAllocationsDialog v-if="appointmentsOpen" v-model="appointmentsOpen" :dispatch-no="currentNo" @saved="appointmentsSaved" />
</template>
<style scoped>
.workflow-order-actions { width: 160px; white-space: nowrap; }
@@ -58,11 +58,6 @@ export interface DispatchItems { dispatchNo: string; recordVersion: Version; sta
export interface DispatchRouteStop { index: number; name: string; address: string | null; destinationKey: string | null }
export interface DispatchRoute { dispatchNo: string; recordVersion: Version; stops: DispatchRouteStop[]; edit: Gate }
export interface DispatchRouteCommand extends VersionCommand { destinationKeys: string[]; reason: string }
export interface AppointmentAllocationOption { batchKey: string; batchNo: string; recordVersion: Version; availablePackages: number; confirmationNo: string | null; appointmentAt: string; appointmentEndAt: string | null; timezone: string }
export interface AppointmentAllocationSource { sourceKey: string; orderNo: string | null; inboundCode: string | null; destinationKey: string; packageCount: number | null; options: AppointmentAllocationOption[]; blockReasons: string[] }
export interface AppointmentAllocationItem { dispatchItemId: string; planPalletId: string; customerId: string; customerName: string | null; palletNo: string; sources: AppointmentAllocationSource[]; allocations: DispatchBatchAllocation[] }
export interface AppointmentAllocations { dispatchNo: string; recordVersion: Version; items: AppointmentAllocationItem[]; edit: Gate }
export interface AppointmentAllocationCommand extends VersionCommand { dispatchItemIds: string[]; batchAllocations: DispatchBatchAllocation[] }
export type DispatchEvidenceKind = 'EXCEPTION' | 'VEHICLE' | 'DRIVER_PHOTO' | 'DRIVER'
export interface DispatchEvidenceFile { fileId: string; fileName: string; contentType: string; sizeBytes: number }
export interface CandidateList { candidates: Candidate[]; snapshotAt: string; complete: boolean; total: number; page: number; size: number; pages: number }