This commit is contained in:
2026-08-18 16:09:43 +08:00
parent f9ae2d94cb
commit 21249d1dde
52 changed files with 980 additions and 698 deletions
+5 -5
View File
@@ -36,7 +36,7 @@
- 默认验收身份:`platform_admin`,用于“后台管理”的样式、路由、权限展示和交互验收。
- 密码不写入仓库;从本机未提交环境变量 `CHONGCHONG_ADMIN_ACCEPTANCE_PASSWORD` 或 `C:/Users/BY-OY/.codex/chongchong-global-dev-acceptance.env` 读取。若凭据不存在,再向用户索取,不重复询问账号名。
- 该身份仅适用于开发环境。不要把密码、Token 或真实接口地址写入源码、测试快照、日志或 `AGENTS.md`;开发环境变更时只更新本机凭据。
- 样式验收优先使用该身份进入真实页面,并按原型还原规则检查加载、空态、错误态、权限态和窄屏布局。
- 样式验收优先使用该身份进入真实页面,当前固定使用 `1920×1080` 视口和 `100%` 浏览器缩放,按原型还原规则检查加载、空态、错误态、权限态和桌面布局。
## 模块快速查询
@@ -54,7 +54,7 @@
| 导航与原型菜单图标 | `core/navigation/adminNavigation.ts`、`core/navigation/prototypeMenuIcons.ts` | 菜单结构、权限与路由同步核对;已有原型图标优先使用映射资源 |
| 平台仓库与仓储资源 | `modules/warehouses/`、`modules/warehouse-resources/` | `warehouse/` |
| 订单列表、详情与入库费用 | `modules/unified-orders/`、`modules/first-leg-orders/`;费用入口 `modules/unified-orders/components/InboundFeeDetailManager.vue`、`modules/unified-orders/api/inboundFeeDetails.ts` | `order/`、`firstleg/`;费用来源审核发布联查 `firstleg/AGENTS.md` |
| 入库、测量和计费工作流 | `modules/inbounds/`、`modules/fba-pricing/`、`modules/dimension-config/`、`modules/fee-items/` | `firstleg/`、`file/`、`integration-service/` |
| 入库、箱唛、手工/设备测量和计费工作流 | `modules/inbounds/pages/InboundOrdersView.vue`;测量接口与页面从 `modules/inbounds/measurement/` 进入;审核、称重和费用继续按 `modules/inbounds/` 相邻子域定位;计价主数据在 `modules/fba-pricing/`、`modules/dimension-config/`、`modules/fee-items/` | `firstleg/`、`order/`、`file/`、`integration-service/`;箱码生成、打印与测量绑定先读 `firstleg/AGENTS.md` |
| 财务账户、账单、收款与成本 | `modules/finance/` | `finance/`;费用来源与柜次成本快照联查 `firstleg/`,凭证联查 `file/` |
| 货柜与智能排柜 | `modules/container-loading/` | `firstleg/loading/`、`firstleg/split/` |
| 预装详情、装柜清单与柜次费用 | `modules/loading-lists/pages/PreloadPlanDetailView.vue`、`modules/loading-lists/components/LoadingListDetailDrawer.vue`、`modules/loading-lists/components/ContainerFeesPanel.vue`、`modules/loading-lists/api/containerFees.ts` | `firstleg/loading/`、`firstleg` 柜次费用、`finance/` 成本分摊、`file/`、`warehouse/` |
@@ -62,7 +62,7 @@
| 拖车预约 | `modules/trucking/` | `firstleg/outbound/dispatch/`、`firstleg/`;承运商联查 `modules/suppliers/` 与 `warehouse/src/main/resources/openapi/supplier-v1.yaml`,服务端契约见 `firstleg/src/main/resources/openapi/truck-appointments-v1.yaml` |
| 报关协同 | `modules/customs/` | `firstleg/customs/`、`file/`;报关代理联查 `modules/suppliers/` 与 `warehouse/src/main/resources/openapi/supplier-v1.yaml`,服务端契约见 `firstleg/src/main/resources/openapi/customs-v1.yaml` |
| 国内出仓确认 | `modules/outbound-dispatch/` | `firstleg/outbound/dispatch/` |
| 主数据与下载 | `modules/master-data/`、`modules/downloads/` | `warehouse/`、`file/` |
| 主数据与下载 | `modules/master-data/`、`modules/downloads/` | `warehouse/`、`file/`;Finance 导出联查 `finance/` 内部 Export Provider 与 File 导出定义 |
## Admin 全量验收快速索引
@@ -113,7 +113,7 @@
- 优先使用 Element Plus 与现有复用组件;图标使用 Lucide,不用表情符号充当功能图标。
- 表格、筛选、抽屉和表单应按实际语义覆盖适用的加载、空数据、错误、禁用和无权限状态,不为不适用的组件机械增加状态。
- 保持键盘可操作、可见焦点、表单标签和足够的颜色对比度;不要只用颜色表达状态。
- 新页面至少适配现有 320px 最小宽度策略,并检查侧栏、表格、抽屉和表单在窄屏下的退化方式。
- 当前样式开发与验收只处理 `1920×1080` 桌面视口,不检查或整改 `720px`、`320px` 及其他窄屏;这些视口不作为测试、截图或交付门禁。保留现有响应式 CSS 和组件能力,不为当前阶段主动删除,用户恢复多视口验收后再统一处理。
## 公共前端组件复用
@@ -146,4 +146,4 @@ pnpm build
- Admin 全量验收唯一入口为 `../../baiyue-firstleg/scripts/admin-test-env-acceptance.mjs`,默认模块列表覆盖平台全模块;单模块定位先查 `../../baiyue-firstleg/scripts/admin-test-env-modules/README.md`。
- 该入口通过测试环境 HTTP 验证真实服务、权限、状态、幂等和跨服务链路,会写入或停用测试数据,必须获得当前对话明确授权。
- `pnpm test/typecheck` 和 `frontend-full-regression.ps1` 仅是本地质量检查,不代表 Admin 服务已完成全量验收。
- HTTP 验收通过后,再使用开发验收账号通过浏览器逐页检查真实接口调用、原型、权限、错误态、空态和窄屏布局。
- HTTP 验收通过后,再使用开发验收账号通过浏览器逐页检查真实接口调用、原型、权限、错误态、空态和 `1920×1080` 桌面布局;当前不追加窄屏浏览器验收。
+3 -2
View File
@@ -20,8 +20,8 @@ const visibleNavigation = filterAdminNavigation(adminNavigation, new Set([
const stubs = {
'el-menu': {
props: ['defaultActive', 'collapse'],
template: '<nav :data-active="defaultActive" :data-collapsed="String(collapse)"><slot /></nav>',
props: ['defaultActive', 'defaultOpeneds', 'collapse'],
template: '<nav :data-active="defaultActive" :data-openeds="JSON.stringify(defaultOpeneds)" :data-collapsed="String(collapse)"><slot /></nav>',
},
'el-sub-menu': {
props: ['index'],
@@ -41,6 +41,7 @@ describe('AdminSidebarMenu', () => {
})
expect(wrapper.get('nav').attributes('data-active')).toBe('/system/users')
expect(wrapper.get('nav').attributes('data-openeds')).toBe('[]')
const groupTitles = wrapper.findAll('[data-kind="group"] header').map((node) => node.text())
const paths = wrapper.findAll('[data-kind="item"]').map((node) => node.attributes('data-index'))
+3 -2
View File
@@ -15,8 +15,9 @@ function getGroupKeys(nodes: AdminNavigationNode[]): string[] {
: [])
}
const defaultOpeneds = computed(() => getGroupKeys(props.items))
const menuInstanceKey = computed(() => `${props.collapsed ? 'collapsed' : 'expanded'}:${defaultOpeneds.value.join('|')}`)
const groupKeys = computed(() => getGroupKeys(props.items))
const defaultOpeneds: string[] = []
const menuInstanceKey = computed(() => `${props.collapsed ? 'collapsed' : 'expanded'}:${groupKeys.value.join('|')}`)
</script>
<template>
@@ -56,10 +56,10 @@ describe('platform container loading API', () => {
vi.mocked(http.post).mockResolvedValue({ code: '200', data: { ...container, oceanBookingId: 101, oceanBookingRequirementId: 202 } })
vi.mocked(http.delete).mockResolvedValue({ code: '200', data: { ...container, oceanBookingId: null, oceanBookingRequirementId: null } })
await createContainerSimulation({ channelCode: 'NY_EXX', warehouseId: '8', containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000 })
await linkContainerOceanBooking('7', { realContainerBookingId: '101', expectedVersion: 3 })
await linkContainerOceanBooking('7', { oceanBookingId: '101', requirementId: '202', containerNo: 'MSCU1234567', expectedVersion: 3 })
await unlinkContainerOceanBooking('7', 4, '空柜改配')
expect(http.post).toHaveBeenNthCalledWith(1, '/platform/v1/containers', { channelCode: 'NY_EXX', warehouseId: '8', containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000 })
expect(http.post).toHaveBeenNthCalledWith(2, '/platform/v1/containers/7/ocean-booking-link', { realContainerBookingId: '101', expectedVersion: 3 })
expect(http.post).toHaveBeenNthCalledWith(2, '/platform/v1/containers/7/ocean-booking-link', { oceanBookingId: '101', requirementId: '202', containerNo: 'MSCU1234567', expectedVersion: 3 })
expect(http.delete).toHaveBeenCalledWith('/platform/v1/containers/7/ocean-booking-link', { data: { expectedVersion: 4, reason: '空柜改配' } })
})
@@ -246,17 +246,17 @@ describe('ContainersView', () => {
expect(source).not.toContain('v-model="form.maxWeightKg"')
})
it('allows first linking after preloading but only offers unlinking for wait-loading containers', () => {
it('links a simulation to a released requirement and records the port container number', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
expect(source).toContain("row.sourceType === 'SIMULATION' && row.recordStatus === 'ACTIVE' && !row.oceanBookingId")
expect(source).toContain("row.sourceType === 'SIMULATION' && row.recordStatus === 'ACTIVE' && row.oceanBookingId")
expect(source).toContain('已预排货但尚未进入 PDA、取证或单证阶段的模拟柜仍可首次关联')
expect(source).toContain('柜次号与装柜明细保持不变')
expect(source).toContain('只有尚未排货的空柜可以解除')
expect(source).toContain('booking.containers')
expect(source).toContain('realContainerBookingId: container.containerBookingId')
expect(source).toContain('linkContainerOceanBooking(target.id, { realContainerBookingId: candidate.realContainerBookingId, expectedVersion: target.version })')
expect(source).toContain('unlinkContainerOceanBooking(target.id, target.version, unlinkReason.value.trim())')
expect(source).toContain("listOceanBookings({ page: 1, size: 200, status: 'RELEASED' })")
expect(source).toContain('booking.containerRequirements')
expect(source).toContain('booking.containers.filter((container) => container.requirementId === requirement.requirementId).length')
expect(source).toContain('请输入港口取得的真实集装箱号')
expect(source).toContain('oceanBookingId: candidate.oceanBookingId, requirementId: candidate.requirementId, containerNo')
expect(source).toContain(':disabled="candidate.disabled"')
expect(source).not.toContain('realContainerBookingId')
expect(source).not.toContain('MERGED')
expect(source).not.toContain('解除订舱关联')
})
})
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { Boxes, Check, Eye, FileText, FileUp, Link2, ListChecks, MoreHorizontal, Pencil, Plus, Sparkles, TriangleAlert, Unlink } from '@lucide/vue'
import { Boxes, Check, Eye, FileText, FileUp, Link2, ListChecks, MoreHorizontal, Pencil, Plus, Sparkles, TriangleAlert } from '@lucide/vue'
import { ElMessage, type FormInstance, type FormRules, type UploadFile } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
@@ -20,7 +20,7 @@ import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/compone
import { CONTAINER_SPECIFICATIONS, applyContainerSpecification, containerSpecification } from '@/shared/domain/containerSpecifications'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ContainerOverviewPanel from '../components/ContainerOverviewPanel.vue'
import { cancelContainerException, completeContainerClearance, createContainerSimulation, downloadContainerDocument, downloadContainerPdaPhoto, getContainer, getContainerClearance, getContainerOverview, linkContainerOceanBooking, listContainers, markContainerException, unlinkContainerOceanBooking, updateContainer, updateContainerTransport, uploadContainerClearance, uploadContainerDocument } from '../api/platformContainerLoading'
import { cancelContainerException, completeContainerClearance, createContainerSimulation, downloadContainerDocument, downloadContainerPdaPhoto, getContainer, getContainerClearance, getContainerOverview, linkContainerOceanBooking, listContainers, markContainerException, updateContainer, updateContainerTransport, uploadContainerClearance, uploadContainerDocument } from '../api/platformContainerLoading'
import type { ContainerBookingResource, ContainerDocumentStatus, ContainerDocumentType, ContainerOverview, ContainerOverviewDocument, ContainerPdaPhoto, ContainerPlanStatus, ContainerSaveRequest, ContainerSimulationCreateRequest } from '../types'
type Filters = QueryModel & { customerId: string; keyword: string; containerType: string; status: ContainerPlanStatus | ''; sailingRange: string[] }
@@ -89,10 +89,9 @@ const linkSaving = ref(false)
const linkError = ref<ApiErrorDetails | null>(null)
const linkTarget = ref<ContainerBookingResource | null>(null)
const linkCandidateKey = ref('')
const linkCandidates = ref<Array<{ key: string; realContainerBookingId: string; label: string }>>([])
const linkContainerNo = ref('')
const linkCandidates = ref<Array<{ key: string; oceanBookingId: string; requirementId: string; label: string; disabled: boolean }>>([])
const selectedTruckingSupplierId = ref('')
const unlinkOpen = ref(false)
const unlinkReason = ref('')
let listSequence = 0
let detailSequence = 0
@@ -353,71 +352,60 @@ async function saveSimulation() {
} catch (error) { simulationError.value = getApiErrorDetails(error, '模拟柜创建失败') }
finally { simulationSaving.value = false }
}
async function loadBookingStatus(status: 'CONFIRMED' | 'RELEASED') {
const first = await listOceanBookings({ page: 1, size: 200, status })
async function loadReleasedBookings() {
const first = await listOceanBookings({ page: 1, size: 200, status: 'RELEASED' })
if (first.pages <= 1) return first.records
const rest = await Promise.all(Array.from({ length: first.pages - 1 }, (_, index) => listOceanBookings({ page: index + 2, size: 200, status })))
const rest = await Promise.all(Array.from({ length: first.pages - 1 }, (_, index) => listOceanBookings({ page: index + 2, size: 200, status: 'RELEASED' })))
return [first, ...rest].flatMap((result) => result.records)
}
async function openLink(source: object) {
const row = source as ContainerBookingResource
linkTarget.value = row
linkCandidateKey.value = ''
linkContainerNo.value = ''
linkCandidates.value = []
linkError.value = null
linkOpen.value = true
linkLoading.value = true
try {
const bookings = (await Promise.all([loadBookingStatus('CONFIRMED'), loadBookingStatus('RELEASED')])).flat()
.filter((booking) => booking.warehouseId === row.warehouseId && booking.channelCode === row.channelCode)
const bookings = await loadReleasedBookings()
const details = await Promise.all(bookings.map((booking) => getOceanBooking(booking.id)))
linkCandidates.value = details.flatMap((booking) => booking.containers
.filter((container) => container.warehouseId === row.warehouseId
&& container.channelCode === row.channelCode
&& container.containerType === row.containerType
&& Number(container.capacityCbm) === Number(row.capacityCbm)
&& Number(container.maxWeightKg) === Number(row.maxWeightKg))
.map((container) => ({
key: container.containerBookingId,
realContainerBookingId: container.containerBookingId,
label: `${booking.bookingNo || '订舱号待补'} · 集装箱号 ${container.containerNo} · 柜次号 ${container.cabinetNo} · ${container.containerType}`,
})))
} catch (error) { linkError.value = getApiErrorDetails(error, '真实订舱柜候选加载失败') }
linkCandidates.value = details.flatMap((booking) => booking.containerRequirements
.map((requirement) => ({ ...requirement, remainingQuantity: Math.max(0, Number(requirement.quantity) - booking.containers.filter((container) => container.requirementId === requirement.requirementId).length) }))
.filter((requirement) => Boolean(requirement.requirementId) && Number(requirement.remainingQuantity) > 0)
.map((requirement) => {
const specificationMatches = booking.warehouseId === row.warehouseId
&& booking.channelCode === row.channelCode
&& requirement.containerType === row.containerType
&& Number(requirement.capacityCbm) === Number(row.capacityCbm)
&& Number(requirement.maxWeightKg) === Number(row.maxWeightKg)
return {
key: `${booking.id}:${requirement.requirementId}`,
oceanBookingId: booking.id,
requirementId: String(requirement.requirementId),
disabled: !specificationMatches,
label: `${booking.bookingNo || '订舱号待补'} · ${requirement.containerType} × ${requirement.remainingQuantity} · ${booking.customerName || '未关联客户'} · ${booking.warehouseName || '仓库待补'} · ${booking.channelName || '渠道待补'}${specificationMatches ? '' : '(与当前柜规格不一致)'}`,
}
}))
} catch (error) { linkError.value = getApiErrorDetails(error, '已放舱订舱候选加载失败') }
finally { linkLoading.value = false }
}
async function saveLink() {
const target = linkTarget.value
const candidate = linkCandidates.value.find((item) => item.key === linkCandidateKey.value)
if (!target || !candidate) { linkError.value = { message: '请选择要关联的真实订舱柜' }; return }
const containerNo = linkContainerNo.value.trim()
if (!target || !candidate || candidate.disabled) { linkError.value = { message: '请选择规格一致且仍有剩余数量的柜型需求' }; return }
if (!containerNo) { linkError.value = { message: '请输入港口取得的真实集装箱号' }; return }
linkSaving.value = true
linkError.value = null
try {
await linkContainerOceanBooking(target.id, { realContainerBookingId: candidate.realContainerBookingId, expectedVersion: target.version })
await linkContainerOceanBooking(target.id, { oceanBookingId: candidate.oceanBookingId, requirementId: candidate.requirementId, containerNo, expectedVersion: target.version })
linkOpen.value = false
ElMessage.success('已关联真实订舱柜,柜次号与装柜明细保持不变')
ElMessage.success('订舱与真实集装箱号已关联,柜次号、装柜计划和明细保持不变')
await loadRows()
} catch (error) { linkError.value = getApiErrorDetails(error, '关联真实订舱柜失败') }
finally { linkSaving.value = false }
}
function openUnlink(source: object) {
linkTarget.value = source as ContainerBookingResource
unlinkReason.value = ''
linkError.value = null
unlinkOpen.value = true
}
async function saveUnlink() {
const target = linkTarget.value
if (!target || !unlinkReason.value.trim()) { linkError.value = { message: '请输入解除关联原因' }; return }
linkSaving.value = true
linkError.value = null
try {
await unlinkContainerOceanBooking(target.id, target.version, unlinkReason.value.trim())
unlinkOpen.value = false
ElMessage.success('真实订舱关联已解除,柜次号保持不变')
await loadRows()
} catch (error) { linkError.value = getApiErrorDetails(error, '解除真实订舱关联失败') }
finally { linkSaving.value = false }
}
async function loadRows() {
const sequence = ++listSequence
loading.value = true; loadError.value = null
@@ -449,7 +437,6 @@ async function openDetail(source: object) {
function handleMoreAction(command: string, source: object) {
const row = source as ContainerBookingResource
if (command === 'link') { void openLink(row); return }
if (command === 'unlink') { openUnlink(row); return }
if (command === 'overview') { void openDetail(row); return }
detailTarget.value = row
void openDetail(row).then(() => {
@@ -603,7 +590,7 @@ onMounted(() => { void Promise.all([loadCustomers(), loadWarehouses(), loadBusin
<section v-if="!loadError" class="surface-panel table-panel">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" stripe preference-key="container-loading-table" empty-text="暂无货柜">
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openSimulation"><Plus :size="16" />新增模拟柜</el-button></template>
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canView" size="small" type="primary" plain :aria-label="containerViewLabel(row as ContainerBookingResource)" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="canView && canEdit && row.planStatus !== 'COMPLETED'" size="small" @click="openEdit(row)"><Pencil :size="15" />{{ row.planStatus === 'LOADING' ? (row.containerNo ? '修改集装箱号' : '录入集装箱号') : '编辑运输信息' }}</el-button><el-button v-if="row.planStatus === 'EDITING' && canSmartLoading" size="small" type="primary" @click="goContainerProcess(row)"><Sparkles :size="15" />排柜</el-button><el-button v-else-if="row.planStatus === 'LOADING' && canViewPreload" size="small" type="primary" plain @click="goContainerProcess(row)"><ListChecks :size="15" />预装清单</el-button><el-button v-else-if="row.planStatus === 'COMPLETED' && canViewLoadingList" size="small" type="primary" plain @click="goContainerProcess(row)"><ListChecks :size="15" />装柜清单</el-button><el-dropdown v-if="canView" trigger="click" @command="handleMoreAction($event, row)"><el-button size="small" plain aria-label="更多货柜操作" title="更多货柜操作"><MoreHorizontal :size="16" /></el-button><template #dropdown><el-dropdown-menu><el-dropdown-item command="overview"><FileText :size="15" />轨迹与日志</el-dropdown-item><el-dropdown-item command="documents"><FileUp :size="15" />DO / PU 资料</el-dropdown-item><el-dropdown-item v-if="canEdit && row.sourceType === 'SIMULATION' && row.recordStatus === 'ACTIVE' && !row.oceanBookingId && ['WAIT_LOADING','EDITING'].includes(row.planStatus)" command="link"><Link2 :size="15" />关联真实订舱柜</el-dropdown-item><el-dropdown-item v-if="canEdit && row.sourceType === 'SIMULATION' && row.recordStatus === 'ACTIVE' && row.oceanBookingId && row.planStatus === 'WAIT_LOADING'" command="unlink"><Unlink :size="15" />解除订舱关联</el-dropdown-item><el-dropdown-item v-if="canEdit" command="exception" divided><TriangleAlert :size="15" />异常处理</el-dropdown-item></el-dropdown-menu></template></el-dropdown></div></template>
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canView" size="small" type="primary" plain :aria-label="containerViewLabel(row as ContainerBookingResource)" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="canView && canEdit && row.planStatus !== 'COMPLETED'" size="small" @click="openEdit(row)"><Pencil :size="15" />{{ row.planStatus === 'LOADING' ? (row.containerNo ? '修改集装箱号' : '录入集装箱号') : '编辑运输信息' }}</el-button><el-button v-if="row.planStatus === 'EDITING' && canSmartLoading" size="small" type="primary" @click="goContainerProcess(row)"><Sparkles :size="15" />排柜</el-button><el-button v-else-if="row.planStatus === 'LOADING' && canViewPreload" size="small" type="primary" plain @click="goContainerProcess(row)"><ListChecks :size="15" />预装清单</el-button><el-button v-else-if="row.planStatus === 'COMPLETED' && canViewLoadingList" size="small" type="primary" plain @click="goContainerProcess(row)"><ListChecks :size="15" />装柜清单</el-button><el-dropdown v-if="canView" trigger="click" @command="handleMoreAction($event, row)"><el-button size="small" plain aria-label="更多货柜操作" title="更多货柜操作"><MoreHorizontal :size="16" /></el-button><template #dropdown><el-dropdown-menu><el-dropdown-item command="overview"><FileText :size="15" />轨迹与日志</el-dropdown-item><el-dropdown-item command="documents"><FileUp :size="15" />DO / PU 资料</el-dropdown-item><el-dropdown-item v-if="canEdit && row.sourceType === 'SIMULATION' && !row.oceanBookingId && ['WAIT_LOADING','EDITING'].includes(row.planStatus)" command="link"><Link2 :size="15" />关联已放舱订舱</el-dropdown-item><el-dropdown-item v-if="canEdit" command="exception" divided><TriangleAlert :size="15" />异常处理</el-dropdown-item></el-dropdown-menu></template></el-dropdown></div></template>
<template #cabinet="{ row }"><strong>{{ row.cabinetNo }}</strong><small>系统生成</small></template>
<template #oceanBooking="{ row }"><span v-if="row.oceanBookingId"><strong>{{ row.billOfLadingNo || '已关联订舱' }}</strong><small>{{ row.containerNo ? `集装箱号:${row.containerNo}` : '集装箱号待录入' }}</small></span><span v-else>未关联</span></template>
<template #containerNo="{ row }">{{ row.containerNo || '—' }}</template>
@@ -642,19 +629,13 @@ onMounted(() => { void Promise.all([loadCustomers(), loadWarehouses(), loadBusin
<LoadErrorState v-if="simulationError" compact title="模拟柜创建失败" :message="simulationError.message" :show-retry="false"><template #details><ApiErrorMeta :details="simulationError" /></template></LoadErrorState>
</AppDialog>
<AppDialog v-model="linkOpen" title="关联真实订舱柜" width="min(620px, calc(100vw - 28px))" confirm-text="确认关联" cancel-text="取消" :confirm-loading="linkSaving" :confirm-disabled="!linkCandidateKey" @confirm="saveLink">
<el-alert title="关联只补充真实订舱关系,不会重建柜次号、装柜计划或已排货明细。" type="info" :closable="false" />
<el-form label-position="top"><el-form-item label="柜次号"><el-input :model-value="linkTarget?.cabinetNo || ''" disabled /></el-form-item><el-form-item label="可关联的真实订舱柜" required><el-select v-model="linkCandidateKey" filterable :loading="linkLoading" placeholder="请选择同仓、同渠道、同规格的已确认订舱柜" no-data-text="暂无可关联的真实订舱柜"><el-option v-for="candidate in linkCandidates" :key="candidate.key" :label="candidate.label" :value="candidate.key" /></el-select></el-form-item></el-form>
<p class="dialog-hint">已预排货但尚未进入 PDA、取证或单证阶段的模拟柜仍可首次关联;最终状态门禁以后端为准。</p>
<AppDialog v-model="linkOpen" title="关联已放舱订舱" width="min(680px, calc(100vw - 28px))" confirm-text="确认关联" cancel-text="取消" :confirm-loading="linkSaving" :confirm-disabled="!linkCandidateKey || !linkContainerNo.trim()" @confirm="saveLink">
<el-alert title="关联后保留当前柜次号、装柜计划和明细,仅补充订舱快照及港口取得的真实集装箱号。" type="info" :closable="false" />
<el-form label-position="top"><el-form-item label="柜次号"><el-input :model-value="linkTarget?.cabinetNo || ''" disabled /></el-form-item><el-form-item label="已放舱订舱及柜型需求" required><el-select v-model="linkCandidateKey" filterable :loading="linkLoading" placeholder="请选择仍有剩余数量的柜型需求" no-data-text="暂无可关联的已放舱订舱需求"><el-option v-for="candidate in linkCandidates" :key="candidate.key" :label="candidate.label" :value="candidate.key" :disabled="candidate.disabled" /></el-select></el-form-item><el-form-item label="真实集装箱号" required><el-input v-model="linkContainerNo" maxlength="64" placeholder="请输入港口取得的真实集装箱号" /></el-form-item></el-form>
<p class="dialog-hint">仓库、渠道、柜型、容量和限重必须与当前模拟柜一致;不一致的候选不可选择。</p>
<LoadErrorState v-if="linkError" compact title="关联失败" :message="linkError.message" :show-retry="false"><template #details><ApiErrorMeta :details="linkError" /></template></LoadErrorState>
</AppDialog>
<AppDialog v-model="unlinkOpen" title="解除真实订舱关联" width="min(520px, calc(100vw - 28px))" confirm-text="确认解除" cancel-text="取消" :confirm-loading="linkSaving" :confirm-disabled="!unlinkReason.trim()" @confirm="saveUnlink">
<el-alert title="只有尚未排货的空柜可以解除;已有排货明细、PDA 或单证事实时服务端会拒绝。柜次号不会被删除。" type="warning" :closable="false" />
<el-form label-position="top"><el-form-item label="解除原因" required><el-input v-model="unlinkReason" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item></el-form>
<LoadErrorState v-if="linkError" compact title="解除关联失败" :message="linkError.message" :show-retry="false"><template #details><ApiErrorMeta :details="linkError" /></template></LoadErrorState>
</AppDialog>
<AppDialog v-model="editorOpen" class="container-editor-dialog" :title="editorTitle" width="min(820px, calc(100vw - 28px))" :confirm-loading="saving" :confirm-disabled="Boolean(warehouseError && !transportOnly)" confirm-text="确定" cancel-text="关闭" @confirm="save">
<LoadErrorState v-if="editorError" title="货柜保存失败" :message="editorError.message" compact :retry-text="form.id ? '刷新最新数据' : '重试保存'" @retry="form.id ? openEdit({ id: form.id }) : save()"><template #details><ApiErrorMeta :details="editorError" /></template></LoadErrorState>
<el-alert v-if="transportOnly" title="拣货中仅允许修改集装箱号和航次,其他字段已锁定。" type="warning" :closable="false" />
@@ -71,12 +71,17 @@ describe('GlobalSmartLoadingView', () => {
expect(listContainers).toHaveBeenCalledWith({ page: 1, size: 100 })
expect(getSmartLoadingPlanByBooking).toHaveBeenCalledWith(container.id)
expect(listSmartLoadingCandidates).toHaveBeenCalledWith(container.id, container.warehouseId)
expect(wrapper.text()).toContain('候选入库单与货物')
expect(wrapper.text()).toContain('候选装柜明细')
expect(wrapper.text()).toContain('美森正班海卡包税')
expect(wrapper.text()).toContain('IN-2')
expect(wrapper.text()).toContain('另一企业')
expect(wrapper.text()).toContain('MSCU-1 装柜方案')
expect(wrapper.text()).not.toContain('LP-99')
expect(wrapper.text()).toContain('IN-1')
expect(wrapper.text()).toContain('示例企业')
expect(wrapper.text()).not.toContain('服装')
expect(wrapper.text()).not.toContain('鞋类')
expect(wrapper.text()).not.toContain('SKU-1')
expect(wrapper.text()).not.toContain('IN-1')
expect(wrapper.text()).not.toContain('IN-2')
expect(wrapper.text()).not.toContain('ORD-1')
expect(wrapper.text()).not.toContain('ORD-2')
})
@@ -136,7 +141,7 @@ describe('GlobalSmartLoadingView', () => {
expect(option.text()).toContain('模拟柜次')
expect(option.text()).toContain('柜次号:待生成')
expect(option.text()).toContain('集装箱号:待录入')
expect(option.text()).toContain('开始装柜前录入集装箱号')
expect(option.text()).not.toContain('开始装柜前录入集装箱号')
expect(option.text()).toContain('渠道:美森正班海卡包税')
expect(option.text()).toContain('仓库:义乌国内仓')
expect(option.text()).toContain('柜型:40HC')
@@ -183,6 +188,11 @@ describe('GlobalSmartLoadingView', () => {
expect(source).not.toContain('customer.tenantCode')
})
it('defaults candidate assignment to all available boxes', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/GlobalSmartLoadingView.vue'), 'utf8')
expect(source).toContain('assignForm.packageCount = candidate.availablePackageCount')
})
it('rounds capacity percentages and hides column settings in embedded tables', async () => {
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue({
...plan,
@@ -254,8 +264,9 @@ describe('GlobalSmartLoadingView', () => {
expect(listSmartLoadingCandidates).toHaveBeenCalledWith(pushed.id, pushed.warehouseId)
expect(wrapper.findAll('.container-option')).toHaveLength(1)
expect(wrapper.text()).toContain('CB-2')
expect(wrapper.text()).toContain('已下发 PDA,仍可维护未完成的装柜明细')
expect(wrapper.text()).toContain('刷新 PDA 明细与版本')
expect(wrapper.text()).not.toContain('已下发 PDA,仍可维护未完成的装柜明细')
expect(wrapper.text()).not.toContain('刷新 PDA 明细与版本')
expect(wrapper.text()).toContain('示例企业')
})
it('hides completed containers from the smart loading workbench', async () => {
@@ -291,7 +302,7 @@ describe('GlobalSmartLoadingView', () => {
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('IN-MATCH')
expect(wrapper.text()).toContain('匹配企业')
expect(wrapper.text()).not.toContain('IN-OTHER')
expect(wrapper.text()).not.toContain('其他货物')
})
@@ -363,7 +374,7 @@ describe('GlobalSmartLoadingView', () => {
expect(removeSmartLoadingItem).toHaveBeenNthCalledWith(1, plan.id, plan.items[0].id, 2, 1)
expect(removeSmartLoadingItem).toHaveBeenNthCalledWith(2, plan.id, secondItem.id, 3, 1)
expect(wrapper.text()).toContain('当前柜次暂无已装货物')
expect(wrapper.text()).toContain('当前柜次暂无装柜明细')
})
it('uses item capabilities to protect picked quantities and removal', async () => {
@@ -445,7 +456,7 @@ describe('GlobalSmartLoadingView', () => {
expect(source).toContain(':label="channel.name"')
expect(source).not.toContain('channel.containerCount')
expect(source).toContain('class="compact-summary" aria-label="当前渠道装柜统计"')
expect(source).toContain('<dt>候选货物</dt>')
expect(source).toContain('<dt>候选明细</dt>')
expect(source).toContain('<dt>柜次</dt>')
expect(source).toContain('<dt>已装</dt>')
expect(source).toContain('<dt>总重量</dt>')
@@ -455,7 +466,7 @@ describe('GlobalSmartLoadingView', () => {
expect(source).toContain('class="capacity-grid"')
expect(source).toContain('智能推荐')
expect(source).toContain('推送预装')
expect(source).toContain('推送预装仅生成预装清单,不会直接开放 PDA')
expect(source).toContain('推送预装的流程说明已由操作确认框承载')
expect(source).not.toContain("canPush && plan?.status === 'EDITING' && selectedContainer?.containerNo")
})
})
@@ -118,20 +118,17 @@ function preferredContainerId() {
const candidateColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 92, fixed: 'left', slotName: 'actions' },
{ key: 'order', label: '入仓码', minWidth: 170, slotName: 'order' },
{ key: 'goodsName', prop: 'goodsName', label: '货物', minWidth: 150 },
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 140 },
{ key: 'availablePackageCount', prop: 'availablePackageCount', label: '可用箱数', width: 100 },
{ key: 'unitVolumeCbm', label: '单箱体积', width: 105, slotName: 'unitVolume' },
{ key: 'unitWeightKg', label: '单箱重量', width: 105, slotName: 'unitWeight' },
{ key: 'unitVolumeCbm', label: '单箱体积', width: 105, slotName: 'unitVolume' },
]
const itemColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 170, fixed: 'left', slotName: 'actions' },
{ key: 'order', label: '入仓码', minWidth: 170, slotName: 'order' },
{ key: 'goods', label: '货物', minWidth: 160, slotName: 'goods' },
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 140 },
{ key: 'packageCount', label: '分配箱数', width: 140, slotName: 'packageCount' },
{ key: 'volumeCbm', label: '体积', width: 105, slotName: 'volume' },
{ key: 'grossWeightKg', label: '毛重', width: 110, slotName: 'weight' },
{ key: 'volumeCbm', label: '体积', width: 105, slotName: 'volume' },
]
function formatNumber(value: number, digits = 3) {
@@ -274,7 +271,7 @@ async function createPlan() {
function openAssign(source: object) {
const candidate = candidateRow(source)
selectedCandidate.value = candidate
assignForm.packageCount = Math.min(candidate.availablePackageCount, 1)
assignForm.packageCount = candidate.availablePackageCount
assignOpen.value = true
}
async function assign() {
@@ -292,8 +289,8 @@ async function assign() {
}))
assignOpen.value = false
await loadSelectedData()
ElMessage.success('货物已加入当前柜次')
}, '分配货物失败', { preserveAssign: true })
ElMessage.success('明细已加入当前柜次')
}, '分配明细失败', { preserveAssign: true })
}
async function recommend() {
if (!plan.value) return
@@ -312,7 +309,7 @@ async function adjust(source: object) {
return
}
const max = adjustmentMax(item)
if (count > max) { ElMessage.warning(`该货物最多可分配 ${max} 箱`); return }
if (count > max) { ElMessage.warning(`该明细最多可分配 ${max} 箱`); return }
await mutate(async () => {
acceptPlan(await adjustSmartLoadingItem(plan.value!.id, item.id, count, plan.value!.version, item.version))
await loadSelectedData()
@@ -322,7 +319,7 @@ async function adjust(source: object) {
async function remove(source: object) {
const item = itemRow(source)
if (!plan.value || !item.canRemove) return
const confirmed = await ElMessageBox.confirm(`确认从当前柜次移除“${item.productNameZh || item.inboundCode || '该货物'}”吗?`, '移除装柜明细', {
const confirmed = await ElMessageBox.confirm(`确认从当前柜次移除“${item.customerName || '当前客户'}”的 ${item.packageCount} 箱明细吗?`, '移除装柜明细', {
type: 'warning', confirmButtonText: '移除', cancelButtonText: '取消',
}).catch(() => false)
if (!confirmed || !plan.value) return
@@ -402,7 +399,7 @@ onMounted(() => { void load(); void loadCustomers() })
<el-tab-pane v-for="channel in channelTabs" :key="channel.code" :label="channel.name" :name="channel.code" />
</el-tabs>
<dl class="compact-summary" aria-label="当前渠道装柜统计">
<div><dt>候选货物</dt><dd><strong>{{ filteredCandidates.length }}</strong> 条</dd></div>
<div><dt>候选明细</dt><dd><strong>{{ filteredCandidates.length }}</strong> 条</dd></div>
<div><dt>柜次</dt><dd><strong>{{ filteredContainers.length }}</strong> 个</dd></div>
<div><dt>已装</dt><dd><strong>{{ totalPackages }}</strong> 箱</dd></div>
<div><dt>总重量</dt><dd><strong>{{ formatNumber(totalWeight, 1) }}</strong> kg</dd></div>
@@ -426,7 +423,7 @@ onMounted(() => { void load(); void loadCustomers() })
<template v-else>
<section class="dispatch-grid" aria-label="智能装柜调度台">
<section class="dispatch-pane candidate-pane">
<header class="pane-header"><div><strong>候选入库单与货物</strong><small>{{ activeChannel?.name || '未选择渠道' }} · {{ selectedContainer?.warehouseName || '请先选择柜次' }}</small></div></header>
<header class="pane-header"><div><strong>候选装柜明细</strong><small>{{ activeChannel?.name || '未选择渠道' }} · {{ selectedContainer?.warehouseName || '请先选择柜次' }}</small></div></header>
<DataTable
:data="filteredCandidates"
:columns="candidateColumns"
@@ -434,7 +431,7 @@ onMounted(() => { void load(); void loadCustomers() })
stripe
preference-key="global-smart-loading-candidates"
column-settings-mode="hide"
:empty-text="selectedContainer ? '当前仓库暂无可装货物' : '暂无可用柜次'"
:empty-text="selectedContainer ? '当前仓库暂无可装明细' : '暂无可用柜次'"
>
<template #actions="{ row }">
<el-button
@@ -446,14 +443,13 @@ onMounted(() => { void load(); void loadCustomers() })
@click="openAssign(row)"
><Plus :size="15" />加入</el-button>
</template>
<template #order="{ row }"><strong>{{ row.inboundCode || '—' }}</strong></template>
<template #unitVolume="{ row }">{{ formatNumber(Number(row.unitVolumeCbm || 0)) }} m³</template>
<template #unitWeight="{ row }">{{ formatNumber(Number(row.unitWeightKg || 0), 1) }} kg</template>
<template #unitVolume="{ row }">{{ formatNumber(Number(row.unitVolumeCbm || 0)) }} m³</template>
</DataTable>
</section>
<aside class="dispatch-pane container-pane" aria-label="货柜列表">
<header class="pane-header"><div><strong>当前渠道柜次</strong><small>显示待装柜、预装编辑和尚未最终完成的 PDA 柜次</small></div></header>
<header class="pane-header"><div><strong>当前渠道柜次</strong></div></header>
<div v-loading="loading" class="container-list">
<el-empty v-if="!filteredContainers.length" description="暂无符合条件的货柜" :image-size="72" />
<button
@@ -474,14 +470,14 @@ onMounted(() => { void load(); void loadCustomers() })
<span>集装箱号:{{ displayActualContainerNo(item) }}</span>
<span>渠道:{{ item.channelName || item.channelCode || '渠道待补充' }}</span>
<span>仓库:{{ item.warehouseName || item.warehouseId || '仓库待补充' }} · 柜型:{{ item.containerType || '柜型待补充' }}</span>
<span v-if="!hasRealContainerNo(item)" class="container-option__hint">可先排货,开始装柜前录入集装箱号</span>
<!-- 空柜的操作时机由状态和操作按钮表达,不在每张柜次卡片重复提示。 -->
</button>
</div>
</aside>
<section class="dispatch-pane plan-pane">
<header class="pane-header plan-header">
<div><strong>{{ hasRealContainerNo(selectedContainer) ? displayActualContainerNo(selectedContainer) : displayCabinetNo(selectedContainer) }} 装柜方案</strong><small>{{ selectedContainer ? `柜次号:${displayCabinetNo(selectedContainer)} · 集装箱号:${displayActualContainerNo(selectedContainer)}` : '尚未选择柜次' }}</small></div>
<div><strong>{{ hasRealContainerNo(selectedContainer) ? displayActualContainerNo(selectedContainer) : displayCabinetNo(selectedContainer) }} 装柜方案</strong></div>
<div class="plan-actions">
<el-tooltip content="刷新当前柜次" placement="top"><el-button aria-label="刷新当前柜次" :loading="selectionLoading" @click="loadSelectedData()"><RefreshCw :size="16" /></el-button></el-tooltip>
<el-button v-if="canAssign && assignable && plan?.status === 'EDITING'" type="primary" plain :loading="mutating" @click="recommend"><Sparkles :size="16" />智能推荐</el-button>
@@ -504,17 +500,12 @@ onMounted(() => { void load(); void loadCustomers() })
<el-button v-if="canCreate" type="primary" :loading="mutating" @click="createPlan"><Plus :size="16" />创建装柜方案</el-button>
</el-empty>
<template v-else-if="plan">
<!--
已下发 PDA 的可编辑说明不再常驻展示,避免挤占装柜明细空间。
操作权限仍由 plan.canEdit、canAdjust 和 canRemove 控制。
-->
<el-alert
v-if="plan.status === 'LOADING' && plan.canEdit"
class="preload-readiness-alert"
type="warning"
:closable="false"
show-icon
title="已下发 PDA,仍可维护未完成的装柜明细"
description="新增、增加或移除未拣明细会刷新 PDA 明细与版本;PDA 端需要刷新后继续操作。"
/>
<el-alert
v-else-if="!plan.canEdit"
v-if="!plan.canEdit"
class="preload-readiness-alert"
:type="plan.finalCompletionStatus === 'COMPLETED' ? 'success' : 'info'"
:closable="false"
@@ -522,25 +513,16 @@ onMounted(() => { void load(); void loadCustomers() })
:title="plan.finalCompletionStatus === 'IN_PROGRESS' ? '正在最终确认,装柜方案暂时只读' : plan.finalCompletionStatus === 'COMPLETED' ? '装柜已完成,方案永久只读' : '当前装柜方案只读'"
:description="plan.blockedReason || '请刷新计划获取最新作业状态。'"
/>
<el-alert
class="preload-readiness-alert"
type="info"
:closable="false"
show-icon
title="推送预装仅生成预装清单,不会直接开放 PDA"
description="后续依次完成:补集装箱号、关联并放舱真实订舱、确认预装;全部满足后才生成 PDA 任务。"
/>
<!-- 推送预装的流程说明已由操作确认框承载,不在方案区重复展示。 -->
<div class="capacity-grid">
<div><div class="meter-title"><span>体积</span><strong>{{ formatNumber(totalVolume) }} / {{ formatNumber(volumeLimit) }} m³</strong></div><el-progress :percentage="Math.min(displayedVolumePercent, 100)" :status="volumePercent > 100 ? 'exception' : undefined" /></div>
<div><div class="meter-title"><span>重量</span><strong>{{ formatNumber(totalWeight, 1) }} / {{ formatNumber(weightLimit, 1) }} kg</strong></div><el-progress :percentage="Math.min(displayedWeightPercent, 100)" :status="weightPercent > 100 ? 'exception' : undefined" /></div>
</div>
<DataTable :data="plan.items" :columns="itemColumns" :loading="selectionLoading || mutating" stripe preference-key="global-smart-loading-items" column-settings-mode="hide" empty-text="当前柜次暂无已装货物">
<DataTable :data="plan.items" :columns="itemColumns" :loading="selectionLoading || mutating" stripe preference-key="global-smart-loading-items" column-settings-mode="hide" empty-text="当前柜次暂无装柜明细">
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canAssign && editable && row.canAdjust" size="small" type="primary" plain :disabled="quantityDrafts[row.id] === row.packageCount" @click="adjust(row)"><Save :size="15" />保存</el-button><el-button v-if="canDelete && editable && row.canRemove" size="small" type="danger" plain @click="remove(row)"><Trash2 :size="15" />移除</el-button><small v-if="row.pickedPackageCount > 0">已拣 {{ row.pickedPackageCount }} 箱</small></div></template>
<template #order="{ row }"><strong>{{ row.inboundCode || '—' }}</strong></template>
<template #goods="{ row }"><strong>{{ row.productNameZh || '未命名货物' }}</strong><small>{{ row.skuCode || '无 SKU' }}</small></template>
<template #packageCount="{ row }"><el-input-number v-model="quantityDrafts[row.id]" :min="row.minPackageCount" :max="adjustmentMax(row)" :step="1" :disabled="!canAssign || !editable || !row.canAdjust" controls-position="right" /></template>
<template #volume="{ row }">{{ formatNumber(Number(row.volumeCbm || 0)) }} m³</template>
<template #weight="{ row }">{{ formatNumber(Number(row.grossWeightKg || 0), 1) }} kg</template>
<template #volume="{ row }">{{ formatNumber(Number(row.volumeCbm || 0)) }} m³</template>
</DataTable>
</template>
</section>
@@ -548,8 +530,8 @@ onMounted(() => { void load(); void loadCustomers() })
<AppDialog v-model="assignOpen" title="加入当前柜次" :confirm-loading="mutating" confirm-text="确认加入" @confirm="assign">
<div v-if="selectedCandidate" class="assign-dialog">
<p><strong>{{ selectedCandidate.goodsName || '未命名货物' }}</strong></p>
<p>{{ selectedCandidate.inboundCode || '入仓码暂缺' }} · 可用 {{ selectedCandidate.availablePackageCount }} 箱</p>
<p><strong>{{ selectedCandidate.customerName || '客户待补充' }}</strong></p>
<p>可用 {{ selectedCandidate.availablePackageCount }} 箱 · 单箱 {{ formatNumber(Number(selectedCandidate.unitWeightKg || 0), 1) }} kg · {{ formatNumber(Number(selectedCandidate.unitVolumeCbm || 0)) }} m³</p>
<el-form label-position="top"><el-form-item label="本次分配箱数" required><el-input-number v-model="assignForm.packageCount" :min="1" :max="selectedCandidate.availablePackageCount" :step="1" controls-position="right" /></el-form-item></el-form>
</div>
</AppDialog>
+3 -1
View File
@@ -61,7 +61,9 @@ export interface ContainerSimulationCreateRequest {
}
export interface ContainerOceanBookingLinkRequest {
realContainerBookingId: string
oceanBookingId: string
requirementId: string
containerNo: string
expectedVersion: number
}
@@ -161,6 +161,16 @@ describe('InboundFeePanel', () => {
expect(document.body.textContent).toContain('没有查看入库费用的权限')
})
it.each(['', ' ', 'null', 'undefined'])('does not request a fee for invalid inboundId %j', async (inboundId) => {
const pinia = setupPermissions(['firstleg:fba-pricing:view'])
mount(InboundFeePanel, {
props: { customerId: '10001', inboundId },
global: { plugins: [pinia, ElementPlus] },
})
await flushPromises()
expect(getLatestInboundFee).not.toHaveBeenCalled()
})
it('treats A0404 as a neutral not-yet-generated state', async () => {
vi.mocked(getLatestInboundFee).mockRejectedValue(new ApiResponseError('费用快照不存在', 'A0404'))
mountPanel(['firstleg:fba-pricing:view'])
+9 -5
View File
@@ -66,6 +66,10 @@ const adjustmentRules: FormRules = {
const canView = computed(() => access.can('firstleg:fba-pricing:view'))
const canAdjust = computed(() => access.can('firstleg:fba-pricing:adjust'))
const customerContextId = computed(() => props.customerId || props.tenantId || '')
const normalizedInboundId = computed(() => {
const identifier = String(props.inboundId ?? '').trim()
return identifier && identifier !== 'null' && identifier !== 'undefined' ? identifier : ''
})
const adjustmentEligible = computed(() => (
snapshot.value?.feeStatus === 'CALCULATED'
&& snapshot.value?.nextProcessStatus === 'WAIT_SPLIT'
@@ -126,14 +130,14 @@ async function loadFee(options: { preserveAdjustment?: boolean } = {}) {
const sequence = ++loadSequence
loadError.value = null
notFound.value = false
if (!customerContextId.value || !props.inboundId || !canView.value) {
if (!customerContextId.value || !normalizedInboundId.value || !canView.value) {
snapshot.value = null
loading.value = false
return
}
loading.value = true
try {
const result = await getLatestInboundFee(customerContextId.value, props.inboundId)
const result = await getLatestInboundFee(customerContextId.value, normalizedInboundId.value)
if (sequence !== loadSequence) return
snapshot.value = result
emit('loaded', result)
@@ -197,7 +201,7 @@ async function submitAdjustment() {
try {
const result = await adjustPublishedInboundFee(
customerContextId.value,
props.inboundId,
normalizedInboundId.value,
payload,
getAdjustmentKey(payload),
)
@@ -219,7 +223,7 @@ async function submitAdjustment() {
}
watch(
() => [customerContextId.value, props.inboundId, access.permissionSignature],
() => [customerContextId.value, normalizedInboundId.value, access.permissionSignature],
() => {
adjustOpen.value = false
snapshot.value = null
@@ -247,7 +251,7 @@ defineExpose({ reload: loadFee })
circle
aria-label="刷新费用快照"
:loading="loading"
:disabled="!customerContextId || !inboundId || !canView"
:disabled="!customerContextId || !normalizedInboundId || !canView"
@click="loadFee()"
><RefreshCw :size="16" /></el-button>
</el-tooltip>
+5
View File
@@ -23,6 +23,11 @@ const snapshot = {
describe('inbound fee API', () => {
beforeEach(() => vi.clearAllMocks())
it.each(['', ' ', 'null', 'undefined'])('rejects invalid inboundId %j without issuing HTTP', async (inboundId) => {
await expect(getLatestInboundFee('10001', inboundId)).rejects.toThrow('inboundId is required')
expect(http.get).not.toHaveBeenCalled()
})
it('loads the latest immutable fee snapshot with string IDs', async () => {
vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: snapshot })
+3 -1
View File
@@ -2,7 +2,9 @@ import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type { AdjustPublishedInboundFeeRequest, InboundFeeSnapshot } from './types'
function feeBasePath(inboundId: string) {
return `/platform/v1/inbounds/${encodeURIComponent(inboundId)}/fees`
const identifier = String(inboundId ?? '').trim()
if (!identifier || identifier === 'null' || identifier === 'undefined') throw new TypeError('inboundId is required')
return `/platform/v1/inbounds/${encodeURIComponent(identifier)}/fees`
}
export async function getLatestInboundFee(tenantId: string, inboundId: string) {
@@ -87,7 +87,7 @@ function measurement(overrides: Partial<InboundMeasurementDetail> = {}): Inbound
volumeWeightKg: 100.2,
intelligentImageFileId: '88001',
machineNo: 'MJ20210327',
measuredAt: '2026-08-04T10:30:00+08:00',
deviceMeasuredAt: '2026-08-04T10:30:00+08:00',
overLimitWarning: null,
outerLengthCm: null,
outerWidthCm: null,
@@ -199,10 +199,13 @@ describe('InboundMeasurementPanel', () => {
const text = wrapper.text()
expect(text).toContain('实测箱数')
expect(text).toContain('货物重量')
expect(text).toContain('货物净重')
expect(text).toContain('总体积')
expect(text).toContain('材积重')
expect(text).toContain('申报信息')
expect(text).toContain('客户申报')
expect(text).toContain('平台实测')
expect(text).toContain('差异 +1.5 kg')
expect(text).toContain('差异 -0.2 m³')
expect(text).toContain('箱规明细')
expect(text).not.toContain('租户')
expect(text).not.toContain('数据版本')
@@ -210,6 +213,8 @@ describe('InboundMeasurementPanel', () => {
expect(wrapper.find('.measurement-table').exists()).toBe(true)
expect(wrapper.find('.measurement-item').exists()).toBe(false)
expect(wrapper.find('.measurement-table').text()).not.toContain('周长')
expect(wrapper.text()).not.toContain('每箱毛重')
expect(wrapper.text()).not.toContain('总重量(kg)')
const source = await import('./InboundMeasurementPanel.vue?raw').then((module) => module.default)
expect(source).toContain('<section class="measurement-declaration"')
expect(source).not.toContain('<el-collapse-item name="declaration"')
@@ -237,6 +242,12 @@ describe('InboundMeasurementPanel', () => {
expect(declaration.find('.measurement-goods-name input').exists()).toBe(true)
})
it('does not render differences when the corresponding declaration is unavailable', async () => {
vi.mocked(getInboundMeasurement).mockResolvedValueOnce(measurement({ originalGrossWeightKg: null, originalVolumeCbm: null }))
const wrapper = await mountPanel()
expect(wrapper.find('.measurement-metrics').text()).not.toContain('差异')
})
it('shows all declaration fields as plain text when measurement is not editable', async () => {
vi.mocked(getInboundMeasurement).mockResolvedValueOnce(measurement({ processStatus: 'WAIT_SPLIT' }))
const wrapper = await mountPanel()
@@ -396,6 +407,16 @@ describe('InboundMeasurementPanel', () => {
expect(wrapper.emitted('updated')).toBeUndefined()
})
it('maps the formal deviceMeasuredAt response field into the read-only measurement time', async () => {
const wrapper = await mountPanel()
await wrapper.get('.el-table__expand-icon').trigger('click')
expect(wrapper.text()).toContain('2026-08-04 10:30:00+08:00')
const source = await import('./InboundMeasurementPanel.vue?raw').then((module) => module.default)
expect(source).toContain('measuredAt: item.deviceMeasuredAt')
expect(source).not.toContain('measuredAt: item.measuredAt')
})
it('blocks normal submission when confirmed box counts do not match the order total', async () => {
vi.mocked(getInboundMeasurement).mockResolvedValueOnce(measurement({ originalPackageCount: 12 }))
const wrapper = await mountPanel()
@@ -214,7 +214,7 @@ function editableItem(item: InboundMeasurementItem): EditableMeasurementItem {
unitWeightKg: item.unitWeightKg,
intelligentImageFileId: item.intelligentImageFileId == null ? null : String(item.intelligentImageFileId),
machineNo: item.machineNo,
measuredAt: item.measuredAt,
measuredAt: item.deviceMeasuredAt,
overLimitWarning: item.overLimitWarning,
outerLengthCm: item.outerLengthCm,
outerWidthCm: item.outerWidthCm,
@@ -398,6 +398,14 @@ function formatNumber(value: number | null | undefined, maximumFractionDigits =
return new Intl.NumberFormat('zh-CN', { maximumFractionDigits }).format(value)
}
function formatDifference(actual: number | null | undefined, declared: number | null | undefined, maximumFractionDigits = 3, unit = '') {
if (actual == null || declared == null || !Number.isFinite(actual) || !Number.isFinite(declared)) return null
const difference = actual - declared
if (difference === 0) return null
const sign = difference > 0 ? '+' : ''
return `差异 ${sign}${formatNumber(difference, maximumFractionDigits)}${unit}`
}
function releaseImagePreview() {
if (imagePreviewUrl.value) URL.revokeObjectURL(imagePreviewUrl.value)
imagePreviewUrl.value = ''
@@ -510,19 +518,20 @@ defineExpose({ saveDraft, submitForReview, updateActualWeights, mayMaintain, may
</div>
</header>
<dl class="measurement-metrics" aria-label="测量结果汇总">
<h4 class="measurement-subheading">平台实测</h4>
<dl class="measurement-metrics" aria-label="平台实测结果汇总">
<div :class="{ 'measurement-metric--warning': !packageCountMatched }">
<dt>实测箱数</dt>
<dd>{{ formatNumber(confirmedPackageCount, 0) }} <small>/ 申报 {{ formatNumber(expectedPackageCount, 0) }}</small></dd>
<dd>{{ formatNumber(confirmedPackageCount, 0) }} <small v-if="formatDifference(confirmedPackageCount, expectedPackageCount, 0)">/ {{ formatDifference(confirmedPackageCount, expectedPackageCount, 0) }}</small></dd>
</div>
<div><dt>实测毛重</dt><dd>{{ formatNumber(detail.actualGrossWeightKg) }} <small>kg</small></dd></div>
<div class="measurement-metric--primary"><dt>货物重量</dt><dd>{{ formatNumber(detail.actualNetWeightKg) }} <small>kg</small></dd></div>
<div><dt>总体积</dt><dd>{{ formatNumber(detail.actualVolumeCbm, 4) }} <small>m³</small></dd></div>
<div><dt>实测毛重</dt><dd>{{ formatNumber(detail.actualGrossWeightKg) }} <small>kg<span v-if="formatDifference(detail.actualGrossWeightKg, detail.originalGrossWeightKg, 3, ' kg')"> · {{ formatDifference(detail.actualGrossWeightKg, detail.originalGrossWeightKg, 3, ' kg') }}</span></small></dd></div>
<div class="measurement-metric--primary"><dt>货物净重</dt><dd>{{ formatNumber(detail.actualNetWeightKg) }} <small>kg</small></dd></div>
<div><dt>总体积</dt><dd>{{ formatNumber(detail.actualVolumeCbm, 4) }} <small>m³<span v-if="formatDifference(detail.actualVolumeCbm, detail.originalVolumeCbm, 4, ' m³')"> · {{ formatDifference(detail.actualVolumeCbm, detail.originalVolumeCbm, 4, ' m³') }}</span></small></dd></div>
<div><dt>材积重</dt><dd>{{ formatNumber(detail.volumeWeightKg) }} <small>kg</small></dd></div>
</dl>
<section class="measurement-declaration" aria-labelledby="measurement-declaration-title">
<h4 id="measurement-declaration-title">申报信息</h4>
<h4 id="measurement-declaration-title">客户申报</h4>
<div class="measurement-data-grid">
<div class="measurement-readonly-field"><span>申报箱数</span><strong>{{ formatNumber(expectedPackageCount, 0) }}</strong></div>
<div class="measurement-readonly-field"><span>申报毛重(kg)</span><strong>{{ formatNumber(detail.originalGrossWeightKg) }}</strong></div>
@@ -554,7 +563,6 @@ defineExpose({ saveDraft, submitForReview, updateActualWeights, mayMaintain, may
<dl class="measurement-row-details">
<div><dt>机器编号</dt><dd>{{ row.machineNo || '人工录入' }}</dd></div>
<div><dt>测量时间</dt><dd>{{ row.measuredAt ? row.measuredAt.replace('T', ' ') : '—' }}</dd></div>
<div><dt>每箱毛重</dt><dd>{{ formatNumber(row.unitWeightKg) }} kg</dd></div>
<div><dt>单箱体积</dt><dd>{{ formatNumber(row.serverValues.unitVolumeCbm, 4) }} m³</dd></div>
<div><dt>箱规周长</dt><dd>{{ formatNumber(row.serverValues.girthCm) }} cm</dd></div>
<div><dt>总材积重</dt><dd>{{ formatNumber(row.serverValues.volumeWeightKg) }} kg</dd></div>
@@ -583,9 +591,6 @@ defineExpose({ saveDraft, submitForReview, updateActualWeights, mayMaintain, may
/>
</template>
</el-table-column>
<el-table-column label="总重量(kg)" width="112" align="right">
<template #default="{ row }">{{ formatNumber(row.serverValues.totalWeightKg) }}</template>
</el-table-column>
<el-table-column label="总体积(m³)" width="108" align="right">
<template #default="{ row }">{{ formatNumber(row.serverValues.totalVolumeCbm, 4) }}</template>
</el-table-column>
@@ -679,6 +684,7 @@ defineExpose({ saveDraft, submitForReview, updateActualWeights, mayMaintain, may
.measurement-summary-context p { display: inline-flex; min-width: 0; align-items: baseline; gap: 6px; margin: 0; overflow-wrap: anywhere; color: var(--cc-text); font-size: 12px; font-weight: 600; }
.measurement-summary-status { display: flex; flex: 0 0 auto; align-items: center; gap: 9px; }
.measurement-metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin: 2px 0; }
.measurement-subheading { margin: 0; color: #344054; font-size: 12px; font-weight: 700; }
.measurement-metrics > div { display: grid; min-width: 0; min-height: 58px; align-content: center; gap: 4px; padding: 5px 18px; border-left: 1px solid var(--cc-border); }
.measurement-metrics > div:first-child { padding-left: 0; border-left: 0; }
.measurement-metrics dt { color: var(--cc-muted); font-size: 12px; }
+1 -1
View File
@@ -34,7 +34,7 @@ function segment(value: string) {
export async function getInboundMeasurement(customerId: string, inboundId: string) {
// Device-scanned fields expected from the backend: originalPackageCount and
// items[].boxCode, machineNo, measuredAt. Keep this compatibility mapping here.
// items[].boxCode, machineNo, deviceMeasuredAt. Keep the response aligned with the backend OpenAPI.
const response = await http.get<ApiEnvelope<InboundMeasurementDetail>, ApiEnvelope<InboundMeasurementDetail>>(
measurementPath(inboundId),
{ params: { tenantId: customerId } },
+1 -1
View File
@@ -28,7 +28,7 @@ export interface InboundMeasurementItem {
volumeWeightKg: number | null
intelligentImageFileId: MeasurementIdentifier | null
machineNo: string | null
measuredAt: string | null
deviceMeasuredAt: string | null
overLimitWarning: string | null
outerLengthCm: number | null
outerWidthCm: number | null
@@ -23,6 +23,15 @@ vi.mock('../weight/components/WeightOcrPanel.vue', () => ({
},
}))
vi.mock('../measurement/components/MeasurementEvidenceThumbnail.vue', () => ({
default: {
name: 'MeasurementEvidenceThumbnail',
props: { customerId: String, inboundId: String, fileId: String, alt: String, emptyText: String },
emits: ['open'],
template: '<button class="mock-measurement-image" @click="$emit(\'open\')">测量图片</button>',
},
}))
const detail: MeasurementReviewDetail = {
inboundId: '90001', inboundCode: 'RK90001', companyName: '柏悦客户', channelCode: 'SEA', channelName: '海运',
processStatus: 'MEASURE_PENDING', measureReviewStatus: 'PENDING', recommendedPalletType: 'STANDARD', reviewReason: null, reviewerId: null, reviewerName: null,
@@ -71,6 +80,8 @@ describe('MeasurementReviewPanel', () => {
expect(wrapper.text()).not.toContain('入仓码')
expect(wrapper.text()).not.toContain('驳回')
expect(wrapper.text()).not.toContain('异常放行')
expect(wrapper.text()).not.toContain('审核历史')
expect(listMeasurementReviewHistory).not.toHaveBeenCalled()
const source = await import('./MeasurementReviewPanel.vue?raw').then((module) => module.default)
expect(source).not.toContain('position: sticky')
expect(source).toContain('review-toolbar__context')
@@ -78,6 +89,7 @@ describe('MeasurementReviewPanel', () => {
expect(source).not.toContain('reasonDialog')
expect(source).not.toContain('saveInboundMeasurementDraft')
expect(source).not.toContain('submitInboundMeasurement')
expect(source).not.toContain('listMeasurementReviewHistory')
})
it('uses the list customer context when the review detail has no customer name', async () => {
@@ -91,6 +103,73 @@ describe('MeasurementReviewPanel', () => {
expect(wrapper.get('.review-toolbar__context').text()).toBe('客户:欧阳的公司 · 渠道:海运')
})
it('shows declared facts, authoritative measurement totals, and box-detail evidence for manual review', async () => {
vi.mocked(getMeasurementReviewDetail).mockResolvedValue({
...detail,
originalPackageCount: 9,
originalGrossWeightKg: 118,
originalVolumeCbm: 2,
actualPackageCount: 10,
actualGrossWeightKg: 120,
actualNetWeightKg: 110,
actualVolumeCbm: 2.2,
volumeWeightKg: 367.4,
items: [{
id: 'item-1', boxCode: 'BOX-001', itemNo: '1', boxSpecification: null, packageCount: 10,
lengthCm: 60, widthCm: 40, heightCm: 30, girthCm: 200, unitWeightKg: 12,
unitVolumeCbm: 0.072, totalWeightKg: 120, totalVolumeCbm: 0.72, volumeWeightKg: 120.2,
intelligentImageFileId: '88001', machineNo: 'DEVICE-01', deviceMeasuredAt: '2026-08-18T10:30:00',
overLimitWarning: null, outerLengthCm: null, outerWidthCm: null, outerHeightCm: null, remark: null,
}],
})
const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] } })
await flushPromises()
const summary = wrapper.get('.review-data').text()
expect(summary).toContain('审核数据总览')
const comparisonRows = wrapper.findAll('.review-comparison__row')
expect(comparisonRows).toHaveLength(2)
expect(comparisonRows[0].text()).toContain('客户申报')
expect(comparisonRows[0].text()).toContain('箱数9')
expect(comparisonRows[0].text()).toContain('毛重118 kg')
expect(comparisonRows[0].text()).not.toContain('实际品名')
expect(comparisonRows[0].text()).not.toContain('原单据号码')
expect(comparisonRows[1].text()).toContain('平台实测')
expect(comparisonRows[1].text()).toContain('箱数10 差异 +1')
expect(comparisonRows[1].text()).toContain('毛重120 kg · 差异 +2 kg')
expect(comparisonRows[1].text()).toContain('货物净重110 kg')
expect(comparisonRows[1].text()).toContain('体积2.2 m³ · 差异 +0.2 m³')
expect(comparisonRows[1].text()).toContain('体积重367.4 kg')
const items = wrapper.get('.review-items').text()
expect(items).toContain('箱规测量明细')
expect(items).toContain('BOX-001')
expect(items).toContain('60 × 40 × 30')
expect(items).toContain('120.2')
expect(items).toContain('2026-08-18 10:30:00')
expect(items).not.toContain('每箱毛重(kg)')
expect(items).not.toContain('总重量(kg)')
const image = wrapper.getComponent({ name: 'MeasurementEvidenceThumbnail' })
expect(image.props('customerId')).toBe('10001')
expect(image.props('inboundId')).toBe('90001')
expect(image.props('fileId')).toBe('88001')
const source = await import('./MeasurementReviewPanel.vue?raw').then((module) => module.default)
expect(source).toContain(':file-id="row.intelligentImageFileId"')
expect(source).toContain('openImagePreview(row.intelligentImageFileId')
expect(source).toContain('downloadInboundMeasurementImage')
expect(source).toContain('.review-table :deep(.el-table__header .cell) { white-space: nowrap; }')
expect(source).toContain('.review-table :deep(.review-time-cell .cell) { white-space: nowrap; }')
})
it('keeps difference labels absent when declared values are unavailable', async () => {
vi.mocked(getMeasurementReviewDetail).mockResolvedValue({ ...detail, originalGrossWeightKg: null, originalVolumeCbm: null })
const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.get('.review-comparison').text()).not.toContain('差异')
})
it('reuses WeightOcrPanel and enables only its review correction mode', async () => {
const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] } })
await flushPromises()
@@ -1,15 +1,17 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Check, Pencil, RotateCcw } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import { downloadInboundMeasurementImage } from '../measurement/api'
import MeasurementEvidenceThumbnail from '../measurement/components/MeasurementEvidenceThumbnail.vue'
import { inboundReviewStatusLabels, inboundStatusLabel } from '../status'
import WeightOcrPanel from '../weight/components/WeightOcrPanel.vue'
import { approveMeasurement, getMeasurementReviewDetail, listMeasurementReviewHistory } from './api'
import type { MeasurementReviewDetail, MeasurementReviewRecord } from './types'
import { approveMeasurement, getMeasurementReviewDetail } from './api'
import type { MeasurementReviewDetail } from './types'
const props = defineProps<{
customerId?: string
@@ -25,14 +27,17 @@ const emit = defineEmits<{ changed: [] }>()
const access = useAccessPreviewStore()
const detail = ref<MeasurementReviewDetail | null>(null)
const history = ref<MeasurementReviewRecord[]>([])
const loading = ref(false)
const historyLoading = ref(false)
const actionLoading = ref('')
const loadError = ref<ApiErrorDetails | null>(null)
const historyError = ref<ApiErrorDetails | null>(null)
const correctionMode = ref(false)
const requestSerial = ref(0)
const imagePreviewOpen = ref(false)
const imagePreviewTitle = ref('测量图片')
const imagePreviewUrl = ref('')
const imagePreviewLoading = ref(false)
const imagePreviewError = ref<ApiErrorDetails | null>(null)
let imagePreviewRequestSerial = 0
const canReview = computed(() => access.can('firstleg:inbound:measurement-review'))
const customerId = computed(() => props.customerId || props.tenantId || '')
@@ -41,15 +46,56 @@ const isRejected = computed(() => detail.value?.measureReviewStatus === 'REJECTE
const reviewStatusLabel = computed(() => inboundStatusLabel(detail.value?.measureReviewStatus, inboundReviewStatusLabels, '未提交'))
const displayCustomerName = computed(() => detail.value?.companyName?.trim() || props.customerName?.trim() || '客户未命名')
const displayChannelName = computed(() => detail.value?.channelName?.trim() || detail.value?.channelCode?.trim() || props.channelName?.trim() || props.channelCode?.trim() || '渠道未填写')
const sourceLabels: Record<string, string> = {
THIRD_PARTY: '第三方设备',
DEVICE: '第三方设备',
OPENAPI: '第三方设备',
MANUAL: '平台人工',
}
const sourceLabel = computed(() => sourceLabels[detail.value?.sourceType || ''] || (detail.value?.sourceType ? '未知来源' : '尚未记录'))
function formatNumber(value: number | null | undefined, maximumFractionDigits = 3) {
if (value === null || value === undefined || !Number.isFinite(value)) return '—'
return new Intl.NumberFormat('zh-CN', { maximumFractionDigits, minimumFractionDigits: 0 }).format(value)
}
function formatDifference(actual: number | null | undefined, declared: number | null | undefined, maximumFractionDigits = 3, unit = '') {
if (actual == null || declared == null || !Number.isFinite(actual) || !Number.isFinite(declared)) return null
const difference = actual - declared
if (difference === 0) return null
const sign = difference > 0 ? '+' : ''
return `差异 ${sign}${formatNumber(difference, maximumFractionDigits)}${unit}`
}
function formatDate(value: string | null | undefined) {
return value ? value.replace('T', ' ') : '—'
}
function resultLabel(value: string | null | undefined) {
if (value === 'APPROVED') return '审核通过'
if (value === 'REJECTED') return '审核驳回'
return '未知结果'
function releaseImagePreview() {
imagePreviewRequestSerial += 1
if (imagePreviewUrl.value) URL.revokeObjectURL(imagePreviewUrl.value)
imagePreviewUrl.value = ''
imagePreviewLoading.value = false
imagePreviewError.value = null
}
async function openImagePreview(fileId: string | null, itemLabel: string) {
if (!fileId) return
releaseImagePreview()
const serial = ++imagePreviewRequestSerial
imagePreviewTitle.value = `测量图片 · ${itemLabel}`
imagePreviewOpen.value = true
imagePreviewLoading.value = true
try {
const blob = await downloadInboundMeasurementImage(customerId.value, props.inboundId, fileId)
if (serial !== imagePreviewRequestSerial) return
imagePreviewUrl.value = URL.createObjectURL(blob)
} catch (error) {
if (serial !== imagePreviewRequestSerial) return
imagePreviewError.value = getApiErrorDetails(error, '测量原图读取失败,请稍后重试')
} finally {
if (serial === imagePreviewRequestSerial) imagePreviewLoading.value = false
}
}
function isConflict(error: unknown) {
@@ -74,21 +120,8 @@ async function loadReview() {
}
}
async function loadHistory() {
historyError.value = null
historyLoading.value = true
try {
history.value = (await listMeasurementReviewHistory(customerId.value, props.inboundId)).records
} catch (error) {
history.value = []
historyError.value = getApiErrorDetails(error, '审核历史加载失败')
} finally {
historyLoading.value = false
}
}
async function reload() {
await Promise.all([loadReview(), loadHistory()])
await loadReview()
}
async function handleWeightChanged() {
@@ -132,6 +165,7 @@ watch(() => [customerId.value, props.inboundId], () => {
onMounted(() => {
if (!customerId.value || !props.inboundId) loadError.value = { message: '缺少所属企业或入库单标识,无法加载审核数据' }
})
onBeforeUnmount(releaseImagePreview)
defineExpose({ confirmApprove, isPending, canReview, actionLoading })
</script>
@@ -150,7 +184,7 @@ defineExpose({ confirmApprove, isPending, canReview, actionLoading })
<div class="review-toolbar__actions">
<el-button v-if="isPending && canReview" type="primary" plain @click="correctionMode = true"><Pencil :size="16" />修改重量</el-button>
<el-button v-if="isPending && canReview" type="primary" :loading="actionLoading === 'approve'" @click="confirmApprove"><Check :size="16" />通过并进入待分柜</el-button>
<el-button :loading="loading || historyLoading" aria-label="刷新审核数据" @click="reload"><RotateCcw :size="16" />刷新</el-button>
<el-button :loading="loading" aria-label="刷新审核数据" @click="reload"><RotateCcw :size="16" />刷新</el-button>
</div>
</div>
@@ -163,6 +197,84 @@ defineExpose({ confirmApprove, isPending, canReview, actionLoading })
<template v-else-if="detail">
<el-alert v-if="detail.reviewReason" type="warning" :closable="false" show-icon :title="`最近审核说明:${detail.reviewReason}`" />
<section class="review-data" aria-labelledby="review-data-title">
<header class="review-section-heading">
<div>
<h3 id="review-data-title">审核数据总览</h3>
</div>
<el-tag effect="plain">{{ sourceLabel }}</el-tag>
</header>
<div class="review-comparison">
<div class="review-comparison__row review-comparison__row--declared">
<strong>客户申报</strong>
<dl class="review-declaration" aria-label="客户申报信息">
<div><dt>箱数</dt><dd>{{ formatNumber(detail.originalPackageCount, 0) }}</dd></div>
<div><dt>毛重</dt><dd>{{ formatNumber(detail.originalGrossWeightKg) }} <small>kg</small></dd></div>
<div><dt>体积</dt><dd>{{ formatNumber(detail.originalVolumeCbm, 4) }} <small>m³</small></dd></div>
</dl>
</div>
<div class="review-comparison__row">
<strong>平台实测</strong>
<dl class="review-metrics" aria-label="平台实测结果汇总">
<div><dt>箱数</dt><dd>{{ formatNumber(detail.actualPackageCount, 0) }} <small v-if="formatDifference(detail.actualPackageCount, detail.originalPackageCount, 0)">{{ formatDifference(detail.actualPackageCount, detail.originalPackageCount, 0) }}</small></dd></div>
<div><dt>毛重</dt><dd>{{ formatNumber(detail.actualGrossWeightKg) }} <small>kg<span v-if="formatDifference(detail.actualGrossWeightKg, detail.originalGrossWeightKg, 3, ' kg')"> · {{ formatDifference(detail.actualGrossWeightKg, detail.originalGrossWeightKg, 3, ' kg') }}</span></small></dd></div>
<div class="review-metric--primary"><dt>货物净重</dt><dd>{{ formatNumber(detail.actualNetWeightKg) }} <small>kg</small></dd></div>
<div><dt>体积</dt><dd>{{ formatNumber(detail.actualVolumeCbm, 4) }} <small>m³<span v-if="formatDifference(detail.actualVolumeCbm, detail.originalVolumeCbm, 4, ' m³')"> · {{ formatDifference(detail.actualVolumeCbm, detail.originalVolumeCbm, 4, ' m³') }}</span></small></dd></div>
<div><dt>体积重</dt><dd>{{ formatNumber(detail.volumeWeightKg) }} <small>kg</small></dd></div>
</dl>
</div>
</div>
</section>
<section class="review-items" aria-labelledby="review-items-title">
<header class="review-section-heading">
<div>
<h3 id="review-items-title">箱规测量明细</h3>
</div>
<span>{{ detail.items.length }} 条</span>
</header>
<div v-if="detail.items.length" class="review-table-scroll">
<el-table :data="detail.items" border stripe table-layout="fixed" class="review-table">
<el-table-column type="index" label="#" width="44" align="center" />
<el-table-column label="箱号" min-width="100" show-overflow-tooltip>
<template #default="{ row }">{{ row.boxCode || row.itemNo || '—' }}</template>
</el-table-column>
<el-table-column label="测量图片" width="98" align="center">
<template #default="{ row }">
<MeasurementEvidenceThumbnail
:customer-id="customerId"
:inbound-id="inboundId"
:file-id="row.intelligentImageFileId"
:alt="`箱规 ${row.boxCode || row.itemNo || row.id} 的测量图片`"
empty-text="无图片"
@open="openImagePreview(row.intelligentImageFileId, row.boxCode || row.itemNo || row.id)"
/>
</template>
</el-table-column>
<el-table-column label="尺寸 长×宽×高(cm)" width="132" align="center">
<template #default="{ row }">{{ formatNumber(row.lengthCm) }} × {{ formatNumber(row.widthCm) }} × {{ formatNumber(row.heightCm) }}</template>
</el-table-column>
<el-table-column prop="packageCount" label="箱数" width="54" align="right">
<template #default="{ row }">{{ formatNumber(row.packageCount, 0) }}</template>
</el-table-column>
<el-table-column label="单箱体积(m³)" width="106" align="right">
<template #default="{ row }">{{ formatNumber(row.unitVolumeCbm, 4) }}</template>
</el-table-column>
<el-table-column label="总体积(m³)" width="96" align="right">
<template #default="{ row }">{{ formatNumber(row.totalVolumeCbm, 4) }}</template>
</el-table-column>
<el-table-column label="体积重(kg)" width="96" align="right">
<template #default="{ row }">{{ formatNumber(row.volumeWeightKg) }}</template>
</el-table-column>
<el-table-column label="测量时间" width="152" class-name="review-time-cell">
<template #default="{ row }">{{ formatDate(row.deviceMeasuredAt) }}</template>
</el-table-column>
</el-table>
</div>
<el-empty v-else description="暂无箱规测量明细" :image-size="48" />
</section>
<WeightOcrPanel
review-mode
:correction-mode="correctionMode"
@@ -170,21 +282,19 @@ defineExpose({ confirmApprove, isPending, canReview, actionLoading })
:inbound-id="inboundId"
@changed="handleWeightChanged"
/>
<el-collapse class="review-history">
<el-collapse-item name="history" :title="`审核历史(${history.length})`">
<el-alert v-if="historyError" type="warning" :closable="false" show-icon><template #title><div class="review-alert-title"><span>{{ historyError.message }}</span><ApiErrorMeta :details="historyError" /></div></template><el-button link type="warning" @click="loadHistory">重试</el-button></el-alert>
<el-skeleton v-else-if="historyLoading" :rows="3" animated />
<el-empty v-else-if="!history.length" description="暂无审核历史" :image-size="48" />
<ol v-else class="history-list">
<li v-for="item in history" :key="item.id">
<div class="history-marker" :class="{ 'history-marker--success': item.reviewResult === 'APPROVED', 'history-marker--danger': item.reviewResult === 'REJECTED' }" />
<div class="history-content"><div><strong>{{ resultLabel(item.reviewResult) }}{{ item.exceptionRelease ? ' · 异常放行' : '' }}</strong><time>{{ formatDate(item.reviewedAt) }}</time></div><p>{{ item.releaseReason || item.reviewReason || '未填写说明' }}</p><span>{{ item.reviewerName || '系统' }}</span></div>
</li>
</ol>
</el-collapse-item>
</el-collapse>
</template>
<el-dialog v-model="imagePreviewOpen" :title="imagePreviewTitle" width="min(760px, 92vw)" destroy-on-close append-to-body @closed="releaseImagePreview">
<div class="review-image-preview">
<el-skeleton v-if="imagePreviewLoading" :rows="5" animated />
<div v-else-if="imagePreviewError" class="review-image-preview__error">
<span>{{ imagePreviewError.message }}</span>
<ApiErrorMeta :details="imagePreviewError" />
</div>
<img v-else-if="imagePreviewUrl" :src="imagePreviewUrl" alt="当前箱规测量原图" />
<el-empty v-else description="暂无可预览图片" />
</div>
</el-dialog>
</section>
</template>
@@ -197,26 +307,45 @@ defineExpose({ confirmApprove, isPending, canReview, actionLoading })
.review-toolbar__actions :deep(.el-button) { margin-left: 0; }
.review-toolbar__status > .review-toolbar__context { padding-left: 8px; border-left: 1px solid var(--cc-border); font-size: 13px; line-height: 1.55; overflow-wrap: anywhere; }
.review-alert-title { display: inline-flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.review-history { border-top: 1px solid var(--cc-border); border-bottom: 0; }
.review-history :deep(.el-collapse-item__header) { height: 42px; color: var(--cc-muted); font-size: 12px; }
.review-history :deep(.el-collapse-item__wrap) { border-bottom: 0; }
.history-list { display: grid; gap: 0; margin: 0; padding: 0; list-style: none; }
.history-list li { display: grid; grid-template-columns: 16px minmax(0, 1fr); gap: 10px; position: relative; padding-bottom: 16px; }
.history-list li:not(:last-child)::after { content: ''; position: absolute; top: 16px; bottom: 0; left: 7px; width: 1px; background: var(--cc-border); }
.history-marker { z-index: 1; width: 14px; height: 14px; margin-top: 2px; border: 3px solid #909399; border-radius: 50%; background: var(--cc-surface); }
.history-marker--success { border-color: #67c23a; }
.history-marker--danger { border-color: #f56c6c; }
.history-content { min-width: 0; }
.history-content > div { display: flex; justify-content: space-between; gap: 12px; }
.history-content time, .history-content > span { color: var(--cc-muted); font-size: 11px; }
.history-content p { margin: 5px 0 4px; color: var(--cc-text); font-size: 13px; overflow-wrap: anywhere; }
.review-data, .review-items { min-width: 0; padding: 0 0 12px; border-bottom: 1px solid var(--cc-border); }
.review-section-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 8px; }
.review-section-heading > div { min-width: 0; }
.review-section-heading h3 { margin: 0; color: var(--cc-text); font-size: 14px; line-height: 1.35; }
.review-section-heading > span { color: var(--cc-muted); font-size: 12px; white-space: nowrap; }
.review-comparison { border-top: 1px solid var(--cc-border); }
.review-comparison__row { display: grid; grid-template-columns: 78px minmax(0, 1fr); align-items: center; min-width: 0; padding: 8px 0; }
.review-comparison__row + .review-comparison__row { border-top: 1px dashed var(--cc-border); }
.review-comparison__row > strong { color: #344054; font-size: 12px; }
.review-metrics, .review-declaration { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 8px 16px; min-width: 0; margin: 0; }
.review-declaration { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.review-metrics > div, .review-declaration > div { display: flex; min-width: 0; align-items: baseline; gap: 6px; }
.review-metrics dt, .review-declaration dt { color: var(--cc-muted); font-size: 11px; line-height: 1.4; }
.review-metrics dd { margin: 0; overflow-wrap: anywhere; color: #172033; font-size: 15px; font-weight: 700; line-height: 1.35; }
.review-metrics dd small, .review-declaration dd small { color: var(--cc-muted); font-size: 11px; font-weight: 500; }
.review-metrics .review-metric--primary dd { color: #067647; }
.review-declaration dd { margin: 0; overflow-wrap: anywhere; color: var(--cc-text); font-size: 13px; font-weight: 650; line-height: 1.4; }
.review-table-scroll { width: 100%; min-width: 0; overflow: hidden; }
.review-table { width: 100%; font-size: 12px; }
.review-table :deep(.el-table__header th) { padding: 8px 0; background: #f6f8fa; color: #475467; font-size: 11px; font-weight: 700; }
.review-table :deep(.el-table__header .cell) { white-space: nowrap; }
.review-table :deep(.el-table__cell) { padding: 7px 0; }
.review-table :deep(.review-time-cell .cell) { white-space: nowrap; }
.review-table :deep(.measurement-evidence-thumb) { min-width: 72px; border-radius: 4px; }
.review-image-preview { display: grid; min-height: 240px; place-items: center; }
.review-image-preview img { display: block; width: auto; max-width: 100%; max-height: 70vh; object-fit: contain; }
.review-image-preview__error { display: grid; justify-items: center; gap: 8px; color: var(--el-color-danger); }
@media (max-width: 720px) {
.review-toolbar { align-items: stretch; flex-direction: column; margin: -14px -14px 0; padding: 12px 14px; }
.review-toolbar__status > .review-toolbar__context { width: 100%; padding-top: 8px; padding-left: 0; border-top: 1px solid var(--cc-border); border-left: 0; }
.review-toolbar__actions { display: grid; width: 100%; grid-template-columns: 1fr; justify-content: stretch; }
.review-toolbar__actions :deep(.el-button) { width: 100%; min-height: 44px; }
.review-section-heading { align-items: stretch; flex-direction: column; }
.review-comparison__row { grid-template-columns: 78px minmax(0, 1fr); align-items: start; }
.review-metrics, .review-declaration { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 360px) {
.review-toolbar { margin: -10px -10px 0; padding: 10px; }
.review-comparison__row { grid-template-columns: 1fr; gap: 6px; }
.review-metrics, .review-declaration { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 7px 10px; }
}
</style>
+1 -1
View File
@@ -17,7 +17,7 @@ export interface MeasurementReviewItem {
volumeWeightKg: number | null
intelligentImageFileId: string | null
machineNo: string | null
measuredAt: string | null
deviceMeasuredAt: string | null
overLimitWarning: string | null
outerLengthCm: number | null
outerWidthCm: number | null
@@ -81,4 +81,9 @@ describe('WeightOcrPanel contract', () => {
expect(source).toContain('@media (max-width: 720px)')
expect(source).toContain('@media (max-width: 360px)')
})
it('keeps each weight summary label and value on one compact line', () => {
expect(source).toContain('.weight-summary { display: flex; align-items: baseline; gap: 6px; white-space: nowrap; }')
expect(source).not.toContain('.weight-summary { display: grid;')
})
})
@@ -609,16 +609,16 @@ defineExpose({ reload: loadWorkspace })
<style scoped>
.weight-workspace { min-width: 0; color: var(--el-text-color-primary); }
.weight-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; padding-bottom: 18px; border-bottom: 1px solid var(--cc-border); }
.weight-heading { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding-bottom: 10px; border-bottom: 1px solid var(--cc-border); }
.weight-heading h3 { display: flex; align-items: center; gap: 8px; margin: 0; font-size: 15px; }
.weight-heading h3 svg { color: var(--cc-primary); }
.weight-heading__actions { display: flex; flex: 0 0 auto; align-items: center; gap: 16px; }
.weight-heading__actions { display: flex; flex: 0 0 auto; align-items: center; flex-wrap: wrap; justify-content: flex-end; gap: 16px; }
.pallet-picker { width: clamp(360px, 36vw, 520px); min-width: 0; }
.pallet-picker :deep(.el-select) { width: 100%; }
.weight-summary { display: grid; gap: 2px; text-align: right; }
.weight-summary-group { display: flex; align-items: center; gap: 18px; }
.weight-summary { display: flex; align-items: baseline; gap: 6px; white-space: nowrap; }
.weight-summary-group { display: flex; align-items: center; flex-wrap: wrap; gap: 20px; }
.weight-summary span { color: var(--cc-muted); font-size: 12px; }
.weight-summary strong { color: #067647; font-size: 18px; }
.weight-summary strong { color: #067647; font-size: 16px; }
.visually-hidden-input { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; }
.weight-action-error { margin-bottom: 14px; }
.weight-list { min-height: 0; margin-top: 14px; }
@@ -652,7 +652,6 @@ defineExpose({ reload: loadWorkspace })
.pallet-picker { width: 100%; min-width: 0; }
.pallet-picker :deep(.el-select) { width: 100%; }
.weight-heading__actions :deep(.el-button) { width: 100%; min-height: 44px; }
.weight-summary { text-align: left; }
.weight-summary-group { width: 100%; justify-content: space-between; }
.weight-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@@ -9,6 +9,7 @@ import {
listConfirmedLoadingLists,
listPreloadPlans,
updatePreloadTransport,
updateLoadingItemWarehouseRemark,
} from './platformLoadingLists'
const preloadSummary = {
@@ -49,7 +50,7 @@ const preloadSummary = {
const detail = {
summary: preloadSummary,
items: [{
itemId: 11, accountId: 12, orderId: 15, orderNo: 'ORD-001', inboundId: 13, inboundDetailId: 14, containerNo: 'MSCU1234567', productQuantity: 20,
itemId: 11, itemVersion: 4, accountId: 12, orderId: 15, orderNo: 'ORD-001', inboundId: 13, inboundDetailId: 14, containerNo: 'MSCU1234567', productQuantity: 20,
customerName: '测试客户', inboundCode: 'RK001', skuCode: 'SKU001', destinationWarehouse: 'ONT8', chineseName: '产品', englishName: null,
goodsImageUrl: null, packageCount: 2, palletCount: 1, grossWeightKg: 10, netWeightKg: 9, volumeCbm: 1,
dimensionalWeightKg: 2, unitsPerPackage: 10, fbaTrackingNo: null, shipmentNo: null, shippingMark: null,
@@ -108,6 +109,22 @@ describe('platform loading lists API', () => {
expect(loaded.summary.status).toBe('COMPLETED')
})
it('updates one item warehouse remark with both optimistic-lock versions and operation id', async () => {
const patch = vi.spyOn(http, 'patch').mockResolvedValue({ code: '200', message: 'ok', data: { id: 100, version: 4, items: [{ id: 11, warehouseRemark: '装柜时注意外箱', version: 5 }] } })
const result = await updateLoadingItemWarehouseRemark('100', '11', {
warehouseRemark: '装柜时注意外箱',
expectedPlanVersion: 3,
expectedItemVersion: 4,
operationId: 'warehouse-remark-1',
})
expect(patch).toHaveBeenCalledWith(
'/platform/v1/first-leg/smart-loading/plans/100/items/11/warehouse-remark',
{ warehouseRemark: '装柜时注意外箱', expectedPlanVersion: 3, expectedItemVersion: 4, operationId: 'warehouse-remark-1' },
)
expect(result).toMatchObject({ id: '100', version: 4, items: [{ id: '11', version: 5, warehouseRemark: '装柜时注意外箱' }] })
})
it('confirms the whole preload plan with expectedVersion and the caller operation id', async () => {
const post = vi.spyOn(http, 'post').mockResolvedValue({
code: '200', message: 'ok', data: {
@@ -80,6 +80,7 @@ export type LoadingListSummary = PreloadSummary | ConfirmedSummary
export interface LoadingItem {
itemId: string
itemVersion: number
accountId: string
customerName: string | null
orderId: string
@@ -172,6 +173,19 @@ export interface TransportUpdateRequest {
operationReason: string | null
}
export interface WarehouseRemarkUpdateRequest {
warehouseRemark: string | null
expectedPlanVersion: number
expectedItemVersion: number
operationId: string
}
export interface WarehouseRemarkUpdateResponse {
id: string
version: number
items: Array<{ id: string; version: number; warehouseRemark: string | null }>
}
export interface PreloadConfirmationResponse {
planId: string
containerBookingId: string
@@ -239,6 +253,7 @@ function normalizeItem(raw: LoadingItem): LoadingItem {
return {
...raw,
itemId: requiredId(raw.itemId),
itemVersion: Number(raw.itemVersion),
accountId: requiredId(raw.accountId),
orderId: requiredId(raw.orderId),
inboundId: requiredId(raw.inboundId),
@@ -312,6 +327,19 @@ export async function updatePreloadTransport(planId: string, warehouseId: string
}
}
export async function updateLoadingItemWarehouseRemark(planId: string, itemId: string, request: WarehouseRemarkUpdateRequest) {
const response = await http.patch<ApiEnvelope<WarehouseRemarkUpdateResponse>, ApiEnvelope<WarehouseRemarkUpdateResponse>>(
`${basePath}/smart-loading/plans/${encodeURIComponent(planId)}/items/${encodeURIComponent(itemId)}/warehouse-remark`,
request,
)
const result = unwrapApiResponse(response)
return {
...result,
id: requiredId(result.id),
items: (result.items || []).map((item) => ({ ...item, id: requiredId(item.id), version: Number(item.version) })),
}
}
export async function confirmPreloadPlan(planId: string, expectedVersion: number, operationId: string) {
const response = await http.post<ApiEnvelope<PreloadConfirmationResponse>, ApiEnvelope<PreloadConfirmationResponse>>(
`${basePath}/loading-plans/${encodeURIComponent(planId)}/confirm`,
@@ -22,10 +22,11 @@ const props = defineProps<{
customsSaving?: boolean
}>()
const emit = defineEmits<{ retry: []; edit: []; confirmPreload: []; attachCustoms: []; completeCustoms: [] }>()
const emit = defineEmits<{ retry: []; edit: []; editWarehouseRemark: [item: LoadingItem]; confirmPreload: []; attachCustoms: []; completeCustoms: [] }>()
const itemColumns: DataTableColumn[] = [
{ key: 'sequence', prop: 'loadSequence', label: '装柜顺序', fixed: 'left', width: 92, hideable: false, movable: false },
{ key: 'actions', label: '操作', fixed: 'left', width: 106, slotName: 'actions', hideable: false, movable: false },
{ key: 'sequence', prop: 'loadSequence', label: '装柜顺序', width: 92 },
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 150 },
{ key: 'orderNo', prop: 'orderNo', label: '订单号', minWidth: 150 },
{ key: 'containerNo', prop: 'containerNo', label: '集装箱号', minWidth: 145, formatter: (row: LoadingItem) => row.containerNo || '—' },
@@ -45,9 +46,9 @@ const itemColumns: DataTableColumn[] = [
{ key: 'fbaTrackingNo', prop: 'fbaTrackingNo', label: 'FBA 追踪编码', minWidth: 155 },
{ key: 'shipmentNo', prop: 'shipmentNo', label: '货件号', minWidth: 140 },
{ key: 'shippingMark', prop: 'shippingMark', label: '唛头', minWidth: 130 },
{ key: 'internalRemark', prop: 'internalRemark', label: '备注(内部)', minWidth: 160, showOverflowTooltip: true },
{ key: 'customerRemark', prop: 'customerRemark', label: '备注(客户)', minWidth: 160, showOverflowTooltip: true },
{ key: 'warehouseRemark', prop: 'warehouseRemark', label: '备注(仓库)', minWidth: 160, showOverflowTooltip: true },
{ key: 'customerRemark', prop: 'customerRemark', label: '备注(客户)', minWidth: 190, slotName: 'customerRemark' },
{ key: 'internalRemark', prop: 'internalRemark', label: '备注(内部)', minWidth: 190, slotName: 'internalRemark' },
{ key: 'warehouseRemark', prop: 'warehouseRemark', label: '备注(仓库)', minWidth: 210, slotName: 'warehouseRemark' },
{ key: 'customsDocument', label: '报关资料', minWidth: 170, slotName: 'customsDocument' },
{ key: 'frozenAt', label: '冻结时间', minWidth: 150, slotName: 'frozenAt' },
]
@@ -143,7 +144,7 @@ function formatNumber(value?: number | null, digits = 3) {
<div v-else-if="mode === 'confirmed'" class="detail-actions"><el-button v-if="canManageCustoms && !detail.customsDocument?.completedAt" @click="emit('attachCustoms')"><FileUp :size="16" />{{ detail.customsDocument ? '更换报关资料' : '上传报关资料' }}</el-button><el-button v-if="canManageCustoms && detail.customsDocument && !detail.customsDocument.completedAt" type="primary" :loading="customsSaving" @click="emit('completeCustoms')"><Check :size="16" />完成报关</el-button><StatusBadge v-if="detail.customsDocument?.completedAt" status="报关完成" /></div>
<section class="detail-section surface-panel">
<header class="detail-section__header"><h2>装柜货物明细</h2><span>{{ detail.items.length }} 条</span></header>
<header class="detail-section__header"><div><h2>装柜货物明细</h2><p>仓库备注将随装柜完成资料下发海外仓</p></div><span>{{ detail.items.length }} 条</span></header>
<DataTable
:data="detail.items"
:columns="itemColumns"
@@ -153,10 +154,17 @@ function formatNumber(value?: number | null, digits = 3) {
empty-text="暂无装柜货物明细"
aria-label="装柜货物明细"
>
<template #actions="{ row }">
<el-button v-if="mode === 'preload' && canEdit && ['EDITING', 'LOADING'].includes(summary.status)" size="small" type="primary" plain :aria-label="`编辑 ${row.inboundCode || row.orderNo} 的仓库备注`" @click="emit('editWarehouseRemark', row as LoadingItem)"><Pencil :size="14" />编辑备注</el-button>
<span v-else aria-label="只读">—</span>
</template>
<template #grossWeight="{ row }">{{ formatNumber(row.grossWeightKg) }}</template>
<template #netWeight="{ row }">{{ formatNumber(row.netWeightKg) }}</template>
<template #volume="{ row }">{{ formatNumber(row.volumeCbm) }}</template>
<template #dimensionalWeight="{ row }">{{ formatNumber(row.dimensionalWeightKg) }}</template>
<template #customerRemark="{ row }"><div class="remark-cell"><span>{{ row.customerRemark || '—' }}</span><small>来源:客户下单</small></div></template>
<template #internalRemark="{ row }"><div class="remark-cell"><span>{{ row.internalRemark || '—' }}</span><small>来源:订单内部备注</small></div></template>
<template #warehouseRemark="{ row }"><div class="remark-cell"><span>{{ row.warehouseRemark || '—' }}</span><small>{{ mode === 'preload' ? '由仓库维护' : '装柜完成时冻结' }}</small></div></template>
<template #frozenAt="{ row }">{{ formatDateTime(row.frozenAt) }}</template>
<template #customsDocument><span class="document-cell">{{ detail.customsDocument?.fileName || '未上传' }}</span></template>
</DataTable>
@@ -192,11 +200,13 @@ function formatNumber(value?: number | null, digits = 3) {
.detail-summary__wide { grid-column: span 2; }
.detail-actions { display: flex; justify-content: flex-end; }
.detail-section, .history-section { min-width: 0; overflow: hidden; border: 1px solid var(--cc-border); background: #fff; }
.detail-section__header { display: flex; align-items: baseline; gap: 10px; padding: 18px 18px 0; }
.detail-section__header { display: flex; align-items: flex-start; gap: 10px; padding: 18px 18px 0; }
.detail-section__header h2 { margin: 0; font-size: 16px; }
.detail-section__header span { color: var(--cc-muted); font-size: 12px; }
.detail-section__header p { margin: 4px 0 0; color: var(--cc-muted); font-size: 12px; }
.detail-section :deep(.el-table) { width: 100%; }
.detail-section small { display: block; margin-top: 3px; color: var(--cc-muted); }
.remark-cell { min-width: 0; line-height: 1.45; overflow-wrap: anywhere; }
.history-section { padding: 18px; }
.history-section header { display: flex; align-items: baseline; gap: 10px; margin-bottom: 18px; }
.history-section h2 { margin: 0; font-size: 16px; }
@@ -142,6 +142,8 @@ describe('LoadingListsView', () => {
})
it('shows every prototype detail field that is backed by the loading item contract', () => {
expect(detailSource.indexOf("key: 'actions'")).toBeLessThan(detailSource.indexOf("key: 'sequence'"))
expect(detailSource).toContain("key: 'actions', label: '操作', fixed: 'left'")
expect(detailSource.indexOf("key: 'orderNo'")).toBeLessThan(detailSource.indexOf("key: 'containerNo'"))
expect(detailSource.indexOf("key: 'containerNo'")).toBeLessThan(detailSource.indexOf("key: 'skuCode'"))
const fields = [
@@ -158,8 +160,8 @@ describe('LoadingListsView', () => {
'fbaTrackingNo',
'shipmentNo',
'shippingMark',
'internalRemark',
'customerRemark',
'internalRemark',
'warehouseRemark',
'customsDocument',
]
@@ -173,6 +175,16 @@ describe('LoadingListsView', () => {
expect(detailSource).not.toContain("label: '退税资料'")
})
it('keeps customer and internal remarks read-only while exposing per-item warehouse remark editing', () => {
expect(detailSource).toContain('来源:客户下单')
expect(detailSource).toContain('来源:订单内部备注')
expect(detailSource).toContain('仓库备注将随装柜完成资料下发海外仓')
expect(detailSource).toContain("emit('editWarehouseRemark', row as LoadingItem)")
expect(detailSource).toContain("mode === 'preload' && canEdit && ['EDITING', 'LOADING'].includes(summary.status)")
expect(detailSource).not.toContain("emit('editCustomerRemark'")
expect(detailSource).not.toContain("emit('editInternalRemark'")
})
it('does not expose unknown history action codes', () => {
expect(detailSource).toContain("CONFIRM_PRELOAD: '确认预装'")
expect(detailSource).toContain("[action] || '状态更新'")
@@ -27,6 +27,28 @@ describe('PreloadPlanDetailView prototype contract', () => {
expect(source).not.toContain('saveMfNote')
})
it('edits only the selected item warehouse remark with optimistic locking', () => {
expect(source).toContain('@edit-warehouse-remark="openWarehouseRemark"')
expect(source).toContain('updateLoadingItemWarehouseRemark(current.summary.planId, target.itemId')
expect(source).toContain('expectedPlanVersion: current.summary.version')
expect(source).toContain('expectedItemVersion: target.itemVersion')
expect(source).toContain('operationId: remarkOperationId.value')
expect(source).toContain('maxlength="1000"')
expect(source).toContain("warehouseRemark.value.trim() || null")
expect(source).toContain('summary: { ...current.summary, version: result.version }')
expect(source).toContain('updatedItem?.version')
expect(source).not.toContain('plan.remark')
})
it('preserves warehouse remark input and refreshes current data on conflicts', () => {
expect(source).toContain("details.code === 'A0409'")
expect(source).toContain('await loadDetail()')
expect(source).toContain("detail.value?.items.find((item) => item.itemId === target.itemId) || target")
expect(source).toContain('当前输入已保留')
expect(source).toContain('计划状态或明细版本已变化')
expect(source).toContain(':confirm-loading="remarkSaving"')
})
it('is the formal container-number entry and does not imply that preload directly opens PDA', () => {
expect(source).toContain("{ required: true, message: '请输入集装箱号'")
expect(source).toContain("detail.value?.summary.containerNo ? '修改集装箱号 / 航次' : '录入集装箱号'")
@@ -47,7 +69,7 @@ describe('PreloadPlanDetailView prototype contract', () => {
it('keeps backend confirmation errors visible and supports narrow dialogs', () => {
expect(source).toContain("confirmError.value = getApiErrorDetails(cause, '确认预装失败,请稍后重试')")
expect(source).toContain('<OperationErrorToast :details="editError || confirmError" />')
expect(source).toContain('<OperationErrorToast :details="remarkError || editError || confirmError" />')
expect(source).not.toContain(':message="confirmError.message"')
expect(source).toContain('width="min(520px, calc(100vw - 28px))"')
expect(source).toContain("label=\"柜次号\"")
@@ -11,7 +11,7 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import LoadingListDetailDrawer from '../components/LoadingListDetailDrawer.vue'
import { confirmPreloadPlan, getPreloadPlan, updatePreloadTransport, type LoadingDetail, type PreloadSummary } from '../api/platformLoadingLists'
import { confirmPreloadPlan, getPreloadPlan, updateLoadingItemWarehouseRemark, updatePreloadTransport, type LoadingDetail, type LoadingItem, type PreloadSummary } from '../api/platformLoadingLists'
const route = useRoute()
const router = useRouter()
@@ -28,6 +28,12 @@ const confirmError = ref<ApiErrorDetails | null>(null)
const confirmOperationId = ref('')
const editFormRef = ref<FormInstance>()
const editForm = reactive({ containerNo: '', voyageNo: '', operationReason: '' })
const remarkOpen = ref(false)
const remarkSaving = ref(false)
const remarkError = ref<ApiErrorDetails | null>(null)
const remarkTarget = ref<LoadingItem | null>(null)
const warehouseRemark = ref('')
const remarkOperationId = ref('')
const planId = computed(() => String(route.params.planId || ''))
const warehouseId = computed(() => typeof route.query.warehouseId === 'string' ? route.query.warehouseId : '')
const canEdit = computed(() => access.can('platform:firstleg:preload:edit'))
@@ -101,6 +107,56 @@ async function saveTransport() {
}
}
function openWarehouseRemark(item: LoadingItem) {
if (!canEdit.value || !detail.value || !['EDITING', 'LOADING'].includes(detail.value.summary.status)) return
remarkTarget.value = item
warehouseRemark.value = item.warehouseRemark || ''
remarkError.value = null
remarkOperationId.value = `admin-preload-warehouse-remark-${detail.value.summary.planId}-${item.itemId}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
remarkOpen.value = true
}
async function saveWarehouseRemark() {
const target = remarkTarget.value
const current = detail.value
if (!target || !current || !canEdit.value || remarkSaving.value) return
if (warehouseRemark.value.length > 1000) {
remarkError.value = { code: 'WAREHOUSE_REMARK_TOO_LONG', message: '仓库备注不能超过 1000 个字符' }
return
}
remarkSaving.value = true
remarkError.value = null
try {
const result = await updateLoadingItemWarehouseRemark(current.summary.planId, target.itemId, {
warehouseRemark: warehouseRemark.value.trim() || null,
expectedPlanVersion: current.summary.version,
expectedItemVersion: target.itemVersion,
operationId: remarkOperationId.value,
})
const updatedItem = result.items.find((item) => item.id === target.itemId)
detail.value = {
...current,
summary: { ...current.summary, version: result.version },
items: current.items.map((item) => item.itemId === target.itemId
? { ...item, itemVersion: updatedItem?.version ?? item.itemVersion, warehouseRemark: updatedItem?.warehouseRemark ?? null }
: item),
}
remarkOpen.value = false
remarkTarget.value = null
remarkOperationId.value = ''
ElMessage.success('仓库备注已更新')
} catch (cause) {
const details = getApiErrorDetails(cause, '仓库备注保存失败,请稍后重试')
if (details.code === 'A0409' || /版本|并发|状态.*变化/.test(details.message)) {
await loadDetail()
remarkTarget.value = detail.value?.items.find((item) => item.itemId === target.itemId) || target
remarkError.value = { ...details, message: '计划状态或明细版本已变化,系统已刷新最新数据;当前输入已保留,请核对后重新保存。' }
} else remarkError.value = details
} finally {
remarkSaving.value = false
}
}
function openConfirmPreload() {
const summary = detail.value?.summary
if (!canEdit.value || !summary?.readiness.canConfirm) return
@@ -133,7 +189,7 @@ onMounted(() => void loadDetail())
<template>
<AdminPage>
<div class="preload-detail-page">
<OperationErrorToast :details="editError || confirmError" />
<OperationErrorToast :details="remarkError || editError || confirmError" />
<el-button link type="primary" class="back-button" @click="goBack"><ArrowLeft :size="16" />返回列表</el-button>
<el-alert
v-if="detail"
@@ -146,7 +202,7 @@ onMounted(() => void loadDetail())
<LoadErrorState v-if="error && !detail" title="预装清单详情加载失败" :message="error.message" :loading="loading" @retry="loadDetail">
<template #details><ApiErrorMeta :details="error" /></template>
</LoadErrorState>
<LoadingListDetailDrawer v-else :mode="'preload'" :loading="loading" :detail="detail" :error="error" :can-edit="canEdit" :confirm-saving="confirmSaving" @retry="loadDetail" @edit="openEdit" @confirm-preload="openConfirmPreload" />
<LoadingListDetailDrawer v-else :mode="'preload'" :loading="loading" :detail="detail" :error="error" :can-edit="canEdit" :confirm-saving="confirmSaving" @retry="loadDetail" @edit="openEdit" @edit-warehouse-remark="openWarehouseRemark" @confirm-preload="openConfirmPreload" />
</div>
<AppDialog v-model="editOpen" class="loading-transport-dialog" :title="editDialogTitle" width="min(620px, calc(100vw - 28px))" confirm-text="保存" :confirm-loading="editSaving" :confirm-disabled="!canEdit" @confirm="saveTransport">
@@ -157,6 +213,16 @@ onMounted(() => void loadDetail())
</el-form>
</AppDialog>
<AppDialog v-model="remarkOpen" class="warehouse-remark-dialog" title="编辑仓库备注" width="min(560px, calc(100vw - 28px))" confirm-text="保存备注" cancel-text="取消" :confirm-loading="remarkSaving" :confirm-disabled="!canEdit" @confirm="saveWarehouseRemark">
<el-alert type="info" :closable="false" show-icon title="仓库备注将随装柜完成资料下发海外仓" />
<el-form label-position="top" class="warehouse-remark-form">
<el-form-item label="客户备注(只读)"><el-input :model-value="remarkTarget?.customerRemark || '—'" disabled /><small>来源:客户下单</small></el-form-item>
<el-form-item label="内部备注(只读)"><el-input :model-value="remarkTarget?.internalRemark || '—'" disabled /><small>来源:订单内部备注</small></el-form-item>
<el-form-item label="仓库备注"><el-input v-model="warehouseRemark" type="textarea" :rows="5" maxlength="1000" show-word-limit placeholder="可填写、修改或清空仓库备注" /></el-form-item>
</el-form>
<LoadErrorState v-if="remarkError" compact title="仓库备注保存失败" :message="remarkError.message" :show-retry="false"><template #details><ApiErrorMeta :details="remarkError" /></template></LoadErrorState>
</AppDialog>
<AppDialog v-model="confirmOpen" class="preload-confirm-dialog" title="确认整份预装计划" width="min(520px, calc(100vw - 28px))" confirm-text="确认预装" :confirm-loading="confirmSaving" :confirm-disabled="!detail?.summary.readiness.canConfirm" @confirm="submitConfirmPreload">
<div class="confirm-content">
<el-alert type="warning" :closable="false" show-icon title="本操作将确认整份预装计划" description="系统会原子确认本计划包含的全部入库预装票据;成功后由服务端按真实条件创建唯一 PDA 任务,当前页面不会伪造 PDA 成功。" />
@@ -176,6 +242,8 @@ onMounted(() => void loadDetail())
.transport-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; }
.transport-form :deep(.el-form-item:last-child) { grid-column: 1 / -1; }
.confirm-content { display: grid; gap: 16px; }
.warehouse-remark-form { margin-top: 14px; }
.warehouse-remark-form small { display: block; margin-top: 4px; color: var(--cc-muted); }
:global(.loading-transport-dialog.el-dialog) { max-height: 90vh; overflow: hidden; }
@media (min-width: 1920px) { .preload-detail-page { width: 100%; max-width: none; gap: 12px; } }
@media (max-width: 720px) { .transport-form { grid-template-columns: 1fr; } .transport-form :deep(.el-form-item:last-child) { grid-column: auto; } }
@@ -1,12 +1,13 @@
import { describe, expect, it, vi } from 'vitest'
import { createOceanBooking, listOceanBookings, listWarehouseLogisticsChannels, performOceanBookingAction, releaseOceanBooking, requestOceanBookingChange } from './platformOceanBookings'
import { createOceanBooking, deleteOceanBookingDraft, listOceanBookings, listWarehouseLogisticsChannels, releaseOceanBooking, updateOceanBookingDraft } from './platformOceanBookings'
import { http } from '@/shared/api/http'
const detailResponse = (version = 0, status = 'CONFIRMED') => ({
const detailResponse = (version = 0, status = 'DRAFT') => ({
oceanBooking: {
id: 123, bookingNo: 'OB123', status, version, customerId: 456, customerName: '客户', shipperName: '发货企业', consigneeName: '收货企业', containerCount: '2',
},
parties: [],
containerRequirements: [{ id: 9007199254740994, oceanBookingId: 123, containerType: '40HC', containerKind: 'FCL', quantity: 2, capacityCbm: 76, maxWeightKg: 28000 }],
containers: [{ containerBookingId: 9007199254740993, cabinetNo: 'CAB-1', containerNo: 'MSCU1234567', requirementId: 9007199254740994, allocationId: 9007199254740995, containerType: '40HC', containerKind: 'FCL', capacityCbm: 76, maxWeightKg: 28000, warehouseId: 9007199254740996, warehouseName: '义乌仓', channelCode: 'SEA', channelName: '海运', customerId: 456, customerName: '客户' }],
histories: [],
})
@@ -23,35 +24,33 @@ describe('ocean bookings api', () => {
})
it('uses version zero for create and extracts the id from the returned Detail', async () => {
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: detailResponse(0, 'CONFIRMED') })
const created = await createOceanBooking({ expectedVersion: 0, data: { customerId: null, customerName: null, channelCode: 'SEA', warehouseId: '8', billOfLadingNo: 'BL-1', vesselName: 'VESSEL', voyageNo: 'V001', estimatedSailingAt: '2026-08-12T10:00:00', estimatedArrivalAt: '2026-09-01T10:00:00', serviceTerm: 'CY-CY', freightTerm: 'PREPAID', parties: [], containers: [{ containerNo: 'MSCU1234567', containerType: '40HC', containerKind: 'FCL', capacityCbm: 76, maxWeightKg: 28000 }] } })
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: detailResponse() })
const created = await createOceanBooking({ expectedVersion: 0, data: { customerId: null, customerName: null, channelCode: null, warehouseId: null, billOfLadingNo: '', vesselName: '', voyageNo: '', estimatedSailingAt: null, estimatedArrivalAt: null, serviceTerm: null, freightTerm: null, parties: [], containerRequirements: [{ containerType: '40HC', containerKind: 'FCL', quantity: 2, capacityCbm: 76, maxWeightKg: 28000 }] } })
const sentPayload = post.mock.calls[0]?.[1] as { expectedVersion: number; data: Record<string, unknown> }
expect(sentPayload).toMatchObject({ expectedVersion: 0, data: { customerId: null, customerName: null, serviceTerm: 'CY-CY', freightTerm: 'PREPAID' } })
expect(sentPayload).toMatchObject({ expectedVersion: 0, data: { customerId: null, customerName: null, channelCode: null, warehouseId: null } })
expect(sentPayload).not.toMatchObject({ data: { customerId: '', customerName: '' } })
expect(sentPayload.data).toMatchObject({ billOfLadingNo: 'BL-1', vesselName: 'VESSEL', voyageNo: 'V001', estimatedSailingAt: '2026-08-12T10:00:00', estimatedArrivalAt: '2026-09-01T10:00:00' })
expect(sentPayload.data).toMatchObject({ containers: [{ containerNo: 'MSCU1234567', containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000 }] })
expect(sentPayload.data).not.toHaveProperty('containerRequirements')
expect(sentPayload.data).toMatchObject({ containerRequirements: [{ containerType: '40HC', quantity: 2, capacityCbm: 76, maxWeightKg: 28000 }] })
expect(sentPayload.data).not.toHaveProperty('containers')
expect(sentPayload.data).not.toHaveProperty('warehouseName')
expect(post.mock.calls[0]?.[2]).toMatchObject({ headers: { 'Idempotency-Key': expect.any(String) } })
expect(created.id).toBe('123')
expect(created.version).toBe(0)
expect(created.status).toBe('CONFIRMED')
expect(created.containers[0]).toMatchObject({ containerBookingId: '9007199254740992', requirementId: '9007199254740994', allocationId: '9007199254740996', warehouseId: '9007199254740996', customerId: '456' })
expect(created.status).toBe('DRAFT')
expect(created.containerRequirements[0]).toMatchObject({ id: '9007199254740994', requirementId: '9007199254740994', quantity: 2 })
expect(created.containers[0]).toMatchObject({ containerBookingId: '9007199254740992', requirementId: '9007199254740994' })
})
it('uses action and change-specific request contracts', async () => {
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: detailResponse(4) })
await performOceanBookingAction('123', 'cancel', { expectedVersion: 3, reason: 'ok' })
expect(post.mock.calls[0]?.[0]).toContain('/123/cancel')
expect(post.mock.calls[0]?.[1]).toEqual({ expectedVersion: 3, reason: 'ok' })
await requestOceanBookingChange('123', { expectedVersion: 4, vesselName: 'VESSEL', voyageNo: 'V001', reason: '调整船期' })
expect(post.mock.calls[1]?.[0]).toContain('/123/change-request')
expect(post.mock.calls[1]?.[1]).toMatchObject({ expectedVersion: 4, vesselName: 'VESSEL', voyageNo: 'V001', reason: '调整船期' })
await releaseOceanBooking('123', { expectedVersion: 4, billOfLadingNo: 'BL-1', vesselName: 'VESSEL', voyageNo: 'V001', estimatedSailingAt: '2026-08-12T10:00:00', estimatedArrivalAt: '2026-09-01T10:00:00' })
expect(post.mock.calls[2]?.[0]).toContain('/123/release')
expect(post.mock.calls[2]?.[1]).toMatchObject({ billOfLadingNo: 'BL-1', vesselName: 'VESSEL', voyageNo: 'V001', estimatedSailingAt: '2026-08-12T10:00:00', estimatedArrivalAt: '2026-09-01T10:00:00' })
it('uses draft update, delete and release contracts without legacy actions', async () => {
const put = vi.spyOn(http, 'put').mockResolvedValue({ code: '200', message: 'ok', data: detailResponse(4) })
const remove = vi.spyOn(http, 'delete').mockResolvedValue({ code: '200', message: 'ok', data: null })
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: detailResponse(5, 'RELEASED') })
const data = { customerId: null, customerName: null, channelCode: null, warehouseId: null, billOfLadingNo: '', vesselName: '', voyageNo: '', estimatedSailingAt: null, estimatedArrivalAt: null, serviceTerm: null, freightTerm: null, parties: [], containerRequirements: [] }
await updateOceanBookingDraft('123', { expectedVersion: 3, data })
await deleteOceanBookingDraft('123', 4)
await releaseOceanBooking('123', { expectedVersion: 4 })
expect(put).toHaveBeenCalledWith('/platform/v1/ocean-bookings/123', { expectedVersion: 3, data }, { headers: { 'Idempotency-Key': expect.any(String) } })
expect(remove).toHaveBeenCalledWith('/platform/v1/ocean-bookings/123', { data: { expectedVersion: 4 }, headers: { 'Idempotency-Key': expect.any(String) } })
expect(post).toHaveBeenCalledWith('/platform/v1/ocean-bookings/123/release', { expectedVersion: 4 }, { headers: { 'Idempotency-Key': expect.any(String) } })
})
it('loads warehouse-scoped logistics channels from the booking contract', async () => {
@@ -1,5 +1,5 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type { OceanBookingActionRequest, OceanBookingChangeRequest, OceanBookingDetail, OceanBookingPage, OceanBookingQuery, OceanBookingResource, OceanBookingSaveRequest, OceanBookingStatus, OceanBookingReleaseRequest } from '../types'
import type { OceanBookingDetail, OceanBookingPage, OceanBookingQuery, OceanBookingResource, OceanBookingSaveRequest, OceanBookingStatus, OceanBookingReleaseRequest } from '../types'
const basePath = '/platform/v1/ocean-bookings'
@@ -34,13 +34,21 @@ function detail(raw: OceanBookingDetail): OceanBookingDetail {
...source,
oceanBooking: source,
parties: (raw.parties || []).map((party) => ({ ...party, id: party.id == null ? undefined : String(party.id) })),
containerRequirements: (raw.containerRequirements || []).map((requirement) => ({
...requirement,
id: requirement.id == null ? undefined : String(requirement.id),
requirementId: requirement.requirementId == null && requirement.id == null ? undefined : String(requirement.requirementId ?? requirement.id),
quantity: Number(requirement.quantity),
remainingQuantity: requirement.remainingQuantity == null ? undefined : Number(requirement.remainingQuantity),
capacityCbm: Number(requirement.capacityCbm),
maxWeightKg: Number(requirement.maxWeightKg),
})),
containers: (raw.containers || []).map((container) => ({
...container,
containerBookingId: String(container.containerBookingId),
requirementId: String(container.requirementId),
allocationId: String(container.allocationId),
warehouseId: String(container.warehouseId),
customerId: container.customerId == null ? null : String(container.customerId),
capacityCbm: Number(container.capacityCbm),
maxWeightKg: Number(container.maxWeightKg),
})),
histories,
statusHistory: histories,
@@ -54,10 +62,9 @@ export async function listOceanBookings(query: OceanBookingQuery) {
export async function getOceanBooking(id: string) { const response = await http.get<ApiEnvelope<OceanBookingDetail>, ApiEnvelope<OceanBookingDetail>>(`${basePath}/${encodeURIComponent(id)}`); return detail(unwrapApiResponse(response)) }
function idempotencyKey() { return `ocean-booking-${Date.now()}-${Math.random().toString(36).slice(2)}` }
export async function createOceanBooking(payload: OceanBookingSaveRequest) { const response = await http.post<ApiEnvelope<OceanBookingDetail>, ApiEnvelope<OceanBookingDetail>>(basePath, payload, { headers: { 'Idempotency-Key': idempotencyKey() } }); return detail(unwrapApiResponse(response)) }
export type OceanBookingAction = 'release' | 'cancel' | 'change-request' | 'change-confirm' | 'change-fail'
export async function performOceanBookingAction(id: string, action: Exclude<OceanBookingAction, 'change-request' | 'release'>, request: OceanBookingActionRequest) { const response = await http.post<ApiEnvelope<OceanBookingDetail>, ApiEnvelope<OceanBookingDetail>>(`${basePath}/${encodeURIComponent(id)}/${action}`, request, { headers: { 'Idempotency-Key': idempotencyKey() } }); return detail(unwrapApiResponse(response)) }
export async function updateOceanBookingDraft(id: string, payload: OceanBookingSaveRequest) { const response = await http.put<ApiEnvelope<OceanBookingDetail>, ApiEnvelope<OceanBookingDetail>>(`${basePath}/${encodeURIComponent(id)}`, payload, { headers: { 'Idempotency-Key': idempotencyKey() } }); return detail(unwrapApiResponse(response)) }
export async function deleteOceanBookingDraft(id: string, expectedVersion: number) { await http.delete(`${basePath}/${encodeURIComponent(id)}`, { data: { expectedVersion }, headers: { 'Idempotency-Key': idempotencyKey() } }) }
export async function releaseOceanBooking(id: string, request: OceanBookingReleaseRequest) { const response = await http.post<ApiEnvelope<OceanBookingDetail>, ApiEnvelope<OceanBookingDetail>>(`${basePath}/${encodeURIComponent(id)}/release`, request, { headers: { 'Idempotency-Key': idempotencyKey() } }); return detail(unwrapApiResponse(response)) }
export async function requestOceanBookingChange(id: string, request: OceanBookingChangeRequest) { const response = await http.post<ApiEnvelope<OceanBookingDetail>, ApiEnvelope<OceanBookingDetail>>(`${basePath}/${encodeURIComponent(id)}/change-request`, request, { headers: { 'Idempotency-Key': idempotencyKey() } }); return detail(unwrapApiResponse(response)) }
export async function printOceanBooking(id: string, type: 'SHIPPING_INSTRUCTION' | 'BILL_CONFIRMATION') { return http.get<Blob, Blob>(`${basePath}/${encodeURIComponent(id)}/print`, { params: { type }, responseType: 'blob' }) }
export async function listWarehouseLogisticsChannels(warehouseId: string): Promise<OceanLogisticsChannel[]> {
const response = await http.get<ApiEnvelope<OceanLogisticsChannel[]>, ApiEnvelope<OceanLogisticsChannel[]>>(`${basePath}/channels`, { params: { originWarehouseId: warehouseId } })
@@ -1,26 +1,24 @@
import { describe, expect, it } from 'vitest'
import { bookingContainerError, createBlankBookingContainer, selectBookingContainerType } from './containerRequirements'
import { containerRequirementError, createBlankContainerRequirement, selectContainerRequirementType } from './containerRequirements'
describe('ocean booking real containers', () => {
it('starts with one real container and applies the fixed platform specification', () => {
const container = createBlankBookingContainer()
expect(container).toEqual({
containerNo: '', containerType: '40HC', containerKind: 'FCL', capacityCbm: 76, maxWeightKg: 28000,
})
selectBookingContainerType(container, '20GP')
expect(container).toMatchObject({ containerType: '20GP', capacityCbm: 33, maxWeightKg: 28000 })
describe('ocean booking container requirements', () => {
it('starts with one requirement and applies the fixed platform specification', () => {
const requirement = createBlankContainerRequirement()
expect(requirement).toEqual({ containerType: '40HC', containerKind: 'FCL', quantity: 1, capacityCbm: 76, maxWeightKg: 28000 })
selectContainerRequirementType(requirement, '20GP')
expect(requirement).toMatchObject({ containerType: '20GP', capacityCbm: 33, maxWeightKg: 28000 })
})
it('requires unique real container numbers and fixed specifications', () => {
const first = { ...createBlankBookingContainer(), containerNo: 'MSCU1234567' }
expect(bookingContainerError([createBlankBookingContainer()])).toContain('尚未填写集装箱号')
expect(bookingContainerError([first, { ...first, containerNo: 'mscu1234567' }])).toContain('重复')
expect(bookingContainerError([{ ...first, capacityCbm: 75 }])).toContain('固定规格')
expect(bookingContainerError([first])).toBeNull()
it('requires positive quantities and fixed specifications', () => {
const requirement = createBlankContainerRequirement()
expect(containerRequirementError([])).toContain('至少保留一项')
expect(containerRequirementError([{ ...requirement, quantity: 0 }])).toContain('数量必须')
expect(containerRequirementError([{ ...requirement, capacityCbm: 75 }])).toContain('固定规格')
expect(containerRequirementError([requirement])).toBeNull()
})
it('limits one booking to twenty real containers', () => {
const containers = Array.from({ length: 21 }, (_, index) => ({ ...createBlankBookingContainer(), containerNo: `MSCU${index}` }))
expect(bookingContainerError(containers)).toContain('最多登记 20 个')
it('limits one booking to twenty requirement rows', () => {
const requirements = Array.from({ length: 21 }, () => createBlankContainerRequirement())
expect(containerRequirementError(requirements)).toContain('最多登记 20 项')
})
})
@@ -1,23 +1,20 @@
import type { OceanBookingContainerInput } from './types'
import type { OceanBookingContainerRequirement } from './types'
import { applyContainerSpecification, containerSpecification } from '@/shared/domain/containerSpecifications'
export function createBlankBookingContainer(): OceanBookingContainerInput {
return { containerNo: '', containerType: '40HC', containerKind: 'FCL', capacityCbm: 76, maxWeightKg: 28000 }
export function createBlankContainerRequirement(): OceanBookingContainerRequirement {
return { containerType: '40HC', containerKind: 'FCL', quantity: 1, capacityCbm: 76, maxWeightKg: 28000 }
}
export function selectBookingContainerType(container: OceanBookingContainerInput, containerType: string) {
applyContainerSpecification(container, containerType)
export function selectContainerRequirementType(requirement: OceanBookingContainerRequirement, containerType: string) {
applyContainerSpecification(requirement, containerType)
}
export function bookingContainerError(containers: OceanBookingContainerInput[]) {
if (!containers.length) return '请至少登记一个真实柜'
if (containers.length > 20) return '单次订舱最多登记 20 个真实柜'
const normalizedNumbers = containers.map((item) => item.containerNo.trim().toUpperCase())
const missingNumberIndex = normalizedNumbers.findIndex((value) => !value)
if (missingNumberIndex >= 0) return `第 ${missingNumberIndex + 1} 个真实柜尚未填写集装箱号`
const duplicateNumber = normalizedNumbers.find((value, index) => normalizedNumbers.indexOf(value) !== index)
if (duplicateNumber) return `集装箱号 ${duplicateNumber} 在本次订舱中重复`
const invalid = containers.find((item) => {
export function containerRequirementError(requirements: OceanBookingContainerRequirement[]) {
if (!requirements.length) return '请至少保留一项柜型需求'
if (requirements.length > 20) return '单次订舱最多登记 20 项柜型需求'
const invalidQuantityIndex = requirements.findIndex((item) => !Number.isInteger(Number(item.quantity)) || Number(item.quantity) < 1)
if (invalidQuantityIndex >= 0) return `第 ${invalidQuantityIndex + 1} 项柜型需求的数量必须是大于 0 的整数`
const invalid = requirements.find((item) => {
const specification = containerSpecification(item.containerType)
return !specification || Number(item.capacityCbm) !== specification.capacityCbm
|| Number(item.maxWeightKg) !== specification.maxWeightKg
@@ -5,195 +5,69 @@ import { describe, expect, it } from 'vitest'
const source = readFileSync(resolve(process.cwd(), 'src/modules/ocean-bookings/pages/OceanBookingDetailView.vue'), 'utf8')
const routesSource = readFileSync(resolve(process.cwd(), 'src/modules/ocean-bookings/routes.ts'), 'utf8')
describe('OceanBookingDetailView interaction contract', () => {
it('recognizes the dedicated create route as an editable new booking', () => {
describe('OceanBookingDetailView draft and release contract', () => {
it('recognizes create and DRAFT detail routes as editable', () => {
expect(source).toContain("route.name === 'ocean-booking-create' || id.value === 'new'")
expect(source).toContain('const editable = computed(() => isNew.value)')
expect(source).toContain("const canSave = computed(() => isNew.value && access.can('platform:firstleg:ocean-booking:create'))")
expect(source).toContain(':disabled="!editable"')
expect(source).not.toContain("status === 'DRAFT'")
expect(source).toContain("const editable = computed(() => isNew.value || detail.value?.status === 'DRAFT')")
expect(source).toContain("platform:firstleg:ocean-booking:edit")
expect(routesSource.indexOf("path: 'ocean-bookings/new'")).toBeLessThan(routesSource.indexOf("path: 'ocean-bookings/:bookingId'"))
expect(routesSource).toContain("name: 'ocean-booking-create'")
expect(routesSource).toContain("permission: 'platform:firstleg:ocean-booking:create'")
})
it('selects business objects and automatically keeps their ids and names together', () => {
expect(source).toContain('placeholder="请选择客户(可选)"')
expect(source).toContain('filterable clearable')
expect(source).toContain('@update:model-value="selectCustomer"')
expect(source).toContain('no-data-text="暂无可选客户"')
expect(source).toContain('customerId: null, customerName: null')
expect(source).toContain("value == null || value === '' ? null : String(value)")
expect(source).toContain('form.data.customerName = form.data.customerId ? form.data.customerName : null')
expect(source).toContain('normalizeDraftData()')
expect(source).not.toContain("customerId: [{ required: true")
expect(source).toContain('placeholder="请选择船公司"')
expect(source).toContain('placeholder="请选择订舱代理"')
expect(source).toContain("listSuppliers({ page: 1, size: 200, category: 'BOOKING', status: 'ACTIVE' })")
expect(source).toContain('supplierResult.records.filter(isActiveBookingAgent)')
expect(source).toContain('bookingSuppliers.value.filter(isActiveBookingAgent)')
expect(source).toContain('selectShippingCompany')
expect(source).toContain('selectBookingAgent')
it('uses only DRAFT and RELEASED actions and removes voyage-change/cancel paths', () => {
expect(source).toContain("detail.value?.status === 'DRAFT'")
expect(source).toContain('删除草稿')
expect(source).toContain('放舱')
expect(source).not.toContain('申请改船')
expect(source).not.toContain('取消订舱')
expect(source).not.toContain('change-request')
expect(source).not.toContain('取消订舱')
expect(source).not.toContain('cancelOceanBooking')
})
it('saves incomplete DRAFT data and sends null for blank nullable fields', () => {
expect(source).toContain('保存草稿')
expect(source).toContain('channelCode: form.data.channelCode?.trim() || null')
expect(source).toContain('warehouseId: form.data.warehouseId?.trim() || null')
expect(source).toContain('parties: form.data.parties.filter((party) => party.partyName.trim())')
expect(source).toContain('containerRequirements: form.data.containerRequirements.map')
expect(source).not.toContain('登记后直接确认')
})
it('renders editable container requirements with fixed specifications', () => {
expect(source).toContain('aria-label="柜型需求"')
expect(source).toContain('containerRequirements')
expect(source).toContain('<th scope="col">数量</th>')
expect(source).toContain('v-model="requirement.quantity"')
expect(source).toContain('selectContainerRequirementType(requirement')
expect(source).toContain('{{ requirement.capacityCbm }} m³')
expect(source).toContain('{{ requirement.maxWeightKg }} kg')
expect(source).not.toContain('v-model="container.containerNo"')
expect(source).not.toContain('新增真实柜')
})
it('performs the complete release gate and presents the irreversible warning', () => {
expect(source).toContain('function releaseValidationError()')
expect(source).toContain('预计到港时间不能早于预计开船时间')
expect(source).toContain("[form.data.customerId, '客户']")
expect(source).toContain('放舱前请填写${missing[1]}')
expect(source).toContain('放舱后资料将同步到关联货柜,不能修改、不能删除;填写错误只能由管理员处理数据库,请确认资料无误。')
expect(source).toContain('releaseOceanBooking(id.value, { expectedVersion: detail.value.version })')
expect(source).not.toContain('releaseReason')
})
it('keeps warehouse-scoped channel and customer/supplier selectors accessible', () => {
expect(source).toContain("warehouseType: 'DOMESTIC', status: 'ACTIVE'")
expect(source).toContain('listWarehouseLogisticsChannels')
expect(source).toContain('placeholder="草稿可暂不选择"')
expect(source).toContain('暂无启用的订舱代理')
expect(source).toContain(':label="supplier.name"')
expect(source).not.toContain('bookingAgentLabel')
expect(source).not.toContain("scope: 'INTERNATIONAL'")
expect(source).toContain('!editable && form.data.bookingAgentId')
expect(source).toContain('form.data.bookingAgentId = supplier ? String(supplier.id) : null')
expect(source).toContain('form.data.bookingAgentName = supplier?.name || null')
expect(source).not.toContain('label="客户 ID"')
expect(source).not.toContain('label="船公司 ID"')
expect(source).not.toContain('label="订舱代理 ID"')
expect(source).toContain('filterable clearable')
})
it('hides unavailable customers instead of filling the selector with disabled options', () => {
expect(source).toContain("listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' })")
expect(source).toContain(':label="customer.tenantName"')
expect(source).not.toContain('customer.tenantCode')
expect(source).toContain("customer.status === 'ACTIVE' || String(customer.id) === String(form.data.customerId)")
expect(source).toContain('v-for="customer in selectableCustomers"')
expect(source).not.toContain(':disabled="customer.status !== \'ACTIVE\'"')
})
it('keeps historical partner names visible when an existing partner is no longer selectable', () => {
expect(source).toContain("form.data.shippingCompanyName || '船公司名称缺失'")
expect(source).toContain("form.data.bookingAgentName || '订舱代理名称缺失'")
expect(source).toContain("from '@/modules/master-data'")
expect(source).toContain("from '@/modules/suppliers'")
})
it('renders the translated status once in the detail heading', () => {
expect(source).toContain('<StatusBadge :status="statusLabel(detail.status)" />')
expect(source).not.toContain('<StatusBadge :status="detail.status" />')
})
it('provides guidance for every free-text booking field', () => {
for (const placeholder of [
'例如:CNNGB', '例如:宁波港', '例如:USLAX', '例如:洛杉矶港',
'请输入最终交货地点', '请输入订舱备注(可选)',
]) expect(source).toContain(`placeholder="${placeholder}"`)
})
it('captures known offline booking facts during creation and only reviews them during release', () => {
for (const field of ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt']) {
expect(source).toContain(`v-model="form.data.${field}"`)
expect(source).toContain(`prop="${field}"`)
}
expect(source).not.toContain('确认放舱时录入')
expect(source).toContain('billOfLadingNo: value.billOfLadingNo || \'\'')
expect(source).toContain('estimatedSailingAt: value.estimatedSailingAt || null')
expect(source).toContain('if (form.data.estimatedArrivalAt! < form.data.estimatedSailingAt!)')
expect(source).toContain('请复核已登记的真实订舱资料。放舱不会重新采集或修改这些信息。')
expect(source).toContain('const billOfLadingNo = detail.value.billOfLadingNo?.trim()')
expect(source).toContain('const estimatedSailingAt = detail.value.estimatedSailingAt')
expect(source).toContain('v-model="releaseReason"')
expect(source).not.toContain('v-model="releaseForm.')
expect(source).toContain('线下订舱资料已登记并确认')
expect(source).toContain('<Save :size="16" />登记订舱')
expect(source).toContain('登记后直接确认')
})
it('uses source-backed free text terms and fixed container specifications', () => {
expect(source).not.toContain('serviceTermOptions')
expect(source).not.toContain('freightTermOptions')
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 :model-value="container.containerType"')
expect(source).toContain('<el-select v-model="container.containerKind"')
expect(source).toContain('<th scope="col">容量</th><th scope="col">限重</th>')
expect(source).toContain('{{ container.capacityCbm }} m³')
expect(source).toContain('{{ container.maxWeightKg }} kg')
expect(source).not.toContain("['PREPAID', 'COLLECT']")
expect(source).not.toContain('<el-input v-model="container.containerType"')
})
it('captures channel, warehouse, capacity snapshots and uses the release DTO', () => {
expect(source).toContain("listPlatformWarehouses({ page: 1, size: 200, warehouseType: 'DOMESTIC', status: 'ACTIVE' })")
expect(source).toContain("warehouse.status === 'ACTIVE' && warehouse.warehouseType === 'DOMESTIC'")
expect(source).toContain('label="物流渠道" prop="channelCode"')
expect(source).toContain('<el-select :model-value="form.data.channelCode" filterable')
expect(source).toContain(':model-value="form.data.channelCode" filterable')
expect(source).not.toContain('<el-input v-model="form.data.channelCode"')
expect(source).toContain('listWarehouseLogisticsChannels')
expect(source).toContain('void loadChannels(warehouseId)')
expect(source).not.toContain('v-model="form.data.channelName"')
expect(source).toContain('form.data.channelCode = \'\'')
expect(source).toContain('function selectWarehouse(value: unknown)')
expect(source).not.toContain('form.data.warehouseName')
expect(source).toContain('@update:model-value="selectWarehouse"')
expect(source).not.toContain('@update:model-value="(value) =>')
expect(source).toContain('selectBookingContainerType(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('createBlankBookingContainer()')
expect(source).toContain('bookingContainerError(form.data.containers)')
expect(source).toContain('function validateBookingContainers()')
expect(source).toContain('form.data.containers.forEach')
})
it('registers one real container per row and keeps each row removable', () => {
expect(source).toContain('@click="removeContainer(index)"')
expect(source).toContain(':aria-label="`删除真实柜 ${index + 1}`"')
expect(source).toContain("form.data.containers.length === 1 ? '至少保留一个真实柜' : '删除该真实柜'")
expect(source).toContain(':disabled="form.data.containers.length === 1"')
expect(source).toContain('function removeContainer(index: number) { if (form.data.containers.length > 1)')
expect(source).toContain('<table class="container-table">')
expect(source).toContain('.container-table-wrap{overflow-x:auto')
expect(source).toContain('v-model="container.containerNo"')
expect(source).toContain('maxlength="64" placeholder="请输入真实集装箱号"')
expect(source).toContain(':disabled="form.data.containers.length >= 20"')
expect(source).toContain('{{ form.data.containers.length }} / 20 个')
expect(source).toContain("detail.containers[index]?.cabinetNo || '—'")
expect(source).not.toContain('container.quantity')
expect(source).not.toContain('form.data.containerRequirements')
expect(source).not.toContain('class="container-row"')
})
it('keeps view, create and edit on the formal detail route with a return action', () => {
expect(source).toContain("router.push('/ocean-bookings')")
expect(source).toContain("router.replace(`/ocean-bookings/${created.id}`)")
expect(source).not.toContain('AppDrawer')
expect(source).not.toContain('打印')
})
it('groups real detail fields into the frozen three-column prototype structure', () => {
expect(source).toContain('aria-label="基本信息"')
expect(source).toContain('aria-label="航线船期"')
expect(source).toContain('aria-label="条款与真实柜"')
expect(source).toContain('grid-template-columns:minmax(0,1.5fr) repeat(2,minmax(0,1fr))')
expect(source).toContain('.booking-group__body--fields,.booking-terms{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));column-gap:10px;row-gap:8px}')
expect(source).toContain('class="booking-field--wide" label="交货地点"')
expect(source).toContain('@media(max-width:720px){.booking-groups,.party-grid{grid-template-columns:1fr}')
expect(source).toContain('@media(max-width:520px){.booking-group__body--fields,.booking-terms{grid-template-columns:1fr}')
expect(source).toContain('min-width:780px')
})
it('keeps the three real booking parties in their own three-column section', () => {
expect(source).toContain("{ value: 'SHIPPER', label: '发货人' }")
expect(source).toContain("{ value: 'CONSIGNEE', label: '收货人' }")
expect(source).toContain("{ value: 'NOTIFY_PARTY', label: '通知人' }")
expect(source).toContain('.party-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr))')
})
it('shows client-side validation failures instead of rendering an empty error alert', () => {
it('keeps dense layout, operation feedback and narrow-screen behavior', () => {
expect(source).toContain('<OperationErrorToast :details="actionError" />')
expect(source).not.toContain(':title="actionError.message"')
expect(source).toContain("actionError.value = { message: '请完善装柜仓库、物流渠道、真实运输资料和运输条款等必填信息' }")
expect(source).toContain("actionError.value = { message: '发货人、收货人和通知人名称均为必填项' }")
expect(source).toContain('aria-label="真实柜登记"')
expect(source).toContain('placeholder="单位名称(必填)"')
})
it('does not expose the removed draft submission lifecycle', () => {
expect(source).not.toContain("submit: '提交'")
expect(source).not.toContain("confirm: '确认订舱'")
expect(source).not.toContain("fail: '标记失败'")
expect(source).not.toContain("status === 'DRAFT'")
expect(source).not.toContain('updateOceanBooking')
expect(source).toContain('LoadErrorState')
expect(source).toContain('@media(max-width:720px)')
expect(source).toContain('@media(max-width:420px)')
})
})
@@ -20,13 +20,12 @@ import { listSuppliers, type Supplier } from '@/modules/suppliers'
import { listPlatformWarehouses, type PlatformWarehouseResource } from '@/modules/warehouses'
import {
createOceanBooking,
deleteOceanBookingDraft,
getOceanBooking,
performOceanBookingAction,
releaseOceanBooking,
requestOceanBookingChange,
listWarehouseLogisticsChannels,
updateOceanBookingDraft,
type OceanLogisticsChannel,
type OceanBookingAction,
} from '../api/platformOceanBookings'
import type {
OceanBookingDetail,
@@ -36,7 +35,7 @@ import type {
OceanBookingStatus,
} from '../types'
import { oceanBookingStatusLabel } from '../status'
import { bookingContainerError, createBlankBookingContainer, selectBookingContainerType } from '../containerRequirements'
import { containerRequirementError, createBlankContainerRequirement, selectContainerRequirementType } from '../containerRequirements'
import { isActiveBookingAgent } from '../bookingAgents'
const partyTypes = [
@@ -49,20 +48,6 @@ const containerKindOptions = [
{ value: 'FCL', label: '整箱 FCL' },
{ value: 'LCL', label: '拼箱 LCL' },
]
const actionLabels: Record<OceanBookingAction, string> = {
release: '确认放舱', cancel: '取消订舱',
'change-request': '申请改船', 'change-confirm': '确认改船', 'change-fail': '改船失败',
}
const actionPermissions: Record<OceanBookingAction, string> = {
release: 'platform:firstleg:ocean-booking:release',
cancel: 'platform:firstleg:ocean-booking:cancel', 'change-request': 'platform:firstleg:ocean-booking:change',
'change-confirm': 'platform:firstleg:ocean-booking:change', 'change-fail': 'platform:firstleg:ocean-booking:change',
}
const actionsByStatus: Partial<Record<OceanBookingStatus, OceanBookingAction[]>> = {
CONFIRMED: ['release', 'change-request', 'cancel'],
CHANGE_PENDING: ['change-confirm', 'change-fail', 'cancel'],
}
const route = useRoute()
const router = useRouter()
const access = useAccessPreviewStore()
@@ -100,30 +85,16 @@ function blankData(): OceanBookingDraftData {
return {
customerId: null, customerName: null, channelCode: '', warehouseId: '', serviceTerm: null, freightTerm: null, parties: blankParties(),
billOfLadingNo: '', vesselName: '', voyageNo: '', estimatedSailingAt: null, estimatedArrivalAt: null,
containers: [createBlankBookingContainer()],
containerRequirements: [createBlankContainerRequirement()],
}
}
const form = reactive<OceanBookingSaveRequest>({ expectedVersion: 0, data: blankData() })
const rules: FormRules = {
warehouseId: [{ required: true, message: '请选择装柜仓库', trigger: 'change' }],
channelCode: [{ required: true, message: '请选择物流渠道', trigger: 'change' }],
billOfLadingNo: [{ required: true, message: '请输入提单号', trigger: 'blur' }],
vesselName: [{ required: true, message: '请输入船名', trigger: 'blur' }],
voyageNo: [{ required: true, message: '请输入航次', trigger: 'blur' }],
estimatedSailingAt: [{ required: true, message: '请选择预计开船时间', trigger: 'change' }],
estimatedArrivalAt: [{ required: true, message: '请选择预计到港时间', trigger: 'change' }],
serviceTerm: [{ required: true, message: '请输入主要运输条款 Service Term', trigger: 'blur' }],
freightTerm: [{ required: true, message: '请输入运费条款 Freight Term', trigger: 'blur' }],
}
const editable = computed(() => isNew.value)
const canSave = computed(() => isNew.value && access.can('platform:firstleg:ocean-booking:create'))
const availableActions = computed(() => (actionsByStatus[detail.value?.status || 'CONFIRMED'] || [])
.filter((action) => access.can(actionPermissions[action])))
const changeDialogOpen = ref(false)
const changeForm = reactive({ vesselName: '', voyageNo: '', estimatedSailingAt: null as string | null, estimatedArrivalAt: null as string | null, reason: '' })
const rules: FormRules = {}
const editable = computed(() => isNew.value || detail.value?.status === 'DRAFT')
const canSave = computed(() => editable.value && access.can(isNew.value ? 'platform:firstleg:ocean-booking:create' : 'platform:firstleg:ocean-booking:edit'))
const canDelete = computed(() => detail.value?.status === 'DRAFT' && access.can('platform:firstleg:ocean-booking:delete'))
const canRelease = computed(() => detail.value?.status === 'DRAFT' && access.can('platform:firstleg:ocean-booking:release'))
const releaseDialogOpen = ref(false)
const releaseReason = ref('')
function statusLabel(status: OceanBookingStatus | string | null | undefined) {
return oceanBookingStatusLabel(status)
@@ -162,10 +133,10 @@ function normalizeDraftData() {
form.data.billOfLadingNo = form.data.billOfLadingNo.trim()
form.data.vesselName = form.data.vesselName.trim()
form.data.voyageNo = form.data.voyageNo.trim()
form.data.containers.forEach((container) => { container.containerNo = container.containerNo.trim() })
form.data.containerRequirements.forEach((requirement) => { requirement.quantity = Number(requirement.quantity) })
}
function selectWarehouse(value: unknown) {
const warehouseId = String(value || '')
const warehouseId = value == null || value === '' ? null : String(value)
form.data.warehouseId = warehouseId
form.data.channelCode = ''
selectedChannelName.value = ''
@@ -215,7 +186,7 @@ function applyDetail(value: OceanBookingDetail) {
placeOfDelivery: value.placeOfDelivery, serviceTerm: value.serviceTerm, freightTerm: value.freightTerm,
remark: value.remark,
parties: value.parties.length ? value.parties.map(({ partyType, partyName, contactName, contactPhone, address }) => ({ partyType, partyName, contactName, contactPhone, address })) : blankParties(),
containers: value.containers.length ? value.containers.map(({ containerNo, containerType, containerKind, capacityCbm, maxWeightKg }) => ({ containerNo, containerType, containerKind, capacityCbm, maxWeightKg })) : [createBlankBookingContainer()],
containerRequirements: value.containerRequirements.length ? value.containerRequirements.map(({ requirementId, containerType, containerKind, quantity, remainingQuantity, capacityCbm, maxWeightKg }) => ({ requirementId, containerType, containerKind, quantity, remainingQuantity, capacityCbm, maxWeightKg })) : [createBlankContainerRequirement()],
}
selectedChannelName.value = value.channelName || ''
channels.value = []
@@ -233,111 +204,77 @@ async function save() {
if (!canSave.value) return
actionError.value = null
normalizeDraftData()
if (!await formRef.value?.validate().catch(() => false)) {
actionError.value = { message: '请完善装柜仓库、物流渠道、真实运输资料和运输条款等必填信息' }
return
}
if (form.data.estimatedArrivalAt! < form.data.estimatedSailingAt!) {
actionError.value = { message: '预计到港时间不能早于预计开船时间' }
return
}
if (form.data.parties.some((party) => !party.partyName.trim())) {
actionError.value = { message: '发货人、收货人和通知人名称均为必填项' }
return
}
if (!validateBookingContainers()) return
const requirementMessage = containerRequirementError(form.data.containerRequirements)
if (requirementMessage) { actionError.value = { message: requirementMessage }; return }
saving.value = true
actionError.value = null
try {
const payload = { ...form.data }
const created = await createOceanBooking({ expectedVersion: 0, data: payload })
ElMessage.success('线下订舱资料已登记并确认')
await router.replace(`/ocean-bookings/${created.id}`)
applyDetail(created)
const payload = {
...form.data,
channelCode: form.data.channelCode?.trim() || null,
warehouseId: form.data.warehouseId?.trim() || null,
parties: form.data.parties.filter((party) => party.partyName.trim()).map((party) => ({ ...party, partyName: party.partyName.trim() })),
containerRequirements: form.data.containerRequirements.map(({ containerType, containerKind, quantity, capacityCbm, maxWeightKg }) => ({ containerType, containerKind, quantity, capacityCbm, maxWeightKg })),
}
const saved = isNew.value
? await createOceanBooking({ expectedVersion: 0, data: payload })
: await updateOceanBookingDraft(id.value, { expectedVersion: detail.value?.version || 0, data: payload })
ElMessage.success('订舱草稿已保存')
if (isNew.value) await router.replace(`/ocean-bookings/${saved.id}`)
applyDetail(saved)
} catch (cause) { actionError.value = getApiErrorDetails(cause) }
finally { saving.value = false }
}
async function requiredReason(action: OceanBookingAction) {
if (!['cancel', 'change-fail'].includes(action)) return null
const result = await ElMessageBox.prompt(`请填写“${actionLabels[action]}”原因,服务端将写入状态历史。`, actionLabels[action], {
inputValidator: (value) => Boolean(value?.trim()) || '原因不能为空',
confirmButtonText: '确认', cancelButtonText: '取消', type: 'warning',
})
return result.value.trim()
}
function openChangeDialog() {
changeForm.vesselName = detail.value?.vesselName || ''
changeForm.voyageNo = detail.value?.voyageNo || ''
changeForm.estimatedSailingAt = detail.value?.estimatedSailingAt || null
changeForm.estimatedArrivalAt = detail.value?.estimatedArrivalAt || null
changeForm.reason = ''
changeDialogOpen.value = true
}
function openReleaseDialog() {
releaseReason.value = ''
normalizeDraftData()
const validationError = releaseValidationError()
if (validationError) { actionError.value = { message: validationError }; return }
releaseDialogOpen.value = true
}
function releaseValidationError() {
const requiredFields: Array<[unknown, string]> = [
[form.data.customerId, '客户'], [form.data.warehouseId, '装柜仓库'], [form.data.channelCode, '物流渠道'],
[form.data.billOfLadingNo, '提单号'], [form.data.vesselName, '船名'], [form.data.voyageNo, '航次'],
[form.data.estimatedSailingAt, '预计开船时间'], [form.data.estimatedArrivalAt, '预计到港时间'],
[form.data.serviceTerm, 'Service Term'], [form.data.freightTerm, 'Freight Term'],
[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 (form.data.parties.some((party) => !party.partyName.trim())) return '放舱前请填写发货人、收货人和通知人名称'
return containerRequirementError(form.data.containerRequirements)
}
async function submitRelease() {
if (!detail.value) return
const billOfLadingNo = detail.value.billOfLadingNo?.trim()
const vesselName = detail.value.vesselName?.trim()
const voyageNo = detail.value.voyageNo?.trim()
const estimatedSailingAt = detail.value.estimatedSailingAt
const estimatedArrivalAt = detail.value.estimatedArrivalAt
if (!billOfLadingNo || !vesselName || !voyageNo || !estimatedSailingAt || !estimatedArrivalAt) {
actionError.value = getApiErrorDetails(new Error('当前订舱缺少已确认运输资料,无法放舱'))
return
}
if (estimatedArrivalAt < estimatedSailingAt) {
actionError.value = getApiErrorDetails(new Error('预计到港时间不能早于预计开船时间'))
return
}
const validationError = releaseValidationError()
if (validationError) { actionError.value = { message: validationError }; releaseDialogOpen.value = false; return }
actionSaving.value = true
actionError.value = null
try {
applyDetail(await releaseOceanBooking(id.value, { expectedVersion: detail.value.version, billOfLadingNo, vesselName, voyageNo, estimatedSailingAt, estimatedArrivalAt, reason: releaseReason.value.trim() || null }))
applyDetail(await releaseOceanBooking(id.value, { expectedVersion: detail.value.version }))
releaseDialogOpen.value = false
ElMessage.success('确认放舱成功')
} catch (cause) { actionError.value = getApiErrorDetails(cause) }
finally { actionSaving.value = false }
}
async function submitChange() {
if (!detail.value || !changeForm.vesselName.trim() || !changeForm.voyageNo.trim() || !changeForm.reason.trim()) return
actionSaving.value = true
async function removeDraft() {
if (!detail.value || !canDelete.value) return
actionError.value = null
try {
applyDetail(await requestOceanBookingChange(id.value, { expectedVersion: detail.value.version, ...changeForm }))
changeDialogOpen.value = false
ElMessage.success('改船申请已提交')
} catch (cause) { actionError.value = getApiErrorDetails(cause) }
finally { actionSaving.value = false }
}
async function runAction(action: OceanBookingAction) {
if (!detail.value || action === 'change-request') { if (action === 'change-request') openChangeDialog(); return }
if (action === 'release') { openReleaseDialog(); return }
actionError.value = null
try {
const reason = await requiredReason(action)
await ElMessageBox.confirm(`确认执行“${actionLabels[action]}”吗?`, actionLabels[action], { confirmButtonText: '确认', cancelButtonText: '取消', type: 'warning' })
await ElMessageBox.confirm('确认删除该订舱草稿?删除后无法恢复。', '删除草稿', { confirmButtonText: '确认删除', cancelButtonText: '取消', type: 'warning' })
actionSaving.value = true
applyDetail(await performOceanBookingAction(id.value, action, { expectedVersion: detail.value.version, reason }))
ElMessage.success(`${actionLabels[action]}成功`)
await deleteOceanBookingDraft(id.value, detail.value.version)
ElMessage.success('订舱草稿已删除')
await router.replace('/ocean-bookings')
} catch (cause) {
if (cause !== 'cancel' && !(cause instanceof Error && cause.message === 'cancel')) actionError.value = getApiErrorDetails(cause)
} finally { actionSaving.value = false }
}
function validateBookingContainers() {
const message = bookingContainerError(form.data.containers)
if (message) {
actionError.value = { message }
return false
}
return true
function addRequirement() {
if (form.data.containerRequirements.length < 20) form.data.containerRequirements.push(createBlankContainerRequirement())
}
function addContainer() {
if (form.data.containers.length < 20) form.data.containers.push(createBlankBookingContainer())
}
function removeContainer(index: number) { if (form.data.containers.length > 1) form.data.containers.splice(index, 1) }
function removeRequirement(index: number) { if (form.data.containerRequirements.length > 1) form.data.containerRequirements.splice(index, 1) }
onMounted(() => { void load(); void loadOptions() })
</script>
@@ -349,7 +286,7 @@ onMounted(() => { void load(); void loadOptions() })
<template #header>
<div class="detail-heading">
<el-button @click="router.push('/ocean-bookings')"><ArrowLeft :size="16" />返回列表</el-button>
<PageToolbar class="booking-page-toolbar" as="section" :title="detail?.bookingNo || '订舱资料'" :description="detail ? `${detail.customerName || '未关联客户'} · ${detail.pol || '—'} → ${detail.pod || '—'}` : '登记线下已确认的真实订舱资料'">
<PageToolbar class="booking-page-toolbar" as="section" :title="detail?.bookingNo || '订舱草稿'" :description="detail ? `${detail.customerName || '未关联客户'} · ${detail.pol || '—'} → ${detail.pod || '—'}` : '先保存订舱草稿,资料完整后再确认放舱'">
<template v-if="detail" #actions><div class="heading-status"><StatusBadge :status="statusLabel(detail.status)" /></div></template>
</PageToolbar>
</div>
@@ -359,10 +296,11 @@ onMounted(() => { void load(); void loadOptions() })
<template v-else>
<section class="surface-panel">
<div class="section-title">
<div><h2>订舱信息</h2><p v-if="detail && !editable">已确认订舱资料仅可查看,船期变更请使用“申请改船”。</p></div>
<div><h2>订舱信息</h2><p v-if="detail?.status === 'DRAFT'">草稿可反复保存;放舱后资料同步至关联货柜并锁定。</p><p v-else-if="detail">已放舱资料只读。</p></div>
<div class="actions">
<el-button v-for="item in availableActions" :key="item" :loading="actionSaving" @click="runAction(item)">{{ actionLabels[item] }}</el-button>
<el-button v-if="canSave" type="primary" :loading="saving" @click="save"><Save :size="16" />登记订舱</el-button>
<el-button v-if="canDelete" type="danger" plain :loading="actionSaving" @click="removeDraft"><Trash2 :size="16" />删除草稿</el-button>
<el-button v-if="canRelease" type="primary" plain :loading="actionSaving" @click="openReleaseDialog">放舱</el-button>
<el-button v-if="canSave" type="primary" :loading="saving" @click="save"><Save :size="16" />保存草稿</el-button>
</div>
</div>
<el-alert v-if="optionsError" type="warning" :closable="false" show-icon :title="optionsError"><el-button link type="warning" :loading="optionsLoading" @click="loadOptions">重新加载选项</el-button></el-alert>
@@ -370,10 +308,10 @@ onMounted(() => { void load(); void loadOptions() })
<section class="booking-group" aria-label="基本信息">
<h3>基本信息</h3><div class="booking-group__body booking-group__body--fields">
<el-form-item label="订舱号"><el-input :model-value="detail?.bookingNo || '保存后生成'" disabled /></el-form-item>
<el-form-item label="客户(可选)"><el-select :model-value="form.data.customerId" filterable clearable placeholder="请选择客户(可选)" no-data-text="暂无可选客户" :loading="optionsLoading" 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></el-form-item>
<el-form-item label="状态"><el-input :model-value="detail ? statusLabel(detail.status) : '登记后直接确认'" disabled /></el-form-item>
<el-form-item label="客户(放舱前必填)"><el-select :model-value="form.data.customerId" filterable clearable placeholder="草稿可暂不选择" no-data-text="暂无可选客户" :loading="optionsLoading" 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></el-form-item>
<el-form-item label="状态"><el-input :model-value="detail ? statusLabel(detail.status) : '保存后为草稿'" disabled /></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="!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 && !channels.some((channel) => channel.channelCode === form.data.channelCode)" :label="selectedChannelName || form.data.channelCode" :value="form.data.channelCode" /><el-option v-for="channel in channels" :key="channel.channelCode" :label="channel.channelName" :value="channel.channelCode"><span>{{ channel.channelName }}</span><small class="channel-code">{{ channel.channelCode }}</small></el-option></el-select><div v-if="channelsError" class="field-error"><span>{{ channelsError }}</span><el-button link type="primary" :loading="channelsLoading" @click="loadChannels(form.data.warehouseId)">重新加载</el-button></div><div v-else-if="channelUnavailable" class="field-hint">该仓库暂无有效物流渠道,请更换仓库或联系管理员。</div></el-form-item>
<el-form-item label="物流渠道" prop="channelCode"><el-select :model-value="form.data.channelCode" filterable :disabled="!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 && !channels.some((channel) => channel.channelCode === form.data.channelCode)" :label="selectedChannelName || form.data.channelCode" :value="form.data.channelCode" /><el-option v-for="channel in channels" :key="channel.channelCode" :label="channel.channelName" :value="channel.channelCode"><span>{{ channel.channelName }}</span><small class="channel-code">{{ channel.channelCode }}</small></el-option></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-item label="提单号" prop="billOfLadingNo"><el-input v-model="form.data.billOfLadingNo" maxlength="128" placeholder="请输入提单号" /></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 placeholder="请选择订舱代理" :loading="optionsLoading" no-data-text="暂无启用的订舱代理" style="width:100%" @update:model-value="selectBookingAgent(String($event || ''))"><el-option v-if="!editable && form.data.bookingAgentId && !selectableBookingAgents.some((supplier) => String(supplier.id) === String(form.data.bookingAgentId))" :label="form.data.bookingAgentName || '订舱代理名称缺失'" :value="String(form.data.bookingAgentId)" /><el-option v-for="supplier in selectableBookingAgents" :key="String(supplier.id)" :label="supplier.name" :value="String(supplier.id)" /></el-select></el-form-item>
@@ -392,34 +330,34 @@ onMounted(() => { void load(); void loadOptions() })
<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>
</div>
</section>
<section class="booking-group" aria-label="条款与真实柜">
<h3>条款与真实柜</h3><div class="booking-group__body">
<section class="booking-group" aria-label="条款与柜型需求">
<h3>条款与柜型需求</h3><div class="booking-group__body">
<div class="booking-terms">
<el-form-item label="主要运输条款 Service Term" prop="serviceTerm"><el-input v-model="form.data.serviceTerm" clearable maxlength="32" placeholder="请输入 Service Term" /></el-form-item>
<el-form-item label="运费条款 Freight Term" prop="freightTerm"><el-input v-model="form.data.freightTerm" clearable maxlength="32" placeholder="请输入 Freight Term" /></el-form-item>
</div>
<div class="container-table-wrap" aria-label="真实柜登记">
<div class="container-table-wrap" aria-label="柜型需求">
<table class="container-table">
<thead><tr><th scope="col">操作</th><th scope="col">集装箱号</th><th scope="col">柜型</th><th scope="col">箱种</th><th scope="col">容量</th><th scope="col">限重</th><th v-if="detail" 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="(container, index) in form.data.containers" :key="detail?.containers[index]?.containerBookingId || index">
<tr v-for="(requirement, index) in form.data.containerRequirements" :key="requirement.requirementId || index">
<td class="container-table__action">
<el-tooltip v-if="editable" :content="form.data.containers.length === 1 ? '至少保留一个真实柜' : '删除该真实柜'" placement="top">
<span><el-button circle plain size="small" type="danger" :aria-label="`删除真实柜 ${index + 1}`" :disabled="form.data.containers.length === 1" @click="removeContainer(index)"><Trash2 :size="14" /></el-button></span>
<el-tooltip v-if="editable" :content="form.data.containerRequirements.length === 1 ? '至少保留一项柜型需求' : '删除该柜型需求'" placement="top">
<span><el-button circle plain size="small" type="danger" :aria-label="`删除柜型需求 ${index + 1}`" :disabled="form.data.containerRequirements.length === 1" @click="removeRequirement(index)"><Trash2 :size="14" /></el-button></span>
</el-tooltip>
<span v-else aria-hidden="true">—</span>
</td>
<td><el-input v-model="container.containerNo" :disabled="!editable" maxlength="64" placeholder="请输入真实集装箱号" /></td>
<td><el-select :model-value="container.containerType" :disabled="!editable" placeholder="请选择柜型" @update:model-value="selectBookingContainerType(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"><span>{{ container.capacityCbm }} m³</span></td>
<td class="container-table__specification"><span>{{ container.maxWeightKg }} kg</span></td>
<td v-if="detail"><span>{{ detail.containers[index]?.cabinetNo || '—' }}</span></td>
<td><el-select :model-value="requirement.containerType" :disabled="!editable" 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="!editable" 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="!editable" :min="1" :max="99" controls-position="right" /></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 v-if="editable" class="container-actions"><el-button class="add-container" size="small" :disabled="form.data.containers.length >= 20" @click="addContainer"><Plus :size="15" />新增真实柜</el-button><span>{{ form.data.containers.length }} / 20 个</span></div>
<div v-if="editable" class="container-actions"><el-button class="add-container" size="small" :disabled="form.data.containerRequirements.length >= 20" @click="addRequirement"><Plus :size="15" />新增柜型需求</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" placeholder="请输入订舱备注(可选)" /></el-form-item>
</div>
</section>
@@ -446,18 +384,9 @@ onMounted(() => { void load(); void loadOptions() })
</template>
</DetailPageLayout>
<AppDialog v-model="changeDialogOpen" title="申请改船" width="min(560px, 94vw)" :confirm-loading="actionSaving" :confirm-disabled="!changeForm.vesselName.trim() || !changeForm.voyageNo.trim() || !changeForm.reason.trim()" confirm-text="提交申请" @confirm="submitChange">
<el-form label-position="top">
<el-form-item label="新船名" required><el-input v-model="changeForm.vesselName" placeholder="请输入新船名" /></el-form-item>
<el-form-item label="新航次" required><el-input v-model="changeForm.voyageNo" placeholder="请输入新航次" /></el-form-item>
<el-form-item label="预计开船"><el-date-picker v-model="changeForm.estimatedSailingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计开船时间" style="width:100%" /></el-form-item>
<el-form-item label="预计到港"><el-date-picker v-model="changeForm.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计到港时间" style="width:100%" /></el-form-item>
<el-form-item label="变更原因" required><el-input v-model="changeForm.reason" type="textarea" placeholder="请说明改船原因" /></el-form-item>
</el-form>
</AppDialog>
<AppDialog v-model="releaseDialogOpen" title="确认放舱" width="min(560px, 94vw)" :confirm-loading="actionSaving" confirm-text="确认放舱" cancel-text="取消" @confirm="submitRelease">
<el-form label-position="top">
<el-alert title="请复核已登记的真实订舱资料。放舱不会重新采集或修改这些信息。" type="info" :closable="false" show-icon />
<el-alert title="放舱后资料将同步到关联货柜,不能修改、不能删除;填写错误只能由管理员处理数据库,请确认资料无误。" type="warning" :closable="false" show-icon />
<el-descriptions class="release-summary" :column="2" border>
<el-descriptions-item label="提单号">{{ detail?.billOfLadingNo || '—' }}</el-descriptions-item>
<el-descriptions-item label="船名">{{ detail?.vesselName || '—' }}</el-descriptions-item>
@@ -465,7 +394,6 @@ onMounted(() => { void load(); void loadOptions() })
<el-descriptions-item label="预计开船">{{ detail?.estimatedSailingAt || '—' }}</el-descriptions-item>
<el-descriptions-item label="预计到港">{{ detail?.estimatedArrivalAt || '—' }}</el-descriptions-item>
</el-descriptions>
<el-form-item label="备注"><el-input v-model="releaseReason" type="textarea" maxlength="1000" placeholder="请输入放舱备注(可选)" /></el-form-item>
</el-form>
</AppDialog>
</AdminPage>
@@ -1,12 +1,37 @@
import { describe, expect, it } from 'vitest'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
describe('OceanBookingsView', () => { const source = readFileSync(resolve(process.cwd(), 'src/modules/ocean-bookings/pages/OceanBookingsView.vue'), 'utf8'); it('keeps the operation column first and left fixed', () => { expect(source.indexOf("key: 'actions'")).toBeLessThan(source.indexOf("prop: 'bookingNo'")); expect(source).toContain("fixed: 'left'") }); it('renders loading, empty and error through shared components', () => { expect(source).toContain('LoadErrorState'); expect(source).toContain(':loading="loading"'); expect(source).toContain(':total="total"') }); it('keeps real booking creation available without mixing draft rows into the formal list', () => { expect(source).toContain('<template #toolbar-actions>'); expect(source).toContain('新增订舱'); expect(source).toContain("router.push('/ocean-bookings/new')"); expect(source).toContain("access.can('platform:firstleg:ocean-booking:create')"); expect(source).not.toContain('PageToolbar') }); it('shows only the formal booking detail action', () => { expect(source).toContain('<Eye :size="15" />查看'); expect(source).not.toContain('<Pencil :size="15" />编辑'); expect(source).not.toContain('MoreHorizontal') }); it('limits status filters to the frozen formal booking states', () => { expect(source).toContain("['CONFIRMED', 'CHANGE_PENDING', 'RELEASED', 'CANCELLED']"); expect(source).toContain('options: formalStatusOptions'); expect(source).toContain(':status="oceanBookingStatusLabel(row.status)"'); expect(source).not.toContain(':status="row.status"') }) })
describe('OceanBookingsView prototype field matrix', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/ocean-bookings/pages/OceanBookingsView.vue'), 'utf8')
const source = readFileSync(resolve(process.cwd(), 'src/modules/ocean-bookings/pages/OceanBookingsView.vue'), 'utf8')
it('keeps the frozen business field sequence after the first action column', () => {
describe('OceanBookingsView', () => {
it('keeps the operation column first and left fixed', () => {
expect(source.indexOf("key: 'actions'")).toBeLessThan(source.indexOf("prop: 'bookingNo'"))
expect(source).toContain("fixed: 'left'")
})
it('renders loading, empty and error states and keeps draft creation available', () => {
expect(source).toContain('LoadErrorState')
expect(source).toContain(':loading="loading"')
expect(source).toContain('新增订舱')
expect(source).toContain("router.push('/ocean-bookings/new')")
})
it('edits DRAFT rows and views RELEASED rows', () => {
expect(source).toContain('<Pencil v-if="row.status === \'DRAFT\'"')
expect(source).toContain("row.status === 'DRAFT' ? '编辑' : '查看'")
expect(source).not.toContain('MoreHorizontal')
})
it('uses only the frozen DRAFT and RELEASED status options', () => {
expect(source).toContain('options: oceanBookingStatusOptions')
expect(source).toContain(':status="oceanBookingStatusLabel(row.status)"')
expect(source).not.toContain('CONFIRMED')
expect(source).not.toContain('CHANGE_PENDING')
expect(source).not.toContain('CANCELLED')
})
it('keeps the prototype business field order after actions', () => {
const fields = ['bookingNo', 'customerName', 'billOfLadingNo', 'shipperName', 'consigneeName', 'containerCount', 'carrierName', 'bookingAgent', 'vesselName', 'pol', 'pod', 'etd', 'eta', 'status', 'updatedAt']
let previous = source.indexOf("key: 'actions'")
for (const field of fields) {
@@ -16,34 +41,10 @@ describe('OceanBookingsView prototype field matrix', () => {
}
})
it('formats schedule and update timestamps without adding print actions', () => {
expect(source).toContain("prop: 'billOfLadingNo', label: '提单号'")
expect(source).toContain("prop: 'containerCount', label: '柜量'")
expect(source).toContain('<template #updatedAt="{ row }">{{ formatDateTime(row.updatedAt) }}</template>')
expect(source).toContain('<template #etd="{ row }">{{ formatDateTime(row.etd) }}</template>')
expect(source).not.toContain('打印')
})
})
describe('OceanBookingsView visual action contract', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/ocean-bookings/pages/OceanBookingsView.vue'), 'utf8')
it('uses bordered business buttons and stacks them on narrow screens', () => {
expect(source).toContain('size="small" type="primary" plain')
expect(source).not.toContain('link type="primary" @click="open(row.id)')
expect(source).toContain('@media(max-width:420px)')
expect(source).toContain('.table-actions')
expect(source).toContain(':total="total" stripe preference-key="ocean-bookings"')
})
})
describe('OceanBookingsView customer filter contract', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/ocean-bookings/pages/OceanBookingsView.vue'), 'utf8')
it('loads active customers, displays names only and submits customerId', () => {
it('loads active customers by name and stays usable on narrow screens', () => {
expect(source).toContain("listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' })")
expect(source).toContain("customer.status === 'ACTIVE'")
expect(source).toContain("label: customer.tenantName")
expect(source).toContain("customerId: String(query.customerId || '') || undefined")
expect(source).toContain('label: customer.tenantName')
expect(source).not.toContain('customer.tenantCode')
expect(source).toContain('@media(max-width:420px)')
})
})
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { Eye, Plus } from '@lucide/vue'
import { Eye, Pencil, Plus } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import DataTable from '@/shared/components/DataTable.vue'
@@ -29,11 +29,10 @@ const customersLoading = ref(false)
const query = reactive<QueryModel>({ customerId: '', keyword: '', status: '' })
const activeCustomers = computed(() => customers.value.filter((customer) => customer.status === 'ACTIVE'))
const canCreate = computed(() => access.can('platform:firstleg:ocean-booking:create'))
const formalStatusOptions = oceanBookingStatusOptions.filter((item) => ['CONFIRMED', 'CHANGE_PENDING', 'RELEASED', 'CANCELLED'].includes(item.value))
const fields = computed<QueryField[]>(() => [
{ key: 'customerId', type: 'select', label: '客户', placeholder: '全部客户', width: 220, options: activeCustomers.value.map((customer) => ({ label: customer.tenantName, value: String(customer.id) })), props: { filterable: true, loading: customersLoading.value } },
{ key: 'keyword', type: 'input', label: '关键词', placeholder: '订舱号/订单号', width: 220 },
{ key: 'status', type: 'select', label: '状态', options: formalStatusOptions },
{ key: 'status', type: 'select', label: '状态', options: oceanBookingStatusOptions },
])
const columns = [
{ key: 'actions', label: '操作', fixed: 'left', width: 100, slotName: 'actions', hideable: false, movable: false },
@@ -69,7 +68,7 @@ watch([page, pageSize], () => void load()); onMounted(() => { void load(); void
<section v-if="!error" class="surface-panel table-panel">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" stripe preference-key="ocean-bookings" aria-label="订舱列表" row-key="id" empty-text="暂无符合筛选条件的订舱">
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="createBooking"><Plus :size="16" />新增订舱</el-button></template>
<template #actions="{ row }"><div class="table-actions"><el-button size="small" type="primary" plain @click="open(row.id)"><Eye :size="15" />查看</el-button></div></template>
<template #actions="{ row }"><div class="table-actions"><el-button size="small" type="primary" plain @click="open(row.id)"><Pencil v-if="row.status === 'DRAFT'" :size="15" /><Eye v-else :size="15" />{{ row.status === 'DRAFT' ? '编辑' : '查看' }}</el-button></div></template>
<template #vessel="{ row }">{{ row.vesselName || '—' }}<span v-if="row.voyageNo"> / {{ row.voyageNo }}</span></template>
<template #etd="{ row }">{{ formatDateTime(row.etd) }}</template>
<template #eta="{ row }">{{ formatDateTime(row.eta) }}</template>
+1 -3
View File
@@ -4,10 +4,8 @@ import { oceanBookingStatusLabel, oceanBookingStatusOptions } from './status'
describe('ocean booking status labels', () => {
it('translates every backend status for selectors and badges', () => {
expect(oceanBookingStatusOptions).toEqual([
{ value: 'CONFIRMED', label: '已确认' },
{ value: 'DRAFT', label: '草稿' },
{ value: 'RELEASED', label: '已放舱' },
{ value: 'CHANGE_PENDING', label: '变更中' },
{ value: 'CANCELLED', label: '已取消' },
])
})
+1 -3
View File
@@ -1,10 +1,8 @@
import type { OceanBookingStatus } from './types'
export const oceanBookingStatusLabels: Record<OceanBookingStatus, string> = {
CONFIRMED: '已确认',
DRAFT: '草稿',
RELEASED: '已放舱',
CHANGE_PENDING: '变更中',
CANCELLED: '已取消',
}
export const oceanBookingStatusOptions = Object.entries(oceanBookingStatusLabels).map(([value, label]) => ({
+25 -20
View File
@@ -1,16 +1,30 @@
export type OceanBookingStatus = 'CONFIRMED' | 'RELEASED' | 'CHANGE_PENDING' | 'CANCELLED'
export type OceanBookingStatus = 'DRAFT' | 'RELEASED'
export type OceanBookingContainerType = '20GP' | '40GP' | '40HC' | '40NR' | '45HC'
export interface OceanBookingParty { id?: string; partyType: string; partyName: string; contactName?: string | null; contactPhone?: string | null; address?: string | null }
export interface OceanBookingContainerInput { containerNo: string; containerType: OceanBookingContainerType; containerKind: string; capacityCbm: number; maxWeightKg: number }
export interface OceanBookingContainerSnapshot extends OceanBookingContainerInput {
containerBookingId: string; cabinetNo: string; requirementId: string; allocationId: string
warehouseId: string; warehouseName: string; channelCode: string; channelName: string
customerId: string | null; customerName: string | null
export interface OceanBookingContainerRequirement {
id?: string
requirementId?: string
containerType: OceanBookingContainerType
containerKind: string
quantity: number
remainingQuantity?: number
capacityCbm: number
maxWeightKg: number
}
export interface OceanBookingAllocatedContainer {
containerBookingId: string
cabinetNo: string
containerNo: string
requirementId: string
containerType: OceanBookingContainerType
containerKind: string
capacityCbm: number
maxWeightKg: number
}
export interface OceanBookingDraftData {
customerId: string | null; customerName: string | null; parties: OceanBookingParty[]; containers: OceanBookingContainerInput[]
channelCode: string; warehouseId: string
customerId: string | null; customerName: string | null; parties: OceanBookingParty[]; containerRequirements: OceanBookingContainerRequirement[]
channelCode: string | null; warehouseId: string | null
billOfLadingNo: string; vesselName: string; voyageNo: string
estimatedSailingAt: string | null; estimatedArrivalAt: string | null
shippingCompanyId?: string | null; shippingCompanyName?: string | null; bookingAgentId?: string | null; bookingAgentName?: string | null
@@ -27,25 +41,16 @@ export interface OceanBookingResource {
vesselName: string | null; voyageNo: string | null; departurePortCode: string | null; departurePortName: string | null
destinationPortCode: string | null; destinationPortName: string | null; placeOfDelivery: string | null
serviceTerm: string | null; freightTerm: string | null; estimatedSailingAt: string | null; estimatedArrivalAt: string | null
pendingVesselName: string | null; pendingVoyageNo: string | null; pendingEstimatedSailingAt: string | null; pendingEstimatedArrivalAt: string | null
changeReason: string | null; failureReason: string | null; cancellationReason: string | null; remark: string | null
remark: string | null
createdAt: string | null; updatedAt: string | null
orderNo: string | null; carrierName: string | null; bookingAgent: string | null; pol: string | null; pod: string | null
mblShipper: string | null; mblConsignee: string | null; soNo: string | null; cutoffAt: string | null
etd: string | null; atd: string | null; eta: string | null; ata: string | null; containerSummary: string | null
amount: string | null; currency: string | null
}
export interface OceanBookingDetail extends OceanBookingResource { oceanBooking: OceanBookingResource; parties: OceanBookingParty[]; containers: OceanBookingContainerSnapshot[]; histories: OceanBookingStatusEvent[]; statusHistory: OceanBookingStatusEvent[] }
export interface OceanBookingDetail extends OceanBookingResource { oceanBooking: OceanBookingResource; parties: OceanBookingParty[]; containerRequirements: OceanBookingContainerRequirement[]; containers: OceanBookingAllocatedContainer[]; histories: OceanBookingStatusEvent[]; statusHistory: OceanBookingStatusEvent[] }
export interface OceanBookingStatusEvent { id: string; fromStatus: OceanBookingStatus | null; toStatus: OceanBookingStatus; action: string; reason?: string | null; operatorName: string | null; occurredAt: string }
export interface OceanBookingPage { records: OceanBookingResource[]; page: number; size: number; total: number; pages: number }
export interface OceanBookingQuery { page: number; size: number; keyword?: string; status?: OceanBookingStatus; customerId?: string }
export interface OceanBookingSaveRequest { expectedVersion: number; data: OceanBookingDraftData }
export interface OceanBookingActionRequest { expectedVersion: number; reason?: string | null }
export interface OceanBookingReleaseRequest { expectedVersion: number; billOfLadingNo: string; vesselName: string; voyageNo: string; estimatedSailingAt: string; estimatedArrivalAt: string; reason?: string | null }
export interface OceanBookingChangeRequest extends OceanBookingActionRequest {
vesselName: string
voyageNo: string
estimatedSailingAt?: string | null
estimatedArrivalAt?: string | null
reason: string
}
export interface OceanBookingReleaseRequest { expectedVersion: number }
@@ -55,7 +55,10 @@ describe('TruckAppointmentForm interaction contract', () => {
expect(source).toContain('label="关联柜次(可选)"')
expect(source).toContain('label="关联订舱(可选)"')
expect(source).toContain("relationWarning.value = '所选关联资源与当前客户不一致")
expect(source).not.toContain("status === 'RELEASED'")
expect(source).toContain("item.status === 'RELEASED'")
expect(source).toContain('已从放舱订舱回填并锁定')
expect(source).toContain(':disabled="Boolean(selectedReleasedBooking)"')
expect(source).toContain('草稿可暂不填写,派车前必填')
expect(source).not.toContain('尚未建立装柜计划')
})
@@ -14,6 +14,7 @@ const emit = defineEmits<{ 'update:modelValue': [SaveTruckAppointmentRequest]; s
const formRef = ref<FormInstance>()
const relationWarning = ref('')
const derivedCustomerSource = ref<'booking' | 'container' | null>(null)
const billOfLadingFromBooking = ref(false)
const defaults: Partial<SaveTruckAppointmentRequest> = { expectedVersion: 0, currency: 'RMB', payableAmount: '0.00', receivableAmount: '0.00', packageCount: 0, palletCount: 0, transitMinutes: 0 }
function normalizeDraft(value: SaveTruckAppointmentRequest): SaveTruckAppointmentRequest { return { ...defaults, ...value, currency: value.currency?.trim().toUpperCase() || 'RMB' } }
const draft = reactive<SaveTruckAppointmentRequest>(normalizeDraft(props.modelValue))
@@ -52,6 +53,7 @@ const selectableWarehouses = computed(() => warehouses.value.filter((item) => (
const selectableCarriers = computed(() => carriers.value.filter((item) => (
item.status === 'ACTIVE'
)))
const selectedReleasedBooking = computed(() => bookings.value.find((item) => item.id === draft.oceanBookingId && item.status === 'RELEASED') || null)
function customerLabel(customer: PlatformTenantResource) { return customer.tenantName }
function selectCustomer(customerId: string) { const customer = selectableCustomers.value.find((item) => String(item.id) === customerId); draft.customerId = customer?.id ? String(customer.id) : null; draft.customerName = customer ? customerLabel(customer) : null; derivedCustomerSource.value = null; relationWarning.value = '' }
function selectCarrier(carrierId: string) { const carrier = selectableCarriers.value.find((item) => String(item.id) === carrierId); draft.carrierId = carrier?.id || null; draft.carrierName = carrier?.name || null }
@@ -74,13 +76,24 @@ function selectContainer(containerId: string) {
}
function selectBooking(bookingId: string) {
const item = bookings.value.find((entry) => entry.id === bookingId)
const previouslyDerivedBillOfLading = billOfLadingFromBooking.value
draft.oceanBookingId = item?.id || null
relationWarning.value = ''
if (!item) {
if (billOfLadingFromBooking.value) draft.billOfLadingNo = null
billOfLadingFromBooking.value = false
if (derivedCustomerSource.value === 'booking') { draft.customerId = null; draft.customerName = null; derivedCustomerSource.value = null }
return
}
applyRelatedCustomer(item.customerId, item.customerName, 'booking')
if (item.status === 'RELEASED') {
if (draft.billOfLadingNo && draft.billOfLadingNo !== item.billOfLadingNo) relationWarning.value = '已放舱订舱的提单号与当前填写值不一致,已使用订舱冻结快照'
draft.billOfLadingNo = item.billOfLadingNo || null
billOfLadingFromBooking.value = true
} else {
if (previouslyDerivedBillOfLading) draft.billOfLadingNo = null
billOfLadingFromBooking.value = false
}
}
async function loadOptions() {
optionsLoading.value = true; optionsError.value = ''
@@ -118,6 +131,7 @@ watch(() => props.readonly, (readonly) => { if (!readonly && !customers.value.le
<el-col :xs="24" :sm="12"><el-form-item label="客户" prop="customerId"><el-select :model-value="draft.customerId" filterable clearable placeholder="请选择客户" :loading="optionsLoading" no-data-text="暂无启用的客户" style="width:100%" @update:model-value="selectCustomer(String($event || ''))"><el-option v-if="draft.customerId && !selectableCustomers.some((item) => String(item.id) === String(draft.customerId))" :label="draft.customerName || '历史客户(需重新选择)'" :value="String(draft.customerId)" disabled /><el-option v-for="item in selectableCustomers" :key="String(item.id)" :label="customerLabel(item)" :value="String(item.id)" /></el-select></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="承运商" prop="carrierId"><el-select :model-value="draft.carrierId" filterable clearable placeholder="请选择承运商" :loading="optionsLoading" no-data-text="暂无可用拖车供应商" style="width:100%" @update:model-value="selectCarrier(String($event || ''))"><el-option v-if="draft.carrierId && !selectableCarriers.some((item) => String(item.id) === String(draft.carrierId))" :label="draft.carrierName || '当前承运商(需重新选择)'" :value="String(draft.carrierId)" disabled /><el-option v-for="item in selectableCarriers" :key="String(item.id)" :label="item.name" :value="String(item.id)" /></el-select></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="预计装箱时间" prop="plannedLoadingAt"><el-date-picker v-model="draft.plannedLoadingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计装箱时间" style="width:100%" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="提单号"><el-input v-model="draft.billOfLadingNo" :disabled="Boolean(selectedReleasedBooking)" maxlength="128" placeholder="草稿可暂不填写,派车前必填" /><div v-if="selectedReleasedBooking" class="field-hint">已从放舱订舱回填并锁定</div></el-form-item></el-col>
<el-col :xs="24"><el-divider content-position="left">可选关联</el-divider></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="关联柜次(可选)"><el-select :model-value="draft.containerBookingId" filterable clearable placeholder="可选择柜次辅助补全" :loading="optionsLoading" no-data-text="暂无可关联柜次" style="width:100%" @update:model-value="selectContainer(String($event || ''))"><el-option v-for="item in containers" :key="item.id" :label="`柜次号:${item.cabinetNo || item.bookingNo || '待生成'} / 集装箱号:${item.containerNo || '待录入'}`" :value="item.id" /></el-select></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="关联订舱(可选)"><el-select :model-value="draft.oceanBookingId" filterable clearable placeholder="可选择订舱辅助补全" :loading="optionsLoading" no-data-text="暂无可关联订舱" style="width:100%" @update:model-value="selectBooking(String($event || ''))"><el-option v-for="item in bookings" :key="item.id" :label="item.bookingNo || '订舱号缺失'" :value="item.id" /></el-select></el-form-item></el-col>
@@ -160,7 +174,8 @@ watch(() => props.readonly, (readonly) => { if (!readonly && !customers.value.le
.form-actions { display: flex; justify-content: flex-end; gap: 8px; flex-wrap: wrap; }
:deep(.el-divider) { margin: 18px 0 12px; }
:deep(.el-divider__text) { color: var(--el-text-color-primary); font-size: 14px; font-weight: 600; }
:deep(.el-input), :deep(.el-textarea), :deep(.el-date-editor) { max-width: 100%; }
:deep(.el-input), :deep(.el-textarea), :deep(.el-date-editor) { max-width: 100%; }
.field-hint { margin-top:4px; color:var(--cc-muted); font-size:12px; }
@media (max-width: 480px) {
.truck-form { padding-inline: 2px; }
.form-actions { position: sticky; bottom: 0; z-index: 2; margin: 8px -2px -4px; padding: 10px 2px; background: var(--el-bg-color); border-top: 1px solid var(--el-border-color-lighter); }
@@ -13,6 +13,7 @@ describe('TruckAttachmentDialog interaction governance', () => {
})
it('provides scoped upload and deletion without exposing manual file ids', () => {
expect(source).toContain("BILL_OF_LADING: '提单文件'")
expect(source).not.toContain('referenceTruckAppointmentAttachment')
expect(source).not.toContain('输入文件服务 ID')
expect(source).toContain('uploadTruckAppointmentAttachment')
@@ -9,7 +9,7 @@ const emit = defineEmits<{ 'update:modelValue': [boolean]; changed: [] }>()
const busy = ref(false)
const selectedFile = ref<File | null>(null)
const selectedType = ref('OTHER')
const attachmentTypeLabels: Record<string, string> = { DISPATCH_ORDER: '派车单', DRIVER_DOCUMENT: '司机证件', VEHICLE_DOCUMENT: '车辆证件', OTHER: '其他附件' }
const attachmentTypeLabels: Record<string, string> = { BILL_OF_LADING: '提单文件', DISPATCH_ORDER: '派车单', DRIVER_DOCUMENT: '司机证件', VEHICLE_DOCUMENT: '车辆证件', OTHER: '其他附件' }
function attachmentTypeLabel(value: string | null | undefined) { return value ? attachmentTypeLabels[value] || '业务附件' : '业务附件' }
function selectFile(upload: { raw?: File }) { selectedFile.value = upload.raw || null }
async function upload() { if (!props.appointmentId || !selectedFile.value) return; busy.value = true; try { await uploadTruckAppointmentAttachment(props.appointmentId, selectedFile.value, selectedType.value); selectedFile.value = null; emit('changed'); ElMessage.success('附件上传成功') } catch { ElMessage.error('上传失败') } finally { busy.value = false } }
@@ -17,4 +17,12 @@ describe('TruckAppointmentDetailView status contract', () => {
expect(source).toContain("customerId: null, carrierId: null, plannedLoadingAt: null")
expect(source).toContain("error.value = getApiErrorDetails(err, '保存失败,请稍后重试')")
})
it('blocks dispatch until a bill number and bill attachment are present', () => {
expect(source).toContain("return '确认派车前请先填写提单号'")
expect(source).toContain("item.attachmentType === 'BILL_OF_LADING'")
expect(source).toContain("return '确认派车前请上传“提单文件”附件'")
expect(source).toContain("status === 'DISPATCHED' && dispatchBlockReason.value")
expect(source).toContain('attachmentOpen.value = true')
})
})
@@ -29,7 +29,17 @@ const availableTargets = computed<TruckAppointmentStatus[]>(() => {
return targets
.filter((status, index, statuses) => status !== resource.value?.status && statuses.indexOf(status) === index)
})
async function changeStatus(status: TruckAppointmentStatus) { if (!resource.value || status === resource.value.status) return; try { await ElMessageBox.confirm(`确认将状态改为“${truckAppointmentStatusLabels[status]}”?`, '状态变更确认', { type: 'warning' }); statusSaving.value = true; if (status === 'RESCHEDULED') { await rescheduleTruckAppointment(id.value, form.plannedLoadingAt || '', form.expectedVersion || 0, '后台改约') } else { const action = actionByTarget[status]; if (!action) throw new Error('当前状态没有可用动作'); await performTruckAppointmentAction(id.value, action, form.expectedVersion || 0) } ElMessage.success('状态已更新'); await load() } catch (err) { if (err !== 'cancel' && !(err instanceof Error && err.message === 'cancel')) error.value = getApiErrorDetails(err, '状态更新失败,请稍后重试') } finally { statusSaving.value = false } }
const dispatchBlockReason = computed(() => {
if (!resource.value || !['PENDING_DISPATCH', 'RESCHEDULED'].includes(resource.value.status)) return ''
if (!form.billOfLadingNo?.trim()) return '确认派车前请先填写提单号'
if (!(resource.value.attachments || []).some((item) => item.attachmentType === 'BILL_OF_LADING')) return '确认派车前请上传“提单文件”附件'
return ''
})
async function changeStatus(status: TruckAppointmentStatus) {
if (!resource.value || status === resource.value.status) return
if (status === 'DISPATCHED' && dispatchBlockReason.value) { error.value = { message: dispatchBlockReason.value }; if (!form.billOfLadingNo?.trim()) return; attachmentOpen.value = true; return }
try { await ElMessageBox.confirm(`确认将状态改为“${truckAppointmentStatusLabels[status]}”?`, '状态变更确认', { confirmButtonText: '确认', cancelButtonText: '取消', type: 'warning' }); statusSaving.value = true; if (status === 'RESCHEDULED') { await rescheduleTruckAppointment(id.value, form.plannedLoadingAt || '', form.expectedVersion || 0, '后台改约') } else { const action = actionByTarget[status]; if (!action) throw new Error('当前状态没有可用动作'); await performTruckAppointmentAction(id.value, action, form.expectedVersion || 0) } ElMessage.success('状态已更新'); await load() } catch (err) { if (err !== 'cancel' && !(err instanceof Error && err.message === 'cancel')) error.value = getApiErrorDetails(err, '状态更新失败,请稍后重试') } finally { statusSaving.value = false }
}
onMounted(load)
</script>
<template>
@@ -43,7 +53,7 @@ onMounted(load)
</template>
<LoadErrorState v-if="error && !resource" title="详情加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<template v-else-if="resource">
<section class="surface-panel status-panel"><div class="status-bar"><span>当前状态:<strong>{{ truckAppointmentStatusLabel(resource.status) }}</strong></span><div v-if="canChangeStatus" class="status-actions"><el-button v-for="status in availableTargets" :key="status" :type="status === 'CANCELLED' ? 'danger' : status === 'EXCEPTION' ? 'warning' : 'primary'" plain :loading="statusSaving" @click="changeStatus(status)">{{ truckAppointmentStatusLabels[status] }}</el-button></div></div><ApiErrorMeta v-if="error" :details="error" /></section>
<section class="surface-panel status-panel"><div class="status-bar"><span>当前状态:<strong>{{ truckAppointmentStatusLabel(resource.status) }}</strong></span><div v-if="canChangeStatus" class="status-actions"><el-button v-for="status in availableTargets" :key="status" :type="status === 'CANCELLED' ? 'danger' : status === 'EXCEPTION' ? 'warning' : 'primary'" plain :loading="statusSaving" @click="changeStatus(status)">{{ truckAppointmentStatusLabels[status] }}</el-button></div></div><el-alert v-if="dispatchBlockReason" :title="dispatchBlockReason" type="warning" :closable="false" show-icon /><ApiErrorMeta v-if="error" :details="error" /></section>
<section class="surface-panel form-panel"><TruckAppointmentForm v-model="form" :resource="resource" :readonly="!canEdit" :submitting="saving" @submit="submit" @attachments="attachmentOpen = true" /></section>
<section class="surface-panel history-section"><h3>状态历史</h3><el-empty v-if="!history.length" description="暂无状态历史" :image-size="48" /><ol v-else><li v-for="(item, index) in history" :key="item.id || `${item.createdAt}-${index}`"><strong>{{ truckAppointmentActionLabel(item.action) }}</strong><span>{{ truckAppointmentStatusLabel(item.fromStatus) }} → {{ truckAppointmentStatusLabel(item.toStatus) }}</span><small>{{ item.operatorName || item.operatorId || '系统' }} · {{ item.createdAt || item.occurredAt || '—' }}<template v-if="item.reason"> · {{ item.reason }}</template></small></li></ol></section>
<TruckAttachmentDialog v-model="attachmentOpen" :appointment-id="id" :attachments="resource.attachments || []" :can-manage="canManageAttachments" @changed="loadRelated" />
+2
View File
@@ -100,6 +100,7 @@ export interface TruckAppointmentResource {
bookingAgentName?: string | null
carrierId?: string | null
carrierName?: string | null
billOfLadingNo?: string | null
fleetContact?: string | null
fleetPhone?: string | null
driverName?: string | null
@@ -146,6 +147,7 @@ export interface SaveTruckAppointmentRequest {
customerName?: string | null
carrierId: string | null
carrierName?: string | null
billOfLadingNo?: string | null
fleetContact?: string | null
fleetPhone?: string | null
driverName?: string | null
@@ -12,6 +12,11 @@ vi.mock('@/shared/api/http', async () => {
describe('inbound fee-detail API', () => {
beforeEach(() => vi.clearAllMocks())
it.each(['', ' ', 'null', 'undefined'])('rejects invalid inboundId %j without issuing HTTP', async (inboundId) => {
await expect(listInboundFeeDetails('100', inboundId)).rejects.toThrow('inboundId is required')
expect(http.get).not.toHaveBeenCalled()
})
it('maps the complete write contract and optimistic version', async () => {
const payload: SaveInboundFeeDetailRequest = { feeItemId: '31', direction: 'PAYABLE', businessDate: '2026-08-12', unitPrice: 12.5, currency: 'CNY', amount: 125, settlementIncluded: true, partnerId: '41', partnerName: '测试供应商', remark: '验收' }
vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: [] })
@@ -50,7 +50,11 @@ export interface InboundFeeItemOption {
export interface InboundFeePartnerOption { id: string | number; partnerName: string }
function basePath(inboundId: string) { return `/platform/v1/inbounds/${encodeURIComponent(inboundId)}/fee-details` }
function basePath(inboundId: string) {
const identifier = String(inboundId ?? '').trim()
if (!identifier || identifier === 'null' || identifier === 'undefined') throw new TypeError('inboundId is required')
return `/platform/v1/inbounds/${encodeURIComponent(identifier)}/fee-details`
}
const config = (accountId: string) => ({ params: { accountId } })
export async function listInboundFeeDetails(accountId: string, inboundId: string) {
@@ -87,6 +87,18 @@ describe('InboundFeeDetailManager', () => {
expect(document.body.textContent).toContain('没有查看费用明细的权限')
})
it.each(['', ' ', 'null', 'undefined'])('does not request fee rows for invalid inboundId %j', async (inboundId) => {
const pinia = createPinia(); setActivePinia(pinia)
const auth = useAuthStore(); auth.token = 'token'; auth.permissions = ['firstleg:inbound:fee-detail:view']
useAccessPreviewStore().initializeAfterLogin()
mount(InboundFeeDetailManager, {
props: { accountId: '100', inboundId },
global: { plugins: [pinia, ElementPlus] },
})
await flushPromises()
expect(listInboundFeeDetails).not.toHaveBeenCalled()
})
it('approves a pending fee then refreshes rows and emits changed', async () => {
const wrapper = mountManager(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:review'])
await flushPromises()
@@ -55,6 +55,10 @@ const rules: FormRules = {
currency: [{ required: true, pattern: /^[A-Z]{3}$/, message: '货币须为三个大写字母', trigger: 'blur' }],
}
const canView = computed(() => access.can('firstleg:inbound:fee-detail:view'))
const normalizedInboundId = computed(() => {
const identifier = String(props.inboundId ?? '').trim()
return identifier && identifier !== 'null' && identifier !== 'undefined' ? identifier : ''
})
const canViewFeeItems = computed(() => access.can('firstleg:fee-item:view'))
const canViewPartners = computed(() => access.can('platform:counterparty:list'))
const directionOptions = computed(() => [
@@ -73,10 +77,13 @@ function reviewLabel(value: string) { return ({ PENDING: '待审核', APPROVED:
function directionLabel(value: string) { return value === 'RECEIVABLE' ? '应收' : '应付' }
async function load() {
if (!canView.value) return
if (!canView.value || !normalizedInboundId.value) {
rows.value = []
return
}
loading.value = true; loadError.value = null
try {
rows.value = await listInboundFeeDetails(props.accountId, props.inboundId)
rows.value = await listInboundFeeDetails(props.accountId, normalizedInboundId.value)
const approved = rows.value.filter((row) => isManual(row) && row.reviewStatus === 'APPROVED' && row.publicationStatus !== 'VOIDED' && row.settlementIncluded)
emit('summary', {
currency: approved.find((row) => row.currency)?.currency || 'CNY',
@@ -142,9 +149,9 @@ async function save() {
saving.value = true; actionError.value = null
try {
const payload = normalizePayload()
if (editorMode.value === 'create') await createInboundFeeDetail(props.accountId, props.inboundId, payload)
else if (editorMode.value === 'edit' && target.value) await updateInboundFeeDetail(props.accountId, props.inboundId, String(target.value.id), payload)
else if (target.value) await adjustInboundFeeDetail(props.accountId, props.inboundId, String(target.value.id), target.value.sourceFeeVersion, payload)
if (editorMode.value === 'create') await createInboundFeeDetail(props.accountId, normalizedInboundId.value, payload)
else if (editorMode.value === 'edit' && target.value) await updateInboundFeeDetail(props.accountId, normalizedInboundId.value, String(target.value.id), payload)
else if (target.value) await adjustInboundFeeDetail(props.accountId, normalizedInboundId.value, String(target.value.id), target.value.sourceFeeVersion, payload)
editorOpen.value = false
await finishAction(editorMode.value === 'adjust' ? '费用调整已提交审核' : '费用明细已保存')
} catch (error) { actionError.value = getApiErrorDetails(error, '费用明细保存失败') }
@@ -154,12 +161,12 @@ async function save() {
async function remove(row: InboundFeeDetail) {
try {
await ElMessageBox.confirm('删除后无法恢复,是否继续?', '删除费用明细', { type: 'warning', confirmButtonText: '删除' })
await deleteInboundFeeDetail(props.accountId, props.inboundId, String(row.id)); await finishAction('费用明细已删除')
await deleteInboundFeeDetail(props.accountId, normalizedInboundId.value, String(row.id)); await finishAction('费用明细已删除')
} catch (error) { if (error !== 'cancel' && error !== 'close') actionError.value = getApiErrorDetails(error, '费用明细删除失败') }
}
async function approve(row: InboundFeeDetail) {
try { await reviewInboundFeeDetail(props.accountId, props.inboundId, String(row.id), 'approve'); await finishAction('费用审核通过') }
try { await reviewInboundFeeDetail(props.accountId, normalizedInboundId.value, String(row.id), 'approve'); await finishAction('费用审核通过') }
catch (error) { actionError.value = getApiErrorDetails(error, '费用审核失败') }
}
@@ -167,7 +174,7 @@ function openReject(row: InboundFeeDetail) { target.value = row; rejectReason.va
async function reject() {
if (!target.value || !rejectReason.value.trim()) { actionError.value = { message: '请填写驳回原因' }; return }
saving.value = true
try { await reviewInboundFeeDetail(props.accountId, props.inboundId, String(target.value.id), 'reject', rejectReason.value.trim()); rejectOpen.value = false; await finishAction('费用已驳回') }
try { await reviewInboundFeeDetail(props.accountId, normalizedInboundId.value, String(target.value.id), 'reject', rejectReason.value.trim()); rejectOpen.value = false; await finishAction('费用已驳回') }
catch (error) { actionError.value = getApiErrorDetails(error, '费用驳回失败') }
finally { saving.value = false }
}
@@ -175,11 +182,11 @@ async function reject() {
async function voidRow(row: InboundFeeDetail) {
try {
await ElMessageBox.confirm('作废会同步撤销尚未结算的财务费用来源,是否继续?', '作废费用明细', { type: 'warning', confirmButtonText: '作废' })
await voidInboundFeeDetail(props.accountId, props.inboundId, String(row.id), row.sourceFeeVersion); await finishAction('费用明细已作废')
await voidInboundFeeDetail(props.accountId, normalizedInboundId.value, String(row.id), row.sourceFeeVersion); await finishAction('费用明细已作废')
} catch (error) { if (error !== 'cancel' && error !== 'close') actionError.value = getApiErrorDetails(error, '费用作废失败') }
}
watch(() => [props.accountId, props.inboundId], () => void load())
watch(() => [props.accountId, normalizedInboundId.value], () => void load())
watch(() => form.direction, (direction) => {
if (direction === 'RECEIVABLE') { form.partnerId = null; form.partnerName = null; partnersError.value = null }
else void loadPartners()
@@ -314,6 +314,14 @@ describe('UnifiedOrdersView contract', () => {
expect(source).toContain('firstLegDestinationTypeLabel(detail.order.order?.destinationType)')
})
it('does not mount fee consumers until a real inboundId exists', () => {
const feeSection = source.slice(source.indexOf("activeDetailTab === 'fees'"), source.indexOf("activeDetailTab === 'history'"))
expect(source).toContain('const normalizedInboundId = computed(() => normalizedIdentifier(detail.value?.summary.inboundId))')
expect(feeSection).toContain('v-if="!normalizedInboundId" description="订单尚未生成入库单,暂无入仓费用"')
expect(feeSection.match(/:inbound-id="normalizedInboundId"/g)).toHaveLength(2)
expect(feeSection).not.toContain('String(detail.summary.inboundId)')
})
it('mounts downstream progress inside the existing order detail without opening unsafe writes', () => {
expect(source).toContain('UnifiedOrderWorkflowPanel')
expect(source).toContain("{ key: 'workflow', label: '后半程作业', icon: OrderFulfillmentIcon }")
@@ -151,6 +151,11 @@ const selectedStatus = ref<UnifiedOrderMainStatus | ''>('')
const orderId = computed(() => typeof route.params.orderId === 'string' ? route.params.orderId : '')
const detailTenantId = computed(() => typeof route.query.tenantId === 'string' ? route.query.tenantId : '')
const detailRouteKey = computed(() => orderId.value ? `${detailTenantId.value}:${orderId.value}` : '')
function normalizedIdentifier(value: unknown) {
const identifier = String(value ?? '').trim()
return identifier && identifier !== 'null' && identifier !== 'undefined' ? identifier : ''
}
const normalizedInboundId = computed(() => normalizedIdentifier(detail.value?.summary.inboundId))
const detailTabs = [
{ key: 'basic', label: '基本信息', icon: OrderBasicInfoIcon },
@@ -1009,6 +1014,8 @@ onBeforeUnmount(() => {
<template v-else-if="activeDetailTab === 'fees'">
<div class="panel-title"><div><h2>费用信息</h2><p>头程自动初算与其他人工费用在同一页维护</p></div><WalletCards :size="19" /></div>
<el-empty v-if="!normalizedInboundId" description="订单尚未生成入库单,暂无入仓费用" :image-size="64" />
<template v-else>
<dl class="fee-overview" aria-label="费用汇总">
<div><dt>对客费用合计</dt><dd>{{ feeMoney(customerFeeTotal) }}</dd><small>已发布头程费用 + 已审核其他应收</small></div>
<div><dt>已审核其他应付</dt><dd>{{ feeMoney(otherFeeSummary.approvedPayable) }}</dd><small>单列展示,不计入对客费用</small></div>
@@ -1016,7 +1023,7 @@ onBeforeUnmount(() => {
<h3 class="fee-section-title">头程自动初算</h3>
<InboundFeePanel
:customer-id="String(detail.summary.tenantId)"
:inbound-id="String(detail.summary.inboundId)"
:inbound-id="normalizedInboundId"
compact
@loaded="automaticFee = $event"
@changed="loadDetail(true)"
@@ -1024,10 +1031,11 @@ onBeforeUnmount(() => {
<h3 class="fee-section-title fee-section-title--secondary">其他费用明细</h3>
<InboundFeeDetailManager
:account-id="String(detail.summary.tenantId)"
:inbound-id="String(detail.summary.inboundId)"
:inbound-id="normalizedInboundId"
@summary="otherFeeSummary = $event"
@changed="loadDetail(true)"
/>
</template>
</template>
<template v-else-if="activeDetailTab === 'history'">