This commit is contained in:
2026-08-17 12:31:27 +08:00
parent c6fa22c1b5
commit 6a0c0b9229
22 changed files with 399 additions and 171 deletions
@@ -2,15 +2,15 @@ import { describe, expect, it } from 'vitest'
import { containerRequirementError, createBlankContainerRequirement } from './containerRequirements'
describe('ocean booking container requirements', () => {
it('does not guess capacity or weight for a new container type', () => {
it('uses the fixed platform specification for a new container type', () => {
expect(createBlankContainerRequirement()).toEqual({
containerType: '40HC', containerKind: 'FCL', capacityCbm: null, maxWeightKg: null, quantity: 1,
containerType: '40HC', containerKind: 'FCL', capacityCbm: 76, maxWeightKg: 28000, quantity: 1,
})
})
it('rejects missing or non-positive capacity, weight and quantity', () => {
expect(containerRequirementError([createBlankContainerRequirement()])).toContain('容量、限重和数量必须大于 0')
expect(containerRequirementError([{ containerType: 'UNKNOWN', containerKind: 'FCL', capacityCbm: 1, maxWeightKg: 1, quantity: 0 }])).toContain('UNKNOWN')
expect(containerRequirementError([{ containerType: 'UNKNOWN', containerKind: 'FCL', capacityCbm: 1, maxWeightKg: 1, quantity: 1 }])).toBeNull()
expect(containerRequirementError([{ ...createBlankContainerRequirement(), quantity: 0 }])).toContain('固定规格')
expect(containerRequirementError([{ containerType: 'UNKNOWN', containerKind: 'FCL', capacityCbm: 1, maxWeightKg: 1, quantity: 1 }])).toContain('UNKNOWN')
expect(containerRequirementError([createBlankContainerRequirement()])).toBeNull()
})
})
@@ -1,10 +1,19 @@
import type { OceanBookingContainer } from './types'
import { applyContainerSpecification, containerSpecification } from '@/shared/domain/containerSpecifications'
export function createBlankContainerRequirement(): OceanBookingContainer {
return { containerType: '40HC', containerKind: 'FCL', capacityCbm: null, maxWeightKg: null, quantity: 1 }
return { containerType: '40HC', containerKind: 'FCL', capacityCbm: 76, maxWeightKg: 28000, quantity: 1 }
}
export function selectContainerRequirementType(requirement: OceanBookingContainer, containerType: string) {
applyContainerSpecification(requirement, containerType)
}
export function containerRequirementError(requirements: OceanBookingContainer[]) {
const invalid = requirements.find((item) => !(Number(item.capacityCbm) > 0) || !(Number(item.maxWeightKg) > 0) || !(Number(item.quantity) > 0))
return invalid ? `箱型 ${invalid.containerType || '未命名'} 的容量、限重和数量必须大于 0,请补充后再提交` : null
const invalid = requirements.find((item) => {
const specification = containerSpecification(item.containerType)
return !specification || Number(item.capacityCbm) !== specification.capacityCbm
|| Number(item.maxWeightKg) !== specification.maxWeightKg || !(Number(item.quantity) > 0)
})
return invalid ? `箱型 ${invalid.containerType || '未命名'} 必须使用平台固定规格,请重新选择柜型` : null
}
@@ -89,14 +89,14 @@ describe('OceanBookingDetailView interaction contract', () => {
it('uses source-backed free text terms and keeps capacity snapshots behind one specification control', () => {
expect(source).not.toContain('serviceTermOptions')
expect(source).not.toContain('freightTermOptions')
expect(source).toContain("const containerTypeOptions = ['20GP', '40GP', '40HC', '40NR', '45HC']")
expect(source).toContain('const containerTypeOptions = CONTAINER_SPECIFICATIONS')
expect(source).toContain('label="主要运输条款 Service Term" prop="serviceTerm"')
expect(source).toContain('<el-input v-model="form.data.serviceTerm" clearable maxlength="32" placeholder="请输入 Service Term"')
expect(source).toContain('label="运费条款 Freight Term" prop="freightTerm"')
expect(source).toContain('<el-input v-model="form.data.freightTerm" clearable maxlength="32" placeholder="请输入 Freight Term"')
expect(source).toContain('<el-select v-model="container.containerType"')
expect(source).toContain('<el-select :model-value="container.containerType"')
expect(source).toContain('<el-select v-model="container.containerKind"')
expect(source).toContain(':aria-label="`设置箱型 ${index + 1} 的容量与限重`"')
expect(source).toContain('规格(必填)')
expect(source).toContain('function containerSpecificationLabel(')
expect(source).not.toContain("['PREPAID', 'COLLECT']")
expect(source).not.toContain('<el-input v-model="container.containerType"')
@@ -117,15 +117,15 @@ describe('OceanBookingDetailView interaction contract', () => {
expect(source).toContain('form.data.warehouseName = warehouses.value.find')
expect(source).toContain('@update:model-value="selectWarehouse"')
expect(source).not.toContain('@update:model-value="(value) =>')
expect(source).toContain('v-model="container.capacityCbm"')
expect(source).toContain('v-model="container.maxWeightKg"')
expect(source).toContain('selectContainerRequirementType(container')
expect(source).not.toContain('v-model="container.capacityCbm"')
expect(source).not.toContain('v-model="container.maxWeightKg"')
expect(source).toContain('releaseOceanBooking(id.value, { expectedVersion: detail.value.version')
expect(source).toContain('title="确认放舱"')
expect(source).toContain('createBlankContainerRequirement()')
expect(source).toContain('containerRequirementError(form.data.containerRequirements)')
expect(source).toContain('function validateContainerRequirements()')
expect(source).not.toContain('capacityCbm: 68')
expect(source).not.toContain('maxWeightKg: 28000')
expect(source).toContain('containerSpecificationLabel(container)')
})
it('keeps every container requirement removable without collapsing its fields', () => {
@@ -2,7 +2,7 @@
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft, Plus, Save, SlidersHorizontal, Trash2 } from '@lucide/vue'
import { ArrowLeft, Plus, Save, Trash2 } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails } from '@/shared/api/http'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
@@ -12,6 +12,7 @@ import AdminPage from '@/shared/components/AdminPage.vue'
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, type PlatformTenantResource } from '@/modules/iam'
import { listPlatformPartners, type PlatformPartnerResource } from '@/modules/master-data'
import { listSuppliers, type Supplier } from '@/modules/suppliers'
@@ -34,14 +35,14 @@ import type {
OceanBookingStatus,
} from '../types'
import { oceanBookingStatusLabel } from '../status'
import { containerRequirementError, createBlankContainerRequirement } from '../containerRequirements'
import { containerRequirementError, createBlankContainerRequirement, selectContainerRequirementType } from '../containerRequirements'
const partyTypes = [
{ value: 'SHIPPER', label: '发货人' },
{ value: 'CONSIGNEE', label: '收货人' },
{ value: 'NOTIFY_PARTY', label: '通知人' },
] as const
const containerTypeOptions = ['20GP', '40GP', '40HC', '40NR', '45HC']
const containerTypeOptions = CONTAINER_SPECIFICATIONS
const containerKindOptions = [
{ value: 'FCL', label: '整箱 FCL' },
{ value: 'LCL', label: '拼箱 LCL' },
@@ -338,7 +339,6 @@ function validateContainerRequirements() {
return true
}
function containerSpecificationLabel(container: OceanBookingDraftData['containerRequirements'][number]) {
if (!(Number(container.capacityCbm) > 0) || !(Number(container.maxWeightKg) > 0)) return '设置规格'
return `${container.capacityCbm} m³ / ${container.maxWeightKg} kg`
}
function addContainer() { form.data.containerRequirements.push(createBlankContainerRequirement()) }
@@ -414,18 +414,9 @@ onMounted(() => { void load(); void loadOptions() })
</el-tooltip>
<span v-else aria-hidden="true">—</span>
</td>
<td><el-select v-model="container.containerType" :disabled="!editable" placeholder="请选择箱型"><el-option v-for="value in containerTypeOptions" :key="value" :label="value" :value="value" /></el-select></td>
<td><el-select :model-value="container.containerType" :disabled="!editable" placeholder="请选择箱型" @update:model-value="selectContainerRequirementType(container, 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="container.containerKind" :disabled="!editable" placeholder="请选择箱种"><el-option v-for="item in containerKindOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></td>
<td class="container-table__specification">
<el-popover v-if="editable" placement="bottom" :width="300" trigger="click">
<template #reference><el-button size="small" plain :type="Number(container.capacityCbm) > 0 && Number(container.maxWeightKg) > 0 ? 'default' : 'warning'" :aria-label="`设置箱型 ${index + 1} 的容量与限重`"><SlidersHorizontal :size="14" />{{ containerSpecificationLabel(container) }}</el-button></template>
<div class="container-specification-fields">
<label><span>容量(m³)</span><el-input-number v-model="container.capacityCbm" :min="0.01" :precision="2" :controls="false" placeholder="请输入容量" /></label>
<label><span>限重(kg)</span><el-input-number v-model="container.maxWeightKg" :min="0.01" :precision="2" :controls="false" placeholder="请输入限重" /></label>
</div>
</el-popover>
<span v-else>{{ containerSpecificationLabel(container) }}</span>
</td>
<td class="container-table__specification"><span>{{ containerSpecificationLabel(container) }}</span></td>
<td><el-input-number v-model="container.quantity" :disabled="!editable" :min="1" :max="999" :controls="false" placeholder="请输入数量" /></td>
</tr>
</tbody>