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] =?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/` |