diff --git a/AGENTS.md b/AGENTS.md index f84bc0bd..dac2a14c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,14 +46,16 @@ | 消息中心 | `modules/notifications/` | Backend `iam/.../notification/`;仅顶部消息中心,不进入左侧菜单 | | 订单列表、详情、商品图片与入库费用 | `modules/unified-orders/`、`modules/first-leg-orders/`;平台商品图片由 `modules/unified-orders/api/unifiedOrders.ts` 按客户与 fileId 下载 | `order/`、`firstleg/`、Finance;图片读取归 `file/` 的 `PlatformProductImageController`。Order 状态为 `SUBMITTED` 时统一列表必须保持 `PENDING_REVIEW/APPROVE_ORDER`,不得被已存在的 Firstleg 准备中入库投影覆盖 | | 客户原始申报资料编辑与渠道变更 | `modules/unified-orders/components/OrderDeclarationEditorDialog.vue`、`api/unifiedOrders.ts`、`pages/UnifiedOrdersView.vue` | 使用订单版本和货物 `itemNo` 整体提交,不允许增删货物行;只编辑客户申报快照,不覆盖仓库/PDA 实测、测量审核或计费事实。渠道变化需二次确认并由 Backend `order/` 联查 `firstleg/` 的分柜、同柜、装柜与重新计价门禁;国内收货仓或中转仓变化必须重新下单 | +| 入库扣货、解除与凭证历史 | `modules/unified-orders/pages/UnifiedOrdersView.vue`、`modules/inbounds/core/api.ts`、`types.ts` | 页面只消费服务端 `holdFlag/currentHoldReason/canHold/canRelease/actionBlockReason/history`;扣货与解除提交当前入库版本、幂等键、必填原因和可选凭证,凭证下载只走 Firstleg 授权入口。Backend `firstleg/` 的 `InboundHoldService`、`InboundHoldGuard`、`InboundHoldEvidenceService`,文件归 `file/` | | 入库、测量、称重库位状态、客户箱规策略与计价主数据 | 测量弹框/工作区 `modules/inbounds/measurement/InboundMeasurementDialog.vue`、`InboundMeasurementPanel.vue`,称重 `modules/inbounds/weight/components/WeightOcrPanel.vue`;主数据 `modules/fba-pricing/`、`modules/dimension-config/`、`modules/customers/components/CustomerBillingRulesPanel.vue`、`modules/fee-items/` | 客户固定四档箱规,默认计算方式为 `ADD/MULTIPLY`;测量行可选实际尺寸、箱规尺寸策略或客户默认尺寸系数,保存后以 Backend `firstleg/` 返回的外箱尺寸、体积重和计费基数为准,不在页面独立重算。费用确认的实际箱数、毛重和体积优先汇总当前 `actualMeasurementRows`,不得用可能滞后的工作区顶层合计覆盖明细。称重页在每张图片卡片只展示服务端 `locationCode/slotStatus/sharedOverflow`,不渲染或自行推导整票预留引导;删除图片须提示槽位仍归本票保留,不得按图片数组下标推导托盘或库位 | | 入库单扣货、解除、凭证与历史 | `modules/inbounds/pages/InboundOrdersView.vue`、详情工作区、`modules/inbounds/core/api.ts` | 列表展示服务端 `holdFlag/currentHoldReason` 并支持筛选;详情只按 `canHold/canRelease/actionBlockReason` 开放操作。扣货与解除提交当前 `version`、原因和独立幂等键,可选凭证先按明确客户与入库单上传;历史文件只调用专用下载入口,不把上传成功但未提交命令的文件冒充扣货证据 | -| 财务与柜级公共费用分摊、客户资金账户 | `modules/finance/`,继续读该目录 `AGENTS.md`;资金账户 `pages/FinanceCustomerAccountsView.vue`,柜费入口 `modules/loading-lists/components/ContainerFeesPanel.vue`、`api/containerFees.ts` | `finance/`、`firstleg/`、`file/`、`docs/财务管理/`;资金账户的实际、冻结、可用余额和流水只取服务端,充值审核和手工充扣携带版本与幂等键。跨客户公共柜费只按服务端柜内客户/订单事实分摊,金额必须守恒,创建与审核、驳回、调整、作废均整组原子处理 | +| 财务、客户资金账户与柜级公共费用分摊 | `modules/finance/`,继续读该目录 `AGENTS.md`;客户账户 `pages/FinanceCustomerAccountsView.vue`,柜费 `modules/loading-lists/components/ContainerFeesPanel.vue`、`api/containerFees.ts` | `finance/`、`firstleg/`、`file/`、`docs/财务管理/`;实际、冻结、可用余额、授信与流水只消费 Finance 权威结果,充值审核和手工充扣携带版本与幂等键。跨客户公共柜费只按服务端柜内客户/订单事实分摊,金额必须守恒,创建与审核、驳回、调整、作废均整组原子处理 | | 货柜、排柜、预装/装柜清单、真实柜号后补与整柜报关 | `modules/container-loading/`、`modules/loading-lists/`;预装详情 `pages/PreloadPlanDetailView.vue`,货柜详情 `pages/ContainerDetailView.vue`,资料/PDA 证据 `components/ContainerOverviewPanel.vue`,报关 `components/ContainerCustomsPanel.vue`、`api/platformContainerCustoms.ts` | 创建柜次时自动生成唯一预装计划,加入入仓码即同步进入该计划,页面不提供创建计划或推送预装操作。预装详情先调用 `/loading-plans/{planId}/lifecycle`,`COMPLETED` 时替换为正式装柜清单详情并关闭旧工作台标签,不按 A0404 或错误文案猜阶段。订舱关联和预装确认允许真实集装箱号为空;首次补录可在 `EDITING/LOADING` 进行,PDA 最终完成前必须补齐;已有柜号纠正只允许 `EDITING` 且任务未领取、无物理作业事实。整柜报关按 `loadingPlanId` 展示、逐资料完成,全买单柜使用 `complete-all-buy`;后端为 `LoadingListService`、`loading-lists-v1.yaml`、`firstleg/customs/container/`,文件归 `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/` | 订舱草稿按当前版本维护资料,放舱后资料与表单只读;打印只对 `RELEASED` 开放。`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/ContainersView.vue`,API `modules/container-loading/api/terminalDrayages.ts` | 按 `containerBookingId` 读取服务端状态、`allowedActions` 和历史,不能复用订舱级拖车表单或自行推进状态;实际到港后选择 `ACTIVE + TRUCKING` 车队,再预约提柜、确认实际提柜与实际还柜。还空截止、超时天/小时只展示服务端基于 `unloadingCompletedAt + 72h` 的结果;Backend `firstleg/terminaldrayage/`、`terminal-drayage-v1.yaml` | | 历史报关协同与国内出仓 | `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/`;与排车、出库、派送、异常退回和一件代发共同组成海外仓全流程 | @@ -69,7 +71,7 @@ | 关键词 | 页面/路由入口 | API 与联查 | |---|---|---| -| 尾程订单与柜级入仓计划进度 | `modules/overseas-warehouse/pages/LastMileOrdersView.vue`、`LastMileOrderDetailView.vue` | 柜子工作台按 `sourceContainerBookingId` 消费 `/orders/container-summaries`;服务端在分页前排除全部入仓码均已有有效独立计划的柜,只按冻结 `plannedArrivalAt` 升序、空值最后返回仍有待生成计划入仓码的柜。卡片只展示服务端计划统计,详情弹框返回全柜订单且不受列表分页截断,不得在浏览器拼柜或自行推导就绪。整柜生成调用 `/inbound-plans/convert-container`,每票返回独立计划且整批原子;转仓候选只取 `modules/iam/api/platformIam.ts` 的平台公共 `ACTIVE + FBA` 仓主档,并提交 ID/编码/名称快照。业务 API `modules/overseas-warehouse/api/lastMileOrders.ts`,Backend `lastleg-service/` | +| 尾程订单控制与柜级入仓计划转换 | `modules/overseas-warehouse/pages/LastMileOrdersView.vue`、`LastMileOrderDetailView.vue` | 柜子工作台按 `sourceContainerBookingId` 消费 `/orders/container-summaries`;服务端在分页前排除全部入仓码均已有有效独立计划的柜,只按冻结 `plannedArrivalAt` 升序、空值最后返回仍有待生成计划入仓码的柜。详情返回全柜订单且不受列表分页截断,不得在浏览器拼柜或自行推导就绪;整柜生成调用 `/inbound-plans/convert-container`,每票返回独立计划且整批原子。单票完整入库计划、暂扣/解除和八行人工节点均从 `api/lastMileOrders.ts` 提交订单版本与幂等键;入仓码只读展示并提交订单冻结值,不允许人工改写,暂扣资格、接柜仓锁定原因和操作能力只消费服务端结果,人工节点一经保存不得回退柜级节点。转仓候选只取 `modules/iam/api/platformIam.ts` 的平台公共 `ACTIVE + FBA` 仓主档;Backend `lastleg-service/` | | 入仓单、入仓计划、FBA 预约 | `modules/overseas-warehouse/pages/InboundOrdersView.vue`、`InboundOrderDetailView.vue` | 列表/预约 `modules/overseas-warehouse/api/inboundOrders.ts`,计划列表/详情 `inboundPlans.ts`;Backend `lastleg-service/inboundplan/` | | 排车查询、智能预排与转派送 | `modules/overseas-warehouse/pages/DispatchesView.vue`、`DispatchDetailView.vue`、`components/SmartDispatchDialog.vue` | `modules/overseas-warehouse/api/dispatches.ts`;Backend `lastleg-service/dispatch/`、`delivery/`;覆盖详情、候选、草稿柜、托盘锁、`pre-arrange` 和 `transfer-delivery`。页面能力必须由正式路由、API 调用和测试共同证明 | | 海外出库与派送 | `pages/OutboundsView.vue`、`DeliveryOrdersView.vue`、`DeliveryOrderDetailView.vue` | `api/outbounds.ts`、`deliveryOrders.ts`;一件代发出库、取消、派送列表/详情及退回入口对应 Backend `lastleg-service/delivery/`、`dropship/` | diff --git a/src/modules/container-loading/api/terminalDrayages.test.ts b/src/modules/container-loading/api/terminalDrayages.test.ts new file mode 100644 index 00000000..037d010f --- /dev/null +++ b/src/modules/container-loading/api/terminalDrayages.test.ts @@ -0,0 +1,56 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { http } from '@/shared/api/http' +import { + getTerminalDrayageHistory, + listTerminalDrayages, + recordTerminalDrayageEmptyReturn, + recordTerminalDrayagePickup, + scheduleTerminalDrayagePickup, + selectTerminalDrayageFleet, +} from './terminalDrayages' + +vi.mock('@/shared/api/http', () => ({ + http: { get: vi.fn(), post: vi.fn() }, + unwrapApiResponse: (response: { data: T }) => response.data, +})) + +const view = { + containerBookingId: '9007199254740993', containerNo: 'MSCU1234567', oceanBookingId: '9007199254740994', + status: 'UNLOADED', version: '3', fleetSupplierId: '9007199254740995', 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: 'OVERDUE', deadlineAt: '2026-09-10T10:00:00', overdueHours: '26', overdueDays: '1' }, + allowedActions: ['SELECT_FLEET', 'RECORD_EMPTY_RETURN'], updatedAt: '2026-09-11T12:00:00', +} + +describe('terminal drayages API', () => { + beforeEach(() => vi.clearAllMocks()) + + it('sends repeated container ids and preserves long identifiers as strings', async () => { + vi.mocked(http.get).mockResolvedValueOnce({ data: [view] }) + const result = await listTerminalDrayages(['9007199254740993', '9007199254740994']) + const config = vi.mocked(http.get).mock.calls[0][1] as { params: URLSearchParams } + expect(config.params.getAll('containerBookingIds')).toEqual(['9007199254740993', '9007199254740994']) + expect(result[0]).toMatchObject({ containerBookingId: '9007199254740993', oceanBookingId: '9007199254740994', fleetSupplierId: '9007199254740995', version: 3 }) + expect(result[0].emptyReturnWarning).toMatchObject({ overdueHours: 26, overdueDays: 1 }) + }) + + it('uses frozen command paths, versions and idempotency keys', async () => { + vi.mocked(http.post).mockResolvedValue({ data: view }) + await selectTerminalDrayageFleet('101', { expectedVersion: 3, fleetSupplierId: '501', fleetSupplierName: '港运车队' }) + await scheduleTerminalDrayagePickup('101', { expectedVersion: 4, pickupAppointmentAt: '2026-09-07T08:00:00' }) + await recordTerminalDrayagePickup('101', { expectedVersion: 5, actualAt: '2026-09-07T09:00:00' }) + await recordTerminalDrayageEmptyReturn('101', { expectedVersion: 6, actualAt: '2026-09-10T09:00:00' }) + expect(http.post).toHaveBeenNthCalledWith(1, '/platform/v1/terminal-drayages/101/fleet', expect.objectContaining({ expectedVersion: 3, fleetSupplierId: '501' }), expect.objectContaining({ headers: { 'Idempotency-Key': expect.stringContaining('terminal-drayage-fleet-') } })) + expect(http.post).toHaveBeenNthCalledWith(2, '/platform/v1/terminal-drayages/101/pickup-appointment', expect.objectContaining({ expectedVersion: 4 }), expect.any(Object)) + expect(http.post).toHaveBeenNthCalledWith(3, '/platform/v1/terminal-drayages/101/pickup', expect.objectContaining({ expectedVersion: 5 }), expect.any(Object)) + expect(http.post).toHaveBeenNthCalledWith(4, '/platform/v1/terminal-drayages/101/empty-return', expect.objectContaining({ expectedVersion: 6 }), expect.any(Object)) + }) + + it('loads operation history from the container business id', async () => { + vi.mocked(http.get).mockResolvedValueOnce({ data: [{ action: 'SELECT_FLEET', fromStatus: 'ARRIVED', toStatus: 'ARRIVED', fromVersion: 0, toVersion: 1, reason: null, operatorId: '9007199254740993', operatorName: '平台管理员', createdAt: '2026-09-07T08:00:00' }] }) + const result = await getTerminalDrayageHistory('101') + expect(http.get).toHaveBeenCalledWith('/platform/v1/terminal-drayages/101/history') + expect(result[0].operatorId).toBe('9007199254740993') + }) +}) diff --git a/src/modules/container-loading/api/terminalDrayages.ts b/src/modules/container-loading/api/terminalDrayages.ts new file mode 100644 index 00000000..10e18da4 --- /dev/null +++ b/src/modules/container-loading/api/terminalDrayages.ts @@ -0,0 +1,101 @@ +import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' + +export type TerminalDrayageStatus = 'WAITING_FOR_ARRIVAL' | 'ARRIVED' | 'PICKUP_SCHEDULED' | 'PICKED_UP' | 'UNLOADED' | 'EMPTY_RETURNED' +export type TerminalDrayageAction = 'SELECT_FLEET' | 'SCHEDULE_PICKUP' | 'RECORD_PICKUP' | 'RECORD_EMPTY_RETURN' +export type EmptyReturnWarningStatus = 'NOT_APPLICABLE' | 'PENDING' | 'OVERDUE' | 'RESOLVED' + +export interface EmptyReturnWarning { + status: EmptyReturnWarningStatus + deadlineAt: string | null + overdueHours: number + overdueDays: number +} + +export interface TerminalDrayageView { + containerBookingId: string + containerNo: string | null + oceanBookingId: string | null + status: TerminalDrayageStatus + version: number + fleetSupplierId: string | null + fleetSupplierName: string | null + pickupAppointmentAt: string | null + actualPickupAt: string | null + estimatedEmptyReturnAt: string | null + actualEmptyReturnAt: string | null + unloadingCompletedAt: string | null + emptyReturnWarning: EmptyReturnWarning + allowedActions: TerminalDrayageAction[] + updatedAt: string | null +} + +export interface TerminalDrayageHistory { + action: TerminalDrayageAction + fromStatus: string + toStatus: string + fromVersion: number + toVersion: number + reason: string | null + operatorId: string + operatorName: string | null + createdAt: string +} + +const path = '/platform/v1/terminal-drayages' +function operationId(action: string) { return `terminal-drayage-${action}-${Date.now()}-${Math.random().toString(36).slice(2)}` } +function id(value: unknown): string { return value == null ? '' : String(value) } +function optionalId(value: unknown): string | null { return value == null ? null : String(value) } +function normalize(raw: TerminalDrayageView): TerminalDrayageView { + return { + ...raw, + containerBookingId: id(raw.containerBookingId), + oceanBookingId: optionalId(raw.oceanBookingId), + fleetSupplierId: optionalId(raw.fleetSupplierId), + version: Number(raw.version || 0), + allowedActions: raw.allowedActions || [], + emptyReturnWarning: { + ...raw.emptyReturnWarning, + overdueHours: Number(raw.emptyReturnWarning?.overdueHours || 0), + overdueDays: Number(raw.emptyReturnWarning?.overdueDays || 0), + }, + } +} + +export async function listTerminalDrayages(containerBookingIds: string[]) { + const params = new URLSearchParams() + containerBookingIds.forEach((containerBookingId) => params.append('containerBookingIds', containerBookingId)) + const response = await http.get, ApiEnvelope>(path, { params }) + return (unwrapApiResponse(response) || []).map(normalize) +} + +export async function getTerminalDrayage(containerBookingId: string) { + const response = await http.get, ApiEnvelope>(`${path}/${encodeURIComponent(containerBookingId)}`) + return normalize(unwrapApiResponse(response)) +} + +export async function getTerminalDrayageHistory(containerBookingId: string) { + const response = await http.get, ApiEnvelope>(`${path}/${encodeURIComponent(containerBookingId)}/history`) + return (unwrapApiResponse(response) || []).map((item) => ({ ...item, operatorId: id(item.operatorId) })) +} + +async function command(containerBookingId: string, action: string, body: object) { + const response = await http.post, ApiEnvelope>( + `${path}/${encodeURIComponent(containerBookingId)}/${action}`, + body, + { headers: { 'Idempotency-Key': operationId(action) } }, + ) + return normalize(unwrapApiResponse(response)) +} + +export function selectTerminalDrayageFleet(containerBookingId: string, body: { expectedVersion: number; fleetSupplierId: string; fleetSupplierName?: string; reason?: string }) { + return command(containerBookingId, 'fleet', body) +} +export function scheduleTerminalDrayagePickup(containerBookingId: string, body: { expectedVersion: number; pickupAppointmentAt: string; reason?: string }) { + return command(containerBookingId, 'pickup-appointment', body) +} +export function recordTerminalDrayagePickup(containerBookingId: string, body: { expectedVersion: number; actualAt: string; reason?: string }) { + return command(containerBookingId, 'pickup', body) +} +export function recordTerminalDrayageEmptyReturn(containerBookingId: string, body: { expectedVersion: number; actualAt: string; reason?: string }) { + return command(containerBookingId, 'empty-return', body) +} diff --git a/src/modules/container-loading/pages/ContainersView.test.ts b/src/modules/container-loading/pages/ContainersView.test.ts index 116af506..a112211e 100644 --- a/src/modules/container-loading/pages/ContainersView.test.ts +++ b/src/modules/container-loading/pages/ContainersView.test.ts @@ -11,6 +11,7 @@ import { listPlatformPartners } from '@/modules/master-data' import { listSuppliers } from '@/modules/suppliers' import { getOceanBooking, listOceanBookingLinkCandidates, listOceanBookings } from '@/modules/ocean-bookings' import { getContainer, getContainerOverview, linkContainerOceanBooking, listContainers, updateContainer } from '../api/platformContainerLoading' +import { getTerminalDrayage, getTerminalDrayageHistory, listTerminalDrayages, recordTerminalDrayageEmptyReturn, selectTerminalDrayageFleet } from '../api/terminalDrayages' import AppDialog from '@/shared/components/AppDialog.vue' vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() })) @@ -21,6 +22,15 @@ vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn().mockResolvedValue 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', () => ({ listTerminalDrayages: vi.fn(), getTerminalDrayage: vi.fn(), getTerminalDrayageHistory: vi.fn(), selectTerminalDrayageFleet: vi.fn(), scheduleTerminalDrayagePickup: vi.fn(), recordTerminalDrayagePickup: vi.fn(), recordTerminalDrayageEmptyReturn: vi.fn() })) + +const drayage = { + containerBookingId: '9001', containerNo: 'MSCU-1', oceanBookingId: '101', status: 'UNLOADED' as const, 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: 'OVERDUE' as const, deadlineAt: '2026-09-10T10:00:00', overdueHours: 26, overdueDays: 1 }, + allowedActions: ['SELECT_FLEET', 'RECORD_EMPTY_RETURN'] as const, updatedAt: '2026-09-11T12:00:00', +} function createTestRouter() { return createRouter({ history: createMemoryHistory(), routes: [ @@ -41,6 +51,11 @@ describe('ContainersView', () => { vi.mocked(listOceanBookings).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 1 }) vi.mocked(listOceanBookingLinkCandidates).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 }) vi.mocked(getOceanBooking).mockResolvedValue({} as never) + vi.mocked(listTerminalDrayages).mockResolvedValue([{ ...drayage, allowedActions: [...drayage.allowedActions] }]) + vi.mocked(getTerminalDrayage).mockResolvedValue({ ...drayage, allowedActions: [...drayage.allowedActions] }) + vi.mocked(getTerminalDrayageHistory).mockResolvedValue([]) + vi.mocked(selectTerminalDrayageFleet).mockResolvedValue({ ...drayage, fleetSupplierId: '502', fleetSupplierName: '新车队', version: 4, allowedActions: [...drayage.allowedActions] }) + vi.mocked(recordTerminalDrayageEmptyReturn).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(listContainers).mockResolvedValue({ records: [{ id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', sourceType: 'SIMULATION', oceanBookingId: null, warehouseId: '8', warehouseName: '义乌仓', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', containerType: '40HC', 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: 76, maxWeightKg: 28000, 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', oceanBookingId: null, warehouseId: '8', warehouseName: '义乌仓', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', containerType: '40HC', 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: 76, maxWeightKg: 28000, 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: [] }) @@ -57,6 +72,9 @@ describe('ContainersView', () => { expect(wrapper.text()).toContain('MSCU-1') expect(wrapper.text()).toContain('宁波 → 长滩') expect(wrapper.find('button[aria-label="查看 CB-1"]').exists()).toBe(true) + expect(listTerminalDrayages).toHaveBeenCalledWith(['9001']) + expect(wrapper.text()).toContain('超时 1 天') + expect(wrapper.text()).toContain('2026-09-10 10:00') }) it('keeps the cross-warehouse list usable when warehouse form options fail', async () => { @@ -243,6 +261,46 @@ describe('ContainersView', () => { expect(source).toContain(':loading="loading" stripe preference-key="container-loading-table"') }) + it('updates an ACTIVE TRUCKING fleet inline and records actual empty return once', async () => { + vi.mocked(listSuppliers).mockResolvedValue({ records: [{ id: '502', name: '新车队', category: 'TRUCKING', status: 'ACTIVE' } as never], page: 1, size: 200, total: 1, pages: 1 }) + const router = createTestRouter() + await router.push('/'); await router.isReady() + const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } }) + await flushPromises() + + const vm = wrapper.vm as unknown as { + rows: Array> + selectRowFleet: (row: Record, supplierId: string) => Promise + openDrayageAction: (row: Record, action: 'RECORD_EMPTY_RETURN') => void + drayageActionAt: string + saveDrayageAction: () => Promise + } + await vm.selectRowFleet(vm.rows[0], '502') + expect(selectTerminalDrayageFleet).toHaveBeenCalledWith('9001', { expectedVersion: 3, fleetSupplierId: '502', fleetSupplierName: '新车队' }) + + vm.openDrayageAction(vm.rows[0], 'RECORD_EMPTY_RETURN') + vm.drayageActionAt = '2026-09-11T13:00:00' + await vm.saveDrayageAction() + expect(recordTerminalDrayageEmptyReturn).toHaveBeenCalledWith('9001', { expectedVersion: 4, actualAt: '2026-09-11T13:00:00', reason: undefined }) + expect(wrapper.text()).toContain('已还柜') + wrapper.unmount() + }) + + it('loads terminal drayage detail and history only when requested', async () => { + vi.mocked(getTerminalDrayageHistory).mockResolvedValueOnce([{ action: 'SELECT_FLEET', fromStatus: 'ARRIVED', toStatus: 'ARRIVED', fromVersion: 0, toVersion: 1, reason: '到港安排', operatorId: '88', operatorName: '管理员', createdAt: '2026-09-07T08:00:00' }]) + const router = createTestRouter() + await router.push('/'); await router.isReady() + const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } }) + await flushPromises() + const vm = wrapper.vm as unknown as { rows: Array>; openDrayageHistory: (row: Record) => Promise } + await vm.openDrayageHistory(vm.rows[0]) + await flushPromises() + expect(getTerminalDrayage).toHaveBeenCalledWith('9001') + expect(getTerminalDrayageHistory).toHaveBeenCalledWith('9001') + expect(wrapper.text()).toContain('到港安排') + wrapper.unmount() + }) + it('opens the existing released-booking workflow when entered from container detail', () => { const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8') expect(source).toContain('route.query.linkContainerId') diff --git a/src/modules/container-loading/pages/ContainersView.vue b/src/modules/container-loading/pages/ContainersView.vue index bc578e49..0f6631cd 100644 --- a/src/modules/container-loading/pages/ContainersView.vue +++ b/src/modules/container-loading/pages/ContainersView.vue @@ -19,6 +19,18 @@ 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 { createContainerSimulation, getContainer, linkContainerOceanBooking, listContainers, updateContainer } from '../api/platformContainerLoading' +import { + getTerminalDrayage, + getTerminalDrayageHistory, + listTerminalDrayages, + recordTerminalDrayageEmptyReturn, + recordTerminalDrayagePickup, + scheduleTerminalDrayagePickup, + selectTerminalDrayageFleet, + type TerminalDrayageAction, + type TerminalDrayageHistory, + type TerminalDrayageView, +} from '../api/terminalDrayages' import type { ContainerBookingResource, ContainerDocumentStatus, ContainerPlanStatus, ContainerSaveRequest, ContainerSimulationCreateRequest } from '../types' type Filters = QueryModel & { customerId: string; keyword: string; containerType: string; status: ContainerPlanStatus | ''; sailingRange: string[] } @@ -68,9 +80,25 @@ const linkContainerNo = ref('') const linkCandidates = ref>([]) const selectedTruckingSupplierId = ref('') const unresolvedTruckingSupplierName = ref(null) +const drayages = ref>({}) +const drayageLoading = ref(false) +const drayageLoadError = ref(null) +const drayageSavingIds = ref([]) +const drayageActionOpen = ref(false) +const drayageAction = ref>('SCHEDULE_PICKUP') +const drayageTarget = ref(null) +const drayageActionAt = ref('') +const drayageReason = ref('') +const drayageActionSaving = ref(false) +const drayageActionError = ref(null) +const drayageHistoryOpen = ref(false) +const drayageHistory = ref([]) +const drayageHistoryLoading = ref(false) +const drayageHistoryError = ref(null) let listSequence = 0 let detailSequence = 0 let linkSequence = 0 +let drayageSequence = 0 function emptyForm(): ContainerForm { return { @@ -140,6 +168,7 @@ const queryFields = computed(() => [ ]) const columns: DataTableColumn[] = [ { key: 'actions', label: '操作', width: 310, fixed: 'left', slotName: 'actions' }, + { key: 'emptyReturnWarning', label: '报空预警', width: 120, slotName: 'emptyReturnWarning' }, { key: 'containerNo', label: '集装箱号', minWidth: 150, slotName: 'containerNo' }, { key: 'planNo', prop: 'planNo', label: '装柜清单号', minWidth: 165, formatter: (row: ContainerBookingResource) => row.planNo || '—' }, { key: 'planStatus', label: '状态', width: 110, slotName: 'status' }, @@ -150,7 +179,11 @@ const columns: DataTableColumn[] = [ { key: 'pickupOrderDocumentStatus', label: 'PU资料', width: 100, formatter: (row: ContainerBookingResource) => documentStatusText(row.pickupOrderDocumentStatus) }, { key: 'oceanBooking', label: '真实订舱', minWidth: 170, slotName: 'oceanBooking' }, { key: 'warehouseName', prop: 'warehouseName', label: '装柜仓库', minWidth: 160 }, - { key: 'terminalTruckingCompany', prop: 'terminalTruckingCompany', label: '码头车队', minWidth: 160 }, + { key: 'terminalTruckingCompany', label: '码头车队', width: 190, slotName: 'terminalTruckingCompany' }, + { key: 'pickupAppointmentAt', label: '预约提柜', width: 165, 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 }, { key: 'route', label: '航线', minWidth: 220, slotName: 'route' }, { key: 'containerType', prop: 'containerType', label: '柜型', width: 100 }, @@ -163,12 +196,45 @@ const columns: DataTableColumn[] = [ { key: 'estimatedPortArrivalAt', label: '预计到港', minWidth: 160, slotName: 'estimatedPortArrivalAt' }, { key: 'actualPortArrivalAt', label: '实际到港', minWidth: 160, slotName: 'actualPortArrivalAt' }, ] +const drayageHistoryColumns: DataTableColumn[] = [ + { key: 'action', label: '动作', width: 130, formatter: (row: TerminalDrayageHistory) => drayageActionText(row.action) }, + { key: 'status', label: '状态变化', minWidth: 180, formatter: (row: TerminalDrayageHistory) => `${drayageStatusText(row.fromStatus)} → ${drayageStatusText(row.toStatus)}` }, + { key: 'version', label: '版本', width: 100, formatter: (row: TerminalDrayageHistory) => `${row.fromVersion} → ${row.toVersion}` }, + { key: 'operatorName', label: '操作人', width: 130, formatter: (row: TerminalDrayageHistory) => row.operatorName || '—' }, + { key: 'createdAt', label: '操作时间', width: 170, formatter: (row: TerminalDrayageHistory) => formatTime(row.createdAt) }, + { key: 'reason', label: '原因', minWidth: 200 }, +] function optional(value: string | null) { return value?.trim() || null } function normalizedContainerNo(value: string | null | undefined) { return String(value || '').trim().toUpperCase() } function statusText(status: ContainerPlanStatus) { return ({ WAIT_LOADING: '待装柜', EDITING: '预装柜中', LOADING: '拣货中', COMPLETED: '装柜完成' })[status] } 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_PICKUP: '确认实际提柜', RECORD_EMPTY_RETURN: '确认实际还柜' } as Record)[action] } +function canDrayage(row: ContainerBookingResource, action: TerminalDrayageAction) { return Boolean(canEdit.value && drayageFor(row.id)?.allowedActions.includes(action)) } +function drayageWarningText(row: ContainerBookingResource) { + const warning = drayageFor(row.id)?.emptyReturnWarning + if (!warning || warning.status === 'NOT_APPLICABLE') return '—' + if (warning.status === 'RESOLVED') return '已还柜' + if (warning.status === 'OVERDUE') return warning.overdueDays > 0 ? `超时 ${warning.overdueDays} 天` : `超时 ${warning.overdueHours} 小时` + return '待还柜' +} +function drayageWarningType(row: ContainerBookingResource) { + const status = drayageFor(row.id)?.emptyReturnWarning.status + return status === 'OVERDUE' ? 'danger' : status === 'RESOLVED' ? 'success' : status === 'PENDING' ? 'warning' : 'info' +} +function drayageWarningTitle(row: ContainerBookingResource) { + const warning = drayageFor(row.id)?.emptyReturnWarning + if (!warning) return '' + const deadline = warning.deadlineAt ? `还柜截止:${formatTime(warning.deadlineAt)}` : '' + return [drayageWarningText(row), deadline].filter(Boolean).join(';') +} +function localDateTimeValue() { + const now = new Date(Date.now() - new Date().getTimezoneOffset() * 60_000) + return now.toISOString().slice(0, 19) +} function containerViewLabel(row: ContainerBookingResource) { const reference = [row.cabinetNo, row.containerNo, row.bookingNo] .find((value) => typeof value === 'string' && value.trim()) @@ -408,6 +474,81 @@ async function saveLink() { } catch (error) { linkError.value = getApiErrorDetails(error, '关联真实订舱柜失败') } finally { linkSaving.value = false } } +function replaceDrayage(value: TerminalDrayageView) { + drayages.value = { ...drayages.value, [value.containerBookingId]: value } +} +async function loadDrayages(targetRows = rows.value) { + const sequence = ++drayageSequence + if (!targetRows.length) { drayages.value = {}; drayageLoadError.value = null; return } + drayageLoading.value = true; drayageLoadError.value = null + try { + const values = await listTerminalDrayages(targetRows.map((row) => row.id)) + if (sequence !== drayageSequence) return + drayages.value = Object.fromEntries(values.map((value) => [value.containerBookingId, value])) + } catch (error) { + if (sequence !== drayageSequence) return + drayages.value = {} + drayageLoadError.value = getApiErrorDetails(error, '码头拖车信息加载失败,请重试') + } finally { if (sequence === drayageSequence) drayageLoading.value = false } +} +async function refreshDrayage(containerBookingId: string) { + const value = await getTerminalDrayage(containerBookingId) + replaceDrayage(value) + return value +} +async function selectRowFleet(source: object, supplierIdValue: unknown) { + const row = source as ContainerBookingResource + const current = drayageFor(row.id) + const supplier = truckingCompanies.value.find((item) => item.id === String(supplierIdValue || '')) + if (!current || !supplier || !canDrayage(row, 'SELECT_FLEET') || drayageSavingIds.value.includes(row.id)) return + drayageSavingIds.value = [...drayageSavingIds.value, row.id] + try { + replaceDrayage(await selectTerminalDrayageFleet(row.id, { expectedVersion: current.version, fleetSupplierId: supplier.id, fleetSupplierName: supplier.name })) + ElMessage.success('码头车队已更新') + } catch (error) { + const details = getApiErrorDetails(error, '码头车队更新失败') + if (details.code === 'A0409') { + try { await refreshDrayage(row.id) } catch { /* 保留原始冲突证据 */ } + ElMessage.warning('运输信息已被其他人修改,已刷新当前柜最新状态') + } else ElMessage.error(details.message) + } finally { drayageSavingIds.value = drayageSavingIds.value.filter((id) => id !== row.id) } +} +function openDrayageAction(row: ContainerBookingResource, action: Exclude) { + if (!canDrayage(row, action)) return + drayageTarget.value = row; drayageAction.value = action; drayageActionAt.value = localDateTimeValue(); drayageReason.value = '' + drayageActionError.value = null; drayageActionOpen.value = true +} +async function saveDrayageAction() { + const row = drayageTarget.value + const current = row ? drayageFor(row.id) : null + if (!row || !current || !drayageActionAt.value || !canDrayage(row, drayageAction.value) || drayageActionSaving.value) return + 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 }) + : drayageAction.value === 'RECORD_PICKUP' + ? await recordTerminalDrayagePickup(row.id, { expectedVersion: current.version, actualAt: drayageActionAt.value, reason }) + : await recordTerminalDrayageEmptyReturn(row.id, { expectedVersion: current.version, actualAt: drayageActionAt.value, reason }) + replaceDrayage(next); drayageActionOpen.value = false + ElMessage.success(`${drayageActionText(drayageAction.value)}成功`) + } catch (error) { + const details = getApiErrorDetails(error, `${drayageActionText(drayageAction.value)}失败`) + if (details.code === 'A0409') { + try { await refreshDrayage(row.id) } catch { /* 保留原始冲突证据 */ } + drayageActionError.value = { ...details, message: '运输信息已被其他人修改,已刷新最新状态;请重新确认后提交' } + } else drayageActionError.value = details + } finally { drayageActionSaving.value = false } +} +async function openDrayageHistory(row: ContainerBookingResource) { + drayageTarget.value = row; drayageHistory.value = []; drayageHistoryError.value = null; drayageHistoryOpen.value = true; drayageHistoryLoading.value = true + try { + const [current, history] = await Promise.all([getTerminalDrayage(row.id), getTerminalDrayageHistory(row.id)]) + if (drayageTarget.value?.id !== row.id) return + replaceDrayage(current); drayageHistory.value = history + } catch (error) { if (drayageTarget.value?.id === row.id) drayageHistoryError.value = getApiErrorDetails(error, '码头拖车记录加载失败') } + finally { if (drayageTarget.value?.id === row.id) drayageHistoryLoading.value = false } +} async function loadRows() { const sequence = ++listSequence loading.value = true; loadError.value = null @@ -421,6 +562,7 @@ async function loadRows() { }) if (sequence !== listSequence) return rows.value = result.records; total.value = result.total + await loadDrayages(result.records) } catch (error) { if (sequence !== listSequence) return rows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, '货柜列表加载失败,请稍后重试') @@ -435,6 +577,8 @@ function handleMoreAction(command: string, source: object) { const row = source as ContainerBookingResource if (command === 'link') { void openLink(row); return } if (command === 'overview') { openDetail(row); return } + if (command === 'drayage-history') { void openDrayageHistory(row); return } + if (command.startsWith('drayage:')) { openDrayageAction(row, command.slice('drayage:'.length) as Exclude); return } if (['exception', 'documents'].includes(command)) void router.push({ name: 'container-detail', params: { containerId: row.id }, query: { action: command } }) } async function openEdit(source: object) { @@ -534,12 +678,19 @@ onMounted(async () => {
+ 重新加载码头拖车信息 - + + + + + + + @@ -595,12 +746,22 @@ onMounted(async () => { + + + + + + + + + +
diff --git a/src/modules/overseas-warehouse/pages/LastMileOrderDetailView.vue b/src/modules/overseas-warehouse/pages/LastMileOrderDetailView.vue index 4c061148..4ff14c13 100644 --- a/src/modules/overseas-warehouse/pages/LastMileOrderDetailView.vue +++ b/src/modules/overseas-warehouse/pages/LastMileOrderDetailView.vue @@ -34,10 +34,12 @@ const mutationError = ref(null) const activeTab = ref('basic') const feeDialogOpen = ref(false) const feeForm = reactive({ item: '', currency: 'USD', amount: 0, status: 'PENDING_REVIEW', description: '' }) +const feeItemOptions = ['仓储费', '入库操作费', '出库操作费', '换标费', '托盘费', '派送费', '转仓费', '异常处理费', '其他'] function text(value: unknown) { return String(value ?? '').trim() || '—' } function yesNo(value: boolean | null | undefined) { return value == null ? '—' : value ? '是' : '否' } function feeStatusLabel(value: string) { return ({ PENDING_REVIEW: '待审核', APPROVED: '审核通过', REJECTED: '已驳回' } as Record)[value] || value } +function isSingleLineBusinessField(field: DetailField) { return field.label === '入仓码' || field.label === '集装箱号' } const referenceValue = computed(() => lastMileOrderReference(detail.value?.order.orderNo, '订单编码待补充')) const orderFields = computed(() => { @@ -158,13 +160,13 @@ onMounted(load)
-

订单信息

{{ field.label }}
{{ field.value }}
-

货柜信息

{{ field.label }}
{{ field.value }}
+

订单信息

{{ field.label }}
{{ field.value }}
+

货柜信息

{{ field.label }}
{{ field.value }}
-

计划信息

{{ field.label }}
{{ field.value }}
+

计划信息

{{ field.label }}
{{ field.value }}
@@ -172,13 +174,13 @@ onMounted(load)
- - - - - - - +
+ + + + + +
@@ -197,11 +199,16 @@ onMounted(load) .detail-fields > div { display: grid; min-width: 0; min-height: 58px; grid-template-columns: minmax(100px, 34%) minmax(0, 1fr); align-items: center; border-bottom: 1px solid var(--cc-border); } .detail-fields dt { color: var(--cc-muted); font-size: 13px; } .detail-fields dd { min-width: 0; margin: 0; color: var(--cc-text); font-weight: 600; overflow-wrap: anywhere; } +.detail-field-value--business { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .plan-panel { margin-bottom: 20px; } .plan-fields { grid-template-columns: repeat(3, minmax(0, 1fr)); } .plan-fields > div { grid-template-columns: minmax(90px, 42%) minmax(0, 1fr); } .related-panel { margin-top: 20px; } .table-panel :deep(.data-table__toolbar) { min-height: 58px; } +.fee-form { min-width: 0; overflow-x: hidden; } +.fee-form-grid { display: grid; min-width: 0; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 20px; } +.fee-form-full { grid-column: 1 / -1; } +.fee-form :deep(.el-select), .fee-form :deep(.el-input-number) { width: 100%; } @media (max-width: 1100px) { .basic-grid { grid-template-columns: 1fr; } .plan-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); } } -@media (max-width: 720px) { .detail-tabs :deep(.el-tabs__header) { padding: 0 10px; } .detail-tabs :deep(.el-tabs__content) { padding: 12px; } .detail-tabs :deep(.el-tabs__item) { padding: 0 12px; } .detail-fields, .plan-fields { grid-template-columns: 1fr; } } +@media (max-width: 720px) { .detail-tabs :deep(.el-tabs__header) { padding: 0 10px; } .detail-tabs :deep(.el-tabs__content) { padding: 12px; } .detail-tabs :deep(.el-tabs__item) { padding: 0 12px; } .detail-fields, .plan-fields, .fee-form-grid { grid-template-columns: 1fr; } .fee-form-full { grid-column: auto; } } diff --git a/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts b/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts index 00911323..7a80717b 100644 --- a/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts +++ b/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts @@ -1,27 +1,37 @@ import ElementPlus from 'element-plus' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' import { createPinia } from 'pinia' import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ApiResponseError } from '@/shared/api/http' import DataTable from '@/shared/components/DataTable.vue' +import AppDialog from '@/shared/components/AppDialog.vue' import QueryToolbar from '@/shared/components/QueryToolbar.vue' import { listAllPlatformWarehouses } from '@/modules/warehouses' import { + changeContainerReceivingWarehouse, + createManualInboundPlan, convertLastMileContainerToInboundPlans, - convertLastMileOrderToInboundPlan, getContainerPlanProgress, listContainerPlanProgress, listLastMileOrders, transferLastMileOrders, + getLastMileOrderManualNodes, + updateLastMileOrderHold, + updateLastMileOrderManualNodes, } from '../api/lastMileOrders' import LastMileOrdersView from './LastMileOrdersView.vue' -vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) })) +const canAccess = vi.hoisted(() => vi.fn<(permissionCode: string) => boolean>(() => true)) +vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: canAccess }) })) vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) })) vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() })) vi.mock('../api/lastMileOrders', () => ({ listLastMileOrders: vi.fn(), listContainerPlanProgress: vi.fn(), getContainerPlanProgress: vi.fn(), - convertLastMileOrderToInboundPlan: vi.fn(), convertLastMileContainerToInboundPlans: vi.fn(), transferLastMileOrders: vi.fn(), + changeContainerReceivingWarehouse: vi.fn(), + createManualInboundPlan: vi.fn(), convertLastMileContainerToInboundPlans: vi.fn(), transferLastMileOrders: vi.fn(), + getLastMileOrderManualNodes: vi.fn(), updateLastMileOrderHold: vi.fn(), updateLastMileOrderManualNodes: vi.fn(), downloadLastMileOrders: vi.fn(), })) @@ -38,6 +48,8 @@ const bookingId = '2092873463568597001' const customerId = '2092618717861048321' const summary = { sourceContainerBookingId: bookingId, containerNo: 'CONT-15', totalOrderCount: 15, + overseasWarehouseId: '9007199254740995', overseasWarehouseCode: 'LAX01', overseasWarehouseName: '洛杉矶接柜仓', + assignmentVersion: 7, canChangeReceivingWarehouse: true, receivingWarehouseLockReason: null, inboundCodeCount: 15, missingInboundCodeCount: 0, planCount: 10, plannedInboundCodeCount: 10, pendingPlanCount: 5, plannedArrivalAt: '2026-09-01T08:00:00', allPlansReady: false, blockingReason: '存在5票尚未生成有效入库计划', @@ -53,8 +65,21 @@ const fbaWarehouse = { }, addressSummary: 'CA Ontario', } +const receivingWarehouse = { + ...fbaWarehouse, + id: '9007199254740996', warehouseCode: 'LAX02', warehouseName: '洛杉矶二号接柜仓', warehouseType: 'OVERSEAS', +} +const currentReceivingWarehouse = { + ...receivingWarehouse, + id: summary.overseasWarehouseId, warehouseCode: summary.overseasWarehouseCode, warehouseName: summary.overseasWarehouseName, +} function selectedRow(overrides: Record = {}) { - return { id: '2092873463568596994', customerId, sourceContainerBookingId: bookingId, orderNo: 'LMO-260827-0001', ...overrides } + return { + id: '2092873463568596994', customerId, sourceContainerBookingId: bookingId, orderNo: 'LMO-260827-0001', + overseasWarehouseId: summary.overseasWarehouseId, overseasWarehouseCode: summary.overseasWarehouseCode, + warehouse: summary.overseasWarehouseName, inboundCode: 'IN-LOCKED', version: 3, pallets: 2, + ...overrides, + } } enableAutoUnmount(afterEach) @@ -98,6 +123,7 @@ async function chooseContainer(wrapper: Awaited>) { describe('LastMileOrdersView pending order and container conversion', () => { beforeEach(() => { + canAccess.mockReset().mockReturnValue(true) vi.stubGlobal('ResizeObserver', ResizeObserverStub) vi.stubGlobal('localStorage', memoryStorage()) vi.mocked(listLastMileOrders).mockReset().mockResolvedValue({ records: [], page: 1, size: 10, total: 0, pages: 0 }) @@ -106,8 +132,12 @@ describe('LastMileOrdersView pending order and container conversion', () => { summary, orders: [{ orderId: '2092873463568596994', customerId, orderNo: 'LMO-1', inboundCode: 'IN-1', orderStatus: 'INBOUND_PLANNED', planId: '2092873463568597010', planNo: 'LIP-1', planStatus: 'PENDING_RECEIVING', planValid: true, plannedArrivalAt: '2026-09-01T08:00:00' }], }) - vi.mocked(convertLastMileOrderToInboundPlan).mockReset().mockResolvedValue({}) + vi.mocked(createManualInboundPlan).mockReset().mockResolvedValue({} as never) + vi.mocked(getLastMileOrderManualNodes).mockReset().mockResolvedValue({ orderId: '2092873463568596994', manualOverrideEnabled: false, nodes: [], version: 0 }) + vi.mocked(updateLastMileOrderHold).mockReset().mockResolvedValue({ orderId: '2092873463568596994', held: true, chargeable: true, version: 1 }) + vi.mocked(updateLastMileOrderManualNodes).mockReset().mockResolvedValue({ orderId: '2092873463568596994', manualOverrideEnabled: true, nodes: [], version: 1 }) vi.mocked(convertLastMileContainerToInboundPlans).mockReset().mockResolvedValue([{ planNo: 'LIP-1' }, { planNo: 'LIP-2' }] as never) + vi.mocked(changeContainerReceivingWarehouse).mockReset().mockResolvedValue({ ...summary, overseasWarehouseId: '9007199254740996', overseasWarehouseCode: 'LAX02', overseasWarehouseName: '洛杉矶二号接柜仓', assignmentVersion: 8 }) vi.mocked(listAllPlatformWarehouses).mockReset().mockResolvedValue([fbaWarehouse] as never) vi.mocked(transferLastMileOrders).mockReset().mockResolvedValue(2) }) @@ -135,13 +165,17 @@ describe('LastMileOrdersView pending order and container conversion', () => { } toolbar.vm.$emit('search', model) await flushPromises() - expect(listLastMileOrders).toHaveBeenLastCalledWith(expect.objectContaining({ - page: 1, orderNo: 'LMO-SEARCH', inboundCode: 'IN-SEARCH', lxCode: 'LX-SEARCH', orderType: 'FIRST_LEG', + expect(listLastMileOrders).toHaveBeenLastCalledWith({ + page: 1, size: 10, orderNo: 'LMO-SEARCH', inboundCode: 'IN-SEARCH', lxCode: 'LX-SEARCH', orderType: 'FIRST_LEG', warehouse: 'LAX', sourceOrderNo: 'SOURCE-SEARCH', containerNo: 'CONT-SEARCH', transferWarehouseCode: 'ONT8', currentStatus: 'DELIVERED', abnormal: true, detained: false, createdFrom: '2026-09-01T00:00:00', createdTo: '2026-09-07T23:59:59', - })) - expect((toolbar.props('availableFields') as Array<{ key: string }>).map((field) => field.key)).toContain('lxCode') + }) + const availableFields = toolbar.props('availableFields') as Array<{ key: string; label: string; placeholder?: string }> + expect(availableFields.find((field) => field.key === 'lxCode')).toMatchObject({ + label: 'LX代码', placeholder: '请输入LX代码', + }) + expect(availableFields.map((field) => field.key)).not.toEqual(expect.arrayContaining(['planNo', 'inboundOrderNo'])) await wrapper.findAll('.el-radio-button').find((item) => item.text() === '待分配')!.trigger('click') await flushPromises() @@ -149,7 +183,10 @@ describe('LastMileOrdersView pending order and container conversion', () => { await wrapper.findAll('.el-radio-button').find((item) => item.text().startsWith('柜子'))!.trigger('click') await flushPromises() - expect(listContainerPlanProgress).toHaveBeenLastCalledWith(expect.objectContaining({ containerNo: 'CONT-SEARCH', createdFrom: '2026-09-01T00:00:00', createdTo: '2026-09-07T23:59:59' })) + expect(listContainerPlanProgress).toHaveBeenLastCalledWith({ + page: 1, size: 10, containerNo: 'CONT-SEARCH', + createdFrom: '2026-09-01T00:00:00', createdTo: '2026-09-07T23:59:59', + }) await wrapper.findAll('.el-radio-button').find((item) => item.text() === '全部')!.trigger('click') await flushPromises() @@ -164,17 +201,52 @@ describe('LastMileOrdersView pending order and container conversion', () => { expect(wrapper.text()).not.toContain('尾程订单加载失败') }) - it('keeps single-order conversion isolated with long order and customer ids', async () => { + it('opens the required single-order inbound-plan form without invoking the cabinet conversion', async () => { const wrapper = await openPending() await selectRows(wrapper, [selectedRow()]) await button(wrapper, '生成入库计划')!.trigger('click') await flushPromises() - expect(convertLastMileOrderToInboundPlan).toHaveBeenCalledTimes(1) - expect(convertLastMileOrderToInboundPlan).toHaveBeenCalledWith({ orderId: '2092873463568596994', customerId }) + expect(document.body.textContent).toContain('计划入库仓') + expect(document.body.textContent).toContain('预计到仓日期') + expect(document.body.textContent).toContain('托盘类型') + const inboundCodeInput = wrapper.findAllComponents({ name: 'ElInput' }) + .find((input) => input.props('placeholder') === '订单冻结入仓码') + expect(inboundCodeInput?.props('disabled')).toBe(true) + const receivingWarehouseInput = wrapper.findAllComponents({ name: 'ElInput' }) + .find((input) => input.props('placeholder') === '当前柜接柜仓') + expect(receivingWarehouseInput?.props('disabled')).toBe(true) + expect(receivingWarehouseInput?.props('modelValue')).toBe('LAX01 · 洛杉矶接柜仓') + expect(document.body.textContent).toContain('接柜仓按柜统一') + expect(listAllPlatformWarehouses).not.toHaveBeenCalled() + expect(createManualInboundPlan).not.toHaveBeenCalled() expect(convertLastMileContainerToInboundPlans).not.toHaveBeenCalled() expect(listContainerPlanProgress).not.toHaveBeenCalled() }) + it('shows the order-frozen inbound code but never sends it or a per-order receiving warehouse override', async () => { + const wrapper = await openPending() + await selectRows(wrapper, [selectedRow()]) + await button(wrapper, '生成入库计划')!.trigger('click') + await flushPromises() + const datePicker = wrapper.find('.command-form').findComponent({ name: 'ElDatePicker' }) + datePicker.vm.$emit('update:modelValue', '2026-09-08') + await wrapper.vm.$nextTick() + const confirm = [...document.body.querySelectorAll('button')] + .find((item) => item.textContent?.includes('保存并生成入库单')) as HTMLButtonElement + confirm.click() + await flushPromises() + + expect(createManualInboundPlan).toHaveBeenCalledWith(expect.objectContaining({ + customerId, orderId: '2092873463568596994', expectedOrderVersion: 3, + plannedArrivalDate: '2026-09-08', palletCount: 2, + }), expect.stringContaining('manual-inbound-plan-')) + const payload = vi.mocked(createManualInboundPlan).mock.calls[0][0] as unknown as Record + expect(payload).not.toHaveProperty('inboundCode') + expect(payload).not.toHaveProperty('plannedWarehouseId') + expect(payload).not.toHaveProperty('plannedWarehouseCode') + expect(payload).not.toHaveProperty('plannedWarehouseName') + }) + it('opens a cabinet selector without requiring order selection and loads server summaries only then', async () => { const wrapper = await openPending() await openContainerDialog(wrapper) @@ -195,7 +267,7 @@ describe('LastMileOrdersView pending order and container conversion', () => { await flushPromises() expect(convertLastMileContainerToInboundPlans).toHaveBeenCalledTimes(1) expect(convertLastMileContainerToInboundPlans).toHaveBeenCalledWith({ sourceContainerBookingId: bookingId }) - expect(convertLastMileOrderToInboundPlan).not.toHaveBeenCalled() + expect(createManualInboundPlan).not.toHaveBeenCalled() expect(document.body.textContent).toContain('已按柜生成 2 个独立入库计划') expect(listLastMileOrders).toHaveBeenCalledTimes(3) }) @@ -216,29 +288,55 @@ describe('LastMileOrdersView pending order and container conversion', () => { expect(wrapper.text()).toContain(`trace-${code}`) }) - it('renders pending cabinets as an arrival-ordered workbench with the remaining inbound-code count', async () => { + it('renders all cabinets as an arrival-ordered workbench with the remaining inbound-code count', async () => { const nullArrival = { ...summary, sourceContainerBookingId: '2092873463568597002', containerNo: 'CONT-NULL', plannedArrivalAt: null, planCount: 0, pendingPlanCount: 15 } - const completed = { ...summary, sourceContainerBookingId: '2092873463568597003', containerNo: 'CONT-DONE', plannedInboundCodeCount: 15, pendingPlanCount: 0, allPlansReady: true } - vi.mocked(listContainerPlanProgress).mockResolvedValueOnce({ records: [completed, summary, nullArrival], page: 1, size: 10, total: 3, pages: 1 }) + vi.mocked(listContainerPlanProgress).mockResolvedValueOnce({ records: [summary, nullArrival], page: 1, size: 10, total: 2, pages: 1 }) const wrapper = await mountView() const containerTab = wrapper.findAll('.el-radio-button').find((item) => item.text().startsWith('柜子')) await containerTab!.trigger('click') await flushPromises() expect(listContainerPlanProgress).toHaveBeenLastCalledWith({ page: 1, size: 10, containerNo: undefined, createdFrom: undefined, createdTo: undefined }) expect(wrapper.text()).toContain('柜子工作台(2)') - expect(wrapper.text()).toContain('待处理柜工作台') - expect(wrapper.text()).toContain('按计划到仓时间由近到远排列') + expect(wrapper.text()).toContain('货柜工作台') + expect(wrapper.text()).toContain('按柜查看和修改海外接柜仓,并为剩余入仓码生成独立入库计划') expect(wrapper.text()).toContain('5个入仓码待生成计划') expect(wrapper.text()).toContain('共 15 个入仓码') expect(wrapper.text()).not.toContain('待补入仓码') expect(wrapper.text()).not.toContain('缺入仓码') expect(wrapper.text()).not.toContain('已生成 10 个计划') expect(wrapper.text()).not.toContain('存在5票尚未生成有效入库计划') - expect(wrapper.text()).not.toContain('CONT-DONE') - expect(wrapper.text()).toContain('2 个柜待处理') + expect(wrapper.text()).toContain('2 个货柜') expect(wrapper.text().indexOf('CONT-15')).toBeLessThan(wrapper.text().indexOf('CONT-NULL')) }) + it('keeps an all-plans-ready cabinet visible and allows only the server-authorized warehouse change', async () => { + const ready = { + ...summary, + planCount: summary.inboundCodeCount, + plannedInboundCodeCount: summary.inboundCodeCount, + pendingPlanCount: 0, + allPlansReady: true, + blockingReason: null, + } + vi.mocked(listContainerPlanProgress).mockResolvedValueOnce({ records: [ready], page: 1, size: 10, total: 1, pages: 1 }) + const wrapper = await mountView() + await wrapper.findAll('.el-radio-button').find((item) => item.text().startsWith('柜子'))!.trigger('click') + await flushPromises() + expect(wrapper.text()).toContain('CONT-15') + expect(wrapper.text()).toContain('0个入仓码待生成计划') + expect((button(wrapper, '生成入库计划')!.element as HTMLButtonElement).disabled).toBe(true) + expect((button(wrapper, '修改接柜仓')!.element as HTMLButtonElement).disabled).toBe(false) + }) + + it('renders the all-cabinet empty state and unit', async () => { + vi.mocked(listContainerPlanProgress).mockResolvedValueOnce({ records: [], page: 1, size: 10, total: 0, pages: 0 }) + const wrapper = await mountView() + await wrapper.findAll('.el-radio-button').find((item) => item.text().startsWith('柜子'))!.trigger('click') + await flushPromises() + expect(wrapper.text()).toContain('暂无货柜数据') + expect(wrapper.text()).not.toContain('已全部生成计划的柜不再展示') + }) + it('uses independent cabinet pagination and opens the complete long-id detail in a dialog', async () => { vi.mocked(listContainerPlanProgress).mockResolvedValue({ records: [summary], page: 1, size: 10, total: 21, pages: 3 }) const wrapper = await mountView() @@ -256,6 +354,10 @@ describe('LastMileOrdersView pending order and container conversion', () => { expect(document.body.textContent).toContain('LMO-1') expect(document.body.textContent).toContain('IN-1') expect(document.body.textContent).toContain('LIP-1') + expect(document.body.textContent).toContain('已生成入库计划') + expect(document.body.textContent).toContain('待收货') + expect(document.body.textContent).not.toContain('INBOUND_PLANNED') + expect(document.body.textContent).not.toContain('PENDING_RECEIVING') expect(document.body.querySelector('.el-dialog')).not.toBeNull() expect(document.body.querySelector('.el-drawer')).toBeNull() }) @@ -276,6 +378,167 @@ describe('LastMileOrdersView pending order and container conversion', () => { expect(getContainerPlanProgress).not.toHaveBeenCalled() }) + it('keeps an invalid historical cabinet readable while blocking plan generation', async () => { + const invalid = { + ...summary, + overseasWarehouseId: null, + overseasWarehouseCode: null, + overseasWarehouseName: null, + blockingReason: '柜内接柜仓业务快照不一致', + } + vi.mocked(listContainerPlanProgress).mockResolvedValueOnce({ records: [invalid], page: 1, size: 10, total: 1, pages: 1 } as never) + vi.mocked(getContainerPlanProgress).mockResolvedValueOnce({ summary: invalid, orders: [] } as never) + const wrapper = await mountView() + await wrapper.findAll('.el-radio-button').find((item) => item.text().startsWith('柜子'))!.trigger('click') + await flushPromises() + + expect(wrapper.text()).toContain('柜内接柜仓业务快照不一致') + expect((button(wrapper, '生成入库计划')!.element as HTMLButtonElement).disabled).toBe(true) + await wrapper.get('.container-workbench-card__detail').trigger('click') + await flushPromises() + expect(getContainerPlanProgress).toHaveBeenCalledWith(bookingId) + expect(document.body.textContent).toContain('柜子详情 · CONT-15') + expect(convertLastMileContainerToInboundPlans).not.toHaveBeenCalled() + }) + + it('shows the receiving warehouse and changes it from ACTIVE OVERSEAS master data with versioning', async () => { + vi.mocked(listAllPlatformWarehouses).mockResolvedValueOnce([currentReceivingWarehouse, receivingWarehouse] as never) + const wrapper = await mountView() + await wrapper.findAll('.el-radio-button').find((item) => item.text().startsWith('柜子'))!.trigger('click') + await flushPromises() + expect(wrapper.text()).toContain('海外接柜仓') + expect(wrapper.text()).toContain('LAX01 · 洛杉矶接柜仓') + expect(wrapper.get('.container-workbench-card__warehouse strong').attributes('title')).toBe('LAX01 · 洛杉矶接柜仓') + expect(listAllPlatformWarehouses).not.toHaveBeenCalled() + await button(wrapper, '修改接柜仓')!.trigger('click') + await flushPromises() + const receivingDialog = wrapper.findAllComponents(AppDialog).find((item) => item.props('title') === '修改海外接柜仓')! + expect(receivingDialog.props('width')).toBe('min(600px, calc(100vw - 28px))') + expect(listAllPlatformWarehouses).toHaveBeenLastCalledWith({ warehouseType: 'OVERSEAS', status: 'ACTIVE' }) + expect(listAllPlatformWarehouses).toHaveBeenCalledTimes(1) + const warehouseSelect = wrapper.findAllComponents({ name: 'ElSelect' }).find((item) => item.props('placeholder') === '请选择 ACTIVE 海外接柜仓')! + expect(warehouseSelect.props('modelValue')).toBe('') + expect(wrapper.text()).not.toContain('9007199254740995') + const warehouseOptions = wrapper.findAllComponents({ name: 'ElOption' }) + expect(warehouseOptions.find((item) => item.props('value') === summary.overseasWarehouseId)?.props('disabled')).toBe(true) + const confirm = [...document.body.querySelectorAll('button')].find((item) => item.textContent?.includes('确认修改')) as HTMLButtonElement + warehouseSelect.vm.$emit('update:modelValue', summary.overseasWarehouseId) + await wrapper.vm.$nextTick() + expect(confirm.disabled).toBe(true) + expect(wrapper.text()).toContain('新的海外接柜仓必须与当前仓不同') + warehouseSelect.vm.$emit('update:modelValue', '9007199254740996') + await wrapper.vm.$nextTick() + expect(confirm.disabled).toBe(false) + confirm.click() + await flushPromises() + expect(changeContainerReceivingWarehouse).toHaveBeenCalledWith(bookingId, { + overseasWarehouseId: '9007199254740996', expectedVersion: 7, remark: undefined, + }, expect.stringMatching(/^container-receiving-warehouse-/)) + expect(listContainerPlanProgress).toHaveBeenCalledTimes(2) + }) + + it('keeps the receiving-warehouse confirmation disabled while candidates are loading', async () => { + let resolveCandidates!: (value: typeof receivingWarehouse[]) => void + vi.mocked(listAllPlatformWarehouses).mockReturnValueOnce(new Promise((resolve) => { resolveCandidates = resolve }) as never) + const wrapper = await mountView() + await wrapper.findAll('.el-radio-button').find((item) => item.text().startsWith('柜子'))!.trigger('click') + await flushPromises() + await button(wrapper, '修改接柜仓')!.trigger('click') + await wrapper.vm.$nextTick() + const confirm = [...document.body.querySelectorAll('button')].find((item) => item.textContent?.includes('确认修改')) as HTMLButtonElement + expect(confirm.disabled).toBe(true) + expect(listAllPlatformWarehouses).toHaveBeenCalledTimes(1) + resolveCandidates([receivingWarehouse]) + await flushPromises() + expect(confirm.disabled).toBe(true) + }) + + it('keeps a disabled receiving-warehouse action discoverable when the server locks the container', async () => { + vi.mocked(listContainerPlanProgress).mockResolvedValueOnce({ + records: [{ ...summary, canChangeReceivingWarehouse: false, receivingWarehouseLockReason: '海外仓已开始拆柜收货' }], + page: 1, size: 10, total: 1, pages: 1, + }) + const wrapper = await mountView() + await wrapper.findAll('.el-radio-button').find((item) => item.text().startsWith('柜子'))!.trigger('click') + await flushPromises() + expect(wrapper.text()).toContain('海外仓已开始拆柜收货') + const changeButton = button(wrapper, '修改接柜仓')! + expect(changeButton.exists()).toBe(true) + expect((changeButton.element as HTMLButtonElement).disabled).toBe(true) + expect(changeButton.attributes('title')).toBe('海外仓已开始拆柜收货') + expect(changeContainerReceivingWarehouse).not.toHaveBeenCalled() + }) + + it('keeps a disabled receiving-warehouse action discoverable without edit permission', async () => { + canAccess.mockImplementation((permissionCode: string) => permissionCode !== 'platform:overseas-warehouse:last-mile-order:edit') + const wrapper = await mountView() + await wrapper.findAll('.el-radio-button').find((item) => item.text().startsWith('柜子'))!.trigger('click') + await flushPromises() + const changeButton = button(wrapper, '修改接柜仓')! + expect(changeButton.exists()).toBe(true) + expect((changeButton.element as HTMLButtonElement).disabled).toBe(true) + expect(changeButton.attributes('title')).toBe('暂无修改接柜仓权限,请联系管理员开通尾程订单编辑权限') + await changeButton.trigger('click') + expect(listAllPlatformWarehouses).not.toHaveBeenCalled() + expect(changeContainerReceivingWarehouse).not.toHaveBeenCalled() + }) + + it('keeps conflict metadata visible and refreshes the stale container summary', async () => { + vi.mocked(listAllPlatformWarehouses).mockResolvedValueOnce([receivingWarehouse] as never) + vi.mocked(changeContainerReceivingWarehouse).mockRejectedValueOnce(new ApiResponseError( + 'version conflict', 'A0409', 200, undefined, 'request-receiving', 'trace-receiving', + )) + const wrapper = await mountView() + await wrapper.findAll('.el-radio-button').find((item) => item.text().startsWith('柜子'))!.trigger('click') + await flushPromises() + await button(wrapper, '修改接柜仓')!.trigger('click') + await flushPromises() + const warehouseSelect = wrapper.findAllComponents({ name: 'ElSelect' }).find((item) => item.props('placeholder') === '请选择 ACTIVE 海外接柜仓')! + warehouseSelect.vm.$emit('update:modelValue', '9007199254740996') + await wrapper.vm.$nextTick() + const confirm = [...document.body.querySelectorAll('button')].find((item) => item.textContent?.includes('确认修改')) as HTMLButtonElement + confirm.click() + await flushPromises() + expect(wrapper.text()).toContain('接柜仓信息已被其他操作更新,请刷新后重试') + expect(wrapper.text()).toContain('request-receiving') + expect(wrapper.text()).toContain('trace-receiving') + expect(listContainerPlanProgress).toHaveBeenCalledTimes(2) + }) + + it('renders an explicit empty state when there are no ACTIVE OVERSEAS receiving warehouses', async () => { + vi.mocked(listAllPlatformWarehouses).mockResolvedValueOnce([]) + const wrapper = await mountView() + await wrapper.findAll('.el-radio-button').find((item) => item.text().startsWith('柜子'))!.trigger('click') + await flushPromises() + await button(wrapper, '修改接柜仓')!.trigger('click') + await flushPromises() + expect(document.body.textContent).toContain('暂无可用的 ACTIVE 海外接柜仓') + expect(changeContainerReceivingWarehouse).not.toHaveBeenCalled() + }) + + it('keeps receiving-warehouse candidate failure metadata visible without an FBA fallback', async () => { + vi.mocked(listAllPlatformWarehouses).mockRejectedValueOnce(new ApiResponseError( + 'receiving warehouse candidate failed', 'A0503', 200, undefined, 'request-receiving-options', 'trace-receiving-options', + )) + const wrapper = await mountView() + await wrapper.findAll('.el-radio-button').find((item) => item.text().startsWith('柜子'))!.trigger('click') + await flushPromises() + await button(wrapper, '修改接柜仓')!.trigger('click') + await flushPromises() + expect(document.body.textContent).toContain('request-receiving-options') + expect(document.body.textContent).toContain('trace-receiving-options') + expect(document.body.textContent).not.toContain('请选择目的 FBA 仓') + expect(changeContainerReceivingWarehouse).not.toHaveBeenCalled() + }) + + it('keeps dense cabinet fields and actions on one line with ellipsis at 1920px', () => { + const source = readFileSync(resolve(process.cwd(), 'src/modules/overseas-warehouse/pages/LastMileOrdersView.vue'), 'utf8') + expect(source).toContain('.container-detail-summary > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }') + expect(source).toContain('.container-workbench-card__warehouse strong { min-width: 0; overflow: hidden; color: var(--cc-text); font-size: 14px; text-overflow: ellipsis; white-space: nowrap; }') + expect(source).toContain('.container-workbench-card__actions { display: flex; justify-content: flex-end; gap: 8px; padding-top: 2px; white-space: nowrap; }') + expect(source).toContain("{ key: 'actions', label: '操作', width: 178, fixed: 'left'") + }) + it('loads only active FBA master data and submits exact string fields for a cross-customer transfer', async () => { const wrapper = await openPending() await selectRows(wrapper, [selectedRow(), selectedRow({ id: '2092873463568596995', customerId: '2092618717861048322' })]) diff --git a/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue b/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue index 03311bd6..67cdb785 100644 --- a/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue +++ b/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue @@ -1,6 +1,6 @@