From bed46a58d809668ccd29b5e388b244123c57839b Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 15 Sep 2026 15:53:03 +0800 Subject: [PATCH 01/19] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/containerArrivalTimes.ts | 37 ++++++++++++++++ .../pages/ContainersView.vue | 44 ++++++++++++++----- src/modules/overseas-warehouse/AGENTS.md | 1 + 3 files changed, 70 insertions(+), 12 deletions(-) create mode 100644 src/modules/container-loading/api/containerArrivalTimes.ts diff --git a/src/modules/container-loading/api/containerArrivalTimes.ts b/src/modules/container-loading/api/containerArrivalTimes.ts new file mode 100644 index 00000000..be5a29c1 --- /dev/null +++ b/src/modules/container-loading/api/containerArrivalTimes.ts @@ -0,0 +1,37 @@ +import { overseasHttp } from '@/shared/api/overseasHttp' +import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' + +export interface ContainerArrivalTime { + sourceContainerBookingId: string + estimatedWarehouseArrivalAt: string | null + actualWarehouseArrivalAt: string | null + version: number +} + +const queryPath = '/platform/v1/last-leg/orders/container-arrival-times/query' + +function operationId(containerBookingId: string) { + return `container-expected-arrival-${containerBookingId}-${Date.now()}-${crypto.randomUUID()}` +} + +export async function listContainerArrivalTimes(sourceContainerBookingIds: string[]) { + if (!sourceContainerBookingIds.length) return [] + const response = await overseasHttp.post, ApiEnvelope>( + queryPath, + { sourceContainerBookingIds }, + ) + return (unwrapApiResponse(response) || []).map((item) => ({ ...item, version: Number(item.version || 0) })) +} + +export async function updateContainerExpectedArrival( + sourceContainerBookingId: string, + body: { estimatedWarehouseArrivalAt: string; expectedVersion: number; remark?: string }, +) { + const response = await overseasHttp.put, ApiEnvelope>( + `/platform/v1/last-leg/orders/container-summaries/${encodeURIComponent(sourceContainerBookingId)}/expected-arrival`, + body, + { headers: { 'Idempotency-Key': operationId(sourceContainerBookingId) } }, + ) + const result = unwrapApiResponse(response) + return { ...result, version: Number(result.version || 0) } +} diff --git a/src/modules/container-loading/pages/ContainersView.vue b/src/modules/container-loading/pages/ContainersView.vue index 391c9479..5283f883 100644 --- a/src/modules/container-loading/pages/ContainersView.vue +++ b/src/modules/container-loading/pages/ContainersView.vue @@ -31,12 +31,12 @@ import { getTerminalDrayage, getTerminalDrayageHistory, recordTerminalDrayageEmptyReturn, - scheduleTerminalDrayagePickup, selectTerminalDrayageFleet, type TerminalDrayageAction, type TerminalDrayageHistory, type TerminalDrayageView, } from '../api/terminalDrayages' +import { listContainerArrivalTimes, updateContainerExpectedArrival, type ContainerArrivalTime } from '../api/containerArrivalTimes' import type { ContainerBookingResource, ContainerDocumentStatus, ContainerPlanStatus, ContainerSaveRequest, ContainerWorkbenchAction, ContainerWorkbenchRow } from '../types' type Filters = QueryModel & { customerId: string; keyword: string; containerType: string; status: ContainerPlanStatus | ''; sailingRange: string[] } @@ -78,6 +78,8 @@ const linkTarget = ref(null) const selectedTruckingSupplierId = ref('') const unresolvedTruckingSupplierName = ref(null) const drayages = ref>({}) +const arrivalTimes = ref>({}) +const arrivalSavingIds = ref([]) const drayageSavingIds = ref([]) const drayageActionOpen = ref(false) const drayageAction = ref>('SCHEDULE_PICKUP') @@ -242,8 +244,8 @@ const allColumns: DataTableColumn[] = [ { key: 'channelGroupCode', label: '计划渠道', minWidth: 140, formatter: (row: ContainerBookingResource) => getSmartLoadingChannelGroup(row.channelGroupCode)?.name || '—' }, { key: 'channelName', label: '实际渠道', minWidth: 160, formatter: (row: ContainerBookingResource) => row.channelName || '待关联订舱' }, { key: 'terminalTruckingCompany', label: '码头车队', width: 190, slotName: 'terminalTruckingCompany' }, - { key: 'pickupAppointmentAt', label: '预约提柜', width: 165, slotName: 'pickupAppointmentAt' }, - { key: 'actualPickupAt', label: '实际提柜', width: 165, slotName: 'actualPickupAt' }, + { key: 'pickupAppointmentAt', label: '预计到仓', width: 210, slotName: 'pickupAppointmentAt' }, + { key: 'actualPickupAt', label: '实际到仓', width: 165, slotName: 'actualPickupAt' }, { key: 'estimatedEmptyReturnAt', label: '预计还柜', width: 165, slotName: 'estimatedEmptyReturnAt' }, { key: 'actualEmptyReturnAt', label: '实际还柜', width: 165, slotName: 'actualEmptyReturnAt' }, { key: 'billOfLadingNo', prop: 'billOfLadingNo', label: '提单号', minWidth: 150 }, @@ -278,8 +280,8 @@ function statusText(status: ContainerPlanStatus) { return ({ WAIT_LOADING: '待 function documentStatusText(status: ContainerDocumentStatus | null | undefined) { return status ? ({ MISSING: '未上传', UPLOADED: '已上传', COMPLETED: '已完成' })[status] : '—' } function formatTime(value: string | null | undefined) { return value?.replace('T', ' ').slice(0, 16) || '—' } function drayageFor(containerBookingId: string) { return drayages.value[containerBookingId] } -function drayageStatusText(status?: string | null) { return ({ WAITING_FOR_ARRIVAL: '待到港', ARRIVED: '已到港', PICKUP_SCHEDULED: '已约提柜', PICKED_UP: '已提柜', UNLOADED: '已卸柜', EMPTY_RETURNED: '已还柜' } as Record)[status || ''] || status || '—' } -function drayageActionText(action: TerminalDrayageAction) { return ({ SELECT_FLEET: '选择码头车队', SCHEDULE_PICKUP: '预约提柜', RECORD_EMPTY_RETURN: '确认还柜', SEND_PICKUP_EMAIL: '发送提柜邮件' } as Record)[action] } +function drayageStatusText(status?: string | null) { return ({ WAITING_FOR_ARRIVAL: '待到港', ARRIVED: '已到港', PICKUP_SCHEDULED: '已设置预计到仓', PICKED_UP: '已实际到仓', UNLOADED: '已卸柜', EMPTY_RETURNED: '已还柜' } as Record)[status || ''] || status || '—' } +function drayageActionText(action: TerminalDrayageAction) { return ({ SELECT_FLEET: '选择码头车队', SCHEDULE_PICKUP: '修改预计到仓', RECORD_EMPTY_RETURN: '确认还柜', SEND_PICKUP_EMAIL: '发送提柜邮件' } as Record)[action] } function hasAction(row: ContainerWorkbenchRow, action: ContainerWorkbenchAction) { return Boolean(row.allowedActions?.includes(action)) } function canDrayage(row: ContainerWorkbenchRow, action: TerminalDrayageAction) { const permission = action === 'SELECT_FLEET' || action === 'SEND_PICKUP_EMAIL' ? canFleetEdit.value : action === 'SCHEDULE_PICKUP' ? canPickupAppointmentEdit.value : canEmptyReturnConfirm.value @@ -555,9 +557,7 @@ async function saveDrayageAction() { drayageActionSaving.value = true; drayageActionError.value = null try { const reason = drayageReason.value.trim() || undefined - const next = drayageAction.value === 'SCHEDULE_PICKUP' - ? await scheduleTerminalDrayagePickup(row.id, { expectedVersion: current.version, pickupAppointmentAt: drayageActionAt.value, reason }) - : await recordTerminalDrayageEmptyReturn(row.id, { expectedVersion: current.version, actualAt: drayageActionAt.value, remark: reason }) + const next = await recordTerminalDrayageEmptyReturn(row.id, { expectedVersion: current.version, actualAt: drayageActionAt.value, remark: reason }) replaceDrayage(next); drayageActionOpen.value = false ElMessage.success(`${drayageActionText(drayageAction.value)}成功`) await loadRows() @@ -569,6 +569,23 @@ async function saveDrayageAction() { } else drayageActionError.value = details } finally { drayageActionSaving.value = false } } +async function saveExpectedArrival(row: ContainerBookingResource, value: string | null) { + const current = arrivalTimes.value[row.id] + if (!value || !current || current.version < 0 || arrivalSavingIds.value.includes(row.id)) return + arrivalSavingIds.value.push(row.id) + try { + const updated = await updateContainerExpectedArrival(row.id, { + estimatedWarehouseArrivalAt: value, + expectedVersion: current.version, + }) + arrivalTimes.value = { ...arrivalTimes.value, [row.id]: updated } + ElMessage.success('预计到仓时间已同步到整柜入库计划') + } catch (error) { + ElMessage.error(getApiErrorDetails(error, '预计到仓时间保存失败').message) + const latest = await listContainerArrivalTimes([row.id]).catch(() => []) + if (latest[0]) arrivalTimes.value = { ...arrivalTimes.value, [row.id]: latest[0] } + } finally { arrivalSavingIds.value = arrivalSavingIds.value.filter((id) => id !== row.id) } +} async function openDrayageHistory(row: ContainerBookingResource) { drayageTarget.value = row; drayageHistory.value = []; drayageHistoryError.value = null; drayageHistoryOpen.value = true; drayageHistoryLoading.value = true try { @@ -592,6 +609,9 @@ async function loadRows() { if (sequence !== listSequence) return rows.value = result.records as ContainerWorkbenchRow[]; total.value = result.total drayages.value = Object.fromEntries(rows.value.flatMap((row) => row.terminalDrayage ? [[row.id, row.terminalDrayage as TerminalDrayageView]] : [])) + const arrivalValues = isOverseas.value ? await listContainerArrivalTimes(rows.value.map((row) => row.id)) : [] + if (sequence !== listSequence) return + arrivalTimes.value = Object.fromEntries(arrivalValues.map((item) => [item.sourceContainerBookingId, item])) } catch (error) { if (sequence !== listSequence) return rows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, '货柜列表加载失败,请稍后重试') @@ -694,14 +714,14 @@ onMounted(async () => { await Promise.all([loadCustomers(), loadRows()]) })
- + - - + + @@ -745,7 +765,7 @@ onMounted(async () => { await Promise.all([loadCustomers(), loadRows()]) }) - + diff --git a/src/modules/overseas-warehouse/AGENTS.md b/src/modules/overseas-warehouse/AGENTS.md index 56b1b3ca..9a2be712 100644 --- a/src/modules/overseas-warehouse/AGENTS.md +++ b/src/modules/overseas-warehouse/AGENTS.md @@ -17,6 +17,7 @@ | 关键词 | 页面/路由入口 | API 与联查 | |---|---|---| +| 排车车型、托位容量与限重提示 | `dispatchVehicleSpecs.ts`、`components/SmartDispatchDialog.vue`、`pages/DispatchesView.vue` | 预排车型与排车编辑选项共用规格入口:大车 30 托/40000 LB,小车 15 托/10000 LB;超重提示在预排组件,不能把前端提示当作服务端允许提交的依据。回归见 `pages/DispatchesView.test.ts`、`pages/prototypeContract.test.ts` | | 仓储转运审核、通过与驳回 | `pages/LastMileOrdersView.vue`、`components/WarehouseTransferReviewDialog.vue` | `api/warehouseTransfers.ts` 的列表、详情及 `/warehouse-transfers/{transferNo}/review`;审核按钮要求 `permissions.ts` 的 `warehouseTransferApprove` 且行类型为 `WAREHOUSE_TRANSFER`、状态为 `PENDING_REVIEW`。请求携带该行客户范围和转运单号,审核结论为 `APPROVE/REJECT`,驳回原因必填;这是尾程订单内的审核弹框,不是独立审核路由或排车转派送入口 | | 尾程订单控制与柜级入仓计划转换 | `pages/LastMileOrdersView.vue`、`pages/LastMileOrderDetailView.vue` | 柜子工作台按 `sourceContainerBookingId` 消费 `/orders/container-summaries`;服务端分页返回全部稳定来源柜,计划已经齐全的柜仍保留用于接柜仓查询和修改,并按冻结 `plannedArrivalAt` 升序、空值最后排序。详情返回全柜订单且不受列表分页截断;单票手工计划保存后,由服务端在整柜最后一票计划齐全时自动生成并绑定全柜托盘码,一票柜立即生成;`resourcePendingPlanCount` 只表示自动生成异常的重试入口,不是正常人工步骤。柜级确认弹框先调用 `/inbound-plans/container-preview`,按服务端 FBA/一件代发/私人地址分组展示头程已有、海外仓补充和最终托数以及 `readiness/blockers`;散货最终托数不得少于头程已有托盘码,差额连续补码且不阻断流程;一件代发必须严格按不可变头程封托托盘数整体入库,单票表单托数锁定且服务端再次失败关闭校验。前端不计算托数、不逐票建计划、不预占托盘,再把同一 `snapshotVersion/snapshotHash` 原样提交 `/inbound-plans/convert-container`,快照冲突后重新获取预览。服务端只为剩余订单逐票生成独立计划,并为异常遗留的人工计划补齐真实托盘,整批原子且重放不得产生第二批资源。单票完整入库计划、暂扣/解除和八行人工节点均从 `api/lastMileOrders.ts` 提交订单版本与幂等键;预计到仓时间只读展示来源柜冻结值,不进入人工计划请求,服务端按整柜校验并写入同一日期;入仓码只读展示并提交订单冻结值,不允许人工改写,暂扣资格、接柜仓锁定原因和操作能力只消费服务端结果,人工节点一经保存不得回退柜级节点。转仓候选只取 `../iam/api/platformIam.ts` 的平台公共 `ACTIVE + FBA` 仓主档;Backend `lastleg-service/` | | 入仓单、入仓计划、FBA 预约 | `pages/InboundOrdersView.vue`、`pages/InboundOrderDetailView.vue` | 列表/预约 `api/inboundOrders.ts`,计划列表/详情 `api/inboundPlans.ts`;Backend `lastleg-service/inboundplan/` | From 7d5bee80b8f85a437a1c399bfe62058cbd9141e4 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 15 Sep 2026 15:53:09 +0800 Subject: [PATCH 02/19] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../container-loading/pages/ContainerDetailView.vue | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/modules/container-loading/pages/ContainerDetailView.vue b/src/modules/container-loading/pages/ContainerDetailView.vue index b884bcd2..82ccf5c7 100644 --- a/src/modules/container-loading/pages/ContainerDetailView.vue +++ b/src/modules/container-loading/pages/ContainerDetailView.vue @@ -38,6 +38,7 @@ import { updateContainerPickupCode, } from '../api/platformContainerLoading' import { recordTerminalDrayageEmptyReturn } from '../api/terminalDrayages' +import { listContainerArrivalTimes, type ContainerArrivalTime } from '../api/containerArrivalTimes' import type { BusinessDocument, ContainerBookingResource, ContainerOverview, ContainerOverviewDocument, ContainerPlanStatus, ContainerWorkbenchAction, ContainerWorkbenchRow } from '../types' const props = withDefaults(defineProps<{ workbench?: 'DOMESTIC' | 'OVERSEAS' }>(), { workbench: 'DOMESTIC' }) @@ -82,6 +83,7 @@ async function rememberWorkbenchTab(tabName: string | number) { } } const workbenchDetail = ref(null) +const arrivalTime = ref(null) const hasAction = (action: ContainerWorkbenchAction) => Boolean(workbenchDetail.value?.allowedActions?.includes(action)) const canEdit = computed(() => isDomestic.value && access.can('platform:firstleg:container:edit') && hasAction('EDIT_CONTAINER')) const canManageCustoms = computed(() => isDomestic.value && access.can('platform:firstleg:customs:manage') && hasAction('MANAGE_CUSTOMS')) @@ -294,8 +296,13 @@ async function load() { if (!containerId.value) return loading.value = true; error.value = null; overviewError.value = null try { - const [resource, aggregate] = await Promise.all([getContainer(containerId.value, props.workbench), getContainerOverview(containerId.value)]) + const [resource, aggregate, arrivals] = await Promise.all([ + getContainer(containerId.value, props.workbench), + getContainerOverview(containerId.value), + isOverseas.value ? listContainerArrivalTimes([containerId.value]) : Promise.resolve([]), + ]) workbenchDetail.value = resource; detail.value = resource; overview.value = aggregate + arrivalTime.value = arrivals[0] || null if (activeTab.value === 'loading-list' && !canViewCurrentLoadingList.value) activeTab.value = 'overview' if (isDomestic.value && activeTab.value === 'loading-list') await loadLoadingDetail(resource, aggregate) if (isOverseas.value && resource.planStatus === 'COMPLETED' && canViewLoadingList.value) { @@ -575,7 +582,7 @@ onMounted(async () => { await load(); if (isOverseas.value && route.query.action

提柜、卸柜与还空

确认还柜
-
码头拖车状态
{{ workbenchDetail?.statusSummary?.terminalDrayageStatus || '—' }}
码头车队
{{ workbenchDetail?.terminalDrayage?.fleetSupplierName || '—' }}
预约提柜
{{ formatTime(workbenchDetail?.terminalDrayage?.pickupAppointmentAt) }}
实际提柜
{{ formatTime(workbenchDetail?.terminalDrayage?.actualPickupAt) }}
卸柜完成
{{ formatTime((workbenchDetail?.actualFacts?.unloadingCompletedAt || workbenchDetail?.terminalDrayage?.unloadingCompletedAt) as string | null) }}
实际还柜
{{ formatTime(workbenchDetail?.terminalDrayage?.actualEmptyReturnAt) }}
+
码头拖车状态
{{ workbenchDetail?.statusSummary?.terminalDrayageStatus || '—' }}
码头车队
{{ workbenchDetail?.terminalDrayage?.fleetSupplierName || '—' }}
预计到仓
{{ formatTime(arrivalTime?.estimatedWarehouseArrivalAt) }}
实际到仓
{{ formatTime(arrivalTime?.actualWarehouseArrivalAt) }}
卸柜完成
{{ formatTime((workbenchDetail?.actualFacts?.unloadingCompletedAt || workbenchDetail?.terminalDrayage?.unloadingCompletedAt) as string | null) }}
实际还柜
{{ formatTime(workbenchDetail?.terminalDrayage?.actualEmptyReturnAt) }}
From c78ecbef31f2e4c1ac2f8b380e7e195267ac97e1 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 15 Sep 2026 16:11:20 +0800 Subject: [PATCH 03/19] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 4 +-- .../api/containerArrivalTimes.test.ts | 20 +++++++++++++++ ...nerDetailView.emptyReturn.behavior.test.ts | 3 +++ .../pages/ContainersView.test.ts | 25 +++++++++++++++++++ .../pages/ContainersView.vue | 15 ++++++++--- 5 files changed, 61 insertions(+), 6 deletions(-) create mode 100644 src/modules/container-loading/api/containerArrivalTimes.test.ts diff --git a/AGENTS.md b/AGENTS.md index b13bbd78..6168659e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,12 +55,12 @@ | 入库单扣货、解除、凭证与历史 | `modules/inbounds/pages/InboundOrdersView.vue`、详情工作区、`modules/inbounds/core/api.ts` | 列表展示服务端 `holdFlag/currentHoldReason` 并支持筛选;详情只按 `canHold/canRelease/actionBlockReason` 开放操作。扣货与解除提交当前 `version`、原因和独立幂等键,可选凭证先按明确客户与入库单上传;历史文件只调用专用下载入口,不把上传成功但未提交命令的文件冒充扣货证据 | | 客户专属价格簿、柜级阶梯运费与受控调价 | 列表/上传 `modules/fba-pricing/carrier-rates/pages/CarrierRatesView.vue`,版本详情/仓库映射/确认 `modules/fba-pricing/carrier-rates/pages/CarrierRateDetailView.vue`,路由 `modules/fba-pricing/routes.ts`;`modules/inbounds/fee/`、`modules/unified-orders/components/InboundFeeDetailManager.vue` | 列表与上传后按 `versionId` 进入 `fba-carrier-rate-detail` 独立详情页,价格行只读;回归入口 `modules/fba-pricing/carrier-rates/pages/CarrierRatesView.test.ts`。价格簿页面显式选择公共报价或具体客户;列表、上传、确认只作用于该 `customerId`,不得混合版本。计价优先消费客户专属 ACTIVE 版本,无专属版本才回退公共 ACTIVE;柜级阶梯金额和各客户分摊只展示服务端结果。调整计费量只能在服务端返回的上下 10% 范围内并提交当前费用版本、原因与二次确认;私人仓尾程派送费使用稳定 `PRIVATE_LAST_MILE_DELIVERY` 代码且只对 `PRIVATE` 目的地开放;一件代发须在预装确认前完成试算,计价后保持服务端 `WAIT_PALLETIZE` 状态 | | 财务、客户资金账户与柜级公共费用分摊 | `modules/finance/`,继续读该目录 `AGENTS.md`;客户账户 `pages/FinanceCustomerAccountsView.vue`,柜费 `modules/loading-lists/components/ContainerFeesPanel.vue`、`api/containerFees.ts` | `finance/`、`firstleg/`、`file/`、`docs/财务管理/`;实际、冻结、可用余额、授信与流水只消费 Finance 权威结果,充值审核和手工充扣携带版本与幂等键。跨客户公共柜费只按服务端柜内客户/订单事实分摊,金额必须守恒,创建与审核、驳回、调整、作废均整组原子处理 | -| 货柜双工作台、排柜、预装/装柜清单、真实柜号后补与整柜报关 | 国内 `modules/container-loading/pages/DomesticContainersView.vue`、海外 `OverseasContainersView.vue`,共享详情实现 `ContainerDetailView.vue`;预装详情 `modules/loading-lists/pages/PreloadPlanDetailView.vue`,资料/PDA 证据 `components/ContainerOverviewPanel.vue`,报关 `components/ContainerCustomsPanel.vue` | 国内“送柜报关”只消费 `/containers/domestic` 与服务端 `allowedActions`,承载模拟柜、订舱关联、智能装柜;模拟柜创建选择与智能装柜一致的 9 类计划渠道,未关联订舱前实际渠道为空,订舱关联候选按计划渠道组匹配、装柜清单页签、出口报关和国内出仓;创建柜次时自动生成唯一预装计划,预装详情先调用 `/loading-plans/{planId}/lifecycle`,完成后深链进入国内货柜详情的装柜清单页签,不再注册独立正式清单导航和路由。订舱关联允许真实集装箱号为空,但 PDA 最终完成前必须补齐;全买单柜继续使用 `complete-all-buy`。海外“清关提柜”只消费 `/containers/overseas`,承载船节点、目的港清关、DO/PU、码头车队、提柜和还空;还空截止事实由服务端基于卸柜完成时间加 72 小时计算,前端不重算。两端统一使用 `containerBookingId`,列表范围由服务端 `scope=ACTIVE/HISTORY` 分页,禁止浏览器筛选或本地推导写资格。后端为 `LoadingListService`、`firstleg/customs/container/`、`firstleg/terminaldrayage/`,文件归 `file/` | +| 货柜双工作台、排柜、预装/装柜清单、真实柜号后补与整柜报关 | 国内 `modules/container-loading/pages/DomesticContainersView.vue`、海外 `OverseasContainersView.vue`,共享详情实现 `ContainerDetailView.vue`;预装详情 `modules/loading-lists/pages/PreloadPlanDetailView.vue`,资料/PDA 证据 `components/ContainerOverviewPanel.vue`,报关 `components/ContainerCustomsPanel.vue` | 国内“送柜报关”只消费 `/containers/domestic` 与服务端 `allowedActions`,承载模拟柜、订舱关联、智能装柜;模拟柜创建选择与智能装柜一致的 9 类计划渠道,未关联订舱前实际渠道为空,订舱关联候选按计划渠道组匹配、装柜清单页签、出口报关和国内出仓;创建柜次时自动生成唯一预装计划,预装详情先调用 `/loading-plans/{planId}/lifecycle`,完成后深链进入国内货柜详情的装柜清单页签,不再注册独立正式清单导航和路由。订舱关联允许真实集装箱号为空,但 PDA 最终完成前必须补齐;全买单柜继续使用 `complete-all-buy`。海外“清关提柜”只消费 `/containers/overseas`,承载船节点、目的港清关、DO/PU、码头车队和还空;原预约/实际提柜展示位改为整柜预计/实际到仓,预计到仓在列表内人工快捷维护并经海外接口同步整柜尾程订单,实际到仓只读海外 PDA 首次开始收货时间。还空截止事实由服务端基于卸柜完成时间加 72 小时计算,前端不重算。两端统一使用 `containerBookingId`,列表范围由服务端 `scope=ACTIVE/HISTORY` 分页,禁止浏览器筛选或本地推导写资格。后端为 `LoadingListService`、`firstleg/customs/container/`、`firstleg/terminaldrayage/` 和 `lastleg-service/`,文件归 `file/` | | 合柜报关 / 同柜不可拆组 | `modules/unified-orders/api/coLoadGroups.ts`、`pages/UnifiedOrdersView.vue`;装柜消费 `modules/container-loading/pages/SmartLoadingWorkbenchView.vue` | 前端只提交订单 ID;Backend `firstleg/loading/coload/` 展开并冻结每票全部可用入仓片段。成员数是入仓片段数,不等于订单票数;只要求同国内仓、同渠道,允许跨客户及不同目的仓港;分配、移除、取消均整组处理 | | 全局智能装柜工作台、入仓码候选与箱数分配 | `modules/container-loading/components/SmartLoadingDialog.vue` 内嵌 `pages/GlobalSmartLoadingView.vue`,柜次页 `SmartLoadingWorkbenchView.vue`,以及 `smartLoadingChannelGroups.ts`、`smartLoadingItems.ts`、`api/platformContainerLoading.ts`;全局页当前不注册独立路由 | Backend `firstleg/` 的 `/inbound-candidates`、`/containers`、`/inbound-assignments`、`/plans/{planId}/pending-review-assignments`、`/group-assignments`、`/unpicked-items`。`orderStatuses` 最多四项且未传表示全部未完成状态;候选状态多选覆盖待审核、待测量、待分柜、装柜中,状态只用于筛选展示,按钮资格以服务端 `canAssign` 为准;待审核/待测量可提前占位但不得提前确认预装或启动 PDA。计划箱数编辑上限、已用和未用展示只消费服务端 `totalPackageCount/remainingPackageCount`,不得从候选页或本地规格重新拼算。人工加入允许跨渠道,智能推荐仍按柜次渠道。柜次容量和限重取服务端字段;同柜组作为单卡整组分页、分配和移除,任一成员已拣时整组保留 | | 国内货柜履约订舱资料、打印、关联候选与出仓前船期 | `modules/ocean-bookings/` 的 `components/OceanBookingDocumentsPanel.vue`、`OceanBookingPrintDialog.vue`,以及 `modules/container-loading/`、`modules/port-schedules/` | 订舱草稿按当前版本维护资料,放舱后资料与表单只读;打印对草稿与已放舱订舱均开放,按当前已保存资料生成,未填写字段显示“—”。`platformOceanBookings.ts` 的 `/link-candidates` 只消费服务端分页,`containerCount` 是柜型需求 `quantity` 总和;Backend `firstleg/oceanbooking/`、`file/`、`print/`、`firstleg/portschedule/` | | 订舱级拖车预约 | `modules/trucking/api/platformTrucking.ts` 的 `/candidates`、`components/TruckAppointmentForm.vue`、`pages/TruckAppointmentCreateView.vue`、`TruckAppointmentsView.vue` | 表单只选择服务端返回的 `RELEASED` 订舱与拖车供应商,不选择或提交客户、柜次、装柜计划或真实集装箱号;提单号和仓库由订舱冻结事实带入。一个订舱最多一张非终态拖车单,重复创建转入现有单继续编辑;PDA 完成不自动推进拖车。附件为平台作用域且不重复提单文件;Backend `TruckAppointmentService`、`truck-appointments-v1.yaml` | -| 目的港码头拖车与还空预警 | 海外清关提柜工作台/详情 `modules/container-loading/pages/OverseasContainersView.vue`、`OverseasContainerDetailView.vue`,API `modules/container-loading/api/terminalDrayages.ts` | 按 `containerBookingId` 读取服务端状态、`allowedActions` 和历史,不能复用订舱级拖车表单或自行推进状态;实际到港后选择 `ACTIVE + TRUCKING` 车队,再预约提柜、确认实际提柜与实际还柜。还空截止、超时天/小时只展示服务端基于权威卸柜完成时间加 72 小时计算的结果;Backend `firstleg/terminaldrayage/`、`terminal-drayage-v1.yaml` 列表消费 `SEND_PICKUP_EMAIL`,邮件记录通过 `terminalDrayages.ts` 查询与重试;发送及换队成功只提示已入队,发送权限复用码头车队编辑权限。 | +| 目的港码头拖车与还空预警 | 海外清关提柜工作台/详情 `modules/container-loading/pages/OverseasContainersView.vue`、`OverseasContainerDetailView.vue`,API `modules/container-loading/api/terminalDrayages.ts`、`containerArrivalTimes.ts` | 与订舱级拖车预约是不同生命周期;按 `containerBookingId` 读取服务端状态、`allowedActions` 和历史,不能复用订舱级拖车表单或自行推进状态。列表“预计到仓”列直接提供人工日期时间选择器,保存时通过 Lastleg 柜级版本和幂等键原子更新该柜全部尾程订单,已有入库计划后禁止修改;“实际到仓”只读 `ll_receiving_container_session.receiving_at`,即海外 PDA 首次开始收货时间。还空截止、超时天/小时只展示服务端基于权威卸柜完成时间加 72 小时计算的结果;Backend `firstleg/terminaldrayage/`、`lastleg-service/`,邮件记录通过 `terminalDrayages.ts` 查询与重试。 | | 历史报关协同与国内出仓 | `modules/customs/`、`modules/outbound-dispatch/` | `modules/customs/` 是历史报关案件/清单/代理提交与回执入口;装柜完成后的整柜退税/买单资料从货柜详情进入,不在旧模块复制;Backend `firstleg/outbound/dispatch/`、`firstleg/customs/`、`file/` | | 主数据、下载、退避任务重试、审计 | `modules/master-data/`、`modules/downloads/pages/DownloadCenterView.vue`、`modules/audit/` | File 导出任务返回 `attemptCount/maxAttempts/nextRetryAt`;仅失败或已进入自动退避的等待任务显示重试,等待任务重试用于立即执行当前投递,不创建新任务;联查 `warehouse/`、`file/`、`audit-service/` | | 海外接柜、入库、库存与库内服务 | `modules/overseas-warehouse/` | `gateway-overseas/`、`lastleg-service/`、`warehouse-overseas-service/`;与排车、出库、派送、异常退回和一件代发共同组成海外仓全流程 | diff --git a/src/modules/container-loading/api/containerArrivalTimes.test.ts b/src/modules/container-loading/api/containerArrivalTimes.test.ts new file mode 100644 index 00000000..c2fe6c21 --- /dev/null +++ b/src/modules/container-loading/api/containerArrivalTimes.test.ts @@ -0,0 +1,20 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { overseasHttp } from '@/shared/api/overseasHttp' +import { listContainerArrivalTimes, updateContainerExpectedArrival } from './containerArrivalTimes' + +vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { post: vi.fn(), put: vi.fn() } })) + +describe('container arrival-time overseas API', () => { + beforeEach(() => vi.clearAllMocks()) + + it('queries visible containers and updates the whole-container expected arrival', async () => { + vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', data: [{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, version: 0 }] }) + vi.mocked(overseasHttp.put).mockResolvedValue({ code: '200', data: { sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', actualWarehouseArrivalAt: null, version: 1 } } as never) + + await listContainerArrivalTimes(['9001']) + await updateContainerExpectedArrival('9001', { estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', expectedVersion: 0 }) + + expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/last-leg/orders/container-arrival-times/query', { sourceContainerBookingIds: ['9001'] }) + expect(overseasHttp.put).toHaveBeenCalledWith('/platform/v1/last-leg/orders/container-summaries/9001/expected-arrival', { estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', expectedVersion: 0 }, { headers: { 'Idempotency-Key': expect.stringContaining('container-expected-arrival-9001-') } }) + }) +}) diff --git a/src/modules/container-loading/pages/ContainerDetailView.emptyReturn.behavior.test.ts b/src/modules/container-loading/pages/ContainerDetailView.emptyReturn.behavior.test.ts index 3e6219c0..f297b23f 100644 --- a/src/modules/container-loading/pages/ContainerDetailView.emptyReturn.behavior.test.ts +++ b/src/modules/container-loading/pages/ContainerDetailView.emptyReturn.behavior.test.ts @@ -6,6 +6,7 @@ import ContainerDetailView from './ContainerDetailView.vue' import { getContainer, getContainerOverview, linkContainerOceanBooking } from '../api/platformContainerLoading' import { listOceanBookingLinkCandidates } from '@/modules/ocean-bookings' import { recordTerminalDrayageEmptyReturn } from '../api/terminalDrayages' +import { listContainerArrivalTimes } from '../api/containerArrivalTimes' import { getContainerOutboundDispatch } from '@/modules/outbound-dispatch' import { ref } from 'vue' import { getConfirmedLoadingList, getPreloadPlan } from '@/modules/loading-lists' @@ -22,6 +23,7 @@ vi.mock('@/modules/outbound-dispatch', () => ({ confirmContainerOutboundDispatch vi.mock('@/modules/port-schedules', () => ({ CarrierScheduleQueryPanel: { template: '
' } })) vi.mock('../api/platformContainerLoading', () => ({ getContainer: vi.fn(), getContainerOverview: vi.fn(), linkContainerOceanBooking: vi.fn(), cancelContainerException: vi.fn(), completeContainerClearance: vi.fn(), downloadContainerClearance: vi.fn(), downloadContainerDocument: vi.fn(), getContainerClearance: vi.fn(), markContainerException: vi.fn(), uploadContainerClearance: vi.fn(), uploadContainerDocument: vi.fn(), updateContainerPickupCode: vi.fn() })) vi.mock('../api/terminalDrayages', () => ({ recordTerminalDrayageEmptyReturn: vi.fn() })) +vi.mock('../api/containerArrivalTimes', () => ({ listContainerArrivalTimes: vi.fn() })) const base = { id: '9001', cabinetNo: 'CB-1', containerNo: 'MSCU-1', planNo: 'LP-1', planStatus: 'COMPLETED', warehouseName: '义乌仓', destinationWarehouseCode: 'LAX01', destinationWarehouseName: '洛杉矶接柜仓', workbenchStage: 'UNLOADED', statusSummary: { terminalDrayageStatus: 'UNLOADED' }, allowedActions: ['RECORD_EMPTY_RETURN'], terminalDrayage: { containerBookingId: '9001', status: 'UNLOADED', version: 3, fleetSupplierId: '501', fleetSupplierName: '港运车队', pickupAppointmentAt: '2026-09-07T08:00:00', actualPickupAt: '2026-09-07T09:00:00', estimatedEmptyReturnAt: '2026-09-10T10:00:00', actualEmptyReturnAt: null, unloadingCompletedAt: '2026-09-07T10:00:00', emptyReturnWarning: { status: 'PENDING', deadlineAt: '2026-09-10T10:00:00', overdueHours: 0, overdueDays: 0 }, allowedActions: ['RECORD_EMPTY_RETURN'], updatedAt: '2026-09-09T10:00:00' }, actualFacts: { unloadingCompletedAt: '2026-09-07T10:00:00' } } const overview = { container: { id: '9001', version: 1 }, loadingPlanId: null, documents: [], exception: { active: false }, timeline: [] } @@ -51,6 +53,7 @@ describe('ContainerDetailView empty return', () => { vi.mocked(getContainerOverview).mockReset().mockResolvedValue(overview as never) vi.mocked(getConfirmedLoadingList).mockReset().mockResolvedValue({ planNo: 'LP-1', items: [] } as never) vi.mocked(recordTerminalDrayageEmptyReturn).mockReset().mockResolvedValue({ ...base.terminalDrayage, status: 'EMPTY_RETURNED', version: 4, actualEmptyReturnAt: '2026-09-09T12:00:00', allowedActions: [] } as never) + vi.mocked(listContainerArrivalTimes).mockReset().mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', actualWarehouseArrivalAt: '2026-09-26T09:00:00', version: 0 }]) }) it('loads departed gates exactly once when read permission arrives after the container detail', async () => { diff --git a/src/modules/container-loading/pages/ContainersView.test.ts b/src/modules/container-loading/pages/ContainersView.test.ts index 34afd62c..5df9d95f 100644 --- a/src/modules/container-loading/pages/ContainersView.test.ts +++ b/src/modules/container-loading/pages/ContainersView.test.ts @@ -14,6 +14,7 @@ import { getOceanBooking, listOceanBookingLinkCandidates, listOceanBookings } fr import { getContainer, getContainerOverview, listContainers, updateContainer } from '../api/platformContainerLoading' import OceanBookingLinkDialog from '../components/OceanBookingLinkDialog.vue' import { getTerminalDrayage, getTerminalDrayageHistory, recordTerminalDrayageEmptyReturn, selectTerminalDrayageFleet, sendTerminalDrayageEmail, getTerminalDrayageEmails } from '../api/terminalDrayages' +import { listContainerArrivalTimes, updateContainerExpectedArrival } from '../api/containerArrivalTimes' import AppDialog from '@/shared/components/AppDialog.vue' import { containerLoadingRoutes } from '../routes' @@ -26,6 +27,7 @@ const accessCan = vi.hoisted(() => vi.fn()) vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: accessCan }) })) vi.mock('../api/platformContainerLoading', () => ({ listContainers: vi.fn(), getContainer: vi.fn(), getContainerOverview: vi.fn(), getContainerClearance: vi.fn(), uploadContainerClearance: vi.fn(), completeContainerClearance: vi.fn(), uploadContainerDocument: vi.fn(), downloadContainerDocument: vi.fn(), downloadContainerPdaPhoto: vi.fn(), markContainerException: vi.fn(), cancelContainerException: vi.fn(), createContainerSimulation: vi.fn(), linkContainerOceanBooking: vi.fn(), unlinkContainerOceanBooking: vi.fn(), updateContainer: vi.fn() })) vi.mock('../api/terminalDrayages', () => ({ sendTerminalDrayageEmail: vi.fn(), getTerminalDrayageEmails: vi.fn(), retryTerminalDrayageEmail: vi.fn(), listTerminalDrayages: vi.fn(), getTerminalDrayage: vi.fn(), getTerminalDrayageHistory: vi.fn(), selectTerminalDrayageFleet: vi.fn(), scheduleTerminalDrayagePickup: vi.fn(), recordTerminalDrayageEmptyReturn: vi.fn() })) +vi.mock('../api/containerArrivalTimes', () => ({ listContainerArrivalTimes: vi.fn(), updateContainerExpectedArrival: vi.fn() })) const drayage = { containerBookingId: '9001', containerNo: 'MSCU-1', oceanBookingId: '101', status: 'UNLOADED' as const, version: 3, @@ -71,6 +73,8 @@ describe('ContainersView', () => { vi.mocked(getTerminalDrayageHistory).mockResolvedValue([]) vi.mocked(selectTerminalDrayageFleet).mockResolvedValue({ ...drayage, fleetSupplierId: '502', fleetSupplierName: '新车队', version: 4, allowedActions: [...drayage.allowedActions] }) vi.mocked(recordTerminalDrayageEmptyReturn).mockReset().mockResolvedValue({ ...drayage, status: 'EMPTY_RETURNED', actualEmptyReturnAt: '2026-09-11T13:00:00', version: 4, emptyReturnWarning: { status: 'RESOLVED', deadlineAt: '2026-09-10T10:00:00', overdueHours: 0, overdueDays: 0 }, allowedActions: ['SELECT_FLEET'] }) + vi.mocked(listContainerArrivalTimes).mockReset().mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', actualWarehouseArrivalAt: '2026-09-26T09:00:00', version: 0 }]) + vi.mocked(updateContainerExpectedArrival).mockReset().mockResolvedValue({ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T16:00:00', actualWarehouseArrivalAt: '2026-09-26T09:00:00', version: 1 }) vi.mocked(listContainers).mockResolvedValue({ records: [{ id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', sourceType: 'SIMULATION', allowedActions: ['EDIT_CONTAINER', 'SELECT_FLEET', 'SCHEDULE_PICKUP', 'RECORD_EMPTY_RETURN'], terminalDrayage: drayage, oceanBookingId: null, warehouseId: '8', warehouseName: '义乌仓', channelGroupCode: 'MATSON_CLX', channelCode: null, channelName: null, containerType: '40HQ', planStatus: 'WAIT_LOADING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 19500, terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null }], page: 1, size: 20, total: 1, pages: 1 }) vi.mocked(getContainer).mockResolvedValue({ id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', sourceType: 'SIMULATION', allowedActions: ['EDIT_CONTAINER'], oceanBookingId: null, warehouseId: '8', warehouseName: '义乌仓', channelGroupCode: 'MATSON_CLX', channelCode: null, channelName: null, containerType: '40HQ', planStatus: 'WAIT_LOADING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 19500, terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null }) vi.mocked(getContainerOverview).mockResolvedValue({ container: { id: '9001', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', status: 'WAIT_LOADING', warehouseId: '8', warehouseName: '义乌仓', voyageNo: null, vesselName: null, version: 0 }, loadingPlanId: null, pda: null, documents: [], exception: { active: false, reason: null, markedBy: null, markedByName: null, markedAt: null }, exceptionHistory: [], timeline: [] }) @@ -541,6 +545,27 @@ describe('ContainersView', () => { expect(source).not.toContain('linkContainerId') }) + it('edits the whole-container expected arrival inline and shows PDA receiving start as actual arrival', async () => { + const wrapper = await mountOverseasRoute() + expect(wrapper.text()).toContain('预计到仓') + expect(wrapper.text()).toContain('实际到仓') + expect(wrapper.text()).toContain('2026-09-26 09:00') + const picker = wrapper.findAllComponents({ name: 'ElDatePicker' }) + .find((item) => item.props('placeholder') === '选择预计到仓') + expect(picker).toBeDefined() + + const vm = wrapper.findComponent(ContainersView).vm as unknown as { + rows: Array> + saveExpectedArrival: (row: Record, value: string) => Promise + } + await vm.saveExpectedArrival(vm.rows[0], '2026-09-25T16:00:00') + await flushPromises() + + expect(updateContainerExpectedArrival).toHaveBeenCalledWith('9001', { + estimatedWarehouseArrivalAt: '2026-09-25T16:00:00', expectedVersion: 0, + }) + }) + it.each(['EDITING', 'LOADING', 'COMPLETED'] as const)('keeps released-booking linking reachable alongside the %s process action', async (planStatus) => { const page = await listContainers({ page: 1, size: 20 }, 'DOMESTIC') vi.mocked(listContainers).mockResolvedValue({ ...page, records: page.records.map((row) => ({ diff --git a/src/modules/container-loading/pages/ContainersView.vue b/src/modules/container-loading/pages/ContainersView.vue index 5283f883..1c884e6f 100644 --- a/src/modules/container-loading/pages/ContainersView.vue +++ b/src/modules/container-loading/pages/ContainersView.vue @@ -79,6 +79,7 @@ const selectedTruckingSupplierId = ref('') const unresolvedTruckingSupplierName = ref(null) const drayages = ref>({}) const arrivalTimes = ref>({}) +const arrivalInputs = ref>({}) const arrivalSavingIds = ref([]) const drayageSavingIds = ref([]) const drayageActionOpen = ref(false) @@ -189,6 +190,7 @@ const canCreate = computed(() => access.can('platform:firstleg:container:create' const canOverseasDocumentEdit = computed(() => access.can('platform:firstleg:terminal-drayage:document:edit')) const canFleetEdit = computed(() => access.can('platform:firstleg:terminal-drayage:fleet:edit')) const canPickupAppointmentEdit = computed(() => access.can('platform:firstleg:terminal-drayage:pickup-appointment:edit')) +const canExpectedArrivalEdit = computed(() => access.can('platform:overseas-warehouse:last-mile-order:edit')) const canEmptyReturnConfirm = computed(() => access.can('platform:firstleg:terminal-drayage:empty-return:confirm')) const canSmartLoading = computed(() => access.can('platform:firstleg:smart-loading:list')) const canViewPreload = computed(() => access.can('platform:firstleg:preload:list')) @@ -579,11 +581,15 @@ async function saveExpectedArrival(row: ContainerBookingResource, value: string expectedVersion: current.version, }) arrivalTimes.value = { ...arrivalTimes.value, [row.id]: updated } - ElMessage.success('预计到仓时间已同步到整柜入库计划') + arrivalInputs.value = { ...arrivalInputs.value, [row.id]: updated.estimatedWarehouseArrivalAt || '' } + ElMessage.success('预计到仓时间已同步整柜,后续入库计划将自动带出') } catch (error) { ElMessage.error(getApiErrorDetails(error, '预计到仓时间保存失败').message) const latest = await listContainerArrivalTimes([row.id]).catch(() => []) - if (latest[0]) arrivalTimes.value = { ...arrivalTimes.value, [row.id]: latest[0] } + if (latest[0]) { + arrivalTimes.value = { ...arrivalTimes.value, [row.id]: latest[0] } + arrivalInputs.value = { ...arrivalInputs.value, [row.id]: latest[0].estimatedWarehouseArrivalAt || '' } + } } finally { arrivalSavingIds.value = arrivalSavingIds.value.filter((id) => id !== row.id) } } async function openDrayageHistory(row: ContainerBookingResource) { @@ -612,6 +618,7 @@ async function loadRows() { const arrivalValues = isOverseas.value ? await listContainerArrivalTimes(rows.value.map((row) => row.id)) : [] if (sequence !== listSequence) return arrivalTimes.value = Object.fromEntries(arrivalValues.map((item) => [item.sourceContainerBookingId, item])) + arrivalInputs.value = Object.fromEntries(arrivalValues.map((item) => [item.sourceContainerBookingId, item.estimatedWarehouseArrivalAt || ''])) } catch (error) { if (sequence !== listSequence) return rows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, '货柜列表加载失败,请稍后重试') @@ -720,7 +727,7 @@ onMounted(async () => { await Promise.all([loadCustomers(), loadRows()]) }) - + @@ -788,7 +795,7 @@ onMounted(async () => { await Promise.all([loadCustomers(), loadRows()]) }) From 14e31d46792ee7070aae7bcf5c33878e947b9ce0 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 15 Sep 2026 16:39:46 +0800 Subject: [PATCH 04/19] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/modules/container-loading/pages/ContainersView.vue | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/modules/container-loading/pages/ContainersView.vue b/src/modules/container-loading/pages/ContainersView.vue index 1c884e6f..ce8b21a8 100644 --- a/src/modules/container-loading/pages/ContainersView.vue +++ b/src/modules/container-loading/pages/ContainersView.vue @@ -727,7 +727,7 @@ onMounted(async () => { await Promise.all([loadCustomers(), loadRows()]) }) - + @@ -795,7 +795,10 @@ onMounted(async () => { await Promise.all([loadCustomers(), loadRows()]) }) From ccc72be4b910bd96aa921df1dbd554fd195175cc Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 15 Sep 2026 17:46:44 +0800 Subject: [PATCH 05/19] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/UnifiedOrdersView.requests.test.ts | 20 +++++++++++++++++++ .../pages/UnifiedOrdersView.test.ts | 3 +++ .../pages/UnifiedOrdersView.vue | 13 +++++++++--- 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/src/modules/unified-orders/pages/UnifiedOrdersView.requests.test.ts b/src/modules/unified-orders/pages/UnifiedOrdersView.requests.test.ts index d336f6ff..f9ecc135 100644 --- a/src/modules/unified-orders/pages/UnifiedOrdersView.requests.test.ts +++ b/src/modules/unified-orders/pages/UnifiedOrdersView.requests.test.ts @@ -199,6 +199,7 @@ describe('UnifiedOrdersView list request coordination', () => { vi.mocked(listPlatformUnifiedOrders).mockResolvedValue({ records: [{ orderId: '2089223263733518337', orderVersion: 7, tenantId: '2089176869176389634', + internalOrderStatus: 'SUBMITTED', inboundCode: 'NYEXX2609030001', expectedArrivalAt: '2026-09-10T08:30:00', estimatedVolumeCbm: 0.25, }], page: 1, size: 20, total: 1, pages: 1, } as never) @@ -235,4 +236,23 @@ describe('UnifiedOrdersView list request coordination', () => { showClose: true, })) }) + + it('shows planning values without editors after the order enters irreversible fulfillment', async () => { + vi.mocked(listPlatformUnifiedOrders).mockResolvedValue({ + records: [{ + orderId: '2099751993783402497', orderVersion: 7, tenantId: '2097708157074210818', + internalOrderStatus: 'PROCESSING', mainStatus: 'LOADED', inboundCode: 'AT0E9B5BF2839C6944CFA3', + expectedArrivalAt: '2026-10-15T00:00:00', estimatedVolumeCbm: 220, + }], page: 1, size: 20, total: 1, pages: 1, + } as never) + + const wrapper = mountView() + await flushPromises() + + expect(wrapper.findComponent({ name: 'ElInputNumber' }).exists()).toBe(false) + expect(wrapper.findComponent({ name: 'ElDatePicker' }).exists()).toBe(false) + expect(wrapper.text()).toContain('220') + expect(wrapper.text()).toContain('2026-10-15 00:00') + expect(updatePlatformUnifiedOrderPlanning).not.toHaveBeenCalled() + }) }) diff --git a/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts b/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts index eb68a49a..c5c36e7e 100644 --- a/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts +++ b/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts @@ -627,6 +627,9 @@ describe('UnifiedOrdersView contract', () => { }) it('only exposes declaration editing before loading and marks channel changes', () => { + expect(source).toContain("new Set(['DRAFT', 'REJECTED', 'SUBMITTED', 'APPROVED'])") + expect(source).toContain("planningEditableOrderStatuses.has(row.internalOrderStatus || '')") + expect(source).toContain('v-if="canEditPlanning(row as UnifiedOrderResource)"') expect(source).toContain("new Set(['PENDING_REVIEW', 'WAIT_MEASURE', 'WAIT_SPLIT'])") expect(source).toContain('declarationEditableStatuses.has(detail.value.summary.mainStatus)') expect(source).not.toContain("detail.value.summary.mainStatus === 'WAIT_MEASURE'") diff --git a/src/modules/unified-orders/pages/UnifiedOrdersView.vue b/src/modules/unified-orders/pages/UnifiedOrdersView.vue index 491d3499..deb31569 100644 --- a/src/modules/unified-orders/pages/UnifiedOrdersView.vue +++ b/src/modules/unified-orders/pages/UnifiedOrdersView.vue @@ -733,7 +733,7 @@ function planningDraft(row: UnifiedOrderResource) { async function savePlanning(row: UnifiedOrderResource) { const draft = planningDraft(row) - if (!can('platform:order:declaration:edit') || draft.saving) return + if (!canEditPlanning(row) || draft.saving) return const estimatedVolumeCbm = draft.estimatedVolumeCbm ?? null draft.estimatedVolumeCbm = estimatedVolumeCbm if (!Number.isInteger(row.orderVersion)) { @@ -777,6 +777,13 @@ async function savePlanning(row: UnifiedOrderResource) { } } +const planningEditableOrderStatuses = new Set(['DRAFT', 'REJECTED', 'SUBMITTED', 'APPROVED']) + +function canEditPlanning(row: UnifiedOrderResource) { + return can('platform:order:declaration:edit') + && planningEditableOrderStatuses.has(row.internalOrderStatus || '') +} + const declarationEditableStatuses = new Set(['PENDING_REVIEW', 'WAIT_MEASURE', 'WAIT_SPLIT']) const canEditDeclaration = computed(() => Boolean( detail.value @@ -2673,7 +2680,7 @@ onBeforeUnmount(() => {