Merge remote-tracking branch 'origin/release/test' into dev
# Conflicts: # AGENTS.md
This commit is contained in:
@@ -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/` |
|
||||
|
||||
@@ -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: <T>(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')
|
||||
})
|
||||
})
|
||||
@@ -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<TerminalDrayageView[]>, ApiEnvelope<TerminalDrayageView[]>>(path, { params })
|
||||
return (unwrapApiResponse(response) || []).map(normalize)
|
||||
}
|
||||
|
||||
export async function getTerminalDrayage(containerBookingId: string) {
|
||||
const response = await http.get<ApiEnvelope<TerminalDrayageView>, ApiEnvelope<TerminalDrayageView>>(`${path}/${encodeURIComponent(containerBookingId)}`)
|
||||
return normalize(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export async function getTerminalDrayageHistory(containerBookingId: string) {
|
||||
const response = await http.get<ApiEnvelope<TerminalDrayageHistory[]>, ApiEnvelope<TerminalDrayageHistory[]>>(`${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<TerminalDrayageView>, ApiEnvelope<TerminalDrayageView>>(
|
||||
`${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)
|
||||
}
|
||||
@@ -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<Record<string, unknown>>
|
||||
selectRowFleet: (row: Record<string, unknown>, supplierId: string) => Promise<void>
|
||||
openDrayageAction: (row: Record<string, unknown>, action: 'RECORD_EMPTY_RETURN') => void
|
||||
drayageActionAt: string
|
||||
saveDrayageAction: () => Promise<void>
|
||||
}
|
||||
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<Record<string, unknown>>; openDrayageHistory: (row: Record<string, unknown>) => Promise<void> }
|
||||
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')
|
||||
|
||||
@@ -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<Array<{ key: string; oceanBookingId: string; requirementId: string; label: string; disabled: boolean }>>([])
|
||||
const selectedTruckingSupplierId = ref('')
|
||||
const unresolvedTruckingSupplierName = ref<string | null>(null)
|
||||
const drayages = ref<Record<string, TerminalDrayageView>>({})
|
||||
const drayageLoading = ref(false)
|
||||
const drayageLoadError = ref<ApiErrorDetails | null>(null)
|
||||
const drayageSavingIds = ref<string[]>([])
|
||||
const drayageActionOpen = ref(false)
|
||||
const drayageAction = ref<Exclude<TerminalDrayageAction, 'SELECT_FLEET'>>('SCHEDULE_PICKUP')
|
||||
const drayageTarget = ref<ContainerBookingResource | null>(null)
|
||||
const drayageActionAt = ref('')
|
||||
const drayageReason = ref('')
|
||||
const drayageActionSaving = ref(false)
|
||||
const drayageActionError = ref<ApiErrorDetails | null>(null)
|
||||
const drayageHistoryOpen = ref(false)
|
||||
const drayageHistory = ref<TerminalDrayageHistory[]>([])
|
||||
const drayageHistoryLoading = ref(false)
|
||||
const drayageHistoryError = ref<ApiErrorDetails | null>(null)
|
||||
let listSequence = 0
|
||||
let detailSequence = 0
|
||||
let linkSequence = 0
|
||||
let drayageSequence = 0
|
||||
|
||||
function emptyForm(): ContainerForm {
|
||||
return {
|
||||
@@ -140,6 +168,7 @@ const queryFields = computed<QueryField[]>(() => [
|
||||
])
|
||||
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<string, string>)[status || ''] || status || '—' }
|
||||
function drayageActionText(action: TerminalDrayageAction) { return ({ SELECT_FLEET: '选择码头车队', SCHEDULE_PICKUP: '预约提柜', RECORD_PICKUP: '确认实际提柜', RECORD_EMPTY_RETURN: '确认实际还柜' } as Record<TerminalDrayageAction, string>)[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<TerminalDrayageAction, 'SELECT_FLEET'>) {
|
||||
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<TerminalDrayageAction, 'SELECT_FLEET'>); 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 () => {
|
||||
<LoadErrorState title="货柜列表加载失败" :message="loadError.message" :loading="loading" @retry="loadRows"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
||||
</template>
|
||||
<section v-if="!loadError" class="surface-panel table-panel">
|
||||
<el-alert v-if="drayageLoadError" class="drayage-load-alert" type="warning" :closable="false" show-icon :title="drayageLoadError.message"><el-button link type="warning" :loading="drayageLoading" @click="loadDrayages()">重新加载码头拖车信息</el-button></el-alert>
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" stripe preference-key="container-loading-table" empty-text="暂无货柜">
|
||||
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openSimulation"><Plus :size="16" />新增模拟柜</el-button></template>
|
||||
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canView" size="small" type="primary" plain :aria-label="containerViewLabel(row as ContainerBookingResource)" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="canView && canEdit && ['WAIT_LOADING', 'EDITING'].includes(row.planStatus)" size="small" @click="openEdit(row)"><Pencil :size="15" />编辑运输信息</el-button><el-button v-if="row.planStatus === 'EDITING' && canSmartLoading" size="small" type="primary" @click="goContainerProcess(row)"><Sparkles :size="15" />智能装柜</el-button><el-button v-else-if="row.planStatus === 'LOADING' && canViewPreload" size="small" type="primary" plain @click="goContainerProcess(row)"><ListChecks :size="15" />预装清单</el-button><el-button v-else-if="row.planStatus === 'COMPLETED' && canViewLoadingList" size="small" type="primary" plain @click="goContainerProcess(row)"><ListChecks :size="15" />装柜清单</el-button><el-dropdown v-if="canView" trigger="click" @command="handleMoreAction($event, row)"><el-button size="small" plain aria-label="更多货柜操作" title="更多货柜操作"><MoreHorizontal :size="16" /></el-button><template #dropdown><el-dropdown-menu><el-dropdown-item command="overview"><FileText :size="15" />轨迹与日志</el-dropdown-item><el-dropdown-item command="documents"><FileUp :size="15" />DO / PU 资料</el-dropdown-item><el-dropdown-item v-if="canEdit && row.sourceType === 'SIMULATION' && !row.oceanBookingId && ['WAIT_LOADING','EDITING'].includes(row.planStatus)" command="link"><Link2 :size="15" />关联已放舱订舱</el-dropdown-item><el-dropdown-item v-if="canEdit" command="exception" divided><TriangleAlert :size="15" />异常处理</el-dropdown-item></el-dropdown-menu></template></el-dropdown></div></template>
|
||||
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canView" size="small" type="primary" plain :aria-label="containerViewLabel(row as ContainerBookingResource)" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="canView && canEdit && ['WAIT_LOADING', 'EDITING'].includes(row.planStatus)" size="small" @click="openEdit(row)"><Pencil :size="15" />编辑运输信息</el-button><el-button v-if="row.planStatus === 'EDITING' && canSmartLoading" size="small" type="primary" @click="goContainerProcess(row)"><Sparkles :size="15" />智能装柜</el-button><el-button v-else-if="row.planStatus === 'LOADING' && canViewPreload" size="small" type="primary" plain @click="goContainerProcess(row)"><ListChecks :size="15" />预装清单</el-button><el-button v-else-if="row.planStatus === 'COMPLETED' && canViewLoadingList" size="small" type="primary" plain @click="goContainerProcess(row)"><ListChecks :size="15" />装柜清单</el-button><el-dropdown v-if="canView" trigger="click" @command="handleMoreAction($event, row)"><el-button size="small" plain aria-label="更多货柜操作" title="更多货柜操作"><MoreHorizontal :size="16" /></el-button><template #dropdown><el-dropdown-menu><el-dropdown-item command="overview"><FileText :size="15" />轨迹与日志</el-dropdown-item><el-dropdown-item command="drayage-history"><FileText :size="15" />码头拖车记录</el-dropdown-item><el-dropdown-item v-if="canDrayage(row as ContainerBookingResource, 'SCHEDULE_PICKUP')" command="drayage:SCHEDULE_PICKUP">预约提柜</el-dropdown-item><el-dropdown-item v-if="canDrayage(row as ContainerBookingResource, 'RECORD_PICKUP')" command="drayage:RECORD_PICKUP">确认实际提柜</el-dropdown-item><el-dropdown-item v-if="canDrayage(row as ContainerBookingResource, 'RECORD_EMPTY_RETURN')" command="drayage:RECORD_EMPTY_RETURN">确认实际还柜</el-dropdown-item><el-dropdown-item command="documents"><FileUp :size="15" />DO / PU 资料</el-dropdown-item><el-dropdown-item v-if="canEdit && row.sourceType === 'SIMULATION' && !row.oceanBookingId && ['WAIT_LOADING','EDITING'].includes(row.planStatus)" command="link"><Link2 :size="15" />关联已放舱订舱</el-dropdown-item><el-dropdown-item v-if="canEdit" command="exception" divided><TriangleAlert :size="15" />异常处理</el-dropdown-item></el-dropdown-menu></template></el-dropdown></div></template>
|
||||
<template #emptyReturnWarning="{ row }"><el-tag :type="drayageWarningType(row as ContainerBookingResource)" effect="light" :title="drayageWarningTitle(row as ContainerBookingResource)">{{ drayageWarningText(row as ContainerBookingResource) }}</el-tag></template>
|
||||
<template #cabinet="{ row }"><strong>{{ row.cabinetNo }}</strong><small>系统生成</small></template>
|
||||
<template #oceanBooking="{ row }"><span v-if="row.oceanBookingId"><strong>{{ row.billOfLadingNo || '已关联订舱' }}</strong><small>{{ row.containerNo ? `集装箱号:${row.containerNo}` : '集装箱号:待补录' }}</small></span><span v-else>未关联</span></template>
|
||||
<template #containerNo="{ row }">{{ row.containerNo || '待补录' }}</template>
|
||||
<template #terminalTruckingCompany="{ row }"><el-select class="fleet-select" :model-value="drayageFor(row.id)?.fleetSupplierId || ''" filterable placeholder="请选择车队" :loading="drayageSavingIds.includes(row.id)" :disabled="!canDrayage(row as ContainerBookingResource, 'SELECT_FLEET') || drayageSavingIds.includes(row.id)" @update:model-value="selectRowFleet(row, $event)"><el-option v-if="drayageFor(row.id)?.fleetSupplierName && !truckingCompanies.some((item) => item.id === drayageFor(row.id)?.fleetSupplierId)" :label="drayageFor(row.id)?.fleetSupplierName || '原码头车队'" :value="drayageFor(row.id)?.fleetSupplierId || ''" disabled /><el-option v-for="supplier in truckingCompanies" :key="supplier.id" :label="supplier.name" :value="supplier.id" /></el-select></template>
|
||||
<template #pickupAppointmentAt="{ row }">{{ formatTime(drayageFor(row.id)?.pickupAppointmentAt) }}</template>
|
||||
<template #actualPickupAt="{ row }">{{ formatTime(drayageFor(row.id)?.actualPickupAt) }}</template>
|
||||
<template #estimatedEmptyReturnAt="{ row }">{{ formatTime(drayageFor(row.id)?.estimatedEmptyReturnAt) }}</template>
|
||||
<template #actualEmptyReturnAt="{ row }">{{ formatTime(drayageFor(row.id)?.actualEmptyReturnAt) }}</template>
|
||||
<template #status="{ row }"><el-tag :type="row.planStatus === 'COMPLETED' ? 'success' : row.planStatus === 'LOADING' ? 'warning' : 'info'">{{ statusText(row.planStatus) }}</el-tag></template>
|
||||
<template #route="{ row }">{{ row.departurePortName || row.departurePortCode || '—' }} → {{ row.destinationPortName || row.destinationPortCode || '—' }}</template>
|
||||
<template #capacity="{ row }">{{ row.capacityCbm ? `${row.capacityCbm} m³` : '—' }} / {{ row.maxWeightKg ? `${row.maxWeightKg} kg` : '—' }}</template>
|
||||
@@ -595,12 +746,22 @@ onMounted(async () => {
|
||||
<el-form-item label="预计到港时间"><el-date-picker v-model="form.estimatedPortArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计到港时间" /></el-form-item>
|
||||
</el-form>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog v-model="drayageActionOpen" :title="drayageActionText(drayageAction)" width="min(520px, calc(100vw - 28px))" :confirm-text="drayageActionText(drayageAction)" cancel-text="取消" :confirm-loading="drayageActionSaving" :confirm-disabled="!drayageActionAt" @confirm="saveDrayageAction">
|
||||
<el-form label-position="top"><el-form-item :label="drayageAction === 'SCHEDULE_PICKUP' ? '预约提柜时间' : drayageAction === 'RECORD_PICKUP' ? '实际提柜时间' : '实际还柜时间'" required><el-date-picker v-model="drayageActionAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择时间" /></el-form-item><el-form-item label="原因/备注"><el-input v-model="drayageReason" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="选填" /></el-form-item></el-form>
|
||||
<LoadErrorState v-if="drayageActionError" compact :title="`${drayageActionText(drayageAction)}失败`" :message="drayageActionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="drayageActionError" /></template></LoadErrorState>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog v-model="drayageHistoryOpen" title="码头拖车记录" width="min(980px, calc(100vw - 28px))" :show-footer="false">
|
||||
<LoadErrorState v-if="drayageHistoryError" compact title="码头拖车记录加载失败" :message="drayageHistoryError.message" :loading="drayageHistoryLoading" @retry="drayageTarget && openDrayageHistory(drayageTarget)"><template #details><ApiErrorMeta :details="drayageHistoryError" /></template></LoadErrorState>
|
||||
<template v-else><el-descriptions v-if="drayageTarget && drayageFor(drayageTarget.id)" :column="4" border class="drayage-summary"><el-descriptions-item label="当前状态">{{ drayageStatusText(drayageFor(drayageTarget.id)?.status) }}</el-descriptions-item><el-descriptions-item label="码头车队">{{ drayageFor(drayageTarget.id)?.fleetSupplierName || '—' }}</el-descriptions-item><el-descriptions-item label="预计还柜">{{ formatTime(drayageFor(drayageTarget.id)?.estimatedEmptyReturnAt) }}</el-descriptions-item><el-descriptions-item label="实际还柜">{{ formatTime(drayageFor(drayageTarget.id)?.actualEmptyReturnAt) }}</el-descriptions-item></el-descriptions><DataTable :data="drayageHistory" :columns="drayageHistoryColumns" :loading="drayageHistoryLoading" stripe column-settings-mode="hide" empty-text="暂无码头拖车操作记录" /></template>
|
||||
</AppDialog>
|
||||
</section>
|
||||
</AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.container-page { display: grid; gap: 16px; min-width: 0; }.container-page small { display: block; margin-top: 4px; color: var(--cc-muted); }.row-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 4px; }.row-actions :deep(.el-button + .el-button) { margin-left: 0; }.form-grid { display: grid; grid-template-columns: minmax(0,1fr); gap: 0; }.form-grid :deep(.el-select),.form-grid :deep(.el-date-editor),.form-grid :deep(.el-input-number),.simulation-form :deep(.el-select),.simulation-form :deep(.el-input-number) { width: 100%; }.details :deep(.el-descriptions__label) { width: 120px; }.dialog-hint { margin: 4px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.6; }
|
||||
.container-page { display: grid; gap: 16px; min-width: 0; }.container-page small { display: block; margin-top: 4px; color: var(--cc-muted); }.row-actions { display: flex; align-items: center; flex-wrap: nowrap; gap: 4px; white-space: nowrap; }.row-actions :deep(.el-button + .el-button) { margin-left: 0; }.form-grid { display: grid; grid-template-columns: minmax(0,1fr); gap: 0; }.form-grid :deep(.el-select),.form-grid :deep(.el-date-editor),.form-grid :deep(.el-input-number),.simulation-form :deep(.el-select),.simulation-form :deep(.el-input-number),.fleet-select { width: 100%; }.details :deep(.el-descriptions__label) { width: 120px; }.dialog-hint { margin: 4px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.6; }.drayage-load-alert { margin-bottom: 12px; }.drayage-summary { margin-bottom: 14px; }
|
||||
:global(.container-editor-dialog.el-dialog) { display: flex; width: min(820px, calc(100vw - 28px)) !important; max-height: 90vh; margin-block: 5vh !important; overflow: hidden; flex-direction: column; border-radius: 12px; }
|
||||
:global(.container-editor-dialog .el-dialog__header) { flex: 0 0 auto; margin: 0; padding: 20px 28px; border-bottom: 1px solid #e5eaf1; }
|
||||
:global(.container-editor-dialog .el-dialog__title) { color: #1e293b; font-size: 18px; font-weight: 700; }
|
||||
|
||||
@@ -4,12 +4,12 @@
|
||||
|
||||
- 本文件适用于 `src/modules/finance/`。产品范围和状态读取 `../../../../../docs/财务管理/` 的产品确认与验收契约,后端读取 `../../../../../baiyue-firstleg/finance/AGENTS.md`。
|
||||
- 页面从 `pages/` 进入,统一 API 从 `api/finance.ts` 进入;费用来源和柜次成本快照联查 `firstleg/`,凭证与异步导出联查 `file/`。
|
||||
- 正式范围包括客户资金账户、客户应收应付、应收款统计、客户对账单、应收结算单和应付结算单。资金账户当前只接充值申请审核和平台手工充值/扣款,冻结/解冻、消费及支付回调后续接入;不要从历史原型自动扩大其他财务功能。
|
||||
- 正式范围包括客户资金账户、充值申请审核、平台手工充值/扣款、授信额度维护、客户应收应付、应收款统计、客户对账单、应收结算单和应付结算单。业务冻结/结算/释放由服务端内部接口和跨区事件调用,不在 Admin 页面复制;第三方支付回调尚未接入,不要从历史原型自动扩大其他财务功能。
|
||||
|
||||
## 客户资金账户
|
||||
|
||||
- `pages/FinanceCustomerAccountsView.vue` 的列表和详情展示实际余额、冻结金额、可用余额;操作列固定第一列。客户上传的充值申请在详情内按待审核优先展示,审核、驳回及凭证预览以服务端申请版本和客户归属为准。
|
||||
- 平台手工充值和扣款必须提交账户版本与幂等键;扣款不得超过服务端可用余额。页面只展示服务端余额和流水结果,不在浏览器补算或回退静态数据。
|
||||
- `pages/FinanceCustomerAccountsView.vue` 的列表和详情展示实际余额、冻结金额、授信额度、已用授信、冻结授信和总可用余额;操作列固定第一列。客户上传的充值申请在详情内按待审核优先展示,审核、驳回及凭证预览以服务端申请版本和客户归属为准。
|
||||
- 平台手工充值、扣款和授信调整必须提交账户版本与幂等键;扣款及授信下限由服务端校验。页面只展示服务端余额、授信和流水结果,不在浏览器补算或回退静态数据。
|
||||
|
||||
## 客户筛选与统计
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ export interface InboundOrderSummaryDto {
|
||||
inboundCode?: string | null
|
||||
lxCode?: string | null
|
||||
orderType?: string | null
|
||||
overseasWarehouseCode?: string | null
|
||||
overseasWarehouseName?: string | null
|
||||
warehouseId?: string | null
|
||||
warehouseCode?: string | null
|
||||
warehouseName?: string | null
|
||||
|
||||
@@ -27,6 +27,7 @@ export interface InboundPlanSummaryDto {
|
||||
inboundCode?: string | null
|
||||
containerNo?: string | null
|
||||
overseasWarehouseId?: string | null
|
||||
overseasWarehouseCode?: string | null
|
||||
overseasWarehouseName?: string | null
|
||||
destinationWarehouseId?: string | null
|
||||
destinationWarehouseCode?: string | null
|
||||
@@ -47,6 +48,8 @@ export interface InboundPlanDetailDto {
|
||||
customerId: string
|
||||
sourceOrderId: string
|
||||
containerNo?: string | null
|
||||
overseasWarehouseCode?: string | null
|
||||
overseasWarehouseName?: string | null
|
||||
status?: string | null
|
||||
lineCount: number
|
||||
palletCount: number
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { overseasHttp } from '@/shared/api/overseasHttp'
|
||||
import { convertLastMileContainerToInboundPlans, convertLastMileOrderToInboundPlan, downloadLastMileOrders, getContainerPlanProgress, getLastMileOrder, listContainerPlanProgress, listLastMileOrders, transferLastMileOrders } from './lastMileOrders'
|
||||
import { changeContainerReceivingWarehouse, convertLastMileContainerToInboundPlans, createManualInboundPlan, downloadLastMileOrders, getContainerPlanProgress, getLastMileOrder, getLastMileOrderManualNodes, listContainerPlanProgress, listLastMileOrders, transferLastMileOrders, updateLastMileOrderHold, updateLastMileOrderManualNodes } from './lastMileOrders'
|
||||
|
||||
vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn(), put: vi.fn(), post: vi.fn() } }))
|
||||
|
||||
@@ -84,12 +84,32 @@ describe('last-mile order overseas API', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the inbound-plan conversion endpoint with the exact selected customer scope', async () => {
|
||||
it('uses the manual inbound-plan endpoint with the complete form and idempotency evidence', async () => {
|
||||
vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', message: 'success', data: {} })
|
||||
await convertLastMileOrderToInboundPlan({ orderId: '190000000000000099', customerId: '190000000000000001' })
|
||||
expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans/convert', {
|
||||
orderId: '190000000000000099', customerId: '190000000000000001',
|
||||
const payload = { orderId: '190000000000000099', customerId: '190000000000000001', expectedOrderVersion: 4,
|
||||
plannedArrivalDate: '2026-09-08', palletCount: 8, palletType: 'STANDARD' as const,
|
||||
assigned: false, mergeRequired: false, urgent: true, remark: '加急' }
|
||||
await createManualInboundPlan(payload, 'plan-key-1')
|
||||
expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans/manual', payload,
|
||||
{ headers: { 'Idempotency-Key': 'plan-key-1' } })
|
||||
expect(payload).not.toHaveProperty('inboundCode')
|
||||
expect(payload).not.toHaveProperty('plannedWarehouseId')
|
||||
expect(payload).not.toHaveProperty('plannedWarehouseCode')
|
||||
expect(payload).not.toHaveProperty('plannedWarehouseName')
|
||||
})
|
||||
|
||||
it('uses versioned idempotent order hold and complete manual-node snapshot endpoints', async () => {
|
||||
vi.mocked(overseasHttp.put).mockResolvedValue({ code: '200', message: 'success', data: {} })
|
||||
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'success', data: { nodes: [] } })
|
||||
await updateLastMileOrderHold('190000000000000099', { held: true, chargeable: true, reason: '海关查验', expectedVersion: 4 }, 'hold-key-1')
|
||||
expect(overseasHttp.put).toHaveBeenLastCalledWith('/platform/v1/last-leg/orders/190000000000000099/hold',
|
||||
{ held: true, chargeable: true, reason: '海关查验', expectedVersion: 4 }, { headers: { 'Idempotency-Key': 'hold-key-1' } })
|
||||
await getLastMileOrderManualNodes('190000000000000099')
|
||||
expect(overseasHttp.get).toHaveBeenLastCalledWith('/platform/v1/last-leg/orders/190000000000000099/manual-nodes')
|
||||
const nodes = [{ nodeCode: 'ESTIMATED_ARRIVAL' as const, nodeAt: '2026-09-10T10:00:00' }]
|
||||
await updateLastMileOrderManualNodes('190000000000000099', 5, nodes, 'node-key-1')
|
||||
expect(overseasHttp.put).toHaveBeenLastCalledWith('/platform/v1/last-leg/orders/190000000000000099/manual-nodes',
|
||||
{ expectedVersion: 5, nodes }, { headers: { 'Idempotency-Key': 'node-key-1' } })
|
||||
})
|
||||
|
||||
it('uses one atomic container request without looping individual conversions', async () => {
|
||||
@@ -128,4 +148,17 @@ describe('last-mile order overseas API', () => {
|
||||
await expect(getContainerPlanProgress('2092873463568597001/unsafe')).resolves.toEqual(detail)
|
||||
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/orders/container-summaries/2092873463568597001%2Funsafe')
|
||||
})
|
||||
|
||||
it('changes one whole container receiving warehouse with version and idempotency evidence', async () => {
|
||||
const updated = { sourceContainerBookingId: '2092873463568597001', overseasWarehouseId: '9007199254740993' }
|
||||
vi.mocked(overseasHttp.put).mockResolvedValue({ code: '200', message: 'success', data: updated })
|
||||
await expect(changeContainerReceivingWarehouse('2092873463568597001/unsafe', {
|
||||
overseasWarehouseId: '9007199254740993', expectedVersion: 7, remark: '改派洛杉矶接柜',
|
||||
}, 'receiving-key-1')).resolves.toEqual(updated)
|
||||
expect(overseasHttp.put).toHaveBeenCalledWith(
|
||||
'/platform/v1/last-leg/orders/container-summaries/2092873463568597001%2Funsafe/receiving-warehouse',
|
||||
{ overseasWarehouseId: '9007199254740993', expectedVersion: 7, remark: '改派洛杉矶接柜' },
|
||||
{ headers: { 'Idempotency-Key': 'receiving-key-1' } },
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -52,6 +52,8 @@ export interface LastMileOrderSummaryDto {
|
||||
sourceOrderNo?: string | null
|
||||
sourceInboundId?: string | null
|
||||
customerName?: string | null
|
||||
overseasWarehouseId?: string | null
|
||||
overseasWarehouseCode?: string | null
|
||||
overseasWarehouseName?: string | null
|
||||
transferWarehouseName?: string | null
|
||||
transferWarehouseId?: string | null
|
||||
@@ -61,6 +63,11 @@ export interface LastMileOrderSummaryDto {
|
||||
containerStatus?: string | null
|
||||
abnormal?: boolean | null
|
||||
detained?: boolean | null
|
||||
holdChargeable?: boolean | null
|
||||
holdReason?: string | null
|
||||
manualOverrideEnabled?: boolean | null
|
||||
nodeSource?: 'CONTAINER' | 'MANUAL' | null
|
||||
version?: number | null
|
||||
palletCount?: number | null
|
||||
packageCount?: number | null
|
||||
volumeCbm?: number | null
|
||||
@@ -155,11 +162,6 @@ export interface TransferDestinationPayload {
|
||||
remark?: string
|
||||
}
|
||||
|
||||
export interface ConvertInboundPlanPayload {
|
||||
orderId: string
|
||||
customerId: string
|
||||
}
|
||||
|
||||
export interface ContainerPlanProgressQuery {
|
||||
page: number
|
||||
size: number
|
||||
@@ -171,6 +173,12 @@ export interface ContainerPlanProgressQuery {
|
||||
export interface ContainerPlanProgressSummaryDto {
|
||||
sourceContainerBookingId: string
|
||||
containerNo: string | null
|
||||
overseasWarehouseId: string | null
|
||||
overseasWarehouseCode: string | null
|
||||
overseasWarehouseName: string | null
|
||||
assignmentVersion: number
|
||||
canChangeReceivingWarehouse: boolean
|
||||
receivingWarehouseLockReason: string | null
|
||||
totalOrderCount: number
|
||||
inboundCodeCount: number
|
||||
missingInboundCodeCount: number
|
||||
@@ -204,8 +212,65 @@ export interface ConvertContainerInboundPlanPayload {
|
||||
sourceContainerBookingId: string
|
||||
}
|
||||
|
||||
export type ManualNodeCode =
|
||||
| 'DOMESTIC_CUSTOMS_COMPLETED'
|
||||
| 'ESTIMATED_DEPARTURE'
|
||||
| 'ACTUAL_DEPARTURE'
|
||||
| 'ESTIMATED_ARRIVAL'
|
||||
| 'OVERSEAS_CUSTOMS_COMPLETED'
|
||||
| 'ACTUAL_ARRIVAL_WAITING_UNLOAD'
|
||||
| 'PICKUP_APPOINTED'
|
||||
| 'UNLOADED'
|
||||
|
||||
export interface ManualInboundPlanPayload {
|
||||
customerId: string
|
||||
orderId: string
|
||||
expectedOrderVersion: number
|
||||
plannedArrivalDate: string
|
||||
palletCount: number
|
||||
palletType: 'STANDARD' | 'WOOD' | 'PAPER'
|
||||
assigned: boolean
|
||||
mergeRequired: boolean
|
||||
urgent: boolean
|
||||
remark?: string
|
||||
}
|
||||
|
||||
export interface HoldLastMileOrderPayload {
|
||||
held: boolean
|
||||
chargeable: boolean
|
||||
reason: string
|
||||
expectedVersion: number
|
||||
}
|
||||
|
||||
export interface HoldLastMileOrderResponse {
|
||||
orderId: string
|
||||
held: boolean
|
||||
chargeable: boolean
|
||||
reason?: string | null
|
||||
version: number
|
||||
}
|
||||
|
||||
export interface ManualNodeValue {
|
||||
nodeCode: ManualNodeCode
|
||||
nodeAt: string | null
|
||||
}
|
||||
|
||||
export interface ManualNodesResponse {
|
||||
orderId: string
|
||||
manualOverrideEnabled: boolean
|
||||
nodes: Array<ManualNodeValue & { source: 'MANUAL' }>
|
||||
version: number
|
||||
}
|
||||
|
||||
export interface ChangeContainerReceivingWarehousePayload {
|
||||
overseasWarehouseId: string
|
||||
expectedVersion: number
|
||||
remark?: string
|
||||
}
|
||||
|
||||
export interface LastMileOrderDetailDto {
|
||||
order: LastMileOrderDto
|
||||
manualNodes?: ManualNodesResponse['nodes']
|
||||
cargo: LastMileCargoDto[]
|
||||
statusHistory: LastMileStatusHistoryDto[]
|
||||
fees: LastMileFeeDto[]
|
||||
@@ -264,13 +329,6 @@ export async function transferLastMileOrders(payload: TransferDestinationPayload
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function convertLastMileOrderToInboundPlan(payload: ConvertInboundPlanPayload) {
|
||||
const response = await overseasHttp.post<ApiEnvelope<unknown>, ApiEnvelope<unknown>>(
|
||||
'/platform/v1/last-leg/inbound-plans/convert', payload,
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function listContainerPlanProgress(query: ContainerPlanProgressQuery): Promise<OverseasPageResult<ContainerPlanProgressSummaryDto>> {
|
||||
const response = await overseasHttp.get<ApiEnvelope<OverseasPagePayload<ContainerPlanProgressSummaryDto>>, ApiEnvelope<OverseasPagePayload<ContainerPlanProgressSummaryDto>>>(
|
||||
'/platform/v1/last-leg/orders/container-summaries',
|
||||
@@ -286,6 +344,56 @@ export async function getContainerPlanProgress(sourceContainerBookingId: string)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function createManualInboundPlan(payload: ManualInboundPlanPayload, idempotencyKey: string) {
|
||||
const response = await overseasHttp.post<ApiEnvelope<InboundPlanDetailDto>, ApiEnvelope<InboundPlanDetailDto>>(
|
||||
'/platform/v1/last-leg/inbound-plans/manual', payload,
|
||||
{ headers: { 'Idempotency-Key': idempotencyKey } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function updateLastMileOrderHold(orderId: string, payload: HoldLastMileOrderPayload, idempotencyKey: string) {
|
||||
const response = await overseasHttp.put<ApiEnvelope<HoldLastMileOrderResponse>, ApiEnvelope<HoldLastMileOrderResponse>>(
|
||||
`/platform/v1/last-leg/orders/${encodeURIComponent(orderId)}/hold`, payload,
|
||||
{ headers: { 'Idempotency-Key': idempotencyKey } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function getLastMileOrderManualNodes(orderId: string) {
|
||||
const response = await overseasHttp.get<ApiEnvelope<ManualNodesResponse>, ApiEnvelope<ManualNodesResponse>>(
|
||||
`/platform/v1/last-leg/orders/${encodeURIComponent(orderId)}/manual-nodes`,
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function updateLastMileOrderManualNodes(
|
||||
orderId: string,
|
||||
expectedVersion: number,
|
||||
nodes: ManualNodeValue[],
|
||||
idempotencyKey: string,
|
||||
) {
|
||||
const response = await overseasHttp.put<ApiEnvelope<ManualNodesResponse>, ApiEnvelope<ManualNodesResponse>>(
|
||||
`/platform/v1/last-leg/orders/${encodeURIComponent(orderId)}/manual-nodes`,
|
||||
{ expectedVersion, nodes },
|
||||
{ headers: { 'Idempotency-Key': idempotencyKey } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function changeContainerReceivingWarehouse(
|
||||
sourceContainerBookingId: string,
|
||||
payload: ChangeContainerReceivingWarehousePayload,
|
||||
idempotencyKey: string,
|
||||
) {
|
||||
const response = await overseasHttp.put<ApiEnvelope<ContainerPlanProgressSummaryDto>, ApiEnvelope<ContainerPlanProgressSummaryDto>>(
|
||||
`/platform/v1/last-leg/orders/container-summaries/${encodeURIComponent(sourceContainerBookingId)}/receiving-warehouse`,
|
||||
payload,
|
||||
{ headers: { 'Idempotency-Key': idempotencyKey } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function convertLastMileContainerToInboundPlans(payload: ConvertContainerInboundPlanPayload) {
|
||||
const response = await overseasHttp.post<ApiEnvelope<InboundPlanDetailDto[]>, ApiEnvelope<InboundPlanDetailDto[]>>(
|
||||
'/platform/v1/last-leg/inbound-plans/convert-container', payload,
|
||||
|
||||
@@ -4,7 +4,15 @@ import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrder
|
||||
import type { DeliveryOrderRow } from './deliveryOrders'
|
||||
|
||||
export interface OutboundRow { id: string; outboundNo: string; dispatchNo?: string | null; overseasWarehouseId?: string | null; lxCodes?: string | null; status?: string | null; palletCount?: number | null; packageCount?: number | null; createdAt?: string | null }
|
||||
export interface OutboundDetail { outbound: OutboundRow; delivery?: DeliveryOrderRow | null }
|
||||
export interface OutboundPalletRow {
|
||||
palletNo?: string | null
|
||||
inboundCode?: string | null
|
||||
destinationWarehouseCode?: string | null
|
||||
destinationWarehouseName?: string | null
|
||||
skuCode?: string | null
|
||||
packageCount?: number | null
|
||||
}
|
||||
export interface OutboundDetail { outbound: OutboundRow; pallets: OutboundPalletRow[]; delivery?: DeliveryOrderRow | null }
|
||||
export interface OutboundQuery { page: number; size: number; documentNo?: string; lxCode?: string; warehouseId?: string; status?: string }
|
||||
export async function listOutbounds(query: OutboundQuery) {
|
||||
const response = await overseasHttp.get<ApiEnvelope<OverseasPagePayload<OutboundRow>>, ApiEnvelope<OverseasPagePayload<OutboundRow>>>('/platform/v1/last-leg/outbound-orders', { params: query })
|
||||
|
||||
@@ -125,6 +125,7 @@ async function movePallet(item: DispatchPalletCandidateDto, containerId: string)
|
||||
const id = palletId(item)
|
||||
const target = containers.value.find((container) => container.id === containerId)
|
||||
if (!target || target.palletIds.includes(id)) return
|
||||
if (!String(item.overseasWarehouseId || '').trim()) { ElMessage.warning('该托盘尚未关联海外仓,不能排车'); return }
|
||||
if (containerFor(id)) { ElMessage.warning('该托盘已在其他装车托盘中'); return }
|
||||
if (target.palletIds.length >= target.capacity) { ElMessage.warning('该装车托盘已达到设置的托数'); return }
|
||||
const nextWeight = containerWeightLb(target) + weightLb(item)
|
||||
@@ -171,6 +172,7 @@ async function close(value = false) {
|
||||
|
||||
async function submit() {
|
||||
if (!assignedIds.value.length) { ElMessage.warning('请至少移入一个托盘'); return }
|
||||
if (assigned.value.some((item) => !String(item.overseasWarehouseId || '').trim())) { ElMessage.warning('所选托盘中存在尚未关联海外仓的托盘'); return }
|
||||
saving.value = true; mutationError.value = null
|
||||
try {
|
||||
await preArrangeDispatch({ planPalletIds: assignedIds.value, draftToken: ensureDraftToken() })
|
||||
|
||||
@@ -52,6 +52,17 @@ describe('last-mile order presentation adapter', () => {
|
||||
}).owner).toBe('—')
|
||||
})
|
||||
|
||||
it('preserves the frozen overseas receiving warehouse id and code as strings', () => {
|
||||
const row = toLastMileOrderListItem({
|
||||
id: '190000000000000099', customerId: '190000000000000001', orderNo: 'LMO-260908-0001',
|
||||
overseasWarehouseId: '190000000000000123', overseasWarehouseCode: 'LAX01', overseasWarehouseName: '洛杉矶接柜仓',
|
||||
})
|
||||
|
||||
expect(row.overseasWarehouseId).toBe('190000000000000123')
|
||||
expect(row.overseasWarehouseCode).toBe('LAX01')
|
||||
expect(row.warehouse).toBe('洛杉矶接柜仓')
|
||||
})
|
||||
|
||||
it('preserves the exact server LMO short number without padding, prefixing or deriving it from id', () => {
|
||||
const serverOrderNo = 'LMO-9A7'
|
||||
const row = toLastMileOrderListItem({ id: '2092873463568596994', customerId: '100', orderNo: serverOrderNo })
|
||||
|
||||
@@ -106,6 +106,8 @@ export function toLastMileOrderListItem(dto: LastMileOrderSummaryDto): LastMileO
|
||||
inboundCode: text(dto.inboundCode),
|
||||
lxCode: text(dto.lxCode),
|
||||
owner: text(dto.customerName),
|
||||
overseasWarehouseId: String(dto.overseasWarehouseId ?? '').trim(),
|
||||
overseasWarehouseCode: String(dto.overseasWarehouseCode ?? '').trim(),
|
||||
warehouse: text(dto.overseasWarehouseName),
|
||||
transferWarehouse: text(dto.transferWarehouseName),
|
||||
fbaWarehouse: text(dto.fbaWarehouseCode),
|
||||
@@ -113,6 +115,11 @@ export function toLastMileOrderListItem(dto: LastMileOrderSummaryDto): LastMileO
|
||||
containerStatus: text(dto.containerStatus),
|
||||
abnormal: yesNo(dto.abnormal),
|
||||
held: yesNo(dto.detained),
|
||||
detained: Boolean(dto.detained),
|
||||
holdChargeable: Boolean(dto.holdChargeable),
|
||||
holdReason: String(dto.holdReason ?? '').trim(),
|
||||
manualNodeOverride: Boolean(dto.manualOverrideEnabled),
|
||||
version: Number(dto.version ?? 0),
|
||||
pallets: metric(dto.palletCount),
|
||||
cartons: metric(dto.packageCount),
|
||||
volume: metric(dto.volumeCbm),
|
||||
|
||||
@@ -8,13 +8,13 @@ const component = (name: string) => readFileSync(resolve(process.cwd(), 'src/mod
|
||||
describe('overseas warehouse 1920px layout acceptance', () => {
|
||||
it('keeps action columns first and wide enough for every visible action', () => {
|
||||
const widths: Record<string, number> = {
|
||||
'LastMileOrdersView.vue': 270,
|
||||
'InboundOrdersView.vue': 190,
|
||||
'DispatchesView.vue': 240,
|
||||
'OutboundsView.vue': 190,
|
||||
'LastMileOrdersView.vue': 178,
|
||||
'InboundOrdersView.vue': 300,
|
||||
'DispatchesView.vue': 270,
|
||||
'OutboundsView.vue': 180,
|
||||
'DeliveryOrdersView.vue': 300,
|
||||
'RelabelOrdersView.vue': 190,
|
||||
'PalletsView.vue': 250,
|
||||
'RelabelOrdersView.vue': 180,
|
||||
'PalletsView.vue': 180,
|
||||
'InventoryView.vue': 110,
|
||||
}
|
||||
for (const [name, width] of Object.entries(widths)) {
|
||||
@@ -25,6 +25,14 @@ describe('overseas warehouse 1920px layout acceptance', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps all inbound-order row actions on one line', () => {
|
||||
const source = page('InboundOrdersView.vue')
|
||||
expect(source).toContain('<template #actions="{ row }"><div class="row-actions">')
|
||||
expect(source).toContain('.row-actions { display: inline-flex; min-width: max-content; align-items: center; flex-wrap: nowrap; gap: 8px; white-space: nowrap; }')
|
||||
expect(source).toContain('.row-actions :deep(.el-button) { flex: 0 0 auto; }')
|
||||
expect(source).toContain('.row-actions :deep(.el-button + .el-button) { margin-left: 0; }')
|
||||
})
|
||||
|
||||
it('assigns explicit desktop widths to dense query inputs', () => {
|
||||
for (const name of ['LastMileOrdersView.vue', 'InboundOrdersView.vue', 'OutboundsView.vue', 'DeliveryOrdersView.vue', 'RelabelOrdersView.vue', 'PalletsView.vue', 'InventoryView.vue']) {
|
||||
const source = page(name)
|
||||
@@ -58,5 +66,13 @@ describe('overseas warehouse 1920px layout acceptance', () => {
|
||||
expect(page('PalletsView.vue')).toContain('.el-form :deep(.el-input-number) { width: 100%; }')
|
||||
expect(page('RelabelOrdersView.vue')).toContain('.el-form :deep(.el-row) { margin-left: 0 !important; margin-right: 0 !important; }')
|
||||
expect(page('PalletsView.vue')).toContain('.el-form :deep(.el-row) { margin-left: 0 !important; margin-right: 0 !important; }')
|
||||
const lastMileDetail = page('LastMileOrderDetailView.vue')
|
||||
expect(lastMileDetail).toContain('class="fee-form-grid"')
|
||||
expect(lastMileDetail).toContain('filterable allow-create clearable default-first-option')
|
||||
expect(lastMileDetail).toContain('.fee-form { min-width: 0; overflow-x: hidden; }')
|
||||
expect(lastMileDetail).toContain('.fee-form :deep(.el-select), .fee-form :deep(.el-input-number) { width: 100%; }')
|
||||
expect(lastMileDetail).toContain("field.label === '入仓码' || field.label === '集装箱号'")
|
||||
expect(lastMileDetail).toContain(':title="isSingleLineBusinessField(field) ? String(field.value) : undefined"')
|
||||
expect(lastMileDetail).toContain('.detail-field-value--business { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -74,7 +74,7 @@ const fields = computed<QueryField[]>(() => [
|
||||
])
|
||||
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 240, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'actions', label: '操作', width: 270, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'dispatchNo', prop: 'dispatchNo', label: '排车单号', minWidth: 170, fixed: 'left' },
|
||||
{ key: 'vehiclePlate', prop: 'vehiclePlate', label: '车牌号', minWidth: 130 },
|
||||
{ key: 'driverName', prop: 'driverName', label: '司机', minWidth: 120 },
|
||||
|
||||
@@ -13,11 +13,16 @@ const planNo = computed(() => String(route.params.orderId || ''))
|
||||
const customerId = computed(() => String(route.query.customerId || ''))
|
||||
const detail = ref<InboundPlanDetailDto | null>(null); const loading = ref(false); const loadError = ref<ApiErrorDetails | null>(null)
|
||||
function text(value: unknown) { return String(value ?? '').trim() || '—' }
|
||||
function receivingWarehouse(code: unknown, name: unknown) {
|
||||
const normalizedCode = String(code ?? '').trim()
|
||||
const normalizedName = String(name ?? '').trim()
|
||||
return normalizedCode && normalizedName ? `${normalizedCode} · ${normalizedName}` : '—'
|
||||
}
|
||||
function status(value: unknown) { const labels: Record<string, string> = { PENDING_ASSIGNMENT: '待入库', PDA_PENDING: '待入库', ASSIGNED: '待入库', PICKING: '拣货中', PARTIAL_INBOUND: '部分入库', COMPLETED: '全部入库' }; return labels[String(value || '')] || text(value) }
|
||||
const firstLine = computed(() => detail.value?.lines[0])
|
||||
const fieldValues = computed<Record<string, string | number>>(() => ({
|
||||
入库单号: text(detail.value?.planNo), 入仓码: text(firstLine.value?.inboundCode), 类型: '—', 货主: '—', 关联订单: text(detail.value?.sourceOrderId),
|
||||
海外仓: text(firstLine.value?.destinationWarehouseName), FBA仓库: text(firstLine.value?.fbaWarehouseCode), 总箱数: detail.value?.expectedPackageCount ?? detail.value?.lines.reduce((sum, line) => sum + line.requiredPackageCount, 0) ?? 0,
|
||||
入库单号: text(detail.value?.planNo), 入仓码: text(firstLine.value?.inboundCode), 类型: '—', 货主: '—', 关联订单: '—',
|
||||
海外仓: receivingWarehouse(detail.value?.overseasWarehouseCode, detail.value?.overseasWarehouseName), FBA仓库: text(firstLine.value?.fbaWarehouseCode), 总箱数: detail.value?.expectedPackageCount ?? detail.value?.lines.reduce((sum, line) => sum + line.requiredPackageCount, 0) ?? 0,
|
||||
应拣货数: detail.value?.expectedPackageCount ?? detail.value?.lines.reduce((sum, line) => sum + line.requiredPackageCount, 0) ?? 0, 实际拣货数: detail.value?.pickedPackageCount ?? detail.value?.allocations.reduce((sum, item) => sum + item.pickedPackageCount, 0) ?? 0,
|
||||
'计费体积(m³)': Number(detail.value?.totalVolumeCbm || 0), 总托盘数: Number(detail.value?.palletCount || 0), 状态: status(detail.value?.status),
|
||||
预约状态: '—', 预约送仓时间: '—', 预计装车时间: '—', 排车状态: '—', 创建时间: '—', 更新时间: '—', 备注: '—',
|
||||
|
||||
@@ -60,11 +60,11 @@ function selectableRow(source: object) {
|
||||
const row = source as InboundOrderListItem
|
||||
if (!row.fbaWarehouse || row.fbaWarehouse === '—') return false
|
||||
const anchor = selectedRows.value[0]
|
||||
return !anchor || anchor.fbaWarehouse === row.fbaWarehouse
|
||||
return !anchor || (anchor.customerId === row.customerId && anchor.fbaWarehouse === row.fbaWarehouse)
|
||||
}
|
||||
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 190, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'actions', label: '操作', width: 300, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'selection', type: 'selection', width: 48, fixed: 'left', selectable: selectableRow },
|
||||
{ key: 'orderNo', prop: 'orderNo', label: '入库单号', minWidth: 170 },
|
||||
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 140 },
|
||||
@@ -84,6 +84,11 @@ const columns: DataTableColumn[] = [
|
||||
]
|
||||
|
||||
function text(value: unknown) { const normalized = String(value ?? '').trim(); return normalized || '—' }
|
||||
function receivingWarehouse(code: unknown, name: unknown) {
|
||||
const normalizedCode = String(code ?? '').trim()
|
||||
const normalizedName = String(name ?? '').trim()
|
||||
return normalizedCode && normalizedName ? `${normalizedCode} · ${normalizedName}` : '—'
|
||||
}
|
||||
function number(value: unknown) { const normalized = Number(value); return Number.isFinite(normalized) ? normalized : 0 }
|
||||
function typeLabel(value: unknown) { return lastMileOrderTypeLabel(value) }
|
||||
function planStatusLabel(value: unknown) { return ({ PENDING_ASSIGNMENT: '待入库', ASSIGNED: '待入库', PDA_PENDING: '待入库', PICKING: '拣货中', COMPLETED: '全部入库', PARTIAL_INBOUND: '部分入库' } as Record<string, string>)[String(value || '')] || text(value) }
|
||||
@@ -92,8 +97,8 @@ function appointmentStatusLabel(value: unknown) { return !value ? '未预约' :
|
||||
function toListItem(dto: InboundOrderSummaryDto): InboundOrderListItem {
|
||||
return {
|
||||
lineId: String(dto.lineId), planId: String(dto.planId), customerId: String(dto.customerId), orderId: String(dto.orderId),
|
||||
warehouseId: text(dto.warehouseId) === '—' ? '' : String(dto.warehouseId), destinationWarehouseCode: text(dto.warehouseCode),
|
||||
orderNo: text(dto.planNo), inboundCode: text(dto.inboundCode), lxCode: text(dto.lxCode), type: typeLabel(dto.orderType), warehouse: text(dto.warehouseName || dto.warehouseCode),
|
||||
warehouseId: text(dto.warehouseId) === '—' ? '' : String(dto.warehouseId), destinationWarehouseCode: text(dto.warehouseCode), destinationWarehouseName: text(dto.warehouseName),
|
||||
orderNo: text(dto.planNo), inboundCode: text(dto.inboundCode), lxCode: text(dto.lxCode), type: typeLabel(dto.orderType), warehouse: receivingWarehouse(dto.overseasWarehouseCode, dto.overseasWarehouseName),
|
||||
fbaWarehouse: text(dto.fbaWarehouseCode), owner: text(dto.customerName), cartons: number(dto.packageCount), pickedCartons: number(dto.pickedPackageCount), volume: number(dto.volumeCbm), pallets: number(dto.palletCount),
|
||||
status: planStatusLabel(dto.status), appointmentNo: text(dto.appointmentNo), appointmentStatus: appointmentStatusLabel(dto.appointmentStatus),
|
||||
appointmentAt: formatOverseasDateTime(dto.appointmentAt), expectedLoadingAt: formatOverseasDateTime(dto.expectedLoadingAt), dispatchStatus: dto.dispatchStatus === 'SCHEDULED' ? '已排车' : '未排车',
|
||||
@@ -172,7 +177,7 @@ async function load(model: QueryModel = filters.value) {
|
||||
function search(model: QueryModel) { page.value = 1; void load(model) }
|
||||
function reset(model: QueryModel) { page.value = 1; void load(model) }
|
||||
function openDetail(source: object) { const row = source as InboundOrderListItem; void router.push({ name: 'overseas-inbound-order-detail', params: { orderId: row.orderNo }, query: { customerId: row.customerId } }) }
|
||||
async function openPlanEdit(source: object) { const row = source as InboundOrderListItem; planEditTarget.value = row; planEditOpen.value = true; planEditLoading.value = true; mutationError.value = null; try { Object.assign(planEditForm, { warehouseId: row.warehouseId, warehouseCode: row.destinationWarehouseCode === '—' ? '' : row.destinationWarehouseCode, warehouseName: row.warehouse, version: row.version }); if (!warehouses.value.length) warehouses.value = (await listReceivingOverseasWarehouses()).records } catch (error) { planEditOpen.value = false; mutationError.value = getApiErrorDetails(error, '海外仓列表加载失败,请重试') } finally { planEditLoading.value = false } }
|
||||
async function openPlanEdit(source: object) { const row = source as InboundOrderListItem; planEditTarget.value = row; planEditOpen.value = true; planEditLoading.value = true; mutationError.value = null; try { Object.assign(planEditForm, { warehouseId: row.warehouseId, warehouseCode: row.destinationWarehouseCode === '—' ? '' : row.destinationWarehouseCode, warehouseName: row.destinationWarehouseName === '—' ? '' : row.destinationWarehouseName, version: row.version }); if (!warehouses.value.length) warehouses.value = (await listReceivingOverseasWarehouses()).records } catch (error) { planEditOpen.value = false; mutationError.value = getApiErrorDetails(error, '海外仓列表加载失败,请重试') } finally { planEditLoading.value = false } }
|
||||
async function submitPlanEdit() { const target = planEditTarget.value; if (!target || !planEditForm.warehouseId) { ElMessage.warning('请选择目的仓'); return } planEditSaving.value = true; mutationError.value = null; try { await updateInboundPlan(target.orderNo, { customerId: target.customerId, overseasWarehouseId: planEditForm.warehouseId, warehouseCode: planEditForm.warehouseCode, warehouseName: planEditForm.warehouseName, version: planEditForm.version }); planEditOpen.value = false; ElMessage.success('入库单已更新'); await load() } catch (error) { mutationError.value = getApiErrorDetails(error, '入库单更新失败,请重试') } finally { planEditSaving.value = false } }
|
||||
onMounted(() => { void load() })
|
||||
</script>
|
||||
@@ -183,7 +188,7 @@ onMounted(() => { void load() })
|
||||
<template v-if="loadError" #state><LoadErrorState title="入库单加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
|
||||
<section v-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-inbound-orders" row-key="lineId" empty-text="暂无入库单" @selection-change="onSelectionChange" @update:page="load()" @update:page-size="page = 1; load()">
|
||||
<template #toolbar-actions><el-button v-if="access.can(permission.inboundOrderAppointment)" plain :disabled="!selectedRows.length" @click="openAppointment"><CalendarCheck2 :size="16" />预约</el-button><el-button v-if="access.can(permission.inboundOrderSmartDispatch)" type="primary" @click="openSmartDispatch"><Truck :size="16" />智能排车</el-button></template>
|
||||
<template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="access.can(permission.inboundOrderEdit) && ['待入库'].includes((row as InboundOrderListItem).status)" size="small" plain @click="openPlanEdit(row)"><Pencil :size="15" />编辑</el-button><el-button v-if="(row as InboundOrderListItem).appointmentNo !== '—' && access.can(permission.inboundOrderAppointment)" size="small" plain @click="openAppointmentEdit(row)"><Pencil :size="15" />预约</el-button></template>
|
||||
<template #actions="{ row }"><div class="row-actions"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="access.can(permission.inboundOrderEdit) && ['待入库'].includes((row as InboundOrderListItem).status)" size="small" plain @click="openPlanEdit(row)"><Pencil :size="15" />编辑</el-button><el-button v-if="(row as InboundOrderListItem).appointmentNo !== '—' && access.can(permission.inboundOrderAppointment)" size="small" plain @click="openAppointmentEdit(row)"><Pencil :size="15" />预约</el-button></div></template>
|
||||
<template #pagination-left>共 {{ total }} 条入库单 · 已选 {{ selectedRows.length }} 条</template>
|
||||
</DataTable></section>
|
||||
</ListPageLayout>
|
||||
@@ -208,5 +213,8 @@ onMounted(() => { void load() })
|
||||
.appointment-summary { margin-bottom: 16px; padding: 10px 12px; border: 1px solid #cdddfd; border-radius: 6px; background: #f5f9ff; color: #52627a; font-size: 13px; }
|
||||
.appointment-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 18px; }
|
||||
.appointment-form :deep(.el-date-editor), .appointment-form :deep(.el-select) { width: 100%; }
|
||||
.row-actions { display: inline-flex; min-width: max-content; align-items: center; flex-wrap: nowrap; gap: 8px; white-space: nowrap; }
|
||||
.row-actions :deep(.el-button) { flex: 0 0 auto; }
|
||||
.row-actions :deep(.el-button + .el-button) { margin-left: 0; }
|
||||
@media (max-width: 720px) { .appointment-form { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
|
||||
@@ -34,10 +34,12 @@ const mutationError = ref<ApiErrorDetails | null>(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<string, string>)[value] || value }
|
||||
function isSingleLineBusinessField(field: DetailField) { return field.label === '入仓码' || field.label === '集装箱号' }
|
||||
|
||||
const referenceValue = computed(() => lastMileOrderReference(detail.value?.order.orderNo, '订单编码待补充'))
|
||||
const orderFields = computed<DetailField[]>(() => {
|
||||
@@ -158,13 +160,13 @@ onMounted(load)
|
||||
<el-tabs v-model="activeTab" class="detail-tabs">
|
||||
<el-tab-pane label="基本信息" name="basic">
|
||||
<div class="basic-grid">
|
||||
<section class="info-panel"><h2>订单信息</h2><dl class="detail-fields"><div v-for="field in orderFields" :key="field.label"><dt>{{ field.label }}</dt><dd>{{ field.value }}</dd></div></dl></section>
|
||||
<section class="info-panel"><h2>货柜信息</h2><dl class="detail-fields"><div v-for="field in containerFields" :key="field.label"><dt>{{ field.label }}</dt><dd>{{ field.value }}</dd></div></dl></section>
|
||||
<section class="info-panel"><h2>订单信息</h2><dl class="detail-fields"><div v-for="field in orderFields" :key="field.label"><dt>{{ field.label }}</dt><dd :class="{ 'detail-field-value--business': isSingleLineBusinessField(field) }" :title="isSingleLineBusinessField(field) ? String(field.value) : undefined">{{ field.value }}</dd></div></dl></section>
|
||||
<section class="info-panel"><h2>货柜信息</h2><dl class="detail-fields"><div v-for="field in containerFields" :key="field.label"><dt>{{ field.label }}</dt><dd :class="{ 'detail-field-value--business': isSingleLineBusinessField(field) }" :title="isSingleLineBusinessField(field) ? String(field.value) : undefined">{{ field.value }}</dd></div></dl></section>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="货物信息" name="cargo"><section class="table-panel"><DataTable :data="cargo" :columns="cargoColumns" column-settings-mode="hide" row-key="id" empty-text="暂无货物明细"><template #toolbar-start><h2>货物信息</h2></template></DataTable></section></el-tab-pane>
|
||||
<el-tab-pane label="计划信息" name="plan">
|
||||
<section class="info-panel plan-panel"><h2>计划信息</h2><dl class="detail-fields plan-fields"><div v-for="field in planFields" :key="field.label"><dt>{{ field.label }}</dt><dd>{{ field.value }}</dd></div></dl></section>
|
||||
<section class="info-panel plan-panel"><h2>计划信息</h2><dl class="detail-fields plan-fields"><div v-for="field in planFields" :key="field.label"><dt>{{ field.label }}</dt><dd :class="{ 'detail-field-value--business': isSingleLineBusinessField(field) }" :title="isSingleLineBusinessField(field) ? String(field.value) : undefined">{{ field.value }}</dd></div></dl></section>
|
||||
<section class="table-panel related-panel"><DataTable :data="documents" :columns="documentColumns" column-settings-mode="hide" empty-text="暂无关联单据"><template #toolbar-start><h2>关联单据</h2></template></DataTable></section>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="费用明细" name="fees"><section class="table-panel"><DataTable :data="fees" :columns="feeColumns" column-settings-mode="hide" row-key="id" empty-text="暂无费用明细"><template #toolbar-start><h2>费用明细</h2></template><template #toolbar-actions><el-button v-if="access.can(permission.lastMileOrderTransfer)" type="primary" @click="openFeeDialog"><Plus :size="16" />新增费用</el-button></template></DataTable></section></el-tab-pane>
|
||||
@@ -172,13 +174,13 @@ onMounted(load)
|
||||
</el-tabs>
|
||||
</section>
|
||||
<AppDialog v-model="feeDialogOpen" title="新增费用" width="min(720px, calc(100vw - 28px))" confirm-text="保存费用" :confirm-loading="saving" @confirm="saveFee">
|
||||
<el-form label-position="top"><el-row :gutter="20">
|
||||
<el-col :span="12"><el-form-item label="费用项目" required><el-input v-model="feeForm.item" maxlength="128" placeholder="如 派送费" /></el-form-item></el-col>
|
||||
<el-col :span="12"><el-form-item label="供应商/说明"><el-input v-model="feeForm.description" maxlength="500" placeholder="请输入供应商或费用说明" /></el-form-item></el-col>
|
||||
<el-col :span="12"><el-form-item label="币种" required><el-select v-model="feeForm.currency"><el-option v-for="currency in ['USD', 'CNY', 'EUR', 'GBP', 'CAD', 'MXN']" :key="currency" :label="currency" :value="currency" /></el-select></el-form-item></el-col>
|
||||
<el-col :span="12"><el-form-item label="金额" required><el-input-number v-model="feeForm.amount" :min="0" :precision="2" :step="10" controls-position="right" placeholder="请输入费用金额" /></el-form-item></el-col>
|
||||
<el-col :span="24"><el-form-item label="状态" required><el-select v-model="feeForm.status"><el-option label="待审核" value="PENDING_REVIEW" /><el-option label="审核通过" value="APPROVED" /><el-option label="已驳回" value="REJECTED" /></el-select></el-form-item></el-col>
|
||||
</el-row></el-form>
|
||||
<el-form class="fee-form" label-position="top"><div class="fee-form-grid">
|
||||
<el-form-item label="费用项目" required><el-select v-model="feeForm.item" filterable allow-create clearable default-first-option placeholder="选择或输入费用项目"><el-option v-for="item in feeItemOptions" :key="item" :label="item" :value="item" /></el-select></el-form-item>
|
||||
<el-form-item label="供应商/说明"><el-input v-model="feeForm.description" maxlength="500" placeholder="请输入供应商或费用说明" /></el-form-item>
|
||||
<el-form-item label="币种" required><el-select v-model="feeForm.currency" placeholder="请选择币种"><el-option v-for="currency in ['USD', 'CNY', 'EUR', 'GBP', 'CAD', 'MXN']" :key="currency" :label="currency" :value="currency" /></el-select></el-form-item>
|
||||
<el-form-item label="金额" required><el-input-number v-model="feeForm.amount" :min="0" :precision="2" :step="10" controls-position="right" placeholder="请输入费用金额" /></el-form-item>
|
||||
<el-form-item class="fee-form-full" label="状态" required><el-select v-model="feeForm.status" placeholder="请选择费用状态"><el-option label="待审核" value="PENDING_REVIEW" /><el-option label="审核通过" value="APPROVED" /><el-option label="已驳回" value="REJECTED" /></el-select></el-form-item>
|
||||
</div></el-form>
|
||||
</AppDialog>
|
||||
</OverseasDetailShell>
|
||||
</template>
|
||||
@@ -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; } }
|
||||
</style>
|
||||
|
||||
@@ -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<string, unknown> = {}) {
|
||||
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<ReturnType<typeof mountView>>) {
|
||||
|
||||
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<string, unknown>
|
||||
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' })])
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { Download, Eye } from '@lucide/vue'
|
||||
import { Download, Eye, Pencil } from '@lucide/vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
|
||||
@@ -13,11 +13,11 @@ import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import { convertLastMileContainerToInboundPlans, convertLastMileOrderToInboundPlan, downloadLastMileOrders, getContainerPlanProgress, listContainerPlanProgress, listLastMileOrders, transferLastMileOrders, type ContainerPlanProgressDetailDto, type ContainerPlanProgressSummaryDto } from '../api/lastMileOrders'
|
||||
import { changeContainerReceivingWarehouse, convertLastMileContainerToInboundPlans, createManualInboundPlan, downloadLastMileOrders, getContainerPlanProgress, getLastMileOrderManualNodes, listContainerPlanProgress, listLastMileOrders, transferLastMileOrders, updateLastMileOrderHold, updateLastMileOrderManualNodes, type ContainerPlanProgressDetailDto, type ContainerPlanProgressSummaryDto, type ManualNodeCode } from '../api/lastMileOrders'
|
||||
import OrderReferenceText from '../components/OrderReferenceText.vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { overseasWarehousePermissions as permission } from '../permissions'
|
||||
import { toLastMileOrderListItem } from '../lastMilePresentation'
|
||||
import { lastMileStatusLabel, toLastMileOrderListItem } from '../lastMilePresentation'
|
||||
import type { LastMileOrderListItem } from '../types'
|
||||
import { saveCsv } from '@/shared/downloadCsv'
|
||||
|
||||
@@ -40,6 +40,28 @@ const actionLoading = ref(false)
|
||||
const exportLoading = ref(false)
|
||||
async function exportOrders() { exportLoading.value = true; try { const { createdAt: range, ...rest } = filters.value; saveCsv(await downloadLastMileOrders({ ...rest, createdFrom: Array.isArray(range) && range[0] ? `${String(range[0]).slice(0, 10)}T00:00:00` : undefined, createdTo: Array.isArray(range) && range[1] ? `${String(range[1]).slice(0, 10)}T23:59:59` : undefined }) as Blob, '尾程订单.csv') } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '尾程订单导出失败,请重试').message) } finally { exportLoading.value = false } }
|
||||
const actionError = ref<ApiErrorDetails | null>(null)
|
||||
const actionTarget = ref<LastMileOrderListItem | null>(null)
|
||||
const inboundPlanOpen = ref(false)
|
||||
const inboundPlanWarehouseLabel = computed(() => [actionTarget.value?.overseasWarehouseCode, actionTarget.value?.warehouse]
|
||||
.filter((value) => value && value !== '—')
|
||||
.join(' · '))
|
||||
const inboundPlanForm = reactive({ plannedArrivalDate: '', inboundCode: '', palletCount: 1, palletType: 'STANDARD' as 'STANDARD' | 'WOOD' | 'PAPER', assigned: false, mergeRequired: false, urgent: false, remark: '' })
|
||||
const holdOpen = ref(false)
|
||||
const holdForm = reactive({ held: true, chargeable: true, reason: '' })
|
||||
const manualNodesOpen = ref(false)
|
||||
const manualNodesLoading = ref(false)
|
||||
const manualNodesVersion = ref(0)
|
||||
const manualNodeDefinitions: Array<{ code: ManualNodeCode; label: string }> = [
|
||||
{ code: 'DOMESTIC_CUSTOMS_COMPLETED', label: '国内报关完成' },
|
||||
{ code: 'ESTIMATED_DEPARTURE', label: '预计开船' },
|
||||
{ code: 'ACTUAL_DEPARTURE', label: '实际开船' },
|
||||
{ code: 'ESTIMATED_ARRIVAL', label: '预计到港' },
|
||||
{ code: 'OVERSEAS_CUSTOMS_COMPLETED', label: '国外清关完成' },
|
||||
{ code: 'ACTUAL_ARRIVAL_WAITING_UNLOAD', label: '实际到港等待卸船' },
|
||||
{ code: 'PICKUP_APPOINTED', label: '预约提柜' },
|
||||
{ code: 'UNLOADED', label: '卸柜' },
|
||||
]
|
||||
const manualNodeValues = reactive<Record<ManualNodeCode, string | null>>(Object.fromEntries(manualNodeDefinitions.map(({ code }) => [code, null])) as Record<ManualNodeCode, string | null>)
|
||||
const transferOpen = ref(false)
|
||||
const transferWarehouseLoading = ref(false)
|
||||
const transferWarehouseError = ref<ApiErrorDetails | null>(null)
|
||||
@@ -50,13 +72,21 @@ const containerOptions = ref<ContainerPlanProgressSummaryDto[]>([])
|
||||
const containerOptionsLoading = ref(false)
|
||||
const containerLoadError = ref<ApiErrorDetails | null>(null)
|
||||
const selectedContainerBookingId = ref('')
|
||||
const receivingWarehouseOpen = ref(false)
|
||||
const receivingWarehouseLoading = ref(false)
|
||||
const receivingWarehouseError = ref<ApiErrorDetails | null>(null)
|
||||
const receivingWarehouseActionError = ref<ApiErrorDetails | null>(null)
|
||||
const receivingWarehouseOptions = ref<TransferWarehouseOption[]>([])
|
||||
const receivingWarehouseTarget = ref<ContainerPlanProgressSummaryDto | null>(null)
|
||||
const receivingWarehouseForm = reactive({ warehouseId: '', remark: '' })
|
||||
let receivingWarehouseIdempotencyKey = ''
|
||||
let containerOptionRequest = 0
|
||||
const statusTab = ref<'全部' | '待分配' | '柜子'>('全部')
|
||||
const filters = ref<QueryModel>({ orderNo: '', orderType: '', warehouse: '', inboundCode: '', lxCode: '', sourceOrderNo: '', containerNo: '', status: '', transferWarehouseCode: '', abnormal: '', detained: '', createdAt: [] })
|
||||
const allFields: QueryField[] = [
|
||||
{ key: 'orderNo', type: 'input', label: '尾程单号', width: 190, placeholder: '请输入尾程单号' },
|
||||
{ key: 'inboundCode', type: 'input', label: '入仓码', width: 180, placeholder: '请输入入仓码' },
|
||||
{ key: 'lxCode', type: 'input', label: 'LX代码', width: 180, placeholder: '请输入领星入库单代码' },
|
||||
{ key: 'lxCode', type: 'input', label: 'LX代码', width: 180, placeholder: '请输入LX代码' },
|
||||
{ key: 'orderType', type: 'select', label: '订单类型', width: 170, placeholder: '全部类型', options: [
|
||||
{ label: '头程订单', value: 'FIRST_LEG' }, { label: '换标下单', value: 'RELABEL_APPLICATION' },
|
||||
{ label: '仓储转运', value: 'WAREHOUSE_TRANSFER' }, { label: '本土散板', value: 'LOCAL_LOOSEBOARD' },
|
||||
@@ -82,6 +112,10 @@ const fields = computed<QueryField[]>(() => statusTab.value === '柜子'
|
||||
? allFields.filter((field) => ['containerNo', 'createdAt'].includes(field.key))
|
||||
: allFields)
|
||||
const selectedContainer = computed(() => containerOptions.value.find((row) => row.sourceContainerBookingId === selectedContainerBookingId.value))
|
||||
const selectedReceivingWarehouse = computed(() => receivingWarehouseOptions.value.find((row) => (
|
||||
row.warehouseId === receivingWarehouseForm.warehouseId
|
||||
&& row.warehouseId !== receivingWarehouseTarget.value?.overseasWarehouseId
|
||||
)))
|
||||
|
||||
interface TransferWarehouseOption {
|
||||
warehouseId: string
|
||||
@@ -112,8 +146,25 @@ function toTransferWarehouseOption(warehouse: PlatformWarehouseSummaryResource):
|
||||
label: [warehouseCode, warehouseName, region].filter(Boolean).join(' · '),
|
||||
}
|
||||
}
|
||||
|
||||
function receivingWarehouseLabel(container: ContainerPlanProgressSummaryDto | null | undefined) {
|
||||
if (!container) return '—'
|
||||
const code = String(container.overseasWarehouseCode || '').trim()
|
||||
const name = String(container.overseasWarehouseName || '').trim()
|
||||
return [code, name].filter(Boolean).join(' · ') || '—'
|
||||
}
|
||||
|
||||
function receivingWarehouseChangeDisabledReason(container: ContainerPlanProgressSummaryDto) {
|
||||
if (!access.can(permission.lastMileOrderTransfer)) return '暂无修改接柜仓权限,请联系管理员开通尾程订单编辑权限'
|
||||
if (!container.canChangeReceivingWarehouse) return container.receivingWarehouseLockReason || '当前柜已锁定,不能修改接柜仓'
|
||||
return ''
|
||||
}
|
||||
|
||||
function newReceivingWarehouseIdempotencyKey() {
|
||||
return `container-receiving-warehouse-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
|
||||
}
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 270, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'actions', label: '操作', width: 178, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'selection', type: 'selection', width: 48, fixed: 'left' },
|
||||
{ key: 'orderNo', prop: 'orderNo', label: '尾程单号', minWidth: 170, slotName: 'orderNo' },
|
||||
{ key: 'consolidated', prop: 'consolidated', label: '合托标记', width: 100, hideable: false },
|
||||
@@ -152,17 +203,44 @@ const columns: DataTableColumn[] = [
|
||||
const containerDetailColumns: DataTableColumn[] = [
|
||||
{ key: 'orderNo', prop: 'orderNo', label: '尾程单号', minWidth: 170 },
|
||||
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 150 },
|
||||
{ key: 'orderStatus', prop: 'orderStatus', label: '订单状态', width: 130 },
|
||||
{ key: 'orderStatus', prop: 'orderStatus', label: '订单状态', width: 130, slotName: 'orderStatus' },
|
||||
{ key: 'planNo', prop: 'planNo', label: '独立入库计划', minWidth: 170 },
|
||||
{ key: 'planStatus', prop: 'planStatus', label: '计划状态', width: 130 },
|
||||
{ key: 'planStatus', prop: 'planStatus', label: '计划状态', width: 130, slotName: 'planStatus' },
|
||||
{ key: 'plannedArrivalAt', prop: 'plannedArrivalAt', label: '计划到仓时间', minWidth: 170, slotName: 'plannedArrivalAt' },
|
||||
]
|
||||
|
||||
function formatDateTime(value: string | null | undefined) {
|
||||
return value ? value.replace('T', ' ').slice(0, 19) : '—'
|
||||
}
|
||||
function inboundPlanStatusLabel(value: unknown) {
|
||||
const code = String(value ?? '').trim()
|
||||
return ({
|
||||
PENDING_ASSIGNMENT: '待入库',
|
||||
ASSIGNED: '待入库',
|
||||
PDA_PENDING: '待入库',
|
||||
PENDING_RECEIVING: '待收货',
|
||||
PICKING: '拣货中',
|
||||
PARTIAL_INBOUND: '部分入库',
|
||||
COMPLETED: '全部入库',
|
||||
CANCELLED: '已取消',
|
||||
} as Record<string, string>)[code] || (code ? '未知状态' : '—')
|
||||
}
|
||||
function canGenerateContainerPlans(container: ContainerPlanProgressSummaryDto) {
|
||||
return Boolean(container.containerNo) && container.pendingPlanCount > 0
|
||||
return Boolean(!container.allPlansReady
|
||||
&& container.containerNo
|
||||
&& container.overseasWarehouseId
|
||||
&& container.overseasWarehouseCode
|
||||
&& container.overseasWarehouseName
|
||||
&& container.assignmentVersion >= 0
|
||||
&& container.pendingPlanCount > 0)
|
||||
}
|
||||
function containerSnapshotBlockReason(container: ContainerPlanProgressSummaryDto) {
|
||||
if (!container.containerNo) return container.blockingReason || '来源柜真实集装箱号缺失、格式无效或存在漂移'
|
||||
if (!container.overseasWarehouseId || !container.overseasWarehouseCode
|
||||
|| !container.overseasWarehouseName || container.assignmentVersion < 0) {
|
||||
return '柜内接柜仓业务快照不一致'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
function pendingInboundCodeCount(container: ContainerPlanProgressSummaryDto) {
|
||||
return Math.max(0, container.inboundCodeCount - container.plannedInboundCodeCount)
|
||||
@@ -190,9 +268,8 @@ async function load(model: QueryModel = filters.value) {
|
||||
createdFrom: dateRange[0] ? `${dateRange[0]}T00:00:00` : undefined,
|
||||
createdTo: dateRange[1] ? `${dateRange[1]}T23:59:59` : undefined,
|
||||
})
|
||||
const pendingRecords = result.records.filter((container) => pendingInboundCodeCount(container) > 0)
|
||||
containerRows.value = pendingRecords
|
||||
containerTotal.value = Math.max(0, result.total - (result.records.length - pendingRecords.length))
|
||||
containerRows.value = result.records
|
||||
containerTotal.value = result.total
|
||||
} catch (error) {
|
||||
containerRows.value = []
|
||||
containerTotal.value = 0
|
||||
@@ -256,6 +333,70 @@ async function openContainerDetail(row: ContainerPlanProgressSummaryDto) {
|
||||
containerDetailLoading.value = false
|
||||
}
|
||||
}
|
||||
async function loadReceivingWarehouseOptions() {
|
||||
receivingWarehouseLoading.value = true
|
||||
receivingWarehouseError.value = null
|
||||
receivingWarehouseOptions.value = []
|
||||
try {
|
||||
const warehouses = await listAllPlatformWarehouses({ warehouseType: 'OVERSEAS', status: 'ACTIVE' })
|
||||
receivingWarehouseOptions.value = warehouses.map(toTransferWarehouseOption)
|
||||
} catch (error) {
|
||||
receivingWarehouseError.value = getApiErrorDetails(error, '海外接柜仓候选加载失败,请重试')
|
||||
} finally {
|
||||
receivingWarehouseLoading.value = false
|
||||
}
|
||||
}
|
||||
function openReceivingWarehouseChange(container: ContainerPlanProgressSummaryDto) {
|
||||
const disabledReason = receivingWarehouseChangeDisabledReason(container)
|
||||
if (disabledReason) {
|
||||
ElMessage.warning(disabledReason)
|
||||
return
|
||||
}
|
||||
receivingWarehouseTarget.value = container
|
||||
Object.assign(receivingWarehouseForm, { warehouseId: '', remark: '' })
|
||||
receivingWarehouseIdempotencyKey = newReceivingWarehouseIdempotencyKey()
|
||||
receivingWarehouseOptions.value = []
|
||||
receivingWarehouseError.value = null
|
||||
receivingWarehouseActionError.value = null
|
||||
actionError.value = null
|
||||
receivingWarehouseOpen.value = true
|
||||
void loadReceivingWarehouseOptions()
|
||||
}
|
||||
async function submitReceivingWarehouseChange() {
|
||||
const target = receivingWarehouseTarget.value
|
||||
const selected = selectedReceivingWarehouse.value
|
||||
if (!target || !selected) {
|
||||
ElMessage.warning('请选择与当前仓不同的有效海外接柜仓')
|
||||
return
|
||||
}
|
||||
actionLoading.value = true
|
||||
actionError.value = null
|
||||
receivingWarehouseActionError.value = null
|
||||
try {
|
||||
await changeContainerReceivingWarehouse(target.sourceContainerBookingId, {
|
||||
overseasWarehouseId: selected.warehouseId,
|
||||
expectedVersion: target.assignmentVersion,
|
||||
remark: receivingWarehouseForm.remark.trim() || undefined,
|
||||
}, receivingWarehouseIdempotencyKey)
|
||||
receivingWarehouseOpen.value = false
|
||||
receivingWarehouseTarget.value = null
|
||||
ElMessage.success('海外接柜仓修改成功')
|
||||
await load()
|
||||
} catch (error) {
|
||||
const details = getApiErrorDetails(error, '海外接柜仓修改失败,请重试')
|
||||
receivingWarehouseActionError.value = details.code === 'A0409'
|
||||
? { ...details, message: '接柜仓信息已被其他操作更新,请刷新后重试' }
|
||||
: details
|
||||
actionError.value = receivingWarehouseActionError.value
|
||||
if (details.code === 'A0409') {
|
||||
receivingWarehouseOpen.value = false
|
||||
receivingWarehouseTarget.value = null
|
||||
await load()
|
||||
}
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
}
|
||||
}
|
||||
function onSelectionChange(selection: object[]) { selectedRows.value = selection as LastMileOrderListItem[] }
|
||||
async function loadTransferWarehouseOptions() {
|
||||
transferWarehouseLoading.value = true
|
||||
@@ -304,26 +445,79 @@ async function submitTransfer() {
|
||||
} catch (error) { actionError.value = getApiErrorDetails(error, '转仓失败,请重试') }
|
||||
finally { actionLoading.value = false }
|
||||
}
|
||||
async function convertInboundPlan() {
|
||||
if (selectedRows.value.length !== 1) { ElMessage.warning('生成入库计划时请选择 1 条尾程订单'); return }
|
||||
const selected = selectedRows.value[0]
|
||||
const customerId = typeof selected.customerId === 'string' ? selected.customerId.trim() : ''
|
||||
if (!customerId) {
|
||||
actionError.value = { message: '所选尾程订单缺少客户标识,无法安全生成入库计划' }
|
||||
function idempotencyKey(prefix: string) {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
|
||||
}
|
||||
function openInboundPlan(source?: object) {
|
||||
const target = source ? source as LastMileOrderListItem : (selectedRows.value.length === 1 ? selectedRows.value[0] : null)
|
||||
if (!target) { ElMessage.warning('生成入库计划时请选择 1 条尾程订单'); return }
|
||||
if (target.detained) { ElMessage.warning('订单处于暂扣状态,解除暂扣后才能生成入库计划'); return }
|
||||
actionTarget.value = target
|
||||
Object.assign(inboundPlanForm, { plannedArrivalDate: '', inboundCode: target.inboundCode === '—' ? '' : target.inboundCode, palletCount: typeof target.pallets === 'number' && target.pallets > 0 ? target.pallets : 1, palletType: 'STANDARD', assigned: false, mergeRequired: false, urgent: false, remark: '' })
|
||||
actionError.value = null
|
||||
inboundPlanOpen.value = true
|
||||
}
|
||||
async function submitInboundPlan() {
|
||||
const row = actionTarget.value
|
||||
const frozenInboundCode = String(row?.inboundCode || '').trim()
|
||||
if (!row || !row.overseasWarehouseId || !inboundPlanForm.plannedArrivalDate || !frozenInboundCode || frozenInboundCode === '—' || inboundPlanForm.palletCount < 1) {
|
||||
ElMessage.warning('当前柜须已分配接柜仓,并完整填写预计到仓日期和托数')
|
||||
return
|
||||
}
|
||||
actionLoading.value = true
|
||||
actionError.value = null
|
||||
actionLoading.value = true; actionError.value = null
|
||||
try {
|
||||
await convertLastMileOrderToInboundPlan({ orderId: String(selected.id), customerId })
|
||||
selectedRows.value = []
|
||||
ElMessage.success('入库计划生成成功')
|
||||
await load()
|
||||
} catch (error) {
|
||||
actionError.value = getApiErrorDetails(error, '生成入库计划失败,请重试')
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
await createManualInboundPlan({
|
||||
customerId: row.customerId, orderId: row.id, expectedOrderVersion: row.version,
|
||||
plannedArrivalDate: inboundPlanForm.plannedArrivalDate,
|
||||
palletCount: inboundPlanForm.palletCount,
|
||||
palletType: inboundPlanForm.palletType, assigned: inboundPlanForm.assigned,
|
||||
mergeRequired: inboundPlanForm.mergeRequired, urgent: inboundPlanForm.urgent,
|
||||
remark: inboundPlanForm.remark.trim() || undefined,
|
||||
}, idempotencyKey('manual-inbound-plan'))
|
||||
inboundPlanOpen.value = false; selectedRows.value = []; ElMessage.success('入库计划已生成'); await load()
|
||||
} catch (error) { actionError.value = getApiErrorDetails(error, '生成入库计划失败,请重试') }
|
||||
finally { actionLoading.value = false }
|
||||
}
|
||||
function openHold(source: object) {
|
||||
const row = source as LastMileOrderListItem
|
||||
actionTarget.value = row
|
||||
Object.assign(holdForm, { held: !row.detained, chargeable: row.detained ? false : true, reason: '' })
|
||||
actionError.value = null
|
||||
holdOpen.value = true
|
||||
}
|
||||
async function submitHold() {
|
||||
const row = actionTarget.value
|
||||
if (!row || !holdForm.reason.trim()) { ElMessage.warning('请输入变更原因'); return }
|
||||
actionLoading.value = true; actionError.value = null
|
||||
try {
|
||||
await updateLastMileOrderHold(row.id, { held: holdForm.held, chargeable: holdForm.chargeable, reason: holdForm.reason.trim(), expectedVersion: row.version }, idempotencyKey('last-mile-hold'))
|
||||
holdOpen.value = false; ElMessage.success(holdForm.held ? '订单已暂扣' : '订单已解除暂扣'); await load()
|
||||
} catch (error) { actionError.value = getApiErrorDetails(error, '暂扣状态保存失败,请重试') }
|
||||
finally { actionLoading.value = false }
|
||||
}
|
||||
async function openManualNodes(source: object) {
|
||||
const row = source as LastMileOrderListItem
|
||||
actionTarget.value = row
|
||||
manualNodesOpen.value = true
|
||||
manualNodesLoading.value = true
|
||||
actionError.value = null
|
||||
manualNodeDefinitions.forEach(({ code }) => { manualNodeValues[code] = null })
|
||||
try {
|
||||
const result = await getLastMileOrderManualNodes(row.id)
|
||||
manualNodesVersion.value = result.version
|
||||
result.nodes.forEach((node) => { manualNodeValues[node.nodeCode] = node.nodeAt })
|
||||
} catch (error) { actionError.value = getApiErrorDetails(error, '人工节点加载失败,请重试') }
|
||||
finally { manualNodesLoading.value = false }
|
||||
}
|
||||
async function submitManualNodes() {
|
||||
const row = actionTarget.value
|
||||
if (!row || !manualNodeDefinitions.some(({ code }) => Boolean(manualNodeValues[code]))) { ElMessage.warning('请至少填写一个节点时间'); return }
|
||||
actionLoading.value = true; actionError.value = null
|
||||
try {
|
||||
await updateLastMileOrderManualNodes(row.id, manualNodesVersion.value, manualNodeDefinitions.map(({ code }) => ({ nodeCode: code, nodeAt: manualNodeValues[code] })), idempotencyKey('last-mile-manual-nodes'))
|
||||
manualNodesOpen.value = false; ElMessage.success('人工节点已保存,该订单不再继承柜级节点'); await load()
|
||||
} catch (error) { actionError.value = getApiErrorDetails(error, '人工节点保存失败,请重试') }
|
||||
finally { actionLoading.value = false }
|
||||
}
|
||||
async function loadContainerOptions(containerNo = '') {
|
||||
const request = ++containerOptionRequest
|
||||
@@ -412,12 +606,17 @@ onMounted(() => { void load() })
|
||||
</LoadErrorState>
|
||||
<section v-if="!loadError && statusTab === '柜子'" class="surface-panel container-workbench" :aria-busy="loading">
|
||||
<header class="container-workbench__header">
|
||||
<div><strong>待处理柜工作台</strong><span>按计划到仓时间由近到远排列,已全部生成计划的柜不再展示</span></div>
|
||||
<el-tag type="warning" effect="plain">{{ containerTotal }} 个柜待处理</el-tag>
|
||||
<div><strong>货柜工作台</strong><span>按柜查看和修改海外接柜仓,并为剩余入仓码生成独立入库计划</span></div>
|
||||
<el-tag type="warning" effect="plain">{{ containerTotal }} 个货柜</el-tag>
|
||||
</header>
|
||||
<div v-if="containerRows.length" class="container-workbench__grid">
|
||||
<article v-for="container in containerRows" :key="container.sourceContainerBookingId" class="container-workbench-card">
|
||||
<span class="container-workbench-card__top"><strong>{{ container.containerNo || '柜号待补充' }}</strong></span>
|
||||
<span class="container-workbench-card__top"><strong :title="container.containerNo || '柜号待补充'">{{ container.containerNo || '柜号待补充' }}</strong></span>
|
||||
<span class="container-workbench-card__warehouse">
|
||||
<small>海外接柜仓</small><strong :title="receivingWarehouseLabel(container)">{{ receivingWarehouseLabel(container) }}</strong>
|
||||
<small v-if="containerSnapshotBlockReason(container)" class="is-locked" :title="containerSnapshotBlockReason(container)">{{ containerSnapshotBlockReason(container) }}</small>
|
||||
<small v-else-if="!container.canChangeReceivingWarehouse && container.receivingWarehouseLockReason" class="is-locked" :title="container.receivingWarehouseLockReason">{{ container.receivingWarehouseLockReason }}</small>
|
||||
</span>
|
||||
<span class="container-workbench-card__arrival"><small>计划到仓</small><strong>{{ formatDateTime(container.plannedArrivalAt) }}</strong></span>
|
||||
<span class="container-workbench-card__pending">
|
||||
<strong>{{ pendingInboundCodeCount(container) }}</strong><span>个入仓码待生成计划</span>
|
||||
@@ -427,19 +626,22 @@ onMounted(() => { void load() })
|
||||
</span>
|
||||
<span class="container-workbench-card__actions">
|
||||
<el-button class="container-workbench-card__detail" plain @click="openContainerDetail(container)">查看详情</el-button>
|
||||
<el-tooltip :content="receivingWarehouseChangeDisabledReason(container)" :disabled="!receivingWarehouseChangeDisabledReason(container)" placement="top">
|
||||
<span><el-button plain :title="receivingWarehouseChangeDisabledReason(container) || '修改接柜仓'" :disabled="Boolean(receivingWarehouseChangeDisabledReason(container))" @click="openReceivingWarehouseChange(container)"><Pencil :size="15" />修改接柜仓</el-button></span>
|
||||
</el-tooltip>
|
||||
<el-button v-if="access.can(permission.inboundOrderCreate)" type="primary" :disabled="actionLoading || !canGenerateContainerPlans(container)" @click="openContainerConversionFor(container)">生成入库计划</el-button>
|
||||
</span>
|
||||
</article>
|
||||
</div>
|
||||
<el-empty v-else-if="!loading" description="暂无待处理柜,所有柜均已完成计划分配" />
|
||||
<el-empty v-else-if="!loading" description="暂无货柜数据" />
|
||||
<div v-if="containerTotal" class="container-workbench__pagination">
|
||||
<span>共 {{ containerTotal }} 个待处理柜</span>
|
||||
<span>共 {{ containerTotal }} 个货柜</span>
|
||||
<el-pagination :current-page="page" :page-size="pageSize" :page-sizes="[10, 20, 50]" :total="containerTotal" layout="sizes, prev, pager, next" background @current-change="changeContainerPage" @size-change="changeContainerPageSize" />
|
||||
</div>
|
||||
</section>
|
||||
<section v-else-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-last-mile-orders" row-key="id" empty-text="暂无尾程订单" @selection-change="onSelectionChange" @update:page="load()" @update:page-size="page = 1; load()">
|
||||
<template #toolbar-actions><el-button v-if="access.can(permission.lastMileOrderExport)" plain :loading="exportLoading" @click="exportOrders"><Download :size="16" />导出订单</el-button><el-button v-if="access.can(permission.lastMileOrderTransfer)" plain :disabled="!selectedRows.length || actionLoading" @click="openTransfer">转仓</el-button><el-button v-if="access.can(permission.inboundOrderCreate)" type="primary" plain :disabled="selectedRows.length !== 1 || actionLoading" @click="convertInboundPlan">生成入库计划</el-button><el-button v-if="statusTab === '待分配' && access.can(permission.inboundOrderCreate)" type="primary" :disabled="actionLoading" @click="openContainerConversion">按柜批量生成</el-button></template>
|
||||
<template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button></template>
|
||||
<template #toolbar-actions><el-button v-if="access.can(permission.lastMileOrderExport)" plain :loading="exportLoading" @click="exportOrders"><Download :size="16" />导出订单</el-button><el-button v-if="access.can(permission.lastMileOrderTransfer)" plain :disabled="!selectedRows.length || actionLoading" @click="openTransfer">转仓</el-button><el-button v-if="access.can(permission.inboundOrderCreate)" type="primary" plain :disabled="selectedRows.length !== 1 || actionLoading" @click="openInboundPlan()">生成入库计划</el-button><el-button v-if="statusTab === '待分配' && access.can(permission.inboundOrderCreate)" type="primary" :disabled="actionLoading" @click="openContainerConversion">按柜批量生成</el-button></template>
|
||||
<template #actions="{ row }"><div class="row-actions"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-dropdown v-if="access.can(permission.lastMileOrderTransfer) || access.can(permission.inboundOrderCreate)" trigger="click"><el-button size="small" plain>更多</el-button><template #dropdown><el-dropdown-menu><el-dropdown-item v-if="access.can(permission.inboundOrderCreate)" :disabled="row.detained" @click="openInboundPlan(row)">填写入库计划</el-dropdown-item><el-dropdown-item v-if="access.can(permission.lastMileOrderTransfer)" @click="openHold(row)">{{ row.detained ? '解除暂扣' : '暂扣订单' }}</el-dropdown-item><el-dropdown-item v-if="access.can(permission.lastMileOrderTransfer)" @click="openManualNodes(row)">人工修改节点</el-dropdown-item></el-dropdown-menu></template></el-dropdown></div></template>
|
||||
<template #orderNo="{ row }"><OrderReferenceText :value="String(row.orderNo || '')" copyable /></template>
|
||||
<template #pagination-left>共 {{ total }} 条尾程订单 · 已选 {{ selectedRows.length }} 条</template>
|
||||
</DataTable></section>
|
||||
@@ -479,23 +681,74 @@ onMounted(() => { void load() })
|
||||
<template v-else-if="containerDetail">
|
||||
<div class="container-detail-summary">
|
||||
<span>柜号:{{ containerDetail.summary.containerNo || '—' }}</span>
|
||||
<span :title="`海外接柜仓:${receivingWarehouseLabel(containerDetail.summary)}`">海外接柜仓:{{ receivingWarehouseLabel(containerDetail.summary) }}</span>
|
||||
<span>计划到仓:{{ formatDateTime(containerDetail.summary.plannedArrivalAt) }}</span>
|
||||
<span>订单:{{ containerDetail.summary.totalOrderCount }}</span>
|
||||
<span>入仓码:{{ containerDetail.summary.inboundCodeCount }}</span>
|
||||
<span>入库计划:{{ containerDetail.summary.planCount }},待生成 {{ pendingInboundCodeCount(containerDetail.summary) }}</span>
|
||||
<span v-if="!containerDetail.summary.canChangeReceivingWarehouse">修改状态:{{ containerDetail.summary.receivingWarehouseLockReason || '当前已锁定' }}</span>
|
||||
</div>
|
||||
<DataTable :data="containerDetail.orders" :columns="containerDetailColumns" :loading="containerDetailLoading" preference-key="overseas-last-mile-container-detail" row-key="orderId" :total="undefined" empty-text="该柜暂无尾程订单">
|
||||
<template #orderStatus="{ row }">{{ lastMileStatusLabel((row as ContainerPlanProgressDetailDto['orders'][number]).orderStatus) }}</template>
|
||||
<template #planStatus="{ row }">{{ inboundPlanStatusLabel((row as ContainerPlanProgressDetailDto['orders'][number]).planStatus) }}</template>
|
||||
<template #plannedArrivalAt="{ row }">{{ formatDateTime((row as ContainerPlanProgressDetailDto['orders'][number]).plannedArrivalAt) }}</template>
|
||||
</DataTable>
|
||||
</template>
|
||||
</AppDialog>
|
||||
<AppDialog v-model="receivingWarehouseOpen" title="修改海外接柜仓" width="min(600px, calc(100vw - 28px))" confirm-text="确认修改" :loading="receivingWarehouseLoading" :confirm-loading="actionLoading" :confirm-disabled="!selectedReceivingWarehouse || Boolean(receivingWarehouseError)" @confirm="submitReceivingWarehouseChange">
|
||||
<LoadErrorState v-if="receivingWarehouseError" title="海外接柜仓候选加载失败" :message="receivingWarehouseError.message" compact @retry="loadReceivingWarehouseOptions">
|
||||
<template #details><ApiErrorMeta :details="receivingWarehouseError" /></template>
|
||||
</LoadErrorState>
|
||||
<el-form v-else label-position="top">
|
||||
<el-form-item label="当前海外接柜仓"><el-input :model-value="receivingWarehouseLabel(receivingWarehouseTarget)" readonly /></el-form-item>
|
||||
<el-form-item label="新的海外接柜仓" required>
|
||||
<el-select v-model="receivingWarehouseForm.warehouseId" filterable clearable placeholder="请选择 ACTIVE 海外接柜仓">
|
||||
<el-option v-for="warehouse in receivingWarehouseOptions" :key="warehouse.warehouseId" :label="warehouse.label" :value="warehouse.warehouseId" :disabled="warehouse.warehouseId === receivingWarehouseTarget?.overseasWarehouseId" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-alert v-if="receivingWarehouseForm.warehouseId === receivingWarehouseTarget?.overseasWarehouseId" title="新的海外接柜仓必须与当前仓不同" type="warning" :closable="false" show-icon />
|
||||
<el-alert v-if="!receivingWarehouseLoading && !receivingWarehouseOptions.length" title="暂无可用的 ACTIVE 海外接柜仓" type="info" :closable="false" show-icon />
|
||||
<el-form-item label="备注"><el-input v-model="receivingWarehouseForm.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入修改备注(选填)" /></el-form-item>
|
||||
<LoadErrorState v-if="receivingWarehouseActionError" title="海外接柜仓修改失败" :message="receivingWarehouseActionError.message" compact>
|
||||
<template #details><ApiErrorMeta :details="receivingWarehouseActionError" /></template>
|
||||
</LoadErrorState>
|
||||
</el-form>
|
||||
</AppDialog>
|
||||
<AppDialog v-model="inboundPlanOpen" title="入库计划" width="min(720px, calc(100vw - 28px))" confirm-text="保存并生成入库单" :confirm-loading="actionLoading" @confirm="submitInboundPlan">
|
||||
<el-alert title="保存后生成入库单;清关完成后入库计划不可更改。" type="info" :closable="false" show-icon />
|
||||
<el-form class="command-form" label-position="top">
|
||||
<div class="command-form__grid"><el-form-item label="计划入库仓"><el-input :model-value="inboundPlanWarehouseLabel" disabled placeholder="当前柜接柜仓" /><small>接柜仓按柜统一;如需变更,请在柜子工作台执行“变更接柜仓”。</small></el-form-item><el-form-item label="预计到仓日期" required><el-date-picker v-model="inboundPlanForm.plannedArrivalDate" type="date" value-format="YYYY-MM-DD" placeholder="请选择日期" /></el-form-item><el-form-item label="入仓码"><el-input :model-value="inboundPlanForm.inboundCode" disabled placeholder="订单冻结入仓码" /></el-form-item><el-form-item label="托数" required><el-input-number v-model="inboundPlanForm.palletCount" :min="1" :max="999" controls-position="right" placeholder="请输入托数" /></el-form-item><el-form-item label="托盘类型"><el-select v-model="inboundPlanForm.palletType" placeholder="请选择托盘类型"><el-option label="标准托盘" value="STANDARD" /><el-option label="木托盘" value="WOOD" /><el-option label="纸托盘" value="PAPER" /></el-select></el-form-item><el-form-item label="是否分配"><el-switch v-model="inboundPlanForm.assigned" inline-prompt active-text="是" inactive-text="否" /></el-form-item><el-form-item label="是否合托"><el-switch v-model="inboundPlanForm.mergeRequired" inline-prompt active-text="是" inactive-text="否" /></el-form-item><el-form-item label="是否加急"><el-switch v-model="inboundPlanForm.urgent" inline-prompt active-text="是" inactive-text="否" /></el-form-item></div>
|
||||
<el-form-item label="备注"><el-input v-model="inboundPlanForm.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="请输入备注(选填)" /></el-form-item>
|
||||
</el-form>
|
||||
</AppDialog>
|
||||
<AppDialog v-model="holdOpen" :title="holdForm.held ? '暂扣订单' : '解除暂扣'" width="min(620px, calc(100vw - 28px))" confirm-text="保存" :confirm-loading="actionLoading" @confirm="submitHold">
|
||||
<el-alert :title="holdForm.held ? '暂扣后不可创建出库计划;解除暂扣后恢复出库计划操作。' : '解除暂扣后恢复该订单的出库计划操作。'" type="warning" :closable="false" show-icon />
|
||||
<el-form class="command-form" label-position="top"><div class="command-form__grid"><el-form-item label="暂扣状态" required><el-radio-group v-model="holdForm.held"><el-radio :value="true">暂扣</el-radio><el-radio :value="false">解除暂扣</el-radio></el-radio-group></el-form-item><el-form-item label="是否计费" required><el-radio-group v-model="holdForm.chargeable"><el-radio :value="true">是</el-radio><el-radio :value="false">否</el-radio></el-radio-group></el-form-item></div><el-form-item label="变更原因" required><el-input v-model="holdForm.reason" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请输入暂扣或解除暂扣原因" /></el-form-item></el-form>
|
||||
</AppDialog>
|
||||
<AppDialog v-model="manualNodesOpen" title="人工修改节点" width="min(700px, calc(100vw - 28px))" confirm-text="保存" :loading="manualNodesLoading" :confirm-loading="actionLoading" @confirm="submitManualNodes">
|
||||
<div class="manual-node-order"><span>尾程单号</span><strong>{{ actionTarget?.orderNo || '—' }}</strong></div>
|
||||
<el-alert title="保存后该订单只显示人工节点,不再跟随柜级节点;请至少填写一个节点时间。" type="warning" :closable="false" show-icon />
|
||||
<div class="manual-node-table"><div class="manual-node-table__head"><span>节点</span><span>节点时间</span></div><div v-for="node in manualNodeDefinitions" :key="node.code" class="manual-node-table__row"><span>{{ node.label }}</span><el-date-picker v-model="manualNodeValues[node.code]" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="年/月/日 --:--" /></div></div>
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.last-mile-toolbar { padding: 14px 16px 0; }
|
||||
.row-actions { display: flex; align-items: center; gap: 6px; white-space: nowrap; }
|
||||
.row-actions :deep(.el-button + .el-button) { margin-left: 0; }
|
||||
.command-form { margin-top: 16px; }
|
||||
.command-form__grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 18px; }
|
||||
.command-form :deep(.el-select), .command-form :deep(.el-date-editor), .command-form :deep(.el-input-number) { width: 100%; }
|
||||
.manual-node-order { display: flex; justify-content: space-between; gap: 16px; margin-bottom: 12px; padding: 12px 14px; border: 1px solid var(--cc-border); border-radius: 6px; background: var(--cc-surface); }
|
||||
.manual-node-table { max-height: 420px; margin-top: 14px; overflow: auto; border: 1px solid var(--cc-border); border-radius: 6px; }
|
||||
.manual-node-table__head, .manual-node-table__row { display: grid; grid-template-columns: 42% 58%; align-items: center; gap: 12px; padding: 10px 14px; }
|
||||
.manual-node-table__head { position: sticky; top: 0; z-index: 1; background: var(--cc-surface); color: var(--cc-muted); font-size: 13px; font-weight: 600; }
|
||||
.manual-node-table__row { border-top: 1px solid var(--cc-border); }
|
||||
.manual-node-table__row :deep(.el-date-editor) { width: 100%; }
|
||||
.last-mile-toolbar :deep(.query-toolbar) { margin-top: 12px; padding: 12px 0 16px; border: 0; border-top: 1px solid var(--cc-border); border-radius: 0; }
|
||||
.container-convert-hint { margin: 0 0 16px; color: var(--cc-muted); line-height: 1.6; }
|
||||
.container-detail-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px 16px; margin-bottom: 12px; padding: 12px 14px; border: 1px solid var(--cc-border); border-radius: 6px; background: var(--cc-surface); font-size: 13px; }
|
||||
.container-detail-summary > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.container-workbench { padding: 18px; }
|
||||
.container-workbench__header { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 16px; }
|
||||
.container-workbench__header > div { display: grid; gap: 4px; }
|
||||
@@ -505,17 +758,22 @@ onMounted(() => { void load() })
|
||||
.container-workbench-card { display: grid; min-width: 0; gap: 14px; padding: 16px; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; color: inherit; font: inherit; text-align: left; transition: border-color .16s ease, box-shadow .16s ease; }
|
||||
.container-workbench-card:hover { border-color: var(--cc-primary); box-shadow: 0 6px 18px rgb(15 23 42 / 8%); }
|
||||
.container-workbench-card__top { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.container-workbench-card__top strong { min-width: 0; overflow-wrap: anywhere; color: var(--cc-text); font-size: 16px; }
|
||||
.container-workbench-card__top strong { min-width: 0; overflow: hidden; color: var(--cc-text); font-size: 16px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.container-workbench-card__top > span { flex: 0 0 auto; color: var(--cc-primary); font-size: 13px; }
|
||||
.container-workbench-card__arrival { display: grid; gap: 4px; }
|
||||
.container-workbench-card__arrival small { color: var(--cc-muted); font-size: 12px; }
|
||||
.container-workbench-card__arrival strong { color: var(--cc-text); font-size: 15px; }
|
||||
.container-workbench-card__warehouse { display: grid; gap: 4px; }
|
||||
.container-workbench-card__warehouse small { color: var(--cc-muted); font-size: 12px; }
|
||||
.container-workbench-card__warehouse strong { min-width: 0; overflow: hidden; color: var(--cc-text); font-size: 14px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.container-workbench-card__warehouse .is-locked { min-width: 0; overflow: hidden; color: #c2410c; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.container-workbench-card__pending { display: flex; align-items: baseline; gap: 8px; padding: 12px 14px; border-left: 3px solid #f59e0b; background: #fffaf0; }
|
||||
.container-workbench-card__pending strong { color: #b45309; font-size: 28px; line-height: 1; }
|
||||
.container-workbench-card__pending span { color: #92400e; font-size: 13px; }
|
||||
.container-workbench-card__facts { display: flex; flex-wrap: wrap; gap: 6px 12px; color: var(--cc-muted); font-size: 12px; }
|
||||
.container-workbench-card__facts .is-warning { color: #c2410c; }
|
||||
.container-workbench-card__actions { display: flex; justify-content: flex-end; gap: 8px; padding-top: 2px; }
|
||||
.container-workbench-card__actions { display: flex; justify-content: flex-end; gap: 8px; padding-top: 2px; white-space: nowrap; }
|
||||
.container-workbench-card__actions :deep(.el-button + .el-button) { margin-left: 0; }
|
||||
.container-workbench__pagination { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--cc-border); color: var(--cc-muted); font-size: 13px; }
|
||||
@media (max-width: 1380px) { .container-workbench__grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
</style>
|
||||
|
||||
@@ -14,6 +14,7 @@ describe('OutboundDetailView', () => {
|
||||
canPermission.mockReset().mockReturnValue(false)
|
||||
vi.mocked(getOutbound).mockReset().mockResolvedValue({
|
||||
outbound: { id: '1', outboundNo: 'OUT/1', dispatchNo: 'PC-1', status: 'PICKED', palletCount: 2, packageCount: 20, createdAt: '2026-09-01T10:00:00' },
|
||||
pallets: [{ palletNo: 'IP-ACY2-20260907-597057-P001', inboundCode: 'NYEXX2609060063', destinationWarehouseCode: 'ONT8', destinationWarehouseName: 'Amazon ONT8', skuCode: 'SKU-001', packageCount: 20 }],
|
||||
delivery: { id: '2', customerId: 'customer-1', deliveryNo: 'DEL-1', outboundNo: 'OUT/1', dispatchNo: 'PC-1', status: 'WAIT_DELIVERY', palletCount: 2, packageCount: 20, createdAt: '2026-09-01T10:00:00' },
|
||||
})
|
||||
})
|
||||
@@ -25,10 +26,39 @@ describe('OutboundDetailView', () => {
|
||||
expect(wrapper.text()).toContain('OUT/1')
|
||||
expect(wrapper.text()).toContain('PC-1')
|
||||
expect(wrapper.text()).toContain('DEL-1')
|
||||
expect(wrapper.text()).toContain('出库托盘')
|
||||
expect(wrapper.text()).toContain('IP-ACY2-20260907-597057-P001')
|
||||
expect(wrapper.text()).toContain('NYEXX2609060063')
|
||||
expect(wrapper.text()).toContain('Amazon ONT8')
|
||||
expect(wrapper.text()).toContain('SKU-001')
|
||||
expect(wrapper.findAll('.outbound-pallet-table .data-table__single-line-cell').length).toBeGreaterThanOrEqual(6)
|
||||
expect(wrapper.text()).not.toContain('取消出库单')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('renders pallet missing values consistently and keeps the empty state in the table', async () => {
|
||||
vi.mocked(getOutbound).mockResolvedValueOnce({
|
||||
outbound: { id: '1', outboundNo: 'OUT/1', status: 'OUTBOUNDED' },
|
||||
pallets: [{ palletNo: null, inboundCode: null, destinationWarehouseCode: null, destinationWarehouseName: null, skuCode: null, packageCount: null }],
|
||||
delivery: null,
|
||||
})
|
||||
const wrapper = mount(OutboundDetailView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
expect(wrapper.get('.outbound-pallet-table').text()).toContain('—')
|
||||
expect(wrapper.text()).toContain('暂无关联派送单')
|
||||
wrapper.unmount()
|
||||
|
||||
vi.mocked(getOutbound).mockResolvedValueOnce({
|
||||
outbound: { id: '1', outboundNo: 'OUT/1', status: 'OUTBOUNDED' },
|
||||
pallets: [],
|
||||
delivery: null,
|
||||
})
|
||||
const emptyWrapper = mount(OutboundDetailView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
expect(emptyWrapper.text()).toContain('暂无出库托盘')
|
||||
emptyWrapper.unmount()
|
||||
})
|
||||
|
||||
it('shows cancellation only for PICKED with the independent permission', async () => {
|
||||
canPermission.mockImplementation((code: string) => code === 'platform:overseas-warehouse:outbound:cancel')
|
||||
const wrapper = mount(OutboundDetailView, { global: { plugins: [ElementPlus] } })
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
|
||||
@@ -20,6 +21,14 @@ const outboundNo = computed(() => String(route.params.outboundNo || '').trim())
|
||||
const detail = ref<OutboundDetail | null>(null); const loading = ref(false); const cancelling = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null); const mutationError = ref<ApiErrorDetails | null>(null)
|
||||
const statusLabels: Record<string, string> = { WAIT_OUTBOUND: '待出库', PICKING: '拣货中', PICKED: '已拣货', OUTBOUNDED: '已出库', CANCELLED: '已取消' }
|
||||
const palletColumns: DataTableColumn[] = [
|
||||
{ key: 'palletNo', label: '托盘码', width: 220 },
|
||||
{ key: 'inboundCode', label: '入仓码', width: 190 },
|
||||
{ key: 'destinationWarehouseCode', label: '目的仓编码', width: 150 },
|
||||
{ key: 'destinationWarehouseName', label: '目的仓名称', minWidth: 180 },
|
||||
{ key: 'skuCode', label: '商品 SKU', width: 170 },
|
||||
{ key: 'packageCount', label: '箱数', width: 100, align: 'right' },
|
||||
]
|
||||
function show(value: unknown) { const text = String(value ?? '').trim(); return text || '—' }
|
||||
function statusLabel(value: unknown) { const code = String(value || ''); return statusLabels[code] || show(code) }
|
||||
const canCancel = computed(() => access.can(permission.outboundCancel) && detail.value?.outbound.status === 'PICKED')
|
||||
@@ -34,7 +43,7 @@ watch(outboundNo, () => { void load() }, { immediate: true })
|
||||
<template #header><div class="detail-heading"><el-button aria-label="返回出库单列表" @click="back"><ArrowLeft :size="16" />返回</el-button><div class="detail-title"><div><h1>出库单详情</h1><p>出库单号 {{ outboundNo }}</p></div><StatusBadge v-if="detail" :status="statusLabel(detail.outbound.status)" /></div><el-button v-if="canCancel" type="danger" plain :loading="cancelling" @click="cancel"><XCircle :size="16" />取消出库单</el-button></div></template>
|
||||
<OperationErrorToast :details="mutationError" />
|
||||
<LoadErrorState v-if="loadError" title="出库单详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
||||
<template v-else-if="detail"><section class="surface-panel detail-section"><h2>出库信息</h2><el-descriptions :column="4" border><el-descriptions-item label="出库单号">{{ show(detail.outbound.outboundNo) }}</el-descriptions-item><el-descriptions-item label="关联排车单">{{ show(detail.outbound.dispatchNo) }}</el-descriptions-item><el-descriptions-item label="状态">{{ statusLabel(detail.outbound.status) }}</el-descriptions-item><el-descriptions-item label="海外仓">{{ show(detail.outbound.overseasWarehouseId) }}</el-descriptions-item><el-descriptions-item label="托盘数">{{ detail.outbound.palletCount ?? '—' }}</el-descriptions-item><el-descriptions-item label="箱数">{{ detail.outbound.packageCount ?? '—' }}</el-descriptions-item><el-descriptions-item label="创建时间">{{ formatOverseasDateTime(detail.outbound.createdAt) }}</el-descriptions-item></el-descriptions></section><section class="surface-panel detail-section"><h2>关联派送</h2><el-empty v-if="!detail.delivery" description="暂无关联派送单" :image-size="56" /><el-descriptions v-else :column="4" border><el-descriptions-item label="派送单号">{{ show(detail.delivery.deliveryNo) }}</el-descriptions-item><el-descriptions-item label="派送状态">{{ show(detail.delivery.status) }}</el-descriptions-item><el-descriptions-item label="运单号">{{ show(detail.delivery.waybillNo) }}</el-descriptions-item><el-descriptions-item label="承运商">{{ show(detail.delivery.carrierName) }}</el-descriptions-item><el-descriptions-item label="司机">{{ show(detail.delivery.driverName) }}</el-descriptions-item><el-descriptions-item label="车牌">{{ show(detail.delivery.vehiclePlate) }}</el-descriptions-item><el-descriptions-item label="计划派送">{{ formatOverseasDateTime(detail.delivery.plannedDeliveryAt) }}</el-descriptions-item><el-descriptions-item label="备注">{{ show(detail.delivery.remark) }}</el-descriptions-item></el-descriptions></section></template>
|
||||
<template v-else-if="detail"><section class="surface-panel detail-section"><h2>出库信息</h2><el-descriptions :column="4" border><el-descriptions-item label="出库单号">{{ show(detail.outbound.outboundNo) }}</el-descriptions-item><el-descriptions-item label="关联排车单">{{ show(detail.outbound.dispatchNo) }}</el-descriptions-item><el-descriptions-item label="状态">{{ statusLabel(detail.outbound.status) }}</el-descriptions-item><el-descriptions-item label="海外仓">{{ show(detail.outbound.overseasWarehouseId) }}</el-descriptions-item><el-descriptions-item label="托盘数">{{ detail.outbound.palletCount ?? '—' }}</el-descriptions-item><el-descriptions-item label="箱数">{{ detail.outbound.packageCount ?? '—' }}</el-descriptions-item><el-descriptions-item label="创建时间">{{ formatOverseasDateTime(detail.outbound.createdAt) }}</el-descriptions-item></el-descriptions></section><section class="surface-panel detail-section"><h2>出库托盘</h2><DataTable class="outbound-pallet-table" aria-label="出库托盘" :data="detail.pallets" :columns="palletColumns" stripe column-settings-mode="hide" empty-text="暂无出库托盘" /></section><section class="surface-panel detail-section"><h2>关联派送</h2><el-empty v-if="!detail.delivery" description="暂无关联派送单" :image-size="56" /><el-descriptions v-else :column="4" border><el-descriptions-item label="派送单号">{{ show(detail.delivery.deliveryNo) }}</el-descriptions-item><el-descriptions-item label="派送状态">{{ show(detail.delivery.status) }}</el-descriptions-item><el-descriptions-item label="运单号">{{ show(detail.delivery.waybillNo) }}</el-descriptions-item><el-descriptions-item label="承运商">{{ show(detail.delivery.carrierName) }}</el-descriptions-item><el-descriptions-item label="司机">{{ show(detail.delivery.driverName) }}</el-descriptions-item><el-descriptions-item label="车牌">{{ show(detail.delivery.vehiclePlate) }}</el-descriptions-item><el-descriptions-item label="计划派送">{{ formatOverseasDateTime(detail.delivery.plannedDeliveryAt) }}</el-descriptions-item><el-descriptions-item label="备注">{{ show(detail.delivery.remark) }}</el-descriptions-item></el-descriptions></section></template>
|
||||
</DetailPageLayout></AdminPage>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ const customerOptions = computed(() => {
|
||||
return [...customers].map(([value, label]) => ({ value, label }))
|
||||
})
|
||||
const availablePositionOptions = computed(() => positionOptions.value.filter(({ balance }) => balance.customerId === selectedCustomerId.value))
|
||||
const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 190, fixed: 'left', slotName: 'actions' }, { key: 'outboundNo', prop: 'outboundNo', label: '出库单号', minWidth: 180 }, { key: 'lxCodes', prop: 'lxCodes', label: 'LX代码', minWidth: 180 }, { key: 'dispatchNo', prop: 'dispatchNo', label: '关联排车单', minWidth: 160 }, { key: 'status', prop: 'status', label: '状态', width: 130, slotName: 'status' }, { key: 'palletCount', prop: 'palletCount', label: '托盘数', width: 100 }, { key: 'packageCount', prop: 'packageCount', label: '箱数', width: 100 }, { key: 'createdAt', prop: 'createdAt', label: '创建时间', minWidth: 180 }]
|
||||
const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions' }, { key: 'outboundNo', prop: 'outboundNo', label: '出库单号', minWidth: 180 }, { key: 'lxCodes', prop: 'lxCodes', label: 'LX代码', minWidth: 180 }, { key: 'dispatchNo', prop: 'dispatchNo', label: '关联排车单', minWidth: 160 }, { key: 'status', prop: 'status', label: '状态', width: 130, slotName: 'status' }, { key: 'palletCount', prop: 'palletCount', label: '托盘数', width: 100 }, { key: 'packageCount', prop: 'packageCount', label: '箱数', width: 100 }, { key: 'createdAt', prop: 'createdAt', label: '创建时间', minWidth: 180 }]
|
||||
const fields: QueryField[] = [{ key: 'documentNo', type: 'input', label: '出库单号', placeholder: '请输入出库单号', width: 220 }, { key: 'lxCode', type: 'input', label: 'LX代码', placeholder: '请输入领星入库单代码', width: 220 }, { key: 'warehouseId', type: 'input', label: '目的仓', placeholder: '请输入目的仓', width: 220 }, { key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 160, options: [{ label: '全部', value: '' }, { label: '待出库', value: 'WAIT_OUTBOUND' }, { label: '已出库', value: 'OUTBOUNDED' }] }]
|
||||
|
||||
async function load(model: QueryModel = filters.value) { Object.assign(filters.value, model); loading.value = true; error.value = null; try { const result = await listOutbounds({ page: page.value, size: pageSize.value, documentNo: String(filters.value.documentNo || '').trim() || undefined, lxCode: String(filters.value.lxCode || '').trim() || undefined, warehouseId: String(filters.value.warehouseId || '').trim() || undefined, status: String(filters.value.status || '').trim() || undefined }); rows.value = result.records.map((row) => ({ ...row, createdAt: formatOverseasDateTime(row.createdAt) })); total.value = result.total } catch (cause) { error.value = getApiErrorDetails(cause, '出库单列表加载失败') } finally { loading.value = false } }
|
||||
|
||||
@@ -41,7 +41,7 @@ const fields = computed<QueryField[]>(() => [
|
||||
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 140, options: [{ label: '闲置', value: 'IDLE' }, { label: '占用', value: 'OCCUPIED' }, { label: '已报废', value: 'SCRAPPED' }] },
|
||||
])
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 250, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'code', prop: 'code', label: '托盘编码', minWidth: 160 }, { key: 'type', prop: 'type', label: '托盘类型', minWidth: 130 },
|
||||
{ key: 'warehouse', prop: 'warehouse', label: '仓库', minWidth: 180 }, { key: 'volume', prop: 'volume', label: '容积(m³)', width: 110, align: 'right' },
|
||||
{ key: 'purchasePrice', prop: 'purchasePrice', label: '采购价格', width: 120, align: 'right' }, { key: 'fbaWarehouse', prop: 'fbaWarehouse', label: '所属FBA仓', minWidth: 140 },
|
||||
|
||||
@@ -37,7 +37,7 @@ const fields = computed<QueryField[]>(() => [
|
||||
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 160, options: [{ label: '待处理', value: 'PENDING_PROCESSING' }, { label: '处理中', value: 'PROCESSING' }, { label: '已完成', value: 'COMPLETED' }, { label: '已驳回', value: 'REJECTED' }, { label: '已取消', value: 'CANCELLED' }] },
|
||||
])
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 190, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'orderNo', label: '换标单号', minWidth: 170 },
|
||||
{ key: 'sku', label: '商品SKU', minWidth: 150 },
|
||||
{ key: 'productName', label: '中文品名', minWidth: 180 },
|
||||
|
||||
@@ -151,7 +151,7 @@ describe('overseas warehouse first-batch prototype contract', () => {
|
||||
expect(tailLeg).toContain('<OrderReferenceText :value="referenceValue" copyable />')
|
||||
const inbound = source('InboundOrderDetailView.vue')
|
||||
expect(inbound).toContain('getInboundPlan')
|
||||
expect(inbound).toContain('sourceOrderId')
|
||||
expect(inbound).not.toContain('detail.value?.sourceOrderId')
|
||||
expect(inbound).toContain('<LoadErrorState')
|
||||
const inventory = source('InventoryDetailView.vue')
|
||||
expect(inventory).toContain('listInventoryPositions')
|
||||
@@ -198,6 +198,7 @@ describe('overseas warehouse first-batch prototype contract', () => {
|
||||
expect(value).toContain('createFbaAppointment')
|
||||
expect(value).toContain('预计装车时间')
|
||||
expect(value).toContain('所选入库单必须属于同一 FBA 仓库')
|
||||
expect(value).toContain('anchor.customerId === row.customerId')
|
||||
expect(value).toContain('<CalendarCheck2 :size="16" />预约')
|
||||
expect(value).toContain('<Truck :size="16" />智能排车')
|
||||
expect(value).toContain('type="primary" @click="openSmartDispatch"')
|
||||
@@ -247,6 +248,8 @@ describe('overseas warehouse first-batch prototype contract', () => {
|
||||
expect(dialog).not.toContain(':model-value="fbaWarehouseCode" readonly')
|
||||
expect(dialog).toContain("sourceStage: 'RECEIVED'")
|
||||
expect(dialog).toContain('一次排车只能装同一客户的托盘')
|
||||
expect(dialog).toContain('该托盘尚未关联海外仓,不能排车')
|
||||
expect(dialog).toContain('所选托盘中存在尚未关联海外仓的托盘')
|
||||
expect(dialog).toContain('function ensureDraftToken()')
|
||||
expect(dialog).toContain('lockDispatchDraftPallet(id, ensureDraftToken(), target.id)')
|
||||
expect(dialog).toContain('function hydrateDraftPallets()')
|
||||
|
||||
@@ -3,16 +3,23 @@ import { createPinia } from 'pinia'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { listPlatformTenants } from '@/modules/iam'
|
||||
import DataTable from '@/shared/components/DataTable.vue'
|
||||
import { listInboundOrders } from '../api/inboundOrders'
|
||||
import { getInboundPlan } from '../api/inboundPlans'
|
||||
import { listInventoryLedger } from '../api/inventory'
|
||||
import { getLastMileOrder } from '../api/lastMileOrders'
|
||||
import InboundOrderDetailView from './InboundOrderDetailView.vue'
|
||||
import InboundOrdersView from './InboundOrdersView.vue'
|
||||
import InventoryTransactionsView from './InventoryTransactionsView.vue'
|
||||
import LastMileOrderDetailView from './LastMileOrderDetailView.vue'
|
||||
|
||||
const routing = vi.hoisted(() => ({ push: vi.fn(), route: { params: {} as Record<string, string>, query: {} as Record<string, string> } }))
|
||||
vi.mock('vue-router', () => ({ useRoute: () => routing.route, useRouter: () => ({ push: routing.push }) }))
|
||||
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() }))
|
||||
vi.mock('../api/inboundOrders', () => ({
|
||||
listInboundOrders: vi.fn(), createFbaAppointment: vi.fn(), getFbaAppointment: vi.fn(),
|
||||
updateFbaAppointment: vi.fn(), updateInboundPlan: vi.fn(),
|
||||
}))
|
||||
vi.mock('../api/inboundPlans', () => ({ getInboundPlan: vi.fn() }))
|
||||
vi.mock('../api/inventory', () => ({ listInventoryLedger: vi.fn() }))
|
||||
vi.mock('../api/lastMileOrders', () => ({ getLastMileOrder: vi.fn(), createLastMileOrderFee: vi.fn() }))
|
||||
@@ -24,11 +31,35 @@ describe('overseas warehouse mounted real API pages', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub); vi.stubGlobal('localStorage', memoryStorage()); routing.push.mockReset(); routing.route.params = {}; routing.route.query = {}
|
||||
vi.mocked(listPlatformTenants).mockReset().mockResolvedValue({ records: [{ id: '10001', tenantName: '客户甲', status: 'ACTIVE' } as never], page: 1, size: 200, total: 1, pages: 1 })
|
||||
vi.mocked(getInboundPlan).mockReset().mockResolvedValue({ id: '9007199254740993', planNo: 'IP-1', customerId: '10001', sourceOrderId: '9007199254740995', status: 'PENDING_ASSIGNMENT', lineCount: 1, palletCount: 1, totalVolumeCbm: 2, lines: [{ id: '1', orderId: '9007199254740995', inboundCode: 'IN-1', fbaWarehouseCode: 'ONT8', destinationWarehouseName: '洛杉矶仓', volumeCbm: 2, requiredPackageCount: 10, plannedPalletCount: 1 }], pallets: [], allocations: [] })
|
||||
vi.mocked(listInboundOrders).mockReset().mockResolvedValue({ records: [], page: 1, size: 10, total: 0, pages: 0 })
|
||||
vi.mocked(getInboundPlan).mockReset().mockResolvedValue({ id: '9007199254740993', planNo: 'IP-1', customerId: '10001', sourceOrderId: '9007199254740995', overseasWarehouseCode: 'US-LAX-01', overseasWarehouseName: '洛杉矶接柜仓', status: 'PENDING_ASSIGNMENT', lineCount: 1, palletCount: 1, totalVolumeCbm: 2, lines: [{ id: '1', orderId: '9007199254740995', inboundCode: 'IN-1', fbaWarehouseCode: 'ABE3', destinationWarehouseCode: 'ABE3', destinationWarehouseName: 'ABE3目的仓', volumeCbm: 2, requiredPackageCount: 10, plannedPalletCount: 1 }], pallets: [], allocations: [] })
|
||||
vi.mocked(listInventoryLedger).mockReset().mockResolvedValue({ records: [{ id: '9007199254740997', customerId: '10001', customerName: '客户甲', destinationWarehouseCode: 'LAX', inboundCode: 'NYEXX2608300001', inboundOrderNo: 'IP-1', businessType: 'STOCK_IN', beforePackages: 0, deltaPackages: 10, afterPackages: 10, occurredAt: '2026-08-20T10:00:00' }], page: 1, size: 20, total: 1, pages: 1 })
|
||||
vi.mocked(getLastMileOrder).mockReset().mockResolvedValue({ order: { id: '9007199254740999', customerId: '10001', customerName: '华远贸易', orderNo: 'LMO-260827-0001', currentStatus: 'PENDING_ASSIGNMENT' }, cargo: [], statusHistory: [], fees: [], operationHistory: [] })
|
||||
vi.mocked(getLastMileOrder).mockReset().mockResolvedValue({ order: { id: '9007199254740999', customerId: '10001', customerName: '华远贸易', orderNo: 'LMO-260827-0001', inboundCode: 'NYEXX2608280033', containerNo: 'TSTU0000061', currentStatus: 'PENDING_ASSIGNMENT' }, cargo: [], statusHistory: [], fees: [], operationHistory: [] })
|
||||
})
|
||||
it('keeps receiving warehouse and FBA warehouse independent in the inbound-plan list', async () => {
|
||||
vi.mocked(listInboundOrders).mockResolvedValue({
|
||||
records: [
|
||||
{ lineId: '1', planId: '11', planNo: 'IP-1', customerId: '10001', orderId: '21', inboundCode: 'IN-1', overseasWarehouseCode: 'US-LAX-01', overseasWarehouseName: '洛杉矶接柜仓', warehouseId: '31', warehouseCode: 'ABE3', warehouseName: 'ABE3目的仓', fbaWarehouseCode: 'ABE3' },
|
||||
{ lineId: '2', planId: '12', planNo: 'IP-2', customerId: '10001', orderId: '22', inboundCode: 'IN-2', overseasWarehouseCode: null, overseasWarehouseName: '不充整接柜仓', warehouseId: '32', warehouseCode: 'ONT8', warehouseName: 'ONT8目的仓', fbaWarehouseCode: 'ONT8' },
|
||||
], page: 1, size: 10, total: 2, pages: 1,
|
||||
})
|
||||
const wrapper = mount(InboundOrdersView, { global: { plugins: [createPinia(), ElementPlus] } })
|
||||
await flushPromises()
|
||||
const rows = wrapper.findComponent(DataTable).props('data') as Array<{ warehouse: string; fbaWarehouse: string; destinationWarehouseName: string }>
|
||||
expect(rows[0]).toMatchObject({ warehouse: 'US-LAX-01 · 洛杉矶接柜仓', fbaWarehouse: 'ABE3', destinationWarehouseName: 'ABE3目的仓' })
|
||||
expect(rows[1]).toMatchObject({ warehouse: '—', fbaWarehouse: 'ONT8', destinationWarehouseName: 'ONT8目的仓' })
|
||||
})
|
||||
it('mounts inbound detail with independent receiving and FBA warehouse references', async () => { routing.route.params = { orderId: 'IP-1' }; routing.route.query = { customerId: '10001' }; const wrapper = mount(InboundOrderDetailView, { global: { plugins: [ElementPlus] } }); await flushPromises(); expect(getInboundPlan).toHaveBeenCalledWith('IP-1', '10001'); expect(wrapper.text()).toContain('IP-1'); expect(wrapper.text()).toContain('IN-1'); expect(wrapper.text()).not.toContain('9007199254740995'); expect(wrapper.text()).toContain('US-LAX-01 · 洛杉矶接柜仓'); expect(wrapper.text()).toContain('ABE3'); expect(wrapper.text()).not.toContain('ABE3目的仓') })
|
||||
it('shows an empty receiving warehouse when either frozen field is missing without falling back to destination data', async () => {
|
||||
vi.mocked(getInboundPlan).mockResolvedValueOnce({ id: '1', planNo: 'IP-2', customerId: '10001', sourceOrderId: '2', overseasWarehouseCode: null, overseasWarehouseName: '不充整接柜仓', status: 'PENDING_ASSIGNMENT', lineCount: 1, palletCount: 0, totalVolumeCbm: 0, lines: [{ id: '3', orderId: '2', inboundCode: 'IN-2', fbaWarehouseCode: 'ABE3', destinationWarehouseCode: 'ABE3', destinationWarehouseName: 'ABE3目的仓', requiredPackageCount: 1, plannedPalletCount: 0 }], pallets: [], allocations: [] })
|
||||
routing.route.params = { orderId: 'IP-2' }; routing.route.query = { customerId: '10001' }
|
||||
const wrapper = mount(InboundOrderDetailView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
const field = (label: string) => wrapper.findAll('dt').find((item) => item.text() === label)?.element.parentElement?.querySelector('dd')?.textContent
|
||||
expect(field('海外仓')).toBe('—')
|
||||
expect(field('FBA仓库')).toBe('ABE3')
|
||||
expect(wrapper.text()).not.toContain('ABE3目的仓')
|
||||
})
|
||||
it('mounts inbound detail with a string-safe source order id', async () => { routing.route.params = { orderId: 'IP-1' }; routing.route.query = { customerId: '10001' }; const wrapper = mount(InboundOrderDetailView, { global: { plugins: [ElementPlus] } }); await flushPromises(); expect(getInboundPlan).toHaveBeenCalledWith('IP-1', '10001'); expect(wrapper.text()).toContain('9007199254740995'); expect(wrapper.text()).toContain('洛杉矶仓') })
|
||||
it('mounts inventory ledger as localized read-only business facts without exposing internal ids', async () => { const wrapper = mount(InventoryTransactionsView, { global: { plugins: [createPinia(), ElementPlus] } }); await flushPromises(); expect(listInventoryLedger).toHaveBeenCalledWith(expect.objectContaining({ page: 1, size: 10 })); expect(listInventoryLedger).not.toHaveBeenCalledWith(expect.objectContaining({ customerId: expect.anything() })); expect(wrapper.text()).toContain('客户甲'); expect(wrapper.text()).toContain('NYEXX2608300001'); expect(wrapper.text()).not.toContain('10001'); expect(wrapper.text()).toContain('IP-1'); expect(wrapper.text()).toContain('10'); expect(wrapper.text()).toContain('入库'); expect(wrapper.text()).not.toContain('STOCK_IN'); expect(wrapper.text()).toContain('不提供出库单跳转'); expect(wrapper.text()).not.toContain('当前流水 DTO'); expect(wrapper.text()).not.toContain('操作人名称待补充') })
|
||||
it('renders the backend owner and complete short order number in last-mile detail', async () => { routing.route.params = { orderId: '9007199254740999' }; const wrapper = mount(LastMileOrderDetailView, { global: { plugins: [createPinia(), ElementPlus] } }); await flushPromises(); expect(getLastMileOrder).toHaveBeenCalledWith('9007199254740999'); expect(wrapper.text()).toContain('华远贸易'); expect(wrapper.text()).toContain('LMO-260827-0001'); expect(wrapper.get('.order-reference-text').attributes('title')).toBe('LMO-260827-0001') })
|
||||
it('renders complete business references with single-line ellipsis and titles', async () => { routing.route.params = { orderId: '9007199254740999' }; const wrapper = mount(LastMileOrderDetailView, { global: { plugins: [createPinia(), ElementPlus] } }); await flushPromises(); expect(getLastMileOrder).toHaveBeenCalledWith('9007199254740999'); expect(wrapper.text()).toContain('华远贸易'); expect(wrapper.text()).toContain('LMO-260827-0001'); expect(wrapper.get('.order-reference-text').attributes('title')).toBe('LMO-260827-0001'); const businessValues = wrapper.findAll('.detail-field-value--business'); expect(businessValues.map((item) => item.attributes('title'))).toEqual(expect.arrayContaining(['NYEXX2608280033', 'TSTU0000061'])); expect(businessValues.every((item) => item.text().length > 0)).toBe(true) })
|
||||
})
|
||||
|
||||
@@ -14,6 +14,8 @@ export interface LastMileOrderListItem {
|
||||
inboundCode: string
|
||||
lxCode: string
|
||||
owner: string
|
||||
overseasWarehouseId: string
|
||||
overseasWarehouseCode: string
|
||||
warehouse: string
|
||||
transferWarehouse: string
|
||||
fbaWarehouse: string
|
||||
@@ -21,6 +23,11 @@ export interface LastMileOrderListItem {
|
||||
containerStatus: string
|
||||
abnormal: string
|
||||
held: string
|
||||
detained: boolean
|
||||
holdChargeable: boolean
|
||||
holdReason: string
|
||||
manualNodeOverride: boolean
|
||||
version: number
|
||||
pallets: number | '—'
|
||||
cartons: number | '—'
|
||||
volume: number | '—'
|
||||
@@ -53,6 +60,7 @@ export interface InboundOrderListItem {
|
||||
orderId: string
|
||||
warehouseId: string
|
||||
destinationWarehouseCode: string
|
||||
destinationWarehouseName: string
|
||||
orderNo: string
|
||||
inboundCode: string
|
||||
lxCode: string
|
||||
|
||||
@@ -12,8 +12,8 @@ const TableStub = defineComponent({
|
||||
})
|
||||
const ColumnStub = defineComponent({
|
||||
inheritAttrs: false,
|
||||
props: { label: String, prop: String, fixed: [String, Boolean], showOverflowTooltip: Boolean },
|
||||
template: '<div class="column-stub" :data-label="label" :data-prop="prop" :data-fixed="String(fixed || \'\')" :data-overflow-tooltip="String(showOverflowTooltip)"><slot /></div>',
|
||||
props: { label: String, prop: String, fixed: [String, Boolean], showOverflowTooltip: Boolean, className: String },
|
||||
template: '<div class="column-stub" :data-label="label" :data-prop="prop" :data-fixed="String(fixed || \'\')" :data-overflow-tooltip="String(showOverflowTooltip)" :data-class-name="className"><slot /></div>',
|
||||
})
|
||||
const ButtonStub = defineComponent({ template: '<button type="button"><slot /></button>' })
|
||||
const PopoverStub = defineComponent({ template: '<div><slot name="reference" /><slot /></div>' })
|
||||
@@ -152,6 +152,36 @@ describe('DataTable column preferences', () => {
|
||||
expect(wrapper.get('.data-table__pagination').text()).toContain('共 0 条')
|
||||
})
|
||||
|
||||
it('keeps ordinary cells on one line with overflow tooltips and exempts system columns', () => {
|
||||
const wrapper = shallowMount(DataTable, {
|
||||
props: {
|
||||
data: [],
|
||||
columnSettingsMode: 'hide',
|
||||
columns: [
|
||||
{ key: 'actions', label: '操作', fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'customerName', label: '客户' },
|
||||
{ key: 'remark', label: '备注', singleLine: false },
|
||||
],
|
||||
},
|
||||
global: {
|
||||
stubs: { ElTable: TableStub, ElTableColumn: ColumnStub, ElButton: ButtonStub, ElPopover: PopoverStub, ElSelect: true, ElOption: true },
|
||||
directives: { loading: {} },
|
||||
},
|
||||
})
|
||||
|
||||
const columns = wrapper.findAll('.column-stub')
|
||||
expect(columns[0].attributes()).toMatchObject({
|
||||
'data-overflow-tooltip': 'undefined',
|
||||
'data-class-name': 'data-table__system-cell',
|
||||
})
|
||||
expect(columns[1].attributes()).toMatchObject({
|
||||
'data-overflow-tooltip': 'true',
|
||||
'data-class-name': 'data-table__single-line-cell',
|
||||
})
|
||||
expect(columns[2].attributes('data-overflow-tooltip')).toBe('undefined')
|
||||
expect(columns[2].attributes('data-class-name')).toBe('')
|
||||
})
|
||||
|
||||
it('uses key as the data property for ordinary columns only', () => {
|
||||
const wrapper = shallowMount(DataTable, {
|
||||
props: {
|
||||
|
||||
@@ -10,6 +10,9 @@ export interface DataTableColumn {
|
||||
hideable?: boolean
|
||||
movable?: boolean
|
||||
defaultVisible?: boolean
|
||||
singleLine?: boolean
|
||||
showOverflowTooltip?: boolean
|
||||
className?: string
|
||||
[attribute: string]: unknown
|
||||
}
|
||||
|
||||
@@ -241,7 +244,7 @@ watch([page, pageCount], ([value, count]) => {
|
||||
if (value !== nextValue) page.value = nextValue
|
||||
}, { immediate: true })
|
||||
|
||||
function columnAttrs({ key, slotName, headerSlotName, hideable, movable, defaultVisible, ...attrs }: DataTableColumn) {
|
||||
function columnAttrs({ key, slotName, headerSlotName, hideable, movable, defaultVisible, singleLine, ...attrs }: DataTableColumn) {
|
||||
const hasDataProperty = attrs.prop !== undefined || attrs.property !== undefined
|
||||
const isSpecialColumn = slotName || ['selection', 'expand', 'index'].includes(String(attrs.type || ''))
|
||||
if (hasDataProperty || isSpecialColumn || key === undefined || key === null || key === '') return attrs
|
||||
@@ -257,6 +260,22 @@ function columnAttrs({ key, slotName, headerSlotName, hideable, movable, default
|
||||
}
|
||||
}
|
||||
|
||||
function singleLineColumn(column: DataTableColumn) {
|
||||
return column.singleLine !== false && !systemColumn(column)
|
||||
}
|
||||
|
||||
function columnOverflowTooltip(column: DataTableColumn) {
|
||||
return singleLineColumn(column) ? column.showOverflowTooltip ?? true : column.showOverflowTooltip
|
||||
}
|
||||
|
||||
function columnClassName(column: DataTableColumn) {
|
||||
return [
|
||||
column.className,
|
||||
systemColumn(column) ? 'data-table__system-cell' : '',
|
||||
singleLineColumn(column) ? 'data-table__single-line-cell' : '',
|
||||
].filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
function columnRenderKey(column: DataTableColumn, index: number) {
|
||||
return String(column.key ?? column.columnKey ?? column.prop ?? column.property ?? index)
|
||||
}
|
||||
@@ -309,6 +328,8 @@ function setPage(value: number) {
|
||||
v-for="(column, index) in visibleColumns"
|
||||
:key="columnRenderKey(column, index)"
|
||||
v-bind="columnAttrs(column)"
|
||||
:class-name="columnClassName(column)"
|
||||
:show-overflow-tooltip="columnOverflowTooltip(column)"
|
||||
>
|
||||
<template v-if="hasSlot(column.headerSlotName)" #header="scope">
|
||||
<slot :name="column.headerSlotName" v-bind="scope" />
|
||||
@@ -379,6 +400,10 @@ function setPage(value: number) {
|
||||
.data-table__page-buttons { gap: 4px; }
|
||||
.data-table__page-button { width: 32px; min-width: 32px; height: 32px; padding: 0; margin-left: 0; font-size: var(--cc-list-page-button-font-size); }
|
||||
.data-table__page-ellipsis { display: inline-flex; width: 24px; height: 32px; align-items: center; justify-content: center; }
|
||||
.data-table :deep(.data-table__single-line-cell .cell) { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.data-table :deep(.data-table__system-cell .cell) { white-space: nowrap; }
|
||||
.data-table :deep(.data-table__system-cell .el-button + .el-button),
|
||||
.data-table :deep(.data-table__system-cell .el-button + .el-dropdown) { margin-left: 8px; }
|
||||
|
||||
@media (min-width: 1920px) {
|
||||
.data-table__toolbar { min-height: 64px; gap: 12px; padding: 10px 14px; }
|
||||
|
||||
Reference in New Issue
Block a user