Release/test 合并上生产 #2
@@ -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` 桌面布局;当前不追加窄屏浏览器验收。
|
||||
|
||||
@@ -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'))
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 })
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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]) => ({
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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'">
|
||||
|
||||
Reference in New Issue
Block a user