Merge branch 'dev' of http://git.buggoglobal.com/html/admin-web into release/test

This commit is contained in:
2026-09-10 20:26:57 +08:00
58 changed files with 865 additions and 123 deletions
+5 -4
View File
@@ -48,8 +48,9 @@
| 客户原始申报资料编辑与渠道变更 | `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/fba-pricing/carrier-rates/`、`modules/inbounds/fee/`、`modules/unified-orders/components/InboundFeeDetailManager.vue` | 价格簿页面显式选择公共报价或具体客户;列表、上传、确认只作用于该 `customerId`,不得混合版本。计价优先消费客户专属 ACTIVE 版本,无专属版本才回退公共 ACTIVE;柜级阶梯金额和各客户分摊只展示服务端结果。调整计费量只能在服务端返回的上下 10% 范围内并提交当前费用版本、原因与二次确认;私人仓尾程派送费使用稳定 `PRIVATE_LAST_MILE_DELIVERY` 代码且只对 `PRIVATE` 目的地开放;一件代发须在预装确认前完成试算,计价后保持服务端 `WAIT_PALLETIZE` 状态 |
| 财务、客户资金账户与柜级公共费用分摊 | `modules/finance/`,继续读该目录 `AGENTS.md`;客户账户 `pages/FinanceCustomerAccountsView.vue`,柜费 `modules/loading-lists/components/ContainerFeesPanel.vue`、`api/containerFees.ts` | `finance/`、`firstleg/`、`file/`、`docs/财务管理/`;账户余额、授信、充值审核和手工充扣只消费 Finance 权威结果。跨客户公共柜费只按服务端柜内客户/订单事实分摊,金额必须守恒,创建与审核、驳回、调整、作废均整组原子处理 |
| 财务、客户资金账户与柜级公共费用分摊 | `modules/finance/`,继续读该目录 `AGENTS.md`;客户账户 `pages/FinanceCustomerAccountsView.vue`,柜费 `modules/loading-lists/components/ContainerFeesPanel.vue`、`api/containerFees.ts` | `finance/`、`firstleg/`、`file/`、`docs/财务管理/`;实际、冻结、可用余额、授信与流水只消费 Finance 权威结果,充值审核和手工充扣携带版本与幂等键。跨客户公共柜费只按服务端柜内客户/订单事实分摊,金额必须守恒,创建与审核、驳回、调整、作废均整组原子处理 |
| 货柜双工作台、排柜、预装/装柜清单、真实柜号后补与整柜报关 | 国内 `modules/container-loading/pages/DomesticContainersView.vue`、海外 `OverseasContainersView.vue`,共享详情实现 `ContainerDetailView.vue`;预装详情 `modules/loading-lists/pages/PreloadPlanDetailView.vue`,资料/PDA 证据 `components/ContainerOverviewPanel.vue`,报关 `components/ContainerCustomsPanel.vue` | 国内“送柜报关”只消费 `/containers/domestic` 与服务端 `allowedActions`,承载模拟柜、订舱关联、智能装柜;模拟柜创建选择与智能装柜一致的 9 类计划渠道,未关联订舱前实际渠道为空,订舱关联候选按计划渠道组匹配、装柜清单页签、出口报关和国内出仓;创建柜次时自动生成唯一预装计划,预装详情先调用 `/loading-plans/{planId}/lifecycle`,完成后深链进入国内货柜详情的装柜清单页签,不再注册独立正式清单导航和路由。订舱关联允许真实集装箱号为空,但 PDA 最终完成前必须补齐;全买单柜继续使用 `complete-all-buy`。海外“清关提柜”只消费 `/containers/overseas`,承载船节点、目的港清关、DO/PU、码头车队、提柜和还空;还空截止事实由服务端基于卸柜完成时间加 72 小时计算,前端不重算。两端统一使用 `containerBookingId`,列表范围由服务端 `scope=ACTIVE/HISTORY` 分页,禁止浏览器筛选或本地推导写资格。后端为 `LoadingListService`、`firstleg/customs/container/`、`firstleg/terminaldrayage/`,文件归 `file/` |
| 合柜报关 / 同柜不可拆组 | `modules/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`,不得从候选页或本地规格重新拼算。人工加入允许跨渠道,智能推荐仍按柜次渠道。柜次容量和限重取服务端字段;同柜组作为单卡整组分页、分配和移除,任一成员已拣时整组保留 |
@@ -62,7 +63,7 @@
| 海外采购、卡车、散板 | `modules/overseas-business/` | `gateway-overseas/`、`lastleg-service/` |
| 海外仓业务合作仓与通用资料 | 合作仓 `modules/warehouses/`;报价/汇率/车辆 `modules/overseas-master-data/` | 国内 `warehouse/`、海外实际资源与服务 |
| 海外仓业务国内出仓后船节点 | `modules/port-schedules/`、`modules/ocean-bookings/`、货柜详情 | `firstleg/portschedule/`、`integration-service/`;预计节点只展示不推进实际进度,手工实际节点必须严格向后追加并防重复,十节点标准见 `../../docs/海外仓业务/船节点标准化契约.md` |
| 海外排车、出库与派送 | `modules/overseas-warehouse/pages/DispatchesView.vue`、`DispatchDetailView.vue`、`OutboundsView.vue`、`DeliveryOrdersView.vue`、`DeliveryOrderDetailView.vue` | API 为 `dispatches.ts`、`outbounds.ts`、`deliveryOrders.ts`;Backend `lastleg-service/dispatch/`、`delivery/`、`dropship/`。一件代发出库从服务端库存位置选择并携带幂等键;排车转派送按稳定 `dispatchNo` 进入服务端命令,可在 `PENDING_DEPARTURE`、`PICKING`、`READY_TO_DEPART` 状态执行并须在 PDA 发车前生成待出库单 |
| 海外排车、出库与派送 | `modules/overseas-warehouse/pages/DispatchesView.vue`、`DispatchDetailView.vue`、`OutboundsView.vue`、`DeliveryOrdersView.vue`、`DeliveryOrderDetailView.vue` | API 为 `dispatches.ts`、`outbounds.ts`、`deliveryOrders.ts`;Backend `lastleg-service/dispatch/`、`delivery/`、`dropship/`。一件代发独立出库,从服务端库存位置选择并携带幂等键;普通出库及换标单经头程排车转出库,按托盘快照绑定头程箱唛,PDA 按托盘作业,详情展示箱唛与已拣数量;排车转派送按稳定 `dispatchNo` 进入服务端命令,可在 `PENDING_DEPARTURE`、`PICKING`、`READY_TO_DEPART` 状态执行并须在 PDA 发车前生成待出库单 |
| 海外异常退回与客户回访 | `modules/overseas-warehouse/pages/ExceptionOrdersView.vue`、`CustomerVisitsView.vue` | API 为 `exceptionOrders.ts`、`customerVisits.ts`;Backend `lastleg-service/exceptionorder/`、`customervisit/`。异常处理只消费服务端 `allowedActions/version`;完成时至少上传一份异常证据。派送退回先从服务端原出库拣货事实选择入仓单和可退箱数,不允许自由填写 SKU/数量;退回海外仓时必须选择服务端校验的目标托盘和库位,库存恢复、异常完成与派送 `RETURNED` 由服务端原子推进。回访附件和派送完成/状态均走海外真实 API |
全量验收按业务从 `../../docs/订单与客户/`、`入库测量与计费/`、`国内货柜履约/`、`财务管理/` 选择最小契约;海外仓业务开发先读 `../../docs/海外仓业务/README.md`。进度、排期或验收状态问题再读取进度表和报告。
@@ -76,10 +77,10 @@
| 尾程订单控制与柜级入仓计划转换 | `modules/overseas-warehouse/pages/LastMileOrdersView.vue`、`LastMileOrderDetailView.vue` | 柜子工作台按 `sourceContainerBookingId` 消费 `/orders/container-summaries`;服务端分页返回全部稳定来源柜,计划已经齐全的柜仍保留用于接柜仓查询和修改,并按冻结 `plannedArrivalAt` 升序、空值最后排序。详情返回全柜订单且不受列表分页截断;单票手工计划保存后,由服务端在整柜最后一票计划齐全时自动生成并绑定全柜托盘码,一票柜立即生成;`resourcePendingPlanCount` 只表示自动生成异常的重试入口,不是正常人工步骤。柜级确认弹框先调用 `/inbound-plans/container-preview`,按服务端 FBA/一件代发/私人地址分组展示托数、`readiness/blockers`,再把同一 `snapshotVersion/snapshotHash` 原样提交 `/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/` |
| 海外出库与派送 | `pages/OutboundsView.vue`、`OutboundDetailView.vue`、`DeliveryOrdersView.vue`、`DeliveryOrderDetailView.vue` | `api/outbounds.ts`、`deliveryOrders.ts`;出库列表按类型筛选并展示中文类型。一件代发新建弹窗直接跨客户加载服务端可用候选,不单独展示客户筛选,海外仓必选并优先默认洛杉矶仓;订单条件可按订单号、入库单号、客户、托盘号、SKU 或品名查询/重置,以托盘+SKU 行分页多选。提交时从所选行解析唯一客户,并校验所选行属于当前唯一海外仓,不要求先选客户。退货出库客户下拉同时展示客户名称与联系电话并支持手机号检索,客户和仓库选项独立容错加载;选择客户后须立即按当前默认/已选海外仓加载库存。SKU 与箱号查询均使用输入态/已查询态分离,只允许点击查询按钮后生效;支持英文逗号批量条件,查询时清除空格并按任一条件命中,左右列表分别提供每页 5/10/20/50 条选择。SKU 列表只展示 `availableQuantity > 0` 的未占用可用数量,合箱后已扣减为零的 SKU 不再作为可选库存显示,实际作业以可用箱唛选择和箱内 SKU 明细扣减为准。取消、派送列表/详情及退回入口对应 Backend `lastleg-service/delivery/`、`dropship/` |
| 异常退回与客户回访 | `pages/ExceptionOrdersView.vue`、`CustomerVisitsView.vue` | `api/exceptionOrders.ts`、`customerVisits.ts`;异常关系解析、异常建档、派送退回、完成/状态和回访附件对应 Backend `lastleg-service/exceptionorder/`、`customervisit/` |
| 库存余额、库位与流水 | `modules/overseas-warehouse/pages/InventoryView.vue`、`InventoryDetailView.vue`、`InventoryTransactionsView.vue` | `modules/overseas-warehouse/api/inventory.ts`;业务库存归 `lastleg-service/inventory/`,实物资源归 `warehouse-overseas-service/` |
| 托盘、箱唛、产品与换标 | `modules/overseas-warehouse/pages/PalletsView.vue`、`CartonsView.vue`、`ProductsView.vue`、`RelabelOrdersView.vue` 及详情页 | `modules/overseas-warehouse/api/pallets.ts`、`relabelOrders.ts`;路由是否公开以 `modules/overseas-warehouse/routes.ts` 为准 |
| 托盘、箱唛、产品与换标 | `modules/overseas-warehouse/pages/PalletsView.vue`、`CartonsView.vue`、`ProductsView.vue`、`RelabelOrdersView.vue` 及详情页 | `modules/overseas-warehouse/api/pallets.ts`、`relabelOrders.ts`;换标详情展示 PDA 完工照片与复核人,状态区分待客户确认和待出库;路由是否公开以 `modules/overseas-warehouse/routes.ts` 为准 |
| 海外采购 | `modules/overseas-business/pages/PurchaseOrdersView.vue`、详情/编辑页 | `modules/overseas-business/api/platformOverseasBusiness.ts` 的 purchase 方法;Backend `lastleg-service/procurement/` |
| 美国卡车、本土散板 | `modules/overseas-business/pages/TransportOrdersView.vue`、`TransportOrderFormView.vue` | 同一 API 的 us-truck/looseboard 方法;Backend `lastleg-service/trucking/`、`looseboard/` |
| 海外报价、月度汇率、车辆 | `modules/overseas-master-data/pages/OverseasQuotesView.vue`、`ExchangeRatesView.vue`、`VehiclesView.vue` | `modules/overseas-master-data/api/overseasMasterData.ts`;Backend `lastleg-service/masterdata/` |
@@ -58,6 +58,8 @@ describe('adminNavigation', () => {
'换标单',
'托盘管理',
'库存管理',
'退货库存管理',
'退货箱号管理',
])
expect(adminNavigation.find((item) => item.key === 'general')?.children?.map((item) => item.title)).toEqual([
'产品信息',
+2
View File
@@ -129,6 +129,8 @@ export const adminNavigation: AdminNavigationNode[] = [
{ key: 'fba-relabel-order', title: '换标单', icon: ClipboardList, path: '/overseas-warehouse/relabel-orders', permission: 'platform:overseas-warehouse:relabel-order:list' },
{ key: 'fba-pallet', title: '托盘管理', icon: WarehouseIcon, path: '/overseas-warehouse/pallets', permission: 'platform:overseas-warehouse:pallet:list' },
{ key: 'overseas-inventory-list', title: '库存管理', icon: WarehouseIcon, path: '/overseas-warehouse/inventory', permission: 'platform:overseas-warehouse:inventory:list' },
{ key: 'return-inventory-list', title: '退货库存管理', icon: WarehouseIcon, path: '/overseas-warehouse/return-inventory', permission: 'platform:overseas-warehouse:return-inventory:list' },
{ key: 'return-cartons-list', title: '退货箱号管理', icon: WarehouseIcon, path: '/overseas-warehouse/return-cartons', permission: 'platform:overseas-warehouse:return-cartons:list' },
],
},
{
@@ -3,7 +3,7 @@ import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { http } from '@/shared/api/http'
import {
adjustSmartLoadingItem, assignPendingReviewSmartLoadingOrder, assignSmartLoadingGroup, assignSmartLoadingItem, getContainer,
adjustSmartLoadingItem, assignPendingReviewSmartLoadingOrder, assignSmartLoadingGroup, assignSmartLoadingItem, assignSmartLoadingPallet, getContainer,
listContainers, listSmartLoadingCandidates, listSmartLoadingWorkbenchContainers, removeAllUnpickedSmartLoadingItems, removeSmartLoadingGroup, removeSmartLoadingItem,
getContainerClearance, downloadContainerClearance, uploadContainerClearance, completeContainerClearance,
getContainerOverview, updateContainerPickupCode, markContainerException, cancelContainerException, uploadContainerDocument, downloadContainerDocument, downloadContainerPdaPhoto,
@@ -52,6 +52,35 @@ describe('platform container loading API', () => {
expect(readFileSync(resolve(process.cwd(), 'src/modules/container-loading/api/platformContainerLoading.ts'), 'utf8')).not.toContain('/transport')
})
it.each(['DOMESTIC', 'OVERSEAS'] as const)('reads the %s detail envelope and exact long identifiers in one request', async (workbench) => {
vi.mocked(http.get).mockResolvedValueOnce({ code: '200', data: {
container: { ...container, id: '9007199254740993', planId: '9007199254740995' },
workbenchStage: 'PRE_LOADING', allowedActions: ['EDIT_CONTAINER'], statusSummary: {}, actualFacts: null, terminalDrayage: null,
} })
expect(await getContainer('9007199254740993', workbench)).toMatchObject({ id: '9007199254740993', planId: '9007199254740995', allowedActions: ['EDIT_CONTAINER'] })
expect(http.get).toHaveBeenCalledExactlyOnceWith(`/platform/v1/containers/${workbench.toLowerCase()}/9007199254740993`)
})
it('keeps empty overseas history pages and query filters without fetching individual details', async () => {
vi.mocked(http.get).mockResolvedValueOnce({ code: '200', data: { records: [], page: 2, size: 20, total: 0, pages: 0 } })
const query = { page: 2, size: 20, warehouseId: '9007199254740993', cabinetNo: 'CB-TEST', scope: 'HISTORY' as const }
expect(await listContainers(query, 'OVERSEAS')).toEqual({ records: [], page: 2, size: 20, total: 0, pages: 0 })
expect(http.get).toHaveBeenCalledExactlyOnceWith('/platform/v1/containers/overseas', { params: query })
})
it('propagates a failed workbench request without retrying retired routes', async () => {
const failure = new Error('workbench unavailable')
vi.mocked(http.get).mockRejectedValueOnce(failure)
await expect(listContainers({ page: 1, size: 20 })).rejects.toBe(failure)
expect(http.get).toHaveBeenCalledTimes(1)
})
it('rejects a retired flat response instead of rendering incorrect container rows', async () => {
vi.mocked(http.get).mockResolvedValueOnce({ code: '200', data: { records: [container], page: 1, size: 20, total: 1, pages: 1 } })
await expect(listContainers({ page: 1, size: 20 })).rejects.toThrow()
expect(http.get).toHaveBeenCalledTimes(1)
})
it('uses the frozen simulation create and ocean booking link contracts', async () => {
vi.mocked(http.post).mockResolvedValue({ code: '200', data: { ...container, oceanBookingId: 101, oceanBookingRequirementId: 202 } })
vi.mocked(http.delete).mockResolvedValue({ code: '200', data: { ...container, oceanBookingId: null, oceanBookingRequirementId: null } })
@@ -91,6 +120,17 @@ describe('platform container loading API', () => {
expect(vi.mocked(http.delete).mock.calls[0]?.[1]).toMatchObject({ data: { expectedPlanVersion: 9, expectedItemVersion: 10, operationId: expect.stringContaining('remove-') } })
})
it('assigns a sealed pallet through the whole-pallet command', async () => {
vi.mocked(http.post).mockResolvedValue({ code: '200', data: plan })
await assignSmartLoadingPallet('10', { palletId: '9007199254740998', expectedVersion: 5 })
expect(http.post).toHaveBeenCalledWith(
'/platform/v1/first-leg/smart-loading/plans/10/pallet-assignments',
{ palletId: '9007199254740998', expectedVersion: 5, operationId: expect.stringContaining('assign-pallet-') },
)
})
it('uses the dedicated provisional assignment command for a pending-review order', async () => {
vi.mocked(http.post).mockResolvedValue({ code: '200', data: plan })
@@ -27,6 +27,8 @@ import type {
ContainerOverviewDocument,
ContainerWorkbenchKind,
ContainerWorkbenchRow,
ContainerWorkbenchItem,
ContainerWorkbenchPage,
} from '../types'
const containerPath = '/platform/v1/containers'
@@ -98,11 +100,10 @@ function container(raw: ContainerBookingResource): ContainerBookingResource {
}
}
function normalizeWorkbenchRow(raw: ContainerWorkbenchRow | ({ container: ContainerBookingResource } & Partial<ContainerWorkbenchRow>)): ContainerWorkbenchRow {
const source = 'container' in raw ? raw.container : raw
function normalizeWorkbenchRow(raw: ContainerWorkbenchItem): ContainerWorkbenchRow {
const drayage = raw.terminalDrayage
return {
...container(source),
...container(raw.container),
workbenchStage: raw.workbenchStage || null,
statusSummary: raw.statusSummary || {},
allowedActions: raw.allowedActions || [],
@@ -128,6 +129,8 @@ function item(raw: SmartLoadingItem): SmartLoadingItem {
id: id(raw.id), loadingPlanId: id(raw.loadingPlanId), accountId: id(raw.accountId),
inboundId: id(raw.inboundId), inboundDetailId: id(raw.inboundDetailId), orderId: id(raw.orderId),
destinationWarehouseId: optionalId(raw.destinationWarehouseId), version: numberValue(raw.version),
palletId: optionalId(raw.palletId), palletNo: raw.palletNo || null,
unitType: raw.unitType, indivisible: Boolean(raw.indivisible),
packageCount: numberValue(raw.packageCount),
pickedPackageCount: numberValue(raw.pickedPackageCount),
minPackageCount: numberValue(raw.minPackageCount, 1),
@@ -162,9 +165,9 @@ function plan(raw: SmartLoadingPlan): SmartLoadingPlan {
export async function listContainers(query: ContainerQuery, workbench: ContainerWorkbenchKind = 'DOMESTIC'): Promise<ContainerPage> {
const params = { ...query, scope: query.scope || 'ACTIVE' }
const response = await http.get<ApiEnvelope<ContainerPage>, ApiEnvelope<ContainerPage>>(`${containerPath}/${workbench.toLowerCase()}`, { params })
const response = await http.get<ApiEnvelope<ContainerWorkbenchPage>, ApiEnvelope<ContainerWorkbenchPage>>(`${containerPath}/${workbench.toLowerCase()}`, { params })
const result = unwrapApiResponse(response)
return { ...result, records: (result.records || []).map((record) => normalizeWorkbenchRow(record as ContainerWorkbenchRow)), page: numberValue(result.page, query.page), size: numberValue(result.size, query.size), total: numberValue(result.total), pages: numberValue(result.pages) }
return { ...result, records: result.records.map(normalizeWorkbenchRow), page: numberValue(result.page, query.page), size: numberValue(result.size, query.size), total: numberValue(result.total), pages: numberValue(result.pages) }
}
export async function getCustomerContainerTransitSummary(customerId: string): Promise<CustomerContainerTransitSummary> {
@@ -180,7 +183,7 @@ export async function getCustomerContainerTransitSummary(customerId: string): Pr
}
export async function getContainer(containerId: string, workbench: ContainerWorkbenchKind = 'DOMESTIC'): Promise<ContainerWorkbenchRow> {
const response = await http.get<ApiEnvelope<ContainerWorkbenchRow>, ApiEnvelope<ContainerWorkbenchRow>>(`${containerPath}/${workbench.toLowerCase()}/${encodeURIComponent(containerId)}`)
const response = await http.get<ApiEnvelope<ContainerWorkbenchItem>, ApiEnvelope<ContainerWorkbenchItem>>(`${containerPath}/${workbench.toLowerCase()}/${encodeURIComponent(containerId)}`)
return normalizeWorkbenchRow(unwrapApiResponse(response))
}
@@ -303,7 +306,8 @@ export async function listSmartLoadingCandidates(query: SmartLoadingCandidateQue
return {
...raw, accountId, customerId: accountId, inboundId: optionalId(raw.inboundId),
orderId: id(raw.orderId), warehouseId: id(raw.warehouseId), destinationWarehouseId: optionalId(raw.destinationWarehouseId),
coLoadGroupId: optionalId(raw.coLoadGroupId),
coLoadGroupId: optionalId(raw.coLoadGroupId), palletId: optionalId(raw.palletId), palletNo: raw.palletNo || null,
unitType: raw.unitType, indivisible: Boolean(raw.indivisible),
memberCount: raw.memberCount == null ? null : numberValue(raw.memberCount),
groupTotalBoxes: raw.groupTotalBoxes == null ? null : numberValue(raw.groupTotalBoxes),
locked: raw.locked == null ? null : Boolean(raw.locked),
@@ -353,6 +357,14 @@ export async function assignSmartLoadingItem(planId: string, payload: { inboundI
return plan(unwrapApiResponse(response))
}
export async function assignSmartLoadingPallet(planId: string, payload: { palletId: string; expectedVersion: number }) {
const response = await http.post<ApiEnvelope<SmartLoadingPlan>, ApiEnvelope<SmartLoadingPlan>>(
`${smartLoadingPath}/plans/${encodeURIComponent(planId)}/pallet-assignments`,
{ ...payload, operationId: operationId('assign-pallet') },
)
return plan(unwrapApiResponse(response))
}
export async function assignPendingReviewSmartLoadingOrder(planId: string, payload: { customerId: string; orderId: string; inboundCode: string; packageCount: number; expectedVersion: number }) {
const response = await http.post<ApiEnvelope<SmartLoadingPlan>, ApiEnvelope<SmartLoadingPlan>>(
`${smartLoadingPath}/plans/${encodeURIComponent(planId)}/pending-review-assignments`,
@@ -23,7 +23,7 @@ const props = defineProps<{
loadingPlanId: string
canList: boolean
canManage: boolean
departureState: 'DEPARTED' | 'WAITING_OTHER_GATES'
departureState: 'DEPARTED' | 'WAITING_OTHER_GATES' | 'UNKNOWN'
}>()
const emit = defineEmits<{ refreshed: [] }>()
@@ -185,7 +185,7 @@ onMounted(load)
</tbody></table>
</div>
</template>
<div v-if="customs.status === 'COMPLETED'" class="departure-state"><strong>国内离仓状态:</strong><el-tag :type="departureState === 'DEPARTED' ? 'success' : 'warning'">{{ departureState === 'DEPARTED' ? '已自动离仓' : '报关已完成,待其他离仓条件' }}</el-tag></div>
<div v-if="customs.status === 'COMPLETED'" class="departure-state"><strong>国内离仓状态:</strong><el-tag :type="departureState === 'DEPARTED' ? 'success' : 'warning'">{{ departureState === 'DEPARTED' ? '已自动离仓' : departureState === 'UNKNOWN' ? '离仓状态尚未读取' : '报关已完成,待其他离仓条件' }}</el-tag></div>
</template>
<el-empty v-else-if="canList" description="当前装柜计划尚未完成,完成装柜后可处理报关" :image-size="56" />
</section>
@@ -50,7 +50,7 @@ async function loadCandidates() {
key: `${candidate.oceanBookingId}:${candidate.requirementId}`,
oceanBookingId: candidate.oceanBookingId,
requirementId: candidate.requirementId,
label: `${candidate.bookingNo} · ${candidate.containerType} × ${candidate.remainingQuantity} · ${candidate.customerName} · ${candidate.warehouseName} · ${candidate.channelName}`,
label: `${candidate.bookingNo} · ${candidate.containerType} × ${candidate.remainingQuantity} · ${candidate.customerName?.trim() || '未关联客户'} · ${candidate.warehouseName} · ${candidate.channelName}`,
}))
} catch (cause) {
if (sequence === requestSequence) error.value = getApiErrorDetails(cause, '已放舱订舱候选加载失败')
@@ -6,6 +6,8 @@ import ContainerDetailView from './ContainerDetailView.vue'
import { getContainer, getContainerOverview, linkContainerOceanBooking } from '../api/platformContainerLoading'
import { listOceanBookingLinkCandidates } from '@/modules/ocean-bookings'
import { recordTerminalDrayageEmptyReturn } from '../api/terminalDrayages'
import { getContainerOutboundDispatch } from '@/modules/outbound-dispatch'
import { ref } from 'vue'
import { getConfirmedLoadingList, getPreloadPlan } from '@/modules/loading-lists'
import DomesticContainerDetailView from './DomesticContainerDetailView.vue'
@@ -37,11 +39,34 @@ describe('ContainerDetailView empty return', () => {
routeState.query = {}
routerPush.mockReset()
canAccess.mockReset().mockReturnValue(true)
vi.mocked(getContainerOutboundDispatch).mockReset().mockResolvedValue({ aggregate: { dispatchStatus: 'WAITING_FOR_LOADING', gate: { inbounds: [] } }, timeline: [] } as never)
vi.mocked(getContainer).mockReset().mockResolvedValue(base as never)
vi.mocked(getContainerOverview).mockReset().mockResolvedValue(overview as never)
vi.mocked(recordTerminalDrayageEmptyReturn).mockReset().mockResolvedValue({ ...base.terminalDrayage, status: 'EMPTY_RETURNED', version: 4, actualEmptyReturnAt: '2026-09-09T12:00:00', allowedActions: [] } as never)
})
it('loads departed gates exactly once when read permission arrives after the container detail', async () => {
const granted = ref(false)
canAccess.mockImplementation((code) => code === 'platform:firstleg:outbound-dispatch:view' ? granted.value : true)
const wrapper = await mountDetail('DOMESTIC')
expect(getContainerOutboundDispatch).not.toHaveBeenCalled()
granted.value = true
await flushPromises()
expect(getContainerOutboundDispatch).toHaveBeenCalledTimes(1)
expect(wrapper.text()).toContain('已确认离仓')
expect(wrapper.text()).toContain('整柜已确认离仓;')
expect(wrapper.text()).not.toContain('全部门禁已通过,可确认整柜离仓')
})
it('reloads authoritative departure state once on each newly opened domestic detail', async () => {
const first = await mountDetail('DOMESTIC')
expect(first.text()).toContain('已确认离仓')
first.unmount()
const reopened = await mountDetail('DOMESTIC')
expect(reopened.text()).toContain('已确认离仓')
expect(getContainerOutboundDispatch).toHaveBeenCalledTimes(2)
})
it.each(['DOMESTIC', 'OVERSEAS'] as const)('gates fee tabs by granted permissions in %s', async (workbench) => {
for (const [firstLeg, lastLeg] of [[true, false], [false, true], [true, true], [false, false]]) {
canAccess.mockImplementation((code) => code === 'firstleg:inbound:fee-detail:view' ? firstLeg! : code === 'platform:finance:customer-arap:list' ? lastLeg! : false)
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft, ClipboardList, FileUp, Link2, Package, Receipt, Wallet } from '@lucide/vue'
import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus'
@@ -200,8 +200,9 @@ const canConfirmDeparture = computed(() => canConfirmOutboundDispatch.value
&& hasAction('CONFIRM_OUTBOUND')
&& outboundDetail.value?.aggregate.dispatchStatus === 'READY_FOR_DISPATCH'
&& outboundGateSummary.value.blocked === 0)
const customsDepartureState = computed<'DEPARTED' | 'WAITING_OTHER_GATES'>(() =>
outboundDetail.value?.aggregate.dispatchStatus === 'WAITING_FOR_LOADING' ? 'DEPARTED' : 'WAITING_OTHER_GATES')
const customsDepartureState = computed<'DEPARTED' | 'WAITING_OTHER_GATES' | 'UNKNOWN'>(() =>
!outboundDetail.value ? 'UNKNOWN'
: outboundDetail.value.aggregate.dispatchStatus === 'WAITING_FOR_LOADING' ? 'DEPARTED' : 'WAITING_OTHER_GATES')
function openOceanBookingLink() {
if (!detail.value || !canLinkOceanBooking.value) return
oceanBookingLinkOpen.value = true
@@ -283,11 +284,19 @@ async function load() {
workbenchDetail.value = resource; detail.value = resource; overview.value = aggregate
if (activeTab.value === 'loading-list' && !canViewCurrentLoadingList.value) activeTab.value = 'overview'
if (isDomestic.value && activeTab.value === 'loading-list') await loadLoadingDetail(resource, aggregate)
if (isDomestic.value && resource.planStatus === 'COMPLETED' && canViewOutboundDispatch.value) await loadOutboundDispatch()
} catch (cause) { error.value = getApiErrorDetails(cause, '货柜详情加载失败') }
finally { loading.value = false }
}
watch(
() => !loading.value && isDomestic.value && detail.value?.planStatus === 'COMPLETED'
&& canViewOutboundDispatch.value ? containerId.value : null,
(eligibleContainerId) => {
if (eligibleContainerId) void loadOutboundDispatch()
else { outboundDetail.value = null; outboundError.value = null }
},
)
async function loadOutboundDispatch() {
if (!containerId.value || !canViewOutboundDispatch.value) return
outboundLoading.value = true; outboundError.value = null; showPassedGates.value = false
@@ -533,7 +542,7 @@ onMounted(async () => { await load(); if (isOverseas.value && route.query.action
<template v-else-if="outboundDetail">
<div class="gate-summary"><span>总门禁 <strong>{{ outboundGateSummary.total }}</strong></span><span class="gate-summary__passed">已通过 <strong>{{ outboundGateSummary.passed }}</strong></span><span :class="{ 'gate-summary__blocked': outboundGateSummary.blocked }">阻断 <strong>{{ outboundGateSummary.blocked }}</strong></span><span class="gate-summary__reference">拖车状态(参考):{{ truckReferenceText(outboundDetail.aggregate.truckAppointmentStatus) }}</span><el-button v-if="outboundGateSummary.passed" link type="primary" @click="showPassedGates = !showPassedGates">{{ showPassedGates ? '收起已通过项' : `查看全部门禁(${outboundGateSummary.total})` }}</el-button></div>
<p v-if="outboundGateSummary.blocked" class="outbound-panel__hint">默认仅展示阻断项;处理完成后重新加载门禁即可确认离仓。</p>
<p v-else-if="!showPassedGates" class="outbound-panel__hint outbound-panel__hint--success">全部门禁已通过,可确认整柜离仓;展开可查看去重后的完整门禁。</p>
<p v-else-if="!showPassedGates" class="outbound-panel__hint outbound-panel__hint--success">{{ customsDepartureState === 'DEPARTED' ? '整柜已确认离仓;展开可查看去重后的完整门禁。' : '全部门禁已通过,可确认整柜离仓;展开可查看去重后的完整门禁。' }}</p>
<div class="gate-list">
<article v-for="row in visibleOutboundGateRows" :key="row.key" class="gate-row">
<el-tag size="small" :type="row.passed ? 'success' : 'danger'">{{ row.passed ? '通过' : '阻断' }}</el-tag><strong>{{ row.name }}</strong><span class="gate-row__actual">{{ row.actual || '暂无事实' }}</span><small>要求:{{ row.requirement || '—' }}</small>
@@ -1,6 +1,7 @@
import ElementPlus from 'element-plus'
import { createPinia, setActivePinia } from 'pinia'
import { createMemoryHistory, createRouter, RouterView } from 'vue-router'
import { defineComponent } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { readFileSync } from 'node:fs'
@@ -36,7 +37,7 @@ const drayage = {
function createTestRouter() {
return createRouter({ history: createMemoryHistory(), routes: [
{ path: '/', component: ContainersView },
{ path: '/', name: 'domestic-container-workbench', component: ContainersView },
{ path: '/container-loading/domestic/:containerId/smart-loading', name: 'smart-loading-workbench', component: { template: '<div />' } },
{ path: '/loading-plans', name: 'loading-plans', component: { template: '<div />' } },
{ path: '/container-loading/domestic/:containerId', name: 'domestic-container-detail', component: { template: '<div />' } },
@@ -624,6 +625,26 @@ describe('ContainersView', () => {
wrapper.unmount()
})
it.each([undefined, null, '', '客户一'])('renders optional booking customer %s without leaking a missing value', async (customerName) => {
vi.mocked(listOceanBookingLinkCandidates).mockResolvedValueOnce({ records: [{
oceanBookingId: '315', bookingNo: 'OB-OPTIONAL-CUSTOMER', requirementId: '45315',
containerType: '40HC', remainingQuantity: 1, customerName, warehouseName: '义乌仓', channelName: 'EXX海卡',
}], page: 1, size: 200, total: 1, pages: 1 } as never)
const router = createTestRouter()
await router.push('/'); await router.isReady()
const wrapper = mount(ContainersView, { attachTo: document.body, global: { plugins: [ElementPlus, router] } })
await flushPromises()
await wrapper.find('button[aria-label="更多货柜操作"]').trigger('click')
await flushPromises()
Array.from(document.body.querySelectorAll<HTMLElement>('.el-dropdown-menu__item'))
.filter((item) => item.textContent?.includes('关联已放舱订舱')).at(-1)!.click()
await flushPromises()
const label = `OB-OPTIONAL-CUSTOMER · 40HC × 1 · ${customerName || '未关联客户'} · 义乌仓 · EXX海卡`
expect(document.body.textContent).toContain(label)
expect((wrapper.findComponent(OceanBookingLinkDialog).vm as unknown as { candidates: Array<{ label: string }> }).candidates[0]?.label).toBe(label)
wrapper.unmount()
})
it('keeps a three-field match selectable when only capacity snapshots differ and blocks duplicate waves', async () => {
let resolveCandidates!: (value: Awaited<ReturnType<typeof listOceanBookingLinkCandidates>>) => void
vi.mocked(listContainers).mockResolvedValue({ records: [{
@@ -1,6 +1,6 @@
<script setup lang="ts">
import ContainerSimulationCreate from '../components/ContainerSimulationCreate.vue'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import ContainerSimulationCreate from '../components/ContainerSimulationCreate.vue'
import { useRouter } from 'vue-router'
import { Boxes, Check, Eye, FileText, FileUp, Link2, ListChecks, Mail, MoreHorizontal, Pencil, Sparkles } from '@lucide/vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
@@ -12,6 +12,7 @@ import {
assignPendingReviewSmartLoadingOrder,
assignSmartLoadingGroup,
assignSmartLoadingItem,
assignSmartLoadingPallet,
getSmartLoadingPlanByBooking,
listContainers,
listSmartLoadingCandidates,
@@ -42,6 +43,7 @@ vi.mock('../api/platformContainerLoading', () => ({
listSmartLoadingWorkbenchContainers: vi.fn(),
createSmartLoadingPlan: vi.fn(),
assignSmartLoadingItem: vi.fn(),
assignSmartLoadingPallet: vi.fn(),
assignPendingReviewSmartLoadingOrder: vi.fn(),
assignSmartLoadingGroup: vi.fn(),
recommendSmartLoading: vi.fn(),
@@ -73,7 +75,7 @@ const plan = {
canAssign: true, canEdit: true, blockedReason: null, finalCompletionStatus: 'NOT_STARTED' as const,
pdaTaskId: null, pdaTaskStatus: null, pdaTaskVersion: null,
coLoadGroups: [],
items: [{ id: '101', loadingPlanId: '99', accountId: '1', inboundId: '2', inboundDetailId: '3', orderId: '4', orderNo: 'ORD-1', inboundCode: 'IN-1', customerName: '示例企业', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', skuCode: 'SKU-1', productNameZh: '服装', packageCount: 6, productQuantity: 6, grossWeightKg: 60, volumeCbm: 1.2, loadSequence: 1, version: 1, pickedPackageCount: 0, minPackageCount: 1, canAdjust: true, canRemove: true }],
items: [{ id: '101', loadingPlanId: '99', accountId: '1', inboundId: '2', inboundDetailId: '3', orderId: '4', orderNo: 'ORD-1', inboundCode: 'IN-1', customerName: '示例企业', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', skuCode: 'SKU-1', productNameZh: '服装', unitType: 'PACKAGE' as const, palletId: null, palletNo: null, indivisible: false, packageCount: 6, productQuantity: 6, grossWeightKg: 60, volumeCbm: 1.2, loadSequence: 1, version: 1, pickedPackageCount: 0, minPackageCount: 1, canAdjust: true, canRemove: true }],
}
const candidate = {
accountId: '1', inboundId: '5', orderId: '7', orderNo: 'ORD-2', inboundCode: 'IN-2',
@@ -88,7 +90,7 @@ function candidatePage(records: unknown[], page = 1, size = 20, total = records.
return {
records: records.map((record) => {
const candidate = record as { accountId?: string; customerId?: string; groupMembers?: unknown[] }
return { customerId: candidate.customerId || candidate.accountId || '', groupMembers: [], ...candidate }
return { customerId: candidate.customerId || candidate.accountId || '', groupMembers: [], unitType: 'PACKAGE', palletId: null, palletNo: null, indivisible: false, ...candidate }
}),
page, size, total, pages: total ? Math.ceil(total / size) : 0,
} as Awaited<ReturnType<typeof listSmartLoadingCandidates>>
@@ -116,7 +118,8 @@ describe('GlobalSmartLoadingView', () => {
it('uses one-line shared text clamps for co-load and assigned-count summaries', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/GlobalSmartLoadingView.vue'), 'utf8')
expect(source).toContain('<LineClampText :content="row.memberOrderSummaries.join(\';\')" />')
expect(source).toContain('<LineClampText v-if="row.coLoadGroup" :content="`${row.packageCount} 箱(整组固定)`" />')
expect(source).toContain('<LineClampText v-else-if="row.coLoadGroup" :content="`${row.packageCount} 箱(整组固定)`" />')
expect(source).toContain("row.unitType === 'SEALED_PALLET'")
expect(source).toContain('.candidate-group { display: grid; width: 100%; min-width: 0;')
expect(source).toContain('.candidate-group :deep(.line-clamp-text) { width: 100%; }')
expect(source).not.toContain('<span v-for="summary in row.memberOrderSummaries"')
@@ -18,6 +18,7 @@ import {
assignPendingReviewSmartLoadingOrder,
assignSmartLoadingGroup,
assignSmartLoadingItem,
assignSmartLoadingPallet,
getSmartLoadingPlanByBooking,
listSmartLoadingCandidates,
listSmartLoadingWorkbenchContainers,
@@ -101,6 +102,7 @@ const warehouseOptions = computed(() => {
const editable = computed(() => plan.value?.canEdit === true)
const assignable = computed(() => plan.value?.canAssign === true)
const assignedInboundIds = computed(() => new Set((plan.value?.items || []).map((item) => item.inboundId)))
const assignedPalletIds = computed(() => new Set((plan.value?.items || []).map((item) => item.palletId).filter(Boolean)))
type SmartLoadingCandidateRow = SmartLoadingCandidate & { memberInboundIds: string[]; memberInboundCodes: string[]; memberOrderSummaries: string[]; memberOrderCount: number; inboundFragmentCount: number; totalGrossWeightKg: number | null; totalVolumeCbm: number | null }
function memberOrderSummaries<T extends { orderId: string; orderNo?: string | null; inboundCode?: string | null }>(members: T[]) {
const orders = new Map<string, { label: string; inboundCodes: string[] }>()
@@ -118,6 +120,7 @@ function groupMemberLabel(group: SmartLoadingPlanCoLoadGroup) {
: `${group.memberOrderCount} 票 · ${group.inboundFragmentCount} 个入仓片段`
}
function candidateTotalGrossWeight(candidate: SmartLoadingCandidate) {
if (candidate.unitType === 'SEALED_PALLET') return candidate.unitGrossWeightKg
return candidate.unitGrossWeightKg == null ? null : Number(candidate.unitGrossWeightKg) * Number(candidate.availablePackageCount || 0)
}
function candidateTotalVolume(candidate: SmartLoadingCandidate) {
@@ -225,20 +228,23 @@ function preferredContainerId() {
const candidateColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' },
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 140, fixed: 'left', showOverflowTooltip: true },
{ key: 'palletNo', prop: 'palletNo', label: '托盘号', minWidth: 145, showOverflowTooltip: true },
{ key: 'unitType', label: '分配单位', width: 105, slotName: 'unitType' },
{ key: 'coLoadGroup', label: '同柜', minWidth: 80, slotName: 'coLoadGroup' },
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 70, slotName: 'customerName' },
{ key: 'orderStatusSnapshot', prop: 'orderStatusSnapshot', label: '订单状态', width: 100, slotName: 'orderStatusSnapshot' },
{ key: 'availablePackageCount', prop: 'availablePackageCount', label: '可用箱数', width: 80 },
{ key: 'availablePackageCount', prop: 'availablePackageCount', label: '箱数', width: 80 },
// { key: 'cartonIdentityStatus', label: '箱号归属', width: 105, slotName: 'cartonIdentityStatus' },
{ key: 'unitGrossWeightKg', label: '单箱毛重', width: 80, slotName: 'unitGrossWeight' },
{ key: 'unitGrossWeightKg', label: '单位毛重', width: 90, slotName: 'unitGrossWeight' },
{ key: 'totalGrossWeightKg', label: '重量', width: 90, slotName: 'candidateTotalWeight' },
{ key: 'averageUnitVolumeCbm', label: '单箱体积', width: 80, slotName: 'averageUnitVolume' },
{ key: 'averageUnitVolumeCbm', label: '单位体积', width: 90, slotName: 'averageUnitVolume' },
{ key: 'totalVolumeCbm', label: '体积', width: 90, slotName: 'candidateTotalVolume' },
{ key: 'estimatedVolumeCbm', label: '预估体积', width: 100, slotName: 'estimatedVolume' },
]
const itemColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' },
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 140, fixed: 'left', showOverflowTooltip: true },
{ key: 'palletNo', prop: 'palletNo', label: '托盘号', minWidth: 145, showOverflowTooltip: true },
{ key: 'coLoadGroup', label: '同柜', minWidth: 80, slotName: 'coLoadGroup' },
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 70, slotName: 'customerName' },
{ key: 'packageCount', label: '分配箱数', width: 140, slotName: 'packageCount' },
@@ -494,7 +500,8 @@ function openAssign(source: object) {
assignOpen.value = true
}
async function assign() {
if (!plan.value || !selectedCandidate.value || assignForm.packageCount < 1) return
if (!plan.value || !selectedCandidate.value) return
if (selectedCandidate.value.unitType !== 'SEALED_PALLET' && assignForm.packageCount < 1) return
if (selectedCandidate.value.coLoadGroupId && (selectedCandidate.value.blockReason || selectedCandidate.value.locked)) return
if (!selectedCandidate.value.coLoadGroupId && assignForm.packageCount > selectedCandidate.value.availablePackageCount) {
ElMessage.warning(`本次最多可分配 ${selectedCandidate.value.availablePackageCount} 箱`)
@@ -502,7 +509,9 @@ async function assign() {
}
await mutate(async () => {
const selected = selectedCandidate.value!
acceptPlan(selected.orderStatusSnapshot === 'PENDING_REVIEW'
acceptPlan(selected.unitType === 'SEALED_PALLET' && selected.palletId
? await assignSmartLoadingPallet(plan.value!.id, { palletId: selected.palletId, expectedVersion: plan.value!.version })
: selected.orderStatusSnapshot === 'PENDING_REVIEW'
? await assignPendingReviewSmartLoadingOrder(plan.value!.id, {
customerId: selected.accountId,
orderId: selected.orderId,
@@ -523,6 +532,10 @@ async function assign() {
}, '分配明细失败', { preserveAssign: true })
}
function candidateBlocked(candidate: SmartLoadingCandidateRow) {
if (candidate.unitType === 'SEALED_PALLET') {
return Boolean(candidate.blockReason) || candidate.locked === true || !candidate.palletId
|| !candidate.indivisible || assignedPalletIds.value.has(candidate.palletId)
}
if (candidate.orderStatusSnapshot === 'PENDING_REVIEW') {
return Boolean(candidate.blockReason) || candidate.locked === true
|| !candidate.accountId || !candidate.inboundCode || candidate.availablePackageCount < 1
@@ -551,6 +564,7 @@ async function recommend() {
}
async function adjust(source: object) {
const item = itemRow(source)
if (item.unitType === 'SEALED_PALLET') return
if (!plan.value || mutating.value) return
const count = Number(quantityDrafts[item.id])
if (count === item.packageCount) return
@@ -716,10 +730,10 @@ onMounted(() => { void load(); void loadCustomers() })
plain
:disabled="mutating || candidateBlocked(row as SmartLoadingCandidateRow)"
@click="openAssign(row)"
><Plus :size="15" />{{ row.coLoadGroupId ? '整组分配' : '加入' }}</el-button>
><Plus :size="15" />{{ row.unitType === 'SEALED_PALLET' ? '整托分配' : row.coLoadGroupId ? '整组分配' : '加入' }}</el-button>
</template>
<template #customerName="{ row }"><LineClampText :content="row.customerName" /></template>
<template #coLoadGroup="{ row }"><div v-if="row.coLoadGroupId" class="candidate-group"><el-tag size="small" type="warning"><LineClampText :content="`${row.groupNo} · ${row.memberOrderCount} 票 · ${row.inboundFragmentCount} 个入仓片段`" /></el-tag><LineClampText :content="row.memberOrderSummaries.join(';')" /><LineClampText :content="`整组 ${row.groupTotalBoxes} 箱,必须全量进入同一柜`" /><LineClampText v-if="row.blockReason" class="candidate-block" :content="row.blockReason" /><LineClampText v-else-if="row.locked" content="该组已锁定,不能重复分配" /></div><LineClampText v-else content="普通单票" /></template>
<template #coLoadGroup="{ row }"><div v-if="row.coLoadGroupId" class="candidate-group"><el-tag size="small" type="warning"><LineClampText :content="`${row.groupNo} · ${row.memberOrderCount} 票 · ${row.inboundFragmentCount} 个入仓片段`" /></el-tag><LineClampText :content="row.memberOrderSummaries.join(';')" /><LineClampText :content="`整组 ${row.groupTotalBoxes} 箱,必须全量进入同一柜`" /><LineClampText v-if="row.blockReason" class="candidate-block" :content="row.blockReason" /><LineClampText v-else-if="row.locked" content="该组已锁定,不能重复分配" /></div><LineClampText v-else :content="row.unitType === 'SEALED_PALLET' ? '封托不可拆' : '普通单票'" /></template>
<template #orderStatusSnapshot="{ row }"><el-tag size="small" effect="plain">{{ candidateStatusLabel(row.orderStatusSnapshot) }}</el-tag></template>
<template #unitGrossWeight="{ row }">{{ formatMeasurement(row.unitGrossWeightKg, 'kg', 1) }}</template>
<template #candidateTotalWeight="{ row }">{{ formatMeasurement(row.totalGrossWeightKg, 'kg', 1) }}</template>
@@ -793,8 +807,9 @@ onMounted(() => { void load(); void loadCustomers() })
<DataTable :data="displayedPlanItems" :columns="itemColumns" :loading="selectionLoading || mutating" stripe preference-key="global-smart-loading-items" column-settings-mode="hide" empty-text="当前柜次暂无装柜明细">
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canDelete && editable && row.canRemove" size="small" type="danger" text :disabled="mutating" @click="remove(row)"><Trash2 :size="15" />删除</el-button></div></template>
<template #customerName="{ row }"><LineClampText :content="row.customerName" /></template>
<template #coLoadGroup="{ row }"><div v-if="row.coLoadGroup" class="candidate-group"><el-tag size="small" type="warning"><LineClampText :content="`${row.coLoadGroup.groupNo} · ${groupMemberLabel(row.coLoadGroup)}`" /></el-tag><LineClampText :content="row.memberOrderSummaries.join(';')" /><LineClampText :content="`固定 ${row.coLoadGroup.fullPackageCount} 箱 · ${row.coLoadGroup.lockedPlanId ? '已锁定' : '未锁定'}`" /></div><LineClampText v-else content="普通单票" /></template>
<template #packageCount="{ row }"><LineClampText v-if="row.coLoadGroup" :content="`${row.packageCount} 箱(整组固定)`" /><div v-else class="package-count-editor"><el-input-number v-if="editingQuantityId === row.id" v-model="quantityDrafts[row.id]" :min="row.minPackageCount" :max="adjustmentMax(row)" :step="1" :controls="false" :disabled="mutating" placeholder="请输入使用箱数" @blur="adjust(row)" @keyup.enter="adjust(row)" /><div v-else class="package-count-summary"><div class="package-count-used"><strong>使用:{{ row.packageCount }}</strong><el-button v-if="canAssign && editable && row.canAdjust" link circle :disabled="mutating" aria-label="调整箱数" @click="startQuantityEdit(row)"><Pencil :size="14" /></el-button></div><small v-if="(row.remainingPackageCount ?? 0) > 0">未用:{{ row.remainingPackageCount }}</small></div><LineClampText v-if="row.detailCount > 1 && editable" content="多规格,需删除后重加" /><LineClampText v-if="row.pickedPackageCount > 0" :content="`已拣 ${row.pickedPackageCount} 箱`" /></div></template>
<template #coLoadGroup="{ row }"><div v-if="row.coLoadGroup" class="candidate-group"><el-tag size="small" type="warning"><LineClampText :content="`${row.coLoadGroup.groupNo} · ${groupMemberLabel(row.coLoadGroup)}`" /></el-tag><LineClampText :content="row.memberOrderSummaries.join(';')" /><LineClampText :content="`固定 ${row.coLoadGroup.fullPackageCount} 箱 · ${row.coLoadGroup.lockedPlanId ? '已锁定' : '未锁定'}`" /></div><LineClampText v-else :content="row.unitType === 'SEALED_PALLET' ? '封托不可拆' : '普通单票'" /></template>
<template #packageCount="{ row }"><LineClampText v-if="row.unitType === 'SEALED_PALLET'" :content="`${row.packageCount} 箱(整托固定)`" /><LineClampText v-else-if="row.coLoadGroup" :content="`${row.packageCount} 箱(整组固定)`" /><div v-else class="package-count-editor"><el-input-number v-if="editingQuantityId === row.id" v-model="quantityDrafts[row.id]" :min="row.minPackageCount" :max="adjustmentMax(row)" :step="1" :controls="false" :disabled="mutating" placeholder="请输入使用箱数" @blur="adjust(row)" @keyup.enter="adjust(row)" /><div v-else class="package-count-summary"><div class="package-count-used"><strong>使用:{{ row.packageCount }}</strong><el-button v-if="canAssign && editable && row.canAdjust" link circle :disabled="mutating" aria-label="调整箱数" @click="startQuantityEdit(row)"><Pencil :size="14" /></el-button></div><small v-if="(row.remainingPackageCount ?? 0) > 0">未用:{{ row.remainingPackageCount }}</small></div><LineClampText v-if="row.detailCount > 1 && editable" content="多规格,需删除后重加" /><LineClampText v-if="row.pickedPackageCount > 0" :content="`已拣 ${row.pickedPackageCount} 箱`" /></div></template>
<template #unitType="{ row }"><el-tag size="small" :type="row.unitType === 'SEALED_PALLET' ? 'success' : 'info'">{{ row.unitType === 'SEALED_PALLET' ? '封托托盘' : '箱件' }}</el-tag></template>
<template #weight="{ row }">{{ formatNumber(Number(row.grossWeightKg || 0), 1) }} kg</template>
<template #volume="{ row }">{{ formatNumber(Number(row.volumeCbm || 0)) }} m³</template>
</DataTable>
@@ -802,7 +817,7 @@ onMounted(() => { void load(); void loadCustomers() })
</section>
</section>
<AppDialog v-model="assignOpen" :title="selectedCandidate?.coLoadGroupId ? '整组加入当前柜次' : '加入当前柜次'" :confirm-loading="mutating" :confirm-disabled="Boolean(selectedCandidate?.blockReason || selectedCandidate?.locked)" :confirm-text="selectedCandidate?.coLoadGroupId ? '确认整组分配' : '确认加入'" @confirm="assign">
<AppDialog v-model="assignOpen" :title="selectedCandidate?.unitType === 'SEALED_PALLET' ? '整托加入当前柜次' : selectedCandidate?.coLoadGroupId ? '整组加入当前柜次' : '加入当前柜次'" :confirm-loading="mutating" :confirm-disabled="Boolean(selectedCandidate?.blockReason || selectedCandidate?.locked)" :confirm-text="selectedCandidate?.unitType === 'SEALED_PALLET' ? '确认整托分配' : selectedCandidate?.coLoadGroupId ? '确认整组分配' : '确认加入'" @confirm="assign">
<div v-if="selectedCandidate" class="assign-dialog">
<p><strong>{{ selectedCandidate.groupNo || selectedCandidate.customerName || '客户待补充' }}</strong></p>
<p>目标柜次:{{ displayCabinetNo(selectedContainer) }} · 集装箱号:{{ displayActualContainerNo(selectedContainer) }}</p>
@@ -814,6 +829,10 @@ onMounted(() => { void load(); void loadCustomers() })
<p v-for="summary in selectedCandidate.memberOrderSummaries" :key="summary">{{ summary }}</p>
<el-alert :title="selectedCandidate.blockReason || '同柜且不可拆:全部订单及其入仓片段按完整箱数一次加入,不能部分输入、单独分配或单独移除。'" :type="selectedCandidate.blockReason ? 'error' : 'warning'" :closable="false" />
</template>
<template v-else-if="selectedCandidate.unitType === 'SEALED_PALLET'">
<p>托盘号 {{ selectedCandidate.palletNo || '—' }} · 入仓码 {{ selectedCandidate.inboundCode || '—' }} · {{ selectedCandidate.availablePackageCount }} 箱</p>
<el-alert title="该封托托盘将作为一个不可拆分单元整托加入;装柜阶段复用此托盘,不会重新生成工作托盘。" type="warning" :closable="false" />
</template>
<template v-else>
<p>入仓码 {{ selectedCandidate.inboundCode || '—' }} · 可用 {{ selectedCandidate.availablePackageCount }} 箱 · 平均单箱毛重 {{ formatMeasurement(selectedCandidate.unitGrossWeightKg, 'kg', 1) }} · 平均单箱体积 {{ formatMeasurement(selectedCandidate.averageUnitVolumeCbm, 'm³') }}</p>
<small>本次只记录该入仓码的计划装柜箱数,具体箱号归属为未核验。</small>
@@ -7,11 +7,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import SmartLoadingWorkbenchView from './SmartLoadingWorkbenchView.vue'
import { assignSmartLoadingGroup, assignSmartLoadingItem, getContainer, getSmartLoadingPlanByBooking, listSmartLoadingCandidates, recommendSmartLoading, removeSmartLoadingGroup } from '../api/platformContainerLoading'
import { assignSmartLoadingGroup, assignSmartLoadingItem, assignSmartLoadingPallet, getContainer, getSmartLoadingPlanByBooking, listSmartLoadingCandidates, recommendSmartLoading, removeSmartLoadingGroup } from '../api/platformContainerLoading'
vi.mock('../api/platformContainerLoading', () => ({
getContainer: vi.fn(), getSmartLoadingPlanByBooking: vi.fn(), listSmartLoadingCandidates: vi.fn(),
createSmartLoadingPlan: vi.fn(), assignSmartLoadingItem: vi.fn(), assignSmartLoadingGroup: vi.fn(), recommendSmartLoading: vi.fn(),
createSmartLoadingPlan: vi.fn(), assignSmartLoadingItem: vi.fn(), assignSmartLoadingPallet: vi.fn(), assignSmartLoadingGroup: vi.fn(), recommendSmartLoading: vi.fn(),
adjustSmartLoadingItem: vi.fn(), removeSmartLoadingItem: vi.fn(), removeSmartLoadingGroup: vi.fn(), pushSmartLoadingPreload: vi.fn(),
}))
vi.mock('../smartLoadingPlanGroups', () => ({ loadSmartLoadingPlanCoLoadGroups: vi.fn().mockResolvedValue(new Map()) }))
@@ -36,7 +36,7 @@ function candidatePage(records: unknown[]) {
return {
records: records.map((record) => {
const candidate = record as { accountId?: string; customerId?: string; groupMembers?: unknown[] }
return { customerId: candidate.customerId || candidate.accountId || '', groupMembers: [], ...candidate }
return { customerId: candidate.customerId || candidate.accountId || '', groupMembers: [], unitType: 'PACKAGE', palletId: null, palletNo: null, indivisible: false, ...candidate }
}),
page: 1, size: 200, total: records.length, pages: records.length ? 1 : 0,
} as Awaited<ReturnType<typeof listSmartLoadingCandidates>>
@@ -46,7 +46,8 @@ describe('SmartLoadingWorkbenchView', () => {
it('uses one-line shared text clamps for co-load and assigned-count summaries', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/SmartLoadingWorkbenchView.vue'), 'utf8')
expect(source).toContain('<LineClampText :content="row.memberOrderSummaries.join(\';\')" />')
expect(source).toContain('<LineClampText v-if="row.coLoadGroup" :content="`${row.packageCount} 箱(整组固定)`" />')
expect(source).toContain('<LineClampText v-else-if="row.coLoadGroup" :content="`${row.packageCount} 箱(整组固定)`" />')
expect(source).toContain("row.unitType === 'SEALED_PALLET'")
expect(source).toContain('.candidate-group { display: grid; width: 100%; min-width: 0;')
expect(source).toContain('.candidate-group :deep(.line-clamp-text) { width: 100%;')
expect(source).toContain('<template #goods="{ row }"><div class="goods-cell"><LineClampText')
@@ -201,4 +202,28 @@ describe('SmartLoadingWorkbenchView', () => {
wrapper.unmount()
})
it('assigns a sealed pallet atomically without a package-count input', async () => {
vi.mocked(listSmartLoadingCandidates).mockResolvedValue(candidatePage([{
...candidate, unitType: 'SEALED_PALLET', palletId: '9007199254740998', palletNo: 'DP-260908-0001',
indivisible: true, availablePackageCount: 12,
}]))
vi.mocked(assignSmartLoadingPallet).mockResolvedValue(planResponse)
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/container-loading/:containerId/smart-loading', component: SmartLoadingWorkbenchView }] })
await router.push(`/container-loading/${container.id}/smart-loading`); await router.isReady()
const wrapper = mount(SmartLoadingWorkbenchView, { attachTo: document.body, global: { plugins: [ElementPlus, router] } })
await flushPromises()
expect(wrapper.text()).toContain('DP-260908-0001')
await wrapper.find('.el-table__body-wrapper button').trigger('click')
await flushPromises()
expect(document.body.textContent).toContain('不可拆分单元整托加入')
expect(document.body.textContent).not.toContain('本次计划装柜箱数')
;([...document.body.querySelectorAll('button')].find((button) => button.textContent?.trim() === '确认整托分配') as HTMLButtonElement).click()
await flushPromises()
expect(assignSmartLoadingPallet).toHaveBeenCalledWith(planResponse.id, { palletId: '9007199254740998', expectedVersion: planResponse.version })
expect(assignSmartLoadingItem).not.toHaveBeenCalled()
wrapper.unmount()
})
})
@@ -12,7 +12,7 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import LineClampText from '@/shared/components/LineClampText.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import {
adjustSmartLoadingItem, assignSmartLoadingGroup, assignSmartLoadingItem, getContainer,
adjustSmartLoadingItem, assignSmartLoadingGroup, assignSmartLoadingItem, assignSmartLoadingPallet, getContainer,
getSmartLoadingPlanByBooking, listSmartLoadingCandidates,
recommendSmartLoading, removeSmartLoadingGroup, removeSmartLoadingItem,
} from '../api/platformContainerLoading'
@@ -46,6 +46,7 @@ const canViewLoadingList = computed(() => access.can('platform:firstleg:loading-
const editable = computed(() => plan.value?.canEdit === true)
const assignable = computed(() => plan.value?.canAssign === true)
const assignedInboundIds = computed(() => new Set((plan.value?.items || []).map((item) => item.inboundId)))
const assignedPalletIds = computed(() => new Set((plan.value?.items || []).map((item) => item.palletId).filter(Boolean)))
type SmartLoadingCandidateRow = SmartLoadingCandidate & { memberInboundIds: string[]; memberInboundCodes: string[]; memberOrderSummaries: string[]; memberOrderCount: number; inboundFragmentCount: number }
function memberOrderSummaries<T extends { orderId: string; orderNo?: string | null; inboundCode?: string | null }>(members: T[]) {
const orders = new Map<string, { label: string; inboundCodes: string[] }>()
@@ -130,6 +131,7 @@ const itemColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' },
{ key: 'sequence', prop: 'loadSequence', label: '顺序', width: 75 },
{ key: 'order', label: '入仓码', minWidth: 190, slotName: 'order' },
{ key: 'palletNo', prop: 'palletNo', label: '托盘号', minWidth: 150, showOverflowTooltip: true },
{ key: 'coLoadGroup', label: '同柜不可拆', minWidth: 210, slotName: 'coLoadGroup' },
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 150, slotName: 'customerName' },
{ key: 'goods', label: '货物', minWidth: 190, slotName: 'goods' },
@@ -140,13 +142,15 @@ const itemColumns: DataTableColumn[] = [
const candidateColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' },
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 190, fixed: 'left', showOverflowTooltip: true },
{ key: 'palletNo', prop: 'palletNo', label: '托盘号', minWidth: 150, showOverflowTooltip: true },
{ key: 'unitType', label: '分配单位', width: 110, slotName: 'unitType' },
{ key: 'coLoadGroup', label: '同柜不可拆', minWidth: 210, slotName: 'coLoadGroup' },
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 150, slotName: 'customerName' },
{ key: 'goodsSummary', prop: 'goodsSummary', label: '货物摘要', minWidth: 180, slotName: 'goodsSummary' },
{ key: 'availablePackageCount', prop: 'availablePackageCount', label: '可用箱数', width: 110 },
{ key: 'availablePackageCount', prop: 'availablePackageCount', label: '箱数', width: 90 },
{ key: 'cartonIdentityStatus', label: '箱号归属', width: 110, slotName: 'cartonIdentityStatus' },
{ key: 'averageUnitVolumeCbm', label: '平均单箱体积', width: 145, slotName: 'averageUnitVolume' },
{ key: 'unitGrossWeightKg', label: '平均单箱毛重', width: 140, slotName: 'unitGrossWeight' },
{ key: 'averageUnitVolumeCbm', label: '单位体积', width: 120, slotName: 'averageUnitVolume' },
{ key: 'unitGrossWeightKg', label: '单位毛重', width: 120, slotName: 'unitGrossWeight' },
]
function formatNumber(value: number, digits = 3) { return Number.isFinite(value) ? value.toLocaleString('zh-CN', { maximumFractionDigits: digits }) : '0' }
@@ -232,12 +236,15 @@ async function assign() {
const currentPlan = plan.value
const selected = selectedCandidate.value
const inboundId = selected?.inboundId
if (!currentPlan || !selected || !inboundId || assignForm.packageCount < 1) return
if (!currentPlan || !selected || !inboundId) return
if (selected.unitType !== 'SEALED_PALLET' && assignForm.packageCount < 1) return
if (selected.coLoadGroupId && (selected.blockReason || selected.locked)) return
mutating.value = true
mutationError.value = null
try {
const next = selected.coLoadGroupId
const next = selected.unitType === 'SEALED_PALLET' && selected.palletId
? await assignSmartLoadingPallet(currentPlan.id, { palletId: selected.palletId, expectedVersion: currentPlan.version })
: selected.coLoadGroupId
? await assignSmartLoadingGroup(currentPlan.id, inboundId, currentPlan.version)
: await assignSmartLoadingItem(currentPlan.id, { inboundId, packageCount: assignForm.packageCount, expectedVersion: currentPlan.version })
await acceptPlanWithGroups(next)
@@ -247,6 +254,10 @@ async function assign() {
finally { mutating.value = false }
}
function candidateBlocked(candidate: SmartLoadingCandidateRow) {
if (candidate.unitType === 'SEALED_PALLET') {
return Boolean(candidate.blockReason) || candidate.locked === true || !candidate.palletId
|| !candidate.indivisible || assignedPalletIds.value.has(candidate.palletId)
}
return Boolean(candidate.blockReason) || candidate.locked === true
|| !candidate.inboundId
|| candidate.memberInboundIds.some((inboundId) => assignedInboundIds.value.has(inboundId))
@@ -273,6 +284,7 @@ async function recommend() {
}
async function adjust(source: object) {
const item = itemRow(source)
if (item.unitType === 'SEALED_PALLET') return
if (!plan.value || mutating.value) return
const count = Number(quantityDrafts[item.id])
if (count === item.packageCount) return
@@ -373,24 +385,25 @@ onMounted(load)
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canDelete && editable && row.canRemove" size="small" type="danger" plain @click="remove(row)"><Trash2 :size="15" />删除</el-button></div></template>
<template #order="{ row }"><strong>{{ row.inboundCode || '—' }}</strong></template>
<template #customerName="{ row }"><LineClampText :content="row.customerName" /></template>
<template #coLoadGroup="{ row }"><div v-if="row.coLoadGroup" class="candidate-group"><el-tag size="small" type="warning"><LineClampText :content="`${row.coLoadGroup.groupNo} · ${groupMemberLabel(row.coLoadGroup)}`" /></el-tag><LineClampText :content="row.memberOrderSummaries.join(';')" /><LineClampText :content="`全部入仓片段联动,固定 ${row.coLoadGroup.fullPackageCount} 箱`" /></div><LineClampText v-else content="普通单票" /></template>
<template #coLoadGroup="{ row }"><div v-if="row.coLoadGroup" class="candidate-group"><el-tag size="small" type="warning"><LineClampText :content="`${row.coLoadGroup.groupNo} · ${groupMemberLabel(row.coLoadGroup)}`" /></el-tag><LineClampText :content="row.memberOrderSummaries.join(';')" /><LineClampText :content="`全部入仓片段联动,固定 ${row.coLoadGroup.fullPackageCount} 箱`" /></div><LineClampText v-else :content="row.unitType === 'SEALED_PALLET' ? '封托不可拆' : '普通单票'" /></template>
<template #goods="{ row }"><div class="goods-cell"><LineClampText :content="row.productNameZh || '未命名货物'" /><LineClampText class="goods-cell__sku" :content="row.skuCode || '无 SKU'" /></div></template>
<template #packageCount="{ row }"><LineClampText v-if="row.coLoadGroup" :content="`${row.packageCount} 箱(整组固定)`" /><div v-else class="package-count-editor"><el-input-number v-if="editingQuantityId === row.id" v-model="quantityDrafts[row.id]" :min="row.minPackageCount" :max="adjustmentMax(row)" :step="1" :controls="false" :disabled="mutating" placeholder="请输入使用箱数" @blur="adjust(row)" @keyup.enter="adjust(row)" /><div v-else class="package-count-summary"><div class="package-count-used"><strong>使用:{{ row.packageCount }}</strong><el-button v-if="canAssign && editable && row.canAdjust" link circle :disabled="mutating" aria-label="调整箱数" @click="startQuantityEdit(row)"><Pencil :size="14" /></el-button></div><small v-if="(row.remainingPackageCount ?? 0) > 0">未用:{{ row.remainingPackageCount }}</small></div><LineClampText v-if="row.detailCount > 1 && editable" :content="`含 ${row.detailCount} 个规格,需删除后重加`" /><LineClampText v-if="row.pickedPackageCount > 0" :content="`已拣 ${row.pickedPackageCount} 箱`" /></div></template>
<template #packageCount="{ row }"><LineClampText v-if="row.unitType === 'SEALED_PALLET'" :content="`${row.packageCount} 箱(整托固定)`" /><LineClampText v-else-if="row.coLoadGroup" :content="`${row.packageCount} 箱(整组固定)`" /><div v-else class="package-count-editor"><el-input-number v-if="editingQuantityId === row.id" v-model="quantityDrafts[row.id]" :min="row.minPackageCount" :max="adjustmentMax(row)" :step="1" :controls="false" :disabled="mutating" placeholder="请输入使用箱数" @blur="adjust(row)" @keyup.enter="adjust(row)" /><div v-else class="package-count-summary"><div class="package-count-used"><strong>使用:{{ row.packageCount }}</strong><el-button v-if="canAssign && editable && row.canAdjust" link circle :disabled="mutating" aria-label="调整箱数" @click="startQuantityEdit(row)"><Pencil :size="14" /></el-button></div><small v-if="(row.remainingPackageCount ?? 0) > 0">未用:{{ row.remainingPackageCount }}</small></div><LineClampText v-if="row.detailCount > 1 && editable" :content="`含 ${row.detailCount} 个规格,需删除后重加`" /><LineClampText v-if="row.pickedPackageCount > 0" :content="`已拣 ${row.pickedPackageCount} 箱`" /></div></template>
<template #volume="{ row }">{{ formatNumber(Number(row.volumeCbm || 0)) }} m³</template><template #weight="{ row }">{{ formatNumber(Number(row.grossWeightKg || 0),1) }} kg</template>
</DataTable>
<DataTable :data="displayedCandidates" :columns="candidateColumns" :loading="loading || mutating" stripe preference-key="smart-loading-candidates" column-settings-mode="hide" empty-text="当前仓库暂无可装柜货物">
<template #toolbar-start><div><h2>候选货物</h2><p>系统基于装柜仓库、物流渠道、可用箱数及 150% 容量上限校验。</p></div></template>
<template #actions="{ row }"><el-button v-if="canAssign && assignable" size="small" type="primary" plain :disabled="candidateBlocked(row as SmartLoadingCandidateRow)" @click="openAssign(row)"><Plus :size="15" />{{ row.coLoadGroupId ? '整组分配' : '分配' }}</el-button></template>
<template #actions="{ row }"><el-button v-if="canAssign && assignable" size="small" type="primary" plain :disabled="candidateBlocked(row as SmartLoadingCandidateRow)" @click="openAssign(row)"><Plus :size="15" />{{ row.unitType === 'SEALED_PALLET' ? '整托分配' : row.coLoadGroupId ? '整组分配' : '分配' }}</el-button></template>
<template #customerName="{ row }"><LineClampText :content="row.customerName" /></template>
<template #goodsSummary="{ row }"><LineClampText :content="row.goodsSummary || '—'" /></template>
<template #coLoadGroup="{ row }"><div v-if="row.coLoadGroupId" class="candidate-group"><el-tag size="small" type="warning"><LineClampText :content="`${row.groupNo} · ${row.memberOrderCount} 票 · ${row.inboundFragmentCount} 个入仓片段`" /></el-tag><LineClampText :content="row.memberOrderSummaries.join(';')" /><LineClampText :content="`整组 ${row.groupTotalBoxes} 箱,必须全量进入同一柜`" /><LineClampText v-if="row.blockReason" class="candidate-block" :content="row.blockReason" /><LineClampText v-else-if="row.locked" content="该组已锁定,不能重复分配" /></div><LineClampText v-else content="普通单票" /></template>
<template #coLoadGroup="{ row }"><div v-if="row.coLoadGroupId" class="candidate-group"><el-tag size="small" type="warning"><LineClampText :content="`${row.groupNo} · ${row.memberOrderCount} 票 · ${row.inboundFragmentCount} 个入仓片段`" /></el-tag><LineClampText :content="row.memberOrderSummaries.join(';')" /><LineClampText :content="`整组 ${row.groupTotalBoxes} 箱,必须全量进入同一柜`" /><LineClampText v-if="row.blockReason" class="candidate-block" :content="row.blockReason" /><LineClampText v-else-if="row.locked" content="该组已锁定,不能重复分配" /></div><LineClampText v-else :content="row.unitType === 'SEALED_PALLET' ? '封托不可拆' : '普通单票'" /></template>
<template #unitType="{ row }"><el-tag size="small" :type="row.unitType === 'SEALED_PALLET' ? 'success' : 'info'">{{ row.unitType === 'SEALED_PALLET' ? '封托托盘' : '箱件' }}</el-tag></template>
<template #averageUnitVolume="{ row }">{{ formatNumber(Number(row.averageUnitVolumeCbm || 0)) }} m³</template><template #unitGrossWeight="{ row }">{{ formatNumber(Number(row.unitGrossWeightKg || 0),1) }} kg</template>
<template #cartonIdentityStatus><el-tag size="small" type="info" effect="plain">未核验</el-tag></template>
</DataTable>
</template>
</template>
</template>
<AppDialog v-model="assignOpen" :title="selectedCandidate?.coLoadGroupId ? '整组加入当前柜次' : '按入仓码分配箱数'" :confirm-loading="mutating" :confirm-disabled="Boolean(selectedCandidate?.blockReason || selectedCandidate?.locked)" :confirm-text="selectedCandidate?.coLoadGroupId ? '确认整组分配' : '确认分配'" @confirm="assign"><div v-if="selectedCandidate" class="assign-dialog"><p><strong>{{ selectedCandidate.groupNo || selectedCandidate.inboundCode || '入仓码暂缺' }}</strong></p><template v-if="selectedCandidate.coLoadGroupId"><p>组内 {{ selectedCandidate.memberOrderCount }} 票、{{ selectedCandidate.inboundFragmentCount }} 个入仓片段,共 {{ selectedCandidate.groupTotalBoxes }} 箱</p><p v-for="summary in selectedCandidate.memberOrderSummaries" :key="summary">{{ summary }}</p><el-alert :title="selectedCandidate.blockReason || '同柜且不可拆:全部订单及其入仓片段按完整箱数一次加入,不能部分输入或单独移除。'" :type="selectedCandidate.blockReason ? 'error' : 'warning'" :closable="false" /></template><template v-else><p>{{ selectedCandidate.goodsSummary || '货物摘要暂缺' }} · 可用 {{ selectedCandidate.availablePackageCount }} 箱</p><el-form label-position="top"><el-form-item label="本次计划装柜箱数" required><el-input-number v-model="assignForm.packageCount" :min="1" :max="selectedCandidate.availablePackageCount" :step="1" controls-position="right" placeholder="请输入本次分配箱数" /></el-form-item></el-form><el-alert :title="explainCandidate(selectedCandidate)" type="info" :closable="false" /><small>只记录数量,具体上柜箱号未核验。</small></template></div></AppDialog>
<AppDialog v-model="assignOpen" :title="selectedCandidate?.unitType === 'SEALED_PALLET' ? '整托加入当前柜次' : selectedCandidate?.coLoadGroupId ? '整组加入当前柜次' : '按入仓码分配箱数'" :confirm-loading="mutating" :confirm-disabled="Boolean(selectedCandidate?.blockReason || selectedCandidate?.locked)" :confirm-text="selectedCandidate?.unitType === 'SEALED_PALLET' ? '确认整托分配' : selectedCandidate?.coLoadGroupId ? '确认整组分配' : '确认分配'" @confirm="assign"><div v-if="selectedCandidate" class="assign-dialog"><p><strong>{{ selectedCandidate.palletNo || selectedCandidate.groupNo || selectedCandidate.inboundCode || '入仓码暂缺' }}</strong></p><template v-if="selectedCandidate.coLoadGroupId"><p>组内 {{ selectedCandidate.memberOrderCount }} 票、{{ selectedCandidate.inboundFragmentCount }} 个入仓片段,共 {{ selectedCandidate.groupTotalBoxes }} 箱</p><p v-for="summary in selectedCandidate.memberOrderSummaries" :key="summary">{{ summary }}</p><el-alert :title="selectedCandidate.blockReason || '同柜且不可拆:全部订单及其入仓片段按完整箱数一次加入,不能部分输入或单独移除。'" :type="selectedCandidate.blockReason ? 'error' : 'warning'" :closable="false" /></template><template v-else-if="selectedCandidate.unitType === 'SEALED_PALLET'"><p>托盘号 {{ selectedCandidate.palletNo || '—' }} · 入仓码 {{ selectedCandidate.inboundCode || '—' }} · {{ selectedCandidate.availablePackageCount }} 箱</p><el-alert title="该封托托盘将作为一个不可拆分单元整托加入;装柜阶段复用此托盘,不会重新生成工作托盘。" type="warning" :closable="false" /></template><template v-else><p>{{ selectedCandidate.goodsSummary || '货物摘要暂缺' }} · 可用 {{ selectedCandidate.availablePackageCount }} 箱</p><el-form label-position="top"><el-form-item label="本次计划装柜箱数" required><el-input-number v-model="assignForm.packageCount" :min="1" :max="selectedCandidate.availablePackageCount" :step="1" controls-position="right" placeholder="请输入本次分配箱数" /></el-form-item></el-form><el-alert :title="explainCandidate(selectedCandidate)" type="info" :closable="false" /><small>只记录数量,具体上柜箱号未核验。</small></template></div></AppDialog>
</section>
</AdminPage>
</template>
@@ -7,7 +7,7 @@ function item(overrides: Partial<SmartLoadingItem> = {}): SmartLoadingItem {
id: '1', loadingPlanId: '10', accountId: '20', inboundId: '30', inboundDetailId: '40',
orderId: '50', orderNo: 'FL-1', inboundCode: 'NYEXX2608180003', customerName: '柏悦物流',
destinationWarehouseId: '60', destinationWarehouseCode: 'LAX', destinationWarehouseName: '洛杉矶仓',
skuCode: 'SKU-1', productNameZh: '货物一', packageCount: 2, productQuantity: 20,
skuCode: 'SKU-1', productNameZh: '货物一', unitType: 'PACKAGE', palletId: null, palletNo: null, indivisible: false, packageCount: 2, productQuantity: 20,
grossWeightKg: 662.8, volumeCbm: 0.334, loadSequence: 2, version: 1,
pickedPackageCount: 0, minPackageCount: 1, canAdjust: true, canRemove: true,
...overrides,
@@ -15,6 +15,17 @@ function item(overrides: Partial<SmartLoadingItem> = {}): SmartLoadingItem {
}
describe('groupSmartLoadingItems', () => {
it('keeps every sealed pallet as an independent non-adjustable allocation unit', () => {
const grouped = groupSmartLoadingItems([
item({ id: 'p1', unitType: 'SEALED_PALLET', palletId: '101', palletNo: 'PLT-101', indivisible: true, packageCount: 4 }),
item({ id: 'p2', unitType: 'SEALED_PALLET', palletId: '102', palletNo: 'PLT-102', indivisible: true, packageCount: 6 }),
])
expect(grouped).toHaveLength(2)
expect(grouped.map((row) => row.palletNo)).toEqual(['PLT-101', 'PLT-102'])
expect(grouped.every((row) => row.canAdjust === false)).toBe(true)
})
it('shows multiple specification rows as one inbound-code row with summed quantities', () => {
const grouped = groupSmartLoadingItems([
item(),
@@ -21,7 +21,9 @@ function joinDistinct(items: SmartLoadingItem[], value: (item: SmartLoadingItem)
export function groupSmartLoadingItems(items: SmartLoadingItem[]): SmartLoadingInboundItem[] {
const groups = new Map<string, SmartLoadingItem[]>()
for (const item of items) {
const key = `${item.accountId}:${item.inboundId}`
const key = item.unitType === 'SEALED_PALLET'
? `${item.accountId}:${item.inboundId}:pallet:${item.palletId}`
: `${item.accountId}:${item.inboundId}:packages`
groups.set(key, [...(groups.get(key) || []), item])
}
@@ -44,7 +46,7 @@ export function groupSmartLoadingItems(items: SmartLoadingItem[]): SmartLoadingI
minPackageCount: sourceItems.reduce((sum, item) => sum + Number(item.minPackageCount || 0), 0),
totalPackageCount: sourceItems.reduce((sum, item) => sum + Number(item.totalPackageCount || item.packageCount || 0), 0),
remainingPackageCount: sourceItems.reduce((sum, item) => sum + Number(item.remainingPackageCount || 0), 0),
canAdjust: sourceItems.length === 1 && first.canAdjust,
canAdjust: first.unitType !== 'SEALED_PALLET' && sourceItems.length === 1 && first.canAdjust,
canRemove: sourceItems.every((item) => item.canRemove),
sourceItems,
detailCount: sourceItems.length,
@@ -5,7 +5,7 @@ import type { SmartLoadingPlan } from './types'
const item = {
id: '701', loadingPlanId: '99', accountId: '1', inboundId: '201', inboundDetailId: '801', orderId: '301',
orderNo: 'ORD-1', inboundCode: 'IN-201', customerName: '客户一', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9',
skuCode: 'SKU-1', productNameZh: '货物一', packageCount: 5, productQuantity: 50, grossWeightKg: 50, volumeCbm: 1,
skuCode: 'SKU-1', productNameZh: '货物一', unitType: 'PACKAGE' as const, palletId: null, palletNo: null, indivisible: false, packageCount: 5, productQuantity: 50, grossWeightKg: 50, volumeCbm: 1,
loadSequence: 1, version: 1, pickedPackageCount: 0, minPackageCount: 1, canAdjust: true, canRemove: true,
}
+22
View File
@@ -255,6 +255,20 @@ export interface ContainerWorkbenchRow extends ContainerBookingResource {
terminalDrayage?: ContainerWorkbenchTerminalDrayage | null
}
/** Wire shape returned by the domestic and overseas workbench query endpoints. */
export interface ContainerWorkbenchItem {
container: ContainerBookingResource
workbenchStage: string
statusSummary: ContainerWorkbenchStatusSummary
allowedActions: ContainerWorkbenchAction[]
actualFacts: ContainerWorkbenchActualFacts | null
terminalDrayage: ContainerWorkbenchTerminalDrayage | null
}
export interface ContainerWorkbenchPage extends Omit<ContainerPage, 'records'> {
records: ContainerWorkbenchItem[]
}
export interface ContainerSaveRequest {
containerNo: string | null
warehouseId: string
@@ -301,6 +315,10 @@ export interface SmartLoadingCandidate {
destinationWarehouseName: string | null
customerName: string | null
goodsSummary: string | null
unitType: 'PACKAGE' | 'SEALED_PALLET'
palletId: string | null
palletNo: string | null
indivisible: boolean
availablePackageCount: number
unitGrossWeightKg: number | null
unitNetWeightKg: number | null
@@ -392,6 +410,10 @@ export interface SmartLoadingItem {
destinationWarehouseName: string | null
skuCode: string | null
productNameZh: string | null
unitType: 'PACKAGE' | 'SEALED_PALLET'
palletId: string | null
palletNo: string | null
indivisible: boolean
packageCount: number
productQuantity: number | null
grossWeightKg: number | null
@@ -116,9 +116,11 @@ describe('CarrierRatesView price-book source contract', () => {
})
it('uses bordered striped tables and explicit semantic business buttons', () => {
expect(source).toContain('row-key="id"\n border\n stripe')
expect(source).toContain('<DataTable')
expect(source).toContain(':columns="columns"')
expect(source).toContain('border stripe aria-label="渠道价格行预览"')
expect(source).toContain('<el-button size="small" plain type="primary" @click="openDetail')
expect(source).toContain('size="small"\n plain\n :loading="downloadingIds.has')
expect(source).toMatch(/size="small"\r?\n\s+plain\r?\n\s+:loading="downloadingIds\.has/)
expect(source).toContain('<Eye :size="15" />{{ detailActionLabel')
expect(source).toContain('<Download :size="15" />原文件')
expect(source).toContain('<CircleCheck :size="16" />确认并立即生效')
@@ -125,7 +125,7 @@ describe('finance G3/G4 page operations', () => {
{ statementLineId: '602', adjustedAmount: 180, reason: '操作费更正' },
],
})
})
}, 15_000)
it('loads AR linkage, uploads a voucher and allocates a real receipt to its statement', async () => {
const pinia = piniaWithPermissions(['platform:finance:settlement-ar:list', 'platform:finance:receipt:create'])
@@ -258,7 +258,7 @@ async function batchApproveMeasurements() {
batchReviewLoading.value = true
try {
await ElMessageBox.confirm(
`确认批量审核通过已勾选的 ${selected.length} 条测量数据吗?通过后将进入待分柜。`,
`确认批量审核通过已勾选的 ${selected.length} 条测量数据吗?一件代发通过后进入待组托,其他订单进入待分柜。`,
'批量审核测量数据',
{ confirmButtonText: '确认批量审核', cancelButtonText: '取消', type: 'warning' },
)
@@ -426,6 +426,7 @@ onBeforeUnmount(() => { disposed = true; requestSerial += 1; optionsRequestSeria
v-if="selectedInbound"
:tenant-id="selectedInbound.tenantId"
:inbound-id="selectedInbound.id"
:fulfillment-type="selectedInbound.fulfillmentType"
:customer-name="selectedInbound.customerName || selectedInbound.companyName"
:channel-name="selectedInbound.channelName"
:channel-code="selectedInbound.channelCode"
@@ -19,7 +19,8 @@ describe('InboundWorkflowWorkspace', () => {
expect(source).toContain('label="测量审核"')
expect(source).toContain('label="费用信息"')
expect(source).toContain('label="状态历史"')
expect(source).toContain('label="其他记录"')
expect(source).toContain("detail.summary.fulfillmentType === 'ONE_CLICK_DROPSHIP' ? '清点组托与其他记录' : '其他记录'")
expect(source).toContain('listPlatformInboundDropshipPallets(props.customerId, props.inboundId)')
})
it('opens measurement editing and review in prototype-sized centered dialogs', () => {
@@ -6,7 +6,7 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import { getPlatformInbound } from '../core/api'
import { getPlatformInbound, listPlatformInboundDropshipPallets } from '../core/api'
import InboundHistoryPanel from '../core/InboundHistoryPanel.vue'
import InboundSummaryPanel from '../core/InboundSummaryPanel.vue'
import type { InboundOrderDetail } from '../core/types'
@@ -37,6 +37,7 @@ let disposed = false
const title = computed(() => detail.value?.summary.inboundCode || '入仓码待补充')
const canViewMeasurement = computed(() => access.can('platform:firstleg:inbound:measure:view'))
const canViewDropshipPallets = computed(() => access.can('platform:firstleg:pda:view'))
const statusLabel = computed(() => inboundStatusLabel(detail.value?.summary.processStatus, inboundProcessStatusLabels, '未知状态'))
async function loadDetail() {
@@ -51,6 +52,9 @@ async function loadDetail() {
loadError.value = null
try {
const response = await getPlatformInbound(props.customerId, props.inboundId)
if (response.summary.fulfillmentType === 'ONE_CLICK_DROPSHIP' && canViewDropshipPallets.value) {
response.dropshipPallets = await listPlatformInboundDropshipPallets(props.customerId, props.inboundId)
}
if (disposed || serial !== requestSerial) return
detail.value = response
} catch (error) {
@@ -174,12 +178,13 @@ onBeforeUnmount(() => {
:sections="['history']"
/>
</el-tab-pane>
<el-tab-pane label="其他记录" name="records" lazy>
<el-tab-pane :label="detail.summary.fulfillmentType === 'ONE_CLICK_DROPSHIP' ? '清点组托与其他记录' : '其他记录'" name="records" lazy>
<InboundHistoryPanel
:pallet-locations="detail.palletLocations"
:dropship-pallets="detail.dropshipPallets"
:customer-id="customerId"
:inbound-id="inboundId"
:can-view-dropship="canViewDropshipPallets"
:files="detail.files"
:internal-notes="detail.internalNotes"
:status-history="[]"
@@ -213,6 +218,7 @@ onBeforeUnmount(() => {
:key="`review-dialog-${revision}`"
:tenant-id="customerId"
:inbound-id="inboundId"
:fulfillment-type="detail?.summary.fulfillmentType"
:customer-name="detail?.summary.customerName || detail?.summary.companyName"
:channel-name="detail?.summary.channelName"
:channel-code="detail?.summary.channelCode"
@@ -0,0 +1,21 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const source = readFileSync(resolve(process.cwd(), 'src/modules/inbounds/core/InboundHistoryPanel.vue'), 'utf8')
describe('InboundHistoryPanel dropship pallet result', () => {
it('shows the frozen pallet, carton and discrepancy facts', () => {
for (const marker of [
'一件代发清点与组托结果', 'pallet.palletNo', 'pallet.status', 'pallet.cartonCount',
'pallet.skuCount', 'pallet.weightKg', 'pallet.volumeCbm', 'pallet.discrepancyCartons', 'pallet.sealedAt',
'item.inboundCode', 'item.skuCode', 'item.cargoName', 'item.skuQuantity', 'item.cartonCount', 'item.cartonMarks',
]) expect(source).toContain(marker)
expect(source).toContain("SEALED: '已封托'")
})
it('keeps the existing scoped pallet-label action available', () => {
expect(source).toContain('downloadDropshipPalletLabel(props.customerId, props.inboundId, pallet.palletId)')
expect(source).toContain('打印托盘唛')
})
})
@@ -9,6 +9,7 @@ const props = withDefaults(defineProps<{
dropshipPallets?: InboundDropshipPallet[]
customerId?: string
inboundId?: string
canViewDropship?: boolean
files: InboundFileSummary[]
internalNotes: InboundInternalNote[]
statusHistory: InboundStatusHistory[]
@@ -16,6 +17,7 @@ const props = withDefaults(defineProps<{
sections?: Array<'dropship' | 'allocations' | 'files' | 'notes' | 'history' | 'remark'>
}>(), {
dropshipPallets: () => [],
canViewDropship: true,
sections: () => ['dropship', 'allocations', 'files', 'notes', 'history', 'remark'],
})
@@ -30,6 +32,9 @@ function date(value: string | null) {
const parsed = new Date(value)
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString('zh-CN', { hour12: false })
}
function dropshipPalletStatus(value: string) {
return ({ OPEN: '清点组托中', SEALED: '已封托' } as Record<string, string>)[value] || value || '状态未知'
}
async function printPallet(pallet: InboundDropshipPallet) {
if (!props.customerId || !props.inboundId || !pallet.palletNo) return
try {
@@ -44,9 +49,35 @@ async function printPallet(pallet: InboundDropshipPallet) {
<template>
<div class="history-panel">
<section v-if="shows('dropship')" class="history-section">
<div class="section-heading"><h3>一件代发绑定托盘</h3><span>{{ dropshipPallets.length }} 个托盘</span></div>
<div v-if="dropshipPallets.length" class="simple-list"><div v-for="pallet in dropshipPallets" :key="pallet.palletId"><strong>{{ value(pallet.palletNo) }} · {{ pallet.cartonCount }} 箱 <el-button v-if="pallet.palletNo" link type="primary" @click="printPallet(pallet)">打印托盘唛</el-button></strong><span v-for="item in pallet.items" :key="`${pallet.palletId}-${item.inboundDetailId}`">{{ item.inboundCode }} · SKU {{ item.skuCode }} · {{ item.cartonCount }} 箱</span></div></div>
<p v-else class="muted-empty">暂无一件代发托盘绑定</p>
<div class="section-heading"><h3>一件代发清点与组托结果</h3><span>{{ dropshipPallets.length }} 个托盘</span></div>
<el-alert v-if="!canViewDropship" title="当前账号缺少一件代发 PDA 查看权限,无法读取清点组托结果。" type="warning" :closable="false" show-icon />
<div v-else-if="dropshipPallets.length" class="dropship-pallet-list">
<article v-for="pallet in dropshipPallets" :key="pallet.palletId" class="dropship-pallet-card">
<header>
<div><strong>{{ value(pallet.palletNo) }}</strong><el-tag size="small" :type="pallet.status === 'SEALED' ? 'success' : 'warning'" effect="plain">{{ dropshipPalletStatus(pallet.status) }}</el-tag></div>
<el-button v-if="pallet.palletNo" link type="primary" @click="printPallet(pallet)">打印托盘唛</el-button>
</header>
<dl class="dropship-pallet-summary">
<div><dt>箱数</dt><dd>{{ pallet.cartonCount }}</dd></div>
<div><dt>SKU 数</dt><dd>{{ pallet.skuCount }}</dd></div>
<div><dt>毛重</dt><dd>{{ number(pallet.weightKg) }} kg</dd></div>
<div><dt>体积</dt><dd>{{ number(pallet.volumeCbm) }} m³</dd></div>
<div><dt>差异箱数</dt><dd><el-tag size="small" :type="pallet.discrepancyCartons ? 'warning' : 'success'">{{ pallet.discrepancyCartons }}</el-tag></dd></div>
<div><dt>封托时间</dt><dd>{{ date(pallet.sealedAt) }}</dd></div>
</dl>
<div class="dropship-item-table">
<div class="dropship-item-row dropship-item-row--header"><span>入仓码</span><span>SKU / 货物</span><span>商品数量</span><span>箱数</span><span>箱码</span></div>
<div v-for="item in pallet.items" :key="`${pallet.palletId}-${item.inboundDetailId}`" class="dropship-item-row">
<span>{{ value(item.inboundCode) }}</span>
<span><strong>{{ value(item.skuCode) }}</strong><small>{{ value(item.cargoName) }}</small></span>
<span>{{ item.skuQuantity }}</span>
<span>{{ item.cartonCount }}</span>
<span>{{ item.cartonMarks.length ? item.cartonMarks.join('、') : '—' }}</span>
</div>
</div>
</article>
</div>
<p v-else class="muted-empty">暂无一件代发清点与组托结果</p>
</section>
<section v-if="shows('allocations')" class="history-section">
<div class="section-heading"><h3>托盘与库位</h3><span>{{ palletLocations.length }} 条分配</span></div>
@@ -93,6 +124,25 @@ async function printPallet(pallet: InboundDropshipPallet) {
.allocation-location { overflow-wrap: anywhere; color: var(--cc-primary); text-align: right; }
.simple-list > div { padding: 10px 12px; border-bottom: 1px solid #edf0f4; font-size: 12px; }
.simple-list strong, .simple-list span { overflow-wrap: anywhere; }
.dropship-pallet-list { display: grid; gap: 12px; }
.dropship-pallet-card { min-width: 0; overflow: hidden; border: 1px solid var(--cc-border); border-radius: 6px; background: #fff; }
.dropship-pallet-card > header { display: flex; min-height: 42px; align-items: center; justify-content: space-between; gap: 12px; padding: 8px 12px; border-bottom: 1px solid #edf0f4; background: #f8fafc; }
.dropship-pallet-card > header > div { display: flex; min-width: 0; align-items: center; gap: 8px; }
.dropship-pallet-card > header strong { overflow-wrap: anywhere; color: #172033; font-size: 13px; }
.dropship-pallet-summary { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); margin: 0; border-bottom: 1px solid #edf0f4; }
.dropship-pallet-summary > div { min-width: 0; padding: 8px 12px; border-right: 1px solid #edf0f4; }
.dropship-pallet-summary > div:last-child { border-right: 0; }
.dropship-pallet-summary dt { color: var(--cc-muted); font-size: 11px; }
.dropship-pallet-summary dd { margin: 3px 0 0; overflow-wrap: anywhere; color: var(--cc-text); font-size: 12px; font-weight: 650; }
.dropship-item-table { min-width: 680px; }
.dropship-pallet-card { overflow-x: auto; }
.dropship-item-row { display: grid; grid-template-columns: minmax(140px, 1fr) minmax(180px, 1.2fr) 100px 70px minmax(220px, 1.5fr); min-width: 800px; border-bottom: 1px solid #edf0f4; }
.dropship-item-row:last-child { border-bottom: 0; }
.dropship-item-row > span { min-width: 0; padding: 8px 12px; overflow-wrap: anywhere; color: var(--cc-text); font-size: 12px; }
.dropship-item-row > span + span { border-left: 1px solid #edf0f4; }
.dropship-item-row small { display: block; margin-top: 3px; color: var(--cc-muted); font-size: 11px; }
.dropship-item-row--header { background: #f8fafc; }
.dropship-item-row--header > span { color: var(--cc-muted); font-size: 11px; font-weight: 650; }
.muted-empty { margin: 0; padding: 18px 12px; border: 1px dashed var(--cc-border); border-radius: 6px; background: #f8fafc; color: var(--cc-muted); font-size: 12px; text-align: center; }
.timeline { display: grid; gap: 0; margin: 0; padding: 0; list-style: none; }
.timeline li { display: grid; grid-template-columns: 16px 1fr; gap: 10px; min-width: 0; padding-bottom: 16px; }
@@ -101,5 +151,6 @@ async function printPallet(pallet: InboundDropshipPallet) {
.timeline strong, .timeline span, .timeline small { display: block; overflow-wrap: anywhere; }
.timeline span, .timeline small { margin-top: 3px; color: var(--cc-muted); font-size: 12px; line-height: 1.5; }
.remark-section p { margin: 0; padding: 12px; border-radius: 6px; background: #f8fafc; color: var(--cc-text); font-size: 13px; line-height: 1.6; white-space: pre-wrap; }
@media (max-width: 720px) { .dropship-pallet-summary { grid-template-columns: 1fr; } .dropship-pallet-summary > div { border-right: 0; border-bottom: 1px solid #edf0f4; } .dropship-pallet-summary > div:last-child { border-bottom: 0; } }
@media (max-width: 480px) { .allocation-row { grid-template-columns: 1fr auto; } .allocation-location { grid-column: 1 / -1; text-align: left; } }
</style>
+15 -1
View File
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { downloadInboundCartonLabels, downloadInboundCartonLabelsBatch, downloadPlatformInboundHoldEvidence, getPlatformInbound, getPlatformInboundHold, holdPlatformInbound, listPlatformInbounds, releasePlatformInbound, uploadPlatformInboundHoldEvidence } from './api'
import { downloadInboundCartonLabels, downloadInboundCartonLabelsBatch, downloadPlatformInboundHoldEvidence, getPlatformInbound, getPlatformInboundHold, holdPlatformInbound, listPlatformInboundDropshipPallets, listPlatformInbounds, releasePlatformInbound, uploadPlatformInboundHoldEvidence } from './api'
vi.mock('@/shared/api/http', () => ({
ApiResponseError: class ApiResponseError extends Error {
@@ -73,6 +73,20 @@ describe('platform inbound query API', () => {
expect(http.get).toHaveBeenCalledWith('/platform/inbounds/19001', { params: { tenantId: '10001' } })
})
it('loads and normalizes the inbound-scoped dropship pallet result', async () => {
vi.mocked(http.get).mockResolvedValue({ code: '200', data: [{
palletId: 9007199254740993n, palletNo: 'PLT-001', status: 'SEALED', cartonCount: '2', skuCount: '1', version: '3',
weightKg: '20.5', volumeCbm: '0.88', discrepancyCartons: '1', sealedAt: '2026-09-08T10:00:00',
items: [{ inboundId: 19001, inboundCode: 'RK001', inboundDetailId: 9007199254740994n, skuCode: 'SKU001', cargoName: '服装', cartonCount: '2', skuQuantity: '19', cartonMarks: ['BOX-001', 'BOX-002'] }],
}] })
await expect(listPlatformInboundDropshipPallets('10001', '19001')).resolves.toMatchObject([{
palletId: '9007199254740993', weightKg: 20.5, volumeCbm: 0.88, discrepancyCartons: 1,
items: [{ inboundId: '19001', inboundDetailId: '9007199254740994', cartonCount: 2, skuQuantity: 19, cartonMarks: ['BOX-001', 'BOX-002'] }],
}])
expect(http.get).toHaveBeenCalledWith('/platform/v1/first-leg/pda-dropship/inbound-orders/19001/pallets', { params: { accountId: '10001' } })
})
it('passes the implemented order, process, measurement and fee review filters without renaming them', async () => {
vi.mocked(http.get).mockResolvedValue({
code: '200', message: 'success', data: { records: [], page: 1, size: 20, total: 0, pages: 0 },
+33 -5
View File
@@ -11,6 +11,7 @@ import type {
InboundPageResult,
InboundPalletLocation,
InboundStatusHistory,
InboundDropshipPallet,
} from './types'
const basePath = '/platform/inbounds'
@@ -102,11 +103,7 @@ function normalizeDetail(raw: InboundOrderDetail): InboundOrderDetail {
return {
...raw,
summary: normalizeInboundSummary(raw.summary),
dropshipPallets: (raw.dropshipPallets || []).map((pallet) => ({
...pallet,
palletId: id(pallet.palletId) || '',
items: (pallet.items || []).map((item) => ({ ...item, inboundId: id(item.inboundId) || '', inboundDetailId: id(item.inboundDetailId) || '' })),
})),
dropshipPallets: [],
palletLocations: (raw.palletLocations || []).map(normalizePallet),
files: (raw.files || []).map(normalizeFile),
internalNotes: (raw.internalNotes || []).map(normalizeNote),
@@ -114,6 +111,29 @@ function normalizeDetail(raw: InboundOrderDetail): InboundOrderDetail {
}
}
function normalizeDropshipPallet(raw: InboundDropshipPallet): InboundDropshipPallet {
return {
...raw,
palletId: id(raw.palletId) || '',
palletNo: raw.palletNo || null,
cartonCount: integerValue(raw.cartonCount) || 0,
skuCount: integerValue(raw.skuCount) || 0,
version: integerValue(raw.version) || 0,
weightKg: numberValue(raw.weightKg),
volumeCbm: numberValue(raw.volumeCbm),
discrepancyCartons: integerValue(raw.discrepancyCartons) || 0,
sealedAt: raw.sealedAt || null,
items: (raw.items || []).map((item) => ({
...item,
inboundId: id(item.inboundId) || '',
inboundDetailId: id(item.inboundDetailId) || '',
cartonCount: integerValue(item.cartonCount) || 0,
skuQuantity: integerValue(item.skuQuantity) || 0,
cartonMarks: item.cartonMarks || [],
})),
}
}
function normalizeHoldDetail(raw: InboundHoldDetail): InboundHoldDetail {
return {
...raw,
@@ -161,6 +181,14 @@ export async function getPlatformInbound(tenantId: string, inboundId: string) {
return normalizeDetail(unwrapApiResponse(response))
}
export async function listPlatformInboundDropshipPallets(accountId: string, inboundId: string) {
const response = await http.get<ApiEnvelope<InboundDropshipPallet[]>, ApiEnvelope<InboundDropshipPallet[]>>(
`/platform/v1/first-leg/pda-dropship/inbound-orders/${encodeURIComponent(inboundId)}/pallets`,
{ params: { accountId } },
)
return (unwrapApiResponse(response) || []).map(normalizeDropshipPallet)
}
export async function getPlatformInboundHold(tenantId: string, inboundId: string) {
const response = await http.get<ApiEnvelope<InboundHoldDetail>, ApiEnvelope<InboundHoldDetail>>(
`${basePath}/${encodeURIComponent(inboundId)}/hold`,
+19 -3
View File
@@ -8,6 +8,7 @@ export type InboundProcessStatus =
| 'WAIT_PRICING'
| 'FEE_CONFIRMING'
| 'INBOUNDING'
| 'WAIT_PALLETIZE'
| 'WAIT_SPLIT'
| 'VOIDED'
@@ -36,6 +37,7 @@ export interface InboundOrderSummary {
deliveryWarehouseId: string | null
deliveryWarehouseName: string | null
businessType: string | null
fulfillmentType: 'STANDARD' | 'ONE_CLICK_DROPSHIP' | string | null
channelCode: string | null
channelName: string | null
taxMode: string | null
@@ -161,12 +163,26 @@ export interface InboundHoldEvidenceUpload {
export interface InboundDropshipPallet {
palletId: string
palletNo: string | null
palletStatus: string
status: 'OPEN' | 'SEALED'
cartonCount: number
skuCount: number
version: number
items: Array<{ inboundId: string; inboundCode: string; inboundDetailId: string; skuCode: string; cargoName: string | null; cartonCount: number; cartonMarks: string[] }>
completedAt: string | null
weightKg: number | null
volumeCbm: number | null
discrepancyCartons: number
sealedAt: string | null
items: InboundDropshipSkuItem[]
}
export interface InboundDropshipSkuItem {
inboundId: string
inboundCode: string
inboundDetailId: string
skuCode: string
cargoName: string | null
cartonCount: number
skuQuantity: number
cartonMarks: string[]
}
export interface InboundPageResult {
@@ -43,7 +43,7 @@ const summary: InboundOrderSummary = {
orderStatusSnapshot: 'APPROVED', measureReviewStatus: 'PENDING', feeStatus: 'UNPRICED', feeReviewStatus: null,
notificationStatus: null, version: 1, expectedArrivalAt: '2026-08-04T10:00:00', customerId: '10001', customerName: '示例客户',
companyId: '10001', companyName: '示例企业', warehouseId: '20001', warehouseName: '义乌收货仓', deliveryWarehouseId: null,
deliveryWarehouseName: null, businessType: 'FBA', channelCode: 'SEA', channelName: '海运', taxMode: 'DDP', containerMode: null,
deliveryWarehouseName: null, businessType: 'FBA', fulfillmentType: 'STANDARD', channelCode: 'SEA', channelName: '海运', taxMode: 'DDP', containerMode: null,
customsInspection: false, destinationType: 'FBA', destinationWarehouseId: '30001', destinationCode: 'ONT8', destinationName: 'ONT8',
holdFlag: false, currentHoldReason: null, totalPackageCount: 6, actualGrossWeightKg: 120, actualNetWeightKg: 110,
actualVolumeCbm: 1.2, volumeWeightKg: 200, chargeableWeightKg: 200, chargeableVolumeCbm: null, palletCount: 1, locationCodes: 'A-01',
@@ -1,4 +1,4 @@
import ElementPlus, { ElMessageBox } from 'element-plus'
import ElementPlus, { ElMessage, ElMessageBox } from 'element-plus'
import { createPinia, setActivePinia } from 'pinia'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -92,6 +92,22 @@ describe('MeasurementReviewPanel', () => {
expect(source).not.toContain('listMeasurementReviewHistory')
})
it.each([true, false])('describes dropship palletizing and uses the server result when pricing succeeds=%s', async (initialPricingSucceeded) => {
vi.mocked(approveMeasurement).mockResolvedValue({ inboundId: '90001', measurementId: '70001', processStatus: 'WAIT_PALLETIZE', feeStatus: initialPricingSucceeded ? 'CALCULATED' : 'UNPRICED', initialPricingSucceeded, initialPricingMessage: null, initialFee: null })
const confirmation = vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue(undefined as never)
const success = vi.spyOn(ElMessage, 'success')
const warning = vi.spyOn(ElMessage, 'warning')
const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001', fulfillmentType: 'ONE_CLICK_DROPSHIP' }, global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).not.toContain('通过并进入待分柜')
await wrapper.findAll('button').find((button) => button.text().includes('通过并进入待一件代发组托'))!.trigger('click')
await flushPromises()
expect(confirmation).toHaveBeenCalledWith(expect.stringContaining('进入待一件代发组托'), '确认测量审核通过', expect.objectContaining({ confirmButtonText: '通过并进入待一件代发组托' }))
expect(initialPricingSucceeded ? success : warning).toHaveBeenCalledWith(expect.stringContaining('入库单已进入待一件代发组托'))
expect(approveMeasurement).toHaveBeenCalledTimes(1)
expect(wrapper.emitted('changed')).toHaveLength(1)
})
it('uses the list customer context when the review detail has no customer name', async () => {
vi.mocked(getMeasurementReviewDetail).mockResolvedValue({ ...detail, companyName: null })
const wrapper = mount(MeasurementReviewPanel, {
@@ -8,7 +8,7 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import { downloadInboundMeasurementImage } from '../measurement/api'
import MeasurementEvidenceThumbnail from '../measurement/components/MeasurementEvidenceThumbnail.vue'
import { inboundReviewStatusLabels, inboundStatusLabel } from '../status'
import { inboundProcessStatusLabels, inboundReviewStatusLabels, inboundStatusLabel } from '../status'
import WeightOcrPanel from '../weight/components/WeightOcrPanel.vue'
import { approveMeasurement, getMeasurementReviewDetail } from './api'
import type { MeasurementReviewDetail } from './types'
@@ -18,6 +18,7 @@ const props = defineProps<{
/** @deprecated 仅用于兼容尚未完成 customerId 映射的上层集成。 */
tenantId?: string
inboundId: string
fulfillmentType?: string | null
customerName?: string | null
channelName?: string | null
channelCode?: string | null
@@ -44,6 +45,8 @@ let imagePreviewRequestSerial = 0
const canReview = computed(() => access.can('firstleg:inbound:measurement-review'))
const customerId = computed(() => props.customerId || props.tenantId || '')
const isPending = computed(() => detail.value?.measureReviewStatus === 'PENDING' && detail.value?.processStatus === 'MEASURE_PENDING')
const approvalTargetLabel = computed(() => props.fulfillmentType === 'ONE_CLICK_DROPSHIP' ? '待一件代发组托' : '待分柜')
const approvalButtonLabel = computed(() => `通过并进入${approvalTargetLabel.value}`)
const isRejected = computed(() => detail.value?.measureReviewStatus === 'REJECTED' && detail.value?.processStatus === 'MEASURE_REJECTED')
const reviewStatusLabel = computed(() => inboundStatusLabel(detail.value?.measureReviewStatus, inboundReviewStatusLabels, '未提交'))
const displayCustomerName = computed(() => detail.value?.companyName?.trim() || props.customerName?.trim() || '客户未命名')
@@ -134,9 +137,9 @@ async function confirmApprove() {
if (!detail.value || !isPending.value || !canReview.value || actionLoading.value || props.postApproveLoading || props.approvalCompleted) return
try {
await ElMessageBox.confirm(
'审核通过后,入库单将进入待分柜。系统会尝试生成初算费用,但初算失败不影响审核通过,后续可由平台人员补充调整。是否继续?',
`审核通过后,入库单将进入${approvalTargetLabel.value}。系统会尝试生成初算费用,但初算失败不影响审核通过,后续可由平台人员补充调整。是否继续?`,
'确认测量审核通过',
{ confirmButtonText: '通过并进入待分柜', cancelButtonText: '返回', type: 'warning' },
{ confirmButtonText: approvalButtonLabel.value, cancelButtonText: '返回', type: 'warning' },
)
} catch {
return
@@ -147,8 +150,9 @@ async function confirmApprove() {
measurementId: detail.value.measurementId,
expectedVersion: detail.value.inboundVersion,
})
if (result.initialPricingSucceeded) ElMessage.success('审核已通过,入库单已进入待分柜,费用已完成初算')
else ElMessage.warning(`审核已通过,入库单已进入待分柜;费用初算未完成${result.initialPricingMessage ? `:${result.initialPricingMessage}` : ''}`)
const resultStatusLabel = inboundStatusLabel(result.processStatus, inboundProcessStatusLabels)
if (result.initialPricingSucceeded) ElMessage.success(`审核已通过,入库单已进入${resultStatusLabel},费用已完成初算`)
else ElMessage.warning(`审核已通过,入库单已进入${resultStatusLabel};费用初算未完成${result.initialPricingMessage ? `:${result.initialPricingMessage}` : ''}`)
emit('changed')
} catch (error) {
ElMessage.error(isConflict(error) ? '数据已被更新,请刷新后重新审核' : getApiErrorDetails(error, '审核通过失败').message)
@@ -184,7 +188,7 @@ defineExpose({ confirmApprove, isPending, canReview, actionLoading })
</div>
<div class="review-toolbar__actions">
<el-button v-if="isPending && canReview" type="primary" plain @click="correctionMode = true"><Pencil :size="16" />修改重量</el-button>
<el-button v-if="isPending && canReview" type="primary" :loading="actionLoading === 'approve' || postApproveLoading" :disabled="approvalCompleted" @click="confirmApprove"><Check :size="16" />{{ approvalCompleted ? '审核已通过' : '通过并进入待分柜' }}</el-button>
<el-button v-if="isPending && canReview" type="primary" :loading="actionLoading === 'approve' || postApproveLoading" :disabled="approvalCompleted" @click="confirmApprove"><Check :size="16" />{{ approvalCompleted ? '审核已通过' : approvalButtonLabel }}</el-button>
<el-button :loading="loading" aria-label="刷新审核数据" @click="reload"><RotateCcw :size="16" />刷新</el-button>
</div>
</div>
+2 -2
View File
@@ -1,12 +1,12 @@
export const inboundProcessStatusLabels: Record<string, string> = {
PREPARING: '订单待审核(可先测量)', WAIT_MEASURE: '待测量', MEASURING: '测量中', MEASURE_PENDING: '待测量审核', MEASURE_REJECTED: '测量已驳回',
WAIT_PRICING: '待计费', FEE_CONFIRMING: '费用处理中', INBOUNDING: '入库处理中', WAIT_PALLETIZE: '待一件代发合托', WAIT_SPLIT: '待分柜', VOIDED: '已作废',
WAIT_PRICING: '待计费', FEE_CONFIRMING: '费用处理中', INBOUNDING: '入库处理中', WAIT_PALLETIZE: '待一件代发组托', WAIT_SPLIT: '待分柜', VOIDED: '已作废',
}
export const inboundOrderStatusLabels: Record<string, string> = {
DRAFT: '草稿', SUBMITTED: '待审核', APPROVED: '已审核', ACCEPTED: '已受理', REJECTED: '已驳回',
PROCESSING: '处理中', COMPLETED: '已完成', CANCELLED: '已取消', PENDING_REVIEW: '待审核',
WAIT_MEASURE: '待测量', WAIT_PALLETIZE: '待一件代发合托', WAIT_SPLIT: '待分柜', LOADING: '装柜中',
WAIT_MEASURE: '待测量', WAIT_PALLETIZE: '待一件代发组托', WAIT_SPLIT: '待分柜', LOADING: '装柜中',
LOADED: '装柜完成', DELIVERED: '派送完成',
}
@@ -51,7 +51,7 @@ const detail = {
items: [{
itemId: 11, itemVersion: 4, accountId: 12, orderId: 15, orderNo: 'ORD-001', inboundId: 13, inboundDetailId: 14, containerNo: 'MSCU1234567', productQuantity: 20,
customerName: '测试客户', customsTaxMode: 'TAX_REFUND', taxRefundFlag: true, inboundCode: 'RK001', skuCode: 'SKU001', destinationWarehouse: 'ONT8', chineseName: '产品', englishName: null,
goodsImageUrl: null, packageCount: 2, palletCount: 1, grossWeightKg: 10, netWeightKg: 9, volumeCbm: 1,
goodsImageUrl: null, unitType: 'SEALED_PALLET', palletId: 9007199254740993n, palletNo: 'PLT-001', indivisible: true, packageCount: 2, palletCount: 1, grossWeightKg: 10, netWeightKg: 9, volumeCbm: 1,
dimensionalWeightKg: 2, unitsPerPackage: 10, fbaTrackingNo: null, shipmentNo: null, shippingMark: null,
internalRemark: null, customerRemark: null, warehouseRemark: null, loadSequence: 1, frozenAt: null,
}],
@@ -77,7 +77,7 @@ describe('platform loading lists API', () => {
vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'ok', data: detail })
const result = await getPreloadPlan('100', '300')
expect(result.items[0]).toMatchObject({ itemId: '11', itemVersion: 4, accountId: '12', orderId: '15', orderNo: 'ORD-001', inboundId: '13', inboundDetailId: '14', containerNo: 'MSCU1234567', productQuantity: '20', customsTaxMode: 'TAX_REFUND', taxRefundFlag: true })
expect(result.items[0]).toMatchObject({ itemId: '11', itemVersion: 4, accountId: '12', orderId: '15', orderNo: 'ORD-001', inboundId: '13', inboundDetailId: '14', unitType: 'SEALED_PALLET', palletId: '9007199254740993', palletNo: 'PLT-001', indivisible: true, containerNo: 'MSCU1234567', productQuantity: '20', customsTaxMode: 'TAX_REFUND', taxRefundFlag: true })
expect(result.summary.containerBookingId).toBe('1900000000000000004')
expect(result.histories[0]).toMatchObject({ historyId: '21', itemId: '11', operationId: 'transport-21', operatorId: '22' })
})
@@ -93,6 +93,10 @@ export interface LoadingItem {
chineseName: string | null
englishName: string | null
goodsImageUrl: string | null
unitType: 'PACKAGE' | 'SEALED_PALLET'
palletId: string | null
palletNo: string | null
indivisible: boolean
packageCount: number
palletCount: number
grossWeightKg: number
@@ -242,6 +246,9 @@ function normalizeItem(raw: LoadingItem): LoadingItem {
orderId: requiredId(raw.orderId),
inboundId: requiredId(raw.inboundId),
inboundDetailId: requiredId(raw.inboundDetailId),
palletId: optionalId(raw.palletId),
palletNo: raw.palletNo || null,
indivisible: Boolean(raw.indivisible),
productQuantity: String(raw.productQuantity ?? '0'),
}
}
@@ -29,6 +29,8 @@ const actionColumn: DataTableColumn = { key: 'actions', label: '操作', fixed:
const baseItemColumns: DataTableColumn[] = [
{ key: 'sequence', prop: 'loadSequence', label: '装柜顺序', width: 92 },
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 170, className: 'identifier-column', showOverflowTooltip: true },
{ key: 'palletNo', prop: 'palletNo', label: '托盘号', minWidth: 155, className: 'identifier-column', showOverflowTooltip: true },
{ key: 'unitType', label: '装柜单位', width: 110, slotName: 'unitType' },
{ key: 'orderNo', prop: 'orderNo', label: '订单号', minWidth: 170, className: 'identifier-column', showOverflowTooltip: true },
{ key: 'containerNo', prop: 'containerNo', label: '集装箱号', minWidth: 165, className: 'identifier-column', showOverflowTooltip: true, formatter: (row: LoadingItem) => row.containerNo || '待补录' },
{ key: 'skuCode', prop: 'skuCode', label: 'SKU', minWidth: 150, className: 'identifier-column', showOverflowTooltip: true },
@@ -202,6 +204,7 @@ function formatNumber(value?: number | null, digits = 3) {
<template #actions="{ row }">
<el-button size="small" type="primary" plain :aria-label="`编辑 ${row.inboundCode || row.orderNo} 的仓库备注`" @click="emit('editWarehouseRemark', row as LoadingItem)"><Pencil :size="14" />编辑备注</el-button>
</template>
<template #unitType="{ row }"><el-tag size="small" :type="row.unitType === 'SEALED_PALLET' ? 'success' : 'info'">{{ row.unitType === 'SEALED_PALLET' ? '封托托盘' : '箱件' }}</el-tag><small v-if="row.indivisible">整托不可拆</small></template>
<template #grossWeight="{ row }">{{ formatNumber(row.grossWeightKg) }}</template>
<template #netWeight="{ row }">{{ formatNumber(row.netWeightKg) }}</template>
<template #taxRefundFlag="{ row }">{{ row.taxRefundFlag === true ? '是' : row.taxRefundFlag === false ? '否' : '—' }}</template>
@@ -219,6 +219,7 @@ export interface TransferDeliveryPayload {
paidAt?: string; paymentMethod?: 'ONLINE' | 'BANK_TRANSFER' | 'CASH' | 'OTHER'; paymentReferenceNo?: string
paymentVoucherFileIds?: string[]; chargeableQuantity?: number; quantityUnit?: string; unitPrice?: number
periodStart?: string; periodEnd?: string; remark?: string }>
palletCartonMarks?: Record<string, string[]>
}
export async function transferDispatchToDelivery(dispatchNo: string, payload: TransferDeliveryPayload) {
const response = await overseasHttp.post<ApiEnvelope<TransferDeliveryResult>, ApiEnvelope<TransferDeliveryResult>>(
@@ -3,17 +3,23 @@ import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders'
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 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; outboundType?: string | null }
export interface OutboundPalletRow {
id?: string | null
palletNo?: string | null
cartonMarks?: string | null
inboundCode?: string | null
destinationWarehouseCode?: string | null
destinationWarehouseName?: string | null
skuCode?: string | null
packageCount?: number | null
pickedPackageCount?: number | null
status?: string | 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 interface OutboundDetail { outbound: OutboundRow; pallets?: OutboundPalletRow[]; delivery?: DeliveryOrderRow | null }
export interface OutboundQuery { page: number; size: number; documentNo?: string; lxCode?: string; warehouseId?: string; status?: string; outboundType?: string }
export interface ReturnOutboundCandidate { returnNo?: string | null; overseasWarehouseId?: string | null; cartonMark?: string | null; fbaCartonMark?: string | null; destinationWarehouseCode?: string | null; destinationWarehouseName?: string | null; totalQuantity?: number | null; status?: string | null }
export interface OneClickOutboundCandidate { inventoryPositionId: string; orderNo?: string | null; customerId?: string | null; customerName?: string | null; overseasWarehouseId?: string | null; overseasWarehouseName?: string | null; palletNo?: string | null; inboundCode?: string | null; skuCode?: string | null; productName?: string | null; availablePackages: number }
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 })
return normalizeOverseasPage(unwrapApiResponse(response))
@@ -22,10 +28,22 @@ export async function getOutbound(outboundNo: string) {
const response = await overseasHttp.get<ApiEnvelope<OutboundDetail>, ApiEnvelope<OutboundDetail>>(`/platform/v1/last-leg/outbound-orders/${encodeURIComponent(outboundNo)}`)
return unwrapApiResponse(response)
}
export async function createOneClickDropshipOutbound(customerId: string, payload: { recipient: string; items: Array<{ inventoryPositionId: string; packages: number; sku?: string }>; remark?: string; idempotencyKey: string }) {
export async function createOneClickDropshipOutbound(customerId: string, payload: { recipient: string; items: Array<{ inventoryPositionId: string; packages: number; sku?: string; cartonMarks?: string[] }>; remark?: string; idempotencyKey: string }) {
const response = await overseasHttp.post<ApiEnvelope<OutboundRow>, ApiEnvelope<OutboundRow>>('/platform/v1/last-leg/outbound-orders/one-click-dropship', payload, { params: { customerId } })
return unwrapApiResponse(response)
}
export async function listOneClickOutboundCandidates(customerId?: string, query: { warehouseId?: string } = {}) {
const response = await overseasHttp.get<ApiEnvelope<OneClickOutboundCandidate[]>, ApiEnvelope<OneClickOutboundCandidate[]>>('/platform/v1/last-leg/outbound-orders/one-click-dropship/candidates', { params: { customerId, ...query } })
return unwrapApiResponse(response).map((item) => ({ ...item, inventoryPositionId: String(item.inventoryPositionId), availablePackages: Number(item.availablePackages || 0) }))
}
export async function listReturnOutboundCandidates(customerId: string, warehouseId: string) {
const response = await overseasHttp.get<ApiEnvelope<ReturnOutboundCandidate[]>, ApiEnvelope<ReturnOutboundCandidate[]>>('/platform/v1/last-leg/outbound-orders/return/candidates', { params: { customerId, warehouseId } })
return unwrapApiResponse(response)
}
export async function createReturnOutbound(customerId: string, payload: { recipient: string; overseasWarehouseId: string; returnNo: string; cartonMarks: string[]; remark?: string; idempotencyKey: string }) {
const response = await overseasHttp.post<ApiEnvelope<OutboundRow>, ApiEnvelope<OutboundRow>>('/platform/v1/last-leg/outbound-orders/return', payload, { params: { customerId } })
return unwrapApiResponse(response)
}
export async function cancelOutbound(outboundNo: string, reason: string) {
const response = await overseasHttp.post<ApiEnvelope<null>, ApiEnvelope<null>>(`/platform/v1/last-leg/outbound-orders/${encodeURIComponent(outboundNo)}/cancel`, { reason })
return unwrapApiResponse(response)
@@ -64,10 +64,20 @@ export interface RelabelSummaryDto {
totalPackageCount?: number | null
notifiedPackageCount?: number | null
skuCodes?: string | null
totalFileId?: string | null
totalFileName?: string | null
totalFileUrl?: string | null
chargeAmount?: number | string | null
chargeCurrency?: string | null
walletStatus?: string | null
walletErrorMessage?: string | null
deliveryMethod?: string | null
printShippingLabel?: boolean | null
shippingLabelFileId?: string | null
shippingLabelFileName?: string | null
fulfillmentStatus?: string | null
outboundOrderNo?: string | null
customerConfirmedAt?: string | null
createdAt?: string | null
}
@@ -79,6 +89,7 @@ export interface RelabelItemDto {
notifiedPackageCount?: number | null
newSkuCode?: string | null
labelRequirement?: string | null
relabelFileId?: string | null
relabelFileName?: string | null
relabelFileUrl?: string | null
shipmentNo?: string | null
@@ -102,6 +113,7 @@ export interface RelabelDetailDto {
remark?: string | null
items: RelabelItemDto[]
history: RelabelHistoryDto[]
completionEvidence?: { taskId: string; photoFileIds: string[]; completionRemark?: string | null; reviewerName?: string | null; reviewRemark?: string | null; completedAt?: string | null } | null
}
export async function listRelabelOrders(query: RelabelOrderQuery) {
@@ -144,3 +156,27 @@ export async function changeRelabelOrderStatus(relabelNo: string, status: string
)
return unwrapApiResponse(response)
}
export async function uploadRelabelPdf(relabelNo: string, file: File, scope: 'TOTAL' | 'ITEM', itemId?: string) {
const body = new FormData()
body.append('file', file)
const response = await overseasHttp.post<ApiEnvelope<RelabelDetailDto>, ApiEnvelope<RelabelDetailDto>>(
`/platform/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/files`, body,
{ params: { scope, itemId }, headers: { 'Content-Type': 'multipart/form-data' } },
)
return unwrapApiResponse(response)
}
export async function downloadRelabelPdf(relabelNo: string, fileId: string, scope: 'TOTAL' | 'ITEM', itemId?: string) {
return overseasHttp.get<Blob, Blob>(
`/platform/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/files/${encodeURIComponent(fileId)}/download`,
{ params: { scope, itemId }, responseType: 'blob' },
)
}
export async function downloadRelabelCompletionPhoto(relabelNo: string, fileId: string) {
return overseasHttp.get<Blob, Blob>(
`/platform/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/completion-photos/${encodeURIComponent(fileId)}/download`,
{ responseType: 'blob' },
)
}
@@ -0,0 +1,16 @@
import { overseasHttp } from '@/shared/api/overseasHttp'
import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders'
export interface ReturnStockRow { customerId: string; overseasWarehouseId: string; skuCode: string; productNameZh?: string; totalQuantity: number; availableQuantity: number; cartonQuantity: number; updatedAt: string }
export interface ReturnCartonRow { customerId: string; overseasWarehouseId: string; cartonMark: string; sourceReturnNo?: string; sourceBatchNo?: string; status: string; fbaCartonMark?: string; destinationWarehouseCode?: string; destinationWarehouseName?: string; relabelNo?: string; relabelStatus?: string; skuTypes: number; totalQuantity: number; length?: number; width?: number; height?: number; weight?: number; createdAt: string }
export interface ReturnCartonDetail extends ReturnCartonRow { customerId: string; items: Array<{ skuCode: string; productNameZh?: string; quantity: number }> }
export interface ReturnCartonLabel extends ReturnCartonDetail { printableHtml: string }
export interface ReturnInventoryMovement { customerId: string; overseasWarehouseId: string; skuCode: string; productNameZh?: string; movementType: 'RECEIPT' | 'MERGE' | 'OUTBOUND'; referenceNo: string; totalDelta: number; availableDelta: number; occurredAt: string }
export interface ReturnInventoryQuery { page: number; size: number; customerId?: string; overseasWarehouseId?: string; skuCode?: string; cartonMark?: string; returnNo?: string; status?: string }
export async function listReturnStock(params: ReturnInventoryQuery) { const response = await overseasHttp.get<ApiEnvelope<OverseasPagePayload<ReturnStockRow>>, ApiEnvelope<OverseasPagePayload<ReturnStockRow>>>('/platform/v1/last-leg/return-inventory', { params }); return normalizeOverseasPage(unwrapApiResponse(response)) }
export async function listReturnCartons(params: ReturnInventoryQuery) { const response = await overseasHttp.get<ApiEnvelope<OverseasPagePayload<ReturnCartonRow>>, ApiEnvelope<OverseasPagePayload<ReturnCartonRow>>>('/platform/v1/last-leg/return-inventory/cartons', { params }); return normalizeOverseasPage(unwrapApiResponse(response)) }
export async function listReturnInventoryMovements(params: ReturnInventoryQuery) { const response = await overseasHttp.get<ApiEnvelope<OverseasPagePayload<ReturnInventoryMovement>>, ApiEnvelope<OverseasPagePayload<ReturnInventoryMovement>>>('/platform/v1/last-leg/return-inventory/movements', { params }); return normalizeOverseasPage(unwrapApiResponse(response)) }
export async function getReturnCarton(cartonMark: string) { const response = await overseasHttp.get<ApiEnvelope<ReturnCartonDetail>, ApiEnvelope<ReturnCartonDetail>>(`/platform/v1/last-leg/return-inventory/cartons/${encodeURIComponent(cartonMark)}`); return unwrapApiResponse(response) }
export async function getReturnCartonLabel(cartonMark: string) { const response = await overseasHttp.get<ApiEnvelope<ReturnCartonLabel>, ApiEnvelope<ReturnCartonLabel>>(`/platform/v1/last-leg/return-inventory/cartons/${encodeURIComponent(cartonMark)}/label`); return unwrapApiResponse(response) }
@@ -33,7 +33,7 @@ const mutationError = ref<ApiErrorDetails | null>(null)
const editOpen = ref(false); const editRow = ref<DispatchListItem | null>(null); const editSaving = ref(false)
const dispatchSnapshots = new Map<string, DispatchSummaryDto>()
const editForm = reactive({ vehiclePlate: '', driverName: '', driverPhone: '', carrierName: '', route: '', departAt: '', fbaAppointmentNo: '', fbaAppointmentStatus: '', vehicleAppointmentStatus: '', estimatedArrivalAt: '' })
const transferForm = reactive({ plannedDeliveryAt: '', deliveryMethod: '', waybillNo: '', carrierName: '', pickupMethod: '', loadingType: '', vehicleType: '', vehicleSize: '', driverName: '', driverPhone: '', vehiclePlate: '', appointmentAccount: '', sellerName: '', deliveryFee: undefined as number | undefined, loadingFee: undefined as number | undefined, appointmentFee: undefined as number | undefined, remark: '' })
const transferForm = reactive({ plannedDeliveryAt: '', deliveryMethod: '', waybillNo: '', carrierName: '', pickupMethod: '', loadingType: '', vehicleType: '', vehicleSize: '', driverName: '', driverPhone: '', vehiclePlate: '', appointmentAccount: '', sellerName: '', deliveryFee: undefined as number | undefined, loadingFee: undefined as number | undefined, appointmentFee: undefined as number | undefined, cartonMarksJson: '', remark: '' })
const deliveryMethodOptions = [
{ label: 'FBA自提', value: 'FBA_PICKUP' }, { label: '客户自提', value: 'CUSTOMER_PICKUP' },
@@ -190,7 +190,7 @@ function canEdit(row: DispatchListItem) { return access.can(permission.dispatchE
function canCancel(row: DispatchListItem) { return access.can(permission.dispatchCancel) && row.statusCode === 'PENDING_DEPARTURE' }
function canTransfer(row: DispatchListItem) { return access.can(permission.dispatchTransferDelivery) && ['PENDING_DEPARTURE', 'PICKING', 'READY_TO_DEPART'].includes(row.statusCode) }
function openEdit(row: DispatchListItem) { if (!canEdit(row)) return; const source = dispatchSnapshots.get(row.dispatchNo); editRow.value = row; Object.assign(editForm, { vehiclePlate: source?.vehiclePlate || '', driverName: source?.driverName || '', driverPhone: source?.driverPhone || '', carrierName: source?.carrierName || '', route: source?.route || '', departAt: source?.departAt || '', fbaAppointmentNo: source?.fbaAppointmentNo || '', fbaAppointmentStatus: source?.fbaAppointmentStatus || '', vehicleAppointmentStatus: source?.vehicleAppointmentStatus || '', estimatedArrivalAt: source?.estimatedArrivalAt || '' }); mutationError.value = null; editOpen.value = true }
function openTransfer(row: DispatchListItem) { if (!canTransfer(row)) return; transferRow.value = row; Object.assign(transferForm, { plannedDeliveryAt: '', deliveryMethod: '', waybillNo: '', carrierName: row.carrierName === '—' ? '' : row.carrierName, pickupMethod: '', loadingType: '', vehicleType: '', vehicleSize: '', driverName: row.driverName === '—' ? '' : row.driverName, driverPhone: row.driverPhone === '—' ? '' : row.driverPhone, vehiclePlate: row.vehiclePlate === '—' ? '' : row.vehiclePlate, appointmentAccount: '', sellerName: '', deliveryFee: undefined, loadingFee: undefined, appointmentFee: undefined, remark: '' }); mutationError.value = null; transferOpen.value = true }
function openTransfer(row: DispatchListItem) { if (!canTransfer(row)) return; transferRow.value = row; Object.assign(transferForm, { plannedDeliveryAt: '', deliveryMethod: '', waybillNo: '', carrierName: row.carrierName === '—' ? '' : row.carrierName, pickupMethod: '', loadingType: '', vehicleType: '', vehicleSize: '', driverName: row.driverName === '—' ? '' : row.driverName, driverPhone: row.driverPhone === '—' ? '' : row.driverPhone, vehiclePlate: row.vehiclePlate === '—' ? '' : row.vehiclePlate, appointmentAccount: '', sellerName: '', deliveryFee: undefined, loadingFee: undefined, appointmentFee: undefined, cartonMarksJson: '', remark: '' }); mutationError.value = null; transferOpen.value = true }
async function submitEdit() { if (!editRow.value || !canEdit(editRow.value) || editSaving.value) return; editSaving.value = true; mutationError.value = null; try { await updateDispatch(editRow.value.dispatchNo, { vehiclePlate: editForm.vehiclePlate.trim() || null, driverName: editForm.driverName.trim() || null, driverPhone: editForm.driverPhone.trim() || null, carrierName: editForm.carrierName.trim() || null, route: editForm.route.trim() || null, departAt: editForm.departAt || null, fbaAppointmentNo: editForm.fbaAppointmentNo.trim() || null, fbaAppointmentStatus: editForm.fbaAppointmentStatus || null, vehicleAppointmentStatus: editForm.vehicleAppointmentStatus || null, estimatedArrivalAt: editForm.estimatedArrivalAt || null }); editOpen.value = false; ElMessage.success('排车信息已保存'); await load() } catch (cause) { mutationError.value = getApiErrorDetails(cause, '排车信息保存失败,请重试') } finally { editSaving.value = false } }
async function cancel(row: DispatchListItem) { if (!canCancel(row)) return; try { await ElMessageBox.confirm(`确认取消排车单 ${row.dispatchNo}?取消后将释放托盘锁。`, '取消排车', { confirmButtonText: '确认取消', cancelButtonText: '返回', type: 'warning' }) } catch { return } mutationError.value = null; try { await cancelDispatch(row.dispatchNo); ElMessage.success('排车单已取消'); await load() } catch (cause) { mutationError.value = getApiErrorDetails(cause, '排车取消失败,请重试') } }
function handleMore(command: string, row: DispatchListItem) { if (command === 'edit') openEdit(row); else if (command === 'cancel') void cancel(row) }
@@ -202,8 +202,12 @@ async function submitTransfer() {
if (isBuyAppointment.value && (!transferForm.sellerName.trim() || transferForm.appointmentFee == null)) { ElMessage.warning('买约时请填写卖家姓名和买约费用'); return }
const feeInputs = [['DELIVERY_FEE', transferForm.deliveryFee, '派送费用'], ['LOADING_FEE', needsLoadingFee.value ? transferForm.loadingFee : undefined, '装车费'], ['APPOINTMENT_FEE', isBuyAppointment.value ? transferForm.appointmentFee : undefined, '买约费用']] as const
const fees = feeInputs.filter(([, amount]) => amount != null).map(([feeType, amount, feeName]) => ({ feeType, feeName, direction: 'RECEIVABLE' as const, currency: 'USD' as const, amount: Number(amount), remark: feeName }))
let palletCartonMarks: Record<string, string[]> | undefined
if (transferForm.cartonMarksJson.trim()) {
try { const parsed = JSON.parse(transferForm.cartonMarksJson); if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error(); palletCartonMarks = Object.fromEntries(Object.entries(parsed).map(([pallet, marks]) => [pallet, Array.isArray(marks) ? marks.map((mark) => String(mark).trim()).filter(Boolean) : []])) } catch { ElMessage.warning('托盘箱唛分配格式不正确,请使用 JSON:{"托盘号":["箱唛1","箱唛2"]}'); return }
}
transferSaving.value = true
try { await transferDispatchToDelivery(transferRow.value.dispatchNo, { idempotencyKey: `web-transfer-${transferRow.value.dispatchNo}-${Date.now()}`, plannedDeliveryAt: transferForm.plannedDeliveryAt, deliveryMethod: transferForm.deliveryMethod, waybillNo: transferForm.waybillNo.trim() || undefined, carrierName: transferForm.carrierName.trim() || undefined, pickupMethod: transferForm.pickupMethod, loadingType: transferForm.loadingType, vehicleType: transferForm.vehicleType || undefined, vehicleSize: transferForm.vehicleSize || undefined, driverName: transferForm.driverName.trim() || undefined, driverPhone: transferForm.driverPhone.trim() || undefined, vehiclePlate: transferForm.vehiclePlate.trim() || undefined, appointmentAccount: transferForm.appointmentAccount || undefined, sellerName: isBuyAppointment.value ? transferForm.sellerName.trim() : undefined, remark: transferForm.remark.trim() || undefined, fees }); transferOpen.value = false; ElMessage.success('已生成出库单和派送单'); await load() } catch (cause) { mutationError.value = getApiErrorDetails(cause, '转派送失败,请重试') } finally { transferSaving.value = false }
try { await transferDispatchToDelivery(transferRow.value.dispatchNo, { idempotencyKey: `web-transfer-${transferRow.value.dispatchNo}-${Date.now()}`, plannedDeliveryAt: transferForm.plannedDeliveryAt, deliveryMethod: transferForm.deliveryMethod, waybillNo: transferForm.waybillNo.trim() || undefined, carrierName: transferForm.carrierName.trim() || undefined, pickupMethod: transferForm.pickupMethod, loadingType: transferForm.loadingType, vehicleType: transferForm.vehicleType || undefined, vehicleSize: transferForm.vehicleSize || undefined, driverName: transferForm.driverName.trim() || undefined, driverPhone: transferForm.driverPhone.trim() || undefined, vehiclePlate: transferForm.vehiclePlate.trim() || undefined, appointmentAccount: transferForm.appointmentAccount || undefined, sellerName: isBuyAppointment.value ? transferForm.sellerName.trim() : undefined, remark: transferForm.remark.trim() || undefined, fees, palletCartonMarks }); transferOpen.value = false; ElMessage.success('已生成出库单和派送单'); await load() } catch (cause) { mutationError.value = getApiErrorDetails(cause, '转派送失败,请重试') } finally { transferSaving.value = false }
}
onMounted(load)
@@ -219,7 +223,7 @@ onMounted(load)
<template #status="{ row }"><StatusBadge :status="String((row as DispatchListItem).status || '—')" /></template>
<template #pagination-left>共 {{ total }} 条排车记录</template>
</DataTable></section>
</ListPageLayout></AdminPage><AppDialog v-model="transferOpen" title="转派送" width="min(1120px, calc(100vw - 28px))" confirm-text="确认提交" :confirm-loading="transferSaving" @confirm="submitTransfer"><el-form label-position="top"><h3>派送信息</h3><div class="transfer-grid"><el-form-item label="排车单号"><el-input :model-value="transferRow?.dispatchNo" readonly /></el-form-item><el-form-item label="预计派送时间" required><el-date-picker v-model="transferForm.plannedDeliveryAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计派送时间" /></el-form-item><el-form-item label="派送方式" required><el-select v-model="transferForm.deliveryMethod" placeholder="请选择"><el-option v-for="option in deliveryMethodOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="快递/车队"><el-input v-model="transferForm.carrierName" placeholder="请输入快递或车队名称" /></el-form-item><el-form-item label="运单号"><el-input v-model="transferForm.waybillNo" placeholder="请输入运单号" /></el-form-item><el-form-item label="提货方式" required><el-select v-model="transferForm.pickupMethod" placeholder="请选择"><el-option v-for="option in pickupMethodOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="装车类型" required><el-select v-model="transferForm.loadingType" placeholder="请选择"><el-option v-for="option in loadingTypeOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="司机/车队名称"><el-input v-model="transferForm.driverName" placeholder="请输入司机或车队名称" /></el-form-item><el-form-item label="联系电话"><el-input v-model="transferForm.driverPhone" placeholder="请输入联系电话" /></el-form-item><el-form-item label="车牌号"><el-input v-model="transferForm.vehiclePlate" placeholder="请输入车牌号" /></el-form-item><el-form-item label="车辆类型"><el-select v-model="transferForm.vehicleType" clearable placeholder="请选择"><el-option v-for="option in vehicleTypeOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="车辆大小"><el-select v-model="transferForm.vehicleSize" clearable placeholder="请选择"><el-option v-for="option in vehicleSizeOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="预约账号"><el-select v-model="transferForm.appointmentAccount" clearable placeholder="请选择"><el-option v-for="option in appointmentAccountOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item v-if="isBuyAppointment" label="卖家姓名" required><el-input v-model="transferForm.sellerName" placeholder="请输入卖家姓名" /></el-form-item></div><h3>费用信息(USD)</h3><div class="transfer-grid"><el-form-item label="派送费用" required><el-input-number v-model="transferForm.deliveryFee" :min="0" :precision="2" controls-position="right" placeholder="请输入派送费用" /></el-form-item><el-form-item v-if="needsLoadingFee" label="装车费" required><el-input-number v-model="transferForm.loadingFee" :min="0" :precision="2" controls-position="right" placeholder="请输入装车费" /></el-form-item><el-form-item v-if="isBuyAppointment" label="买约费用" required><el-input-number v-model="transferForm.appointmentFee" :min="0" :precision="2" controls-position="right" placeholder="请输入买约费用" /></el-form-item></div><el-form-item label="备注"><el-input v-model="transferForm.remark" type="textarea" :rows="3" placeholder="请输入派送备注(选填)" /></el-form-item><el-alert title="费用不会自动带出,请手工填写后提交;提交后将生成待出库单、待派送单,并保存费用明细" type="info" :closable="false" /></el-form></AppDialog>
</ListPageLayout></AdminPage><AppDialog v-model="transferOpen" title="转派送" width="min(1120px, calc(100vw - 28px))" confirm-text="确认提交" :confirm-loading="transferSaving" @confirm="submitTransfer"><el-form label-position="top"><h3>派送信息</h3><div class="transfer-grid"><el-form-item label="排车单号"><el-input :model-value="transferRow?.dispatchNo" readonly /></el-form-item><el-form-item label="预计派送时间" required><el-date-picker v-model="transferForm.plannedDeliveryAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计派送时间" /></el-form-item><el-form-item label="派送方式" required><el-select v-model="transferForm.deliveryMethod" placeholder="请选择"><el-option v-for="option in deliveryMethodOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="快递/车队"><el-input v-model="transferForm.carrierName" placeholder="请输入快递或车队名称" /></el-form-item><el-form-item label="运单号"><el-input v-model="transferForm.waybillNo" placeholder="请输入运单号" /></el-form-item><el-form-item label="提货方式" required><el-select v-model="transferForm.pickupMethod" placeholder="请选择"><el-option v-for="option in pickupMethodOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="装车类型" required><el-select v-model="transferForm.loadingType" placeholder="请选择"><el-option v-for="option in loadingTypeOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="司机/车队名称"><el-input v-model="transferForm.driverName" placeholder="请输入司机或车队名称" /></el-form-item><el-form-item label="联系电话"><el-input v-model="transferForm.driverPhone" placeholder="请输入联系电话" /></el-form-item><el-form-item label="车牌号"><el-input v-model="transferForm.vehiclePlate" placeholder="请输入车牌号" /></el-form-item><el-form-item label="车辆类型"><el-select v-model="transferForm.vehicleType" clearable placeholder="请选择"><el-option v-for="option in vehicleTypeOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="车辆大小"><el-select v-model="transferForm.vehicleSize" clearable placeholder="请选择"><el-option v-for="option in vehicleSizeOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="预约账号"><el-select v-model="transferForm.appointmentAccount" clearable placeholder="请选择"><el-option v-for="option in appointmentAccountOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item v-if="isBuyAppointment" label="卖家姓名" required><el-input v-model="transferForm.sellerName" placeholder="请输入卖家姓名" /></el-form-item></div><el-form-item label="托盘箱唛确认"><el-input v-model="transferForm.cartonMarksJson" type="textarea" :rows="3" placeholder="按实际托盘填写 JSON,例如 {&quot;PALLET-001&quot;:[&quot;箱唛1&quot;,&quot;箱唛2&quot;]}" /><div class="form-hint">入库未逐箱绑定时,在出库前确认箱唛属于哪个实际托盘。</div></el-form-item><h3>费用信息(USD)</h3><div class="transfer-grid"><el-form-item label="派送费用" required><el-input-number v-model="transferForm.deliveryFee" :min="0" :precision="2" controls-position="right" placeholder="请输入派送费用" /></el-form-item><el-form-item v-if="needsLoadingFee" label="装车费" required><el-input-number v-model="transferForm.loadingFee" :min="0" :precision="2" controls-position="right" placeholder="请输入装车费" /></el-form-item><el-form-item v-if="isBuyAppointment" label="买约费用" required><el-input-number v-model="transferForm.appointmentFee" :min="0" :precision="2" controls-position="right" placeholder="请输入买约费用" /></el-form-item></div><el-form-item label="备注"><el-input v-model="transferForm.remark" type="textarea" :rows="3" placeholder="请输入派送备注(选填)" /></el-form-item><el-alert title="费用不会自动带出,请手工填写后提交;提交后将生成待出库单、待派送单,并保存费用明细" type="info" :closable="false" /></el-form></AppDialog>
<AppDialog v-model="editOpen" title="编辑排车" width="min(900px, calc(100vw - 48px))" confirm-text="保存" :confirm-loading="editSaving" @confirm="submitEdit"><el-alert :title="`当前状态:${editRow?.status || '—'};普通编辑不会修改排车状态。`" type="info" :closable="false" /><el-form label-position="top"><div class="edit-grid"><el-form-item label="车牌号"><el-input v-model="editForm.vehiclePlate" placeholder="请输入车牌号" /></el-form-item><el-form-item label="司机"><el-input v-model="editForm.driverName" placeholder="请输入司机姓名" /></el-form-item><el-form-item label="联系电话"><el-input v-model="editForm.driverPhone" placeholder="请输入联系电话" /></el-form-item><el-form-item label="拖车公司名称"><el-input v-model="editForm.carrierName" placeholder="请输入拖车公司名称" /></el-form-item><el-form-item label="路线"><el-input v-model="editForm.route" placeholder="请输入运输路线" /></el-form-item><el-form-item label="发车时间"><el-date-picker v-model="editForm.departAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item label="预计到达时间"><el-date-picker v-model="editForm.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item label="FBA预约号"><el-input v-model="editForm.fbaAppointmentNo" placeholder="请输入 FBA 预约号" /></el-form-item><el-form-item label="FBA预约状态"><el-select v-model="editForm.fbaAppointmentStatus" clearable><el-option label="待预约" value="PENDING" /><el-option label="已预约" value="BOOKED" /><el-option label="已确认" value="CONFIRMED" /><el-option label="已取消" value="CANCELLED" /></el-select></el-form-item><el-form-item label="车辆预约状态"><el-select v-model="editForm.vehicleAppointmentStatus" clearable><el-option label="待预约" value="PENDING" /><el-option label="已预约" value="BOOKED" /><el-option label="已确认" value="CONFIRMED" /><el-option label="已取消" value="CANCELLED" /></el-select></el-form-item></div></el-form></AppDialog>
</template>
<style scoped>.transfer-grid,.edit-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:0 16px}.transfer-grid :deep(.el-select),.transfer-grid :deep(.el-date-editor),.transfer-grid :deep(.el-input-number),.edit-grid :deep(.el-select),.edit-grid :deep(.el-date-editor){width:100%}h3{margin:4px 0 12px}@media(max-width:900px){.transfer-grid,.edit-grid{grid-template-columns:1fr}}</style>
@@ -183,13 +183,13 @@ onMounted(load)
</section>
<AppDialog v-model="feeDialogOpen" title="新增费用" width="min(720px, calc(100vw - 28px))" confirm-text="保存费用" :confirm-loading="saving" @confirm="saveFee">
<el-form class="fee-form" label-position="top"><div class="fee-form-grid">
<el-form-item label="费用项目" required><el-select v-model="feeForm.itemCode" filterable placeholder="请选择费用项目" @change="feeForm.itemName = feeItemOptions.find(item => item.code === feeForm.itemCode)?.name || ''"><el-option v-for="item in feeItemOptions" :key="item.code" :label="item.name" :value="item.code" /></el-select></el-form-item>
<el-form-item label="费用项目" required><el-select v-model="feeForm.itemCode" filterable allow-create clearable default-first-option placeholder="请选择或输入费用项目" @change="feeForm.itemName = feeItemOptions.find(item => item.code === feeForm.itemCode)?.name || String(feeForm.itemCode).trim()"><el-option v-for="item in feeItemOptions" :key="item.code" :label="item.name" :value="item.code" /></el-select></el-form-item>
<el-form-item label="收付方向" required><el-select v-model="feeForm.direction"><el-option label="应收客户" value="RECEIVABLE" /><el-option label="应付供应商" value="PAYABLE" /></el-select></el-form-item>
<el-form-item v-if="feeForm.direction === 'PAYABLE'" label="供应商" required><el-select v-model="feeForm.supplierId" filterable placeholder="请选择供应商"><el-option v-for="item in supplierOptions" :key="String(item.id)" :label="item.name" :value="String(item.id)" /></el-select></el-form-item>
<el-form-item label="业务日期" required><el-date-picker v-model="feeForm.businessDate" type="date" value-format="YYYY-MM-DD" /></el-form-item>
<el-form-item label="币种" required><el-select v-model="feeForm.currency" placeholder="请选择币种"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /></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 label="计费数量"><el-input-number v-model="feeForm.quantity" :min="0.0001" :precision="4" controls-position="right" /></el-form-item><el-form-item label="计费单位"><el-input v-model="feeForm.quantityUnit" :disabled="feeForm.quantity == null" placeholder="如 箱、托、天、m³" /></el-form-item>
<el-form-item label="计费数量"><el-input-number v-model="feeForm.quantity" :min="0.0001" :precision="4" controls-position="right" placeholder="请输入计费数量" /></el-form-item><el-form-item label="计费单位"><el-input v-model="feeForm.quantityUnit" :disabled="feeForm.quantity == null" placeholder="如 箱、托、天、m³" /></el-form-item>
<el-form-item label="计费开始日期"><el-date-picker v-model="feeForm.periodStart" type="date" value-format="YYYY-MM-DD" placeholder="仓租等周期费用填写" /></el-form-item><el-form-item label="计费结束日期"><el-date-picker v-model="feeForm.periodEnd" type="date" value-format="YYYY-MM-DD" placeholder="仓租等周期费用填写" /></el-form-item>
</div></el-form>
</AppDialog>
@@ -27,10 +27,14 @@ const palletColumns: DataTableColumn[] = [
{ key: 'destinationWarehouseCode', label: '目的仓编码', width: 150 },
{ key: 'destinationWarehouseName', label: '目的仓名称', minWidth: 180 },
{ key: 'skuCode', label: '商品 SKU', width: 170 },
{ key: 'cartonMarks', label: '箱唛', minWidth: 220 },
{ key: 'packageCount', label: '箱数', width: 100, align: 'right' },
{ key: 'pickedPackageCount', label: '已拣箱数', width: 110, align: 'right' },
{ key: 'status', label: '状态', width: 110 },
]
function show(value: unknown) { const text = String(value ?? '').trim(); return text || '—' }
function statusLabel(value: unknown) { const code = String(value || ''); return statusLabels[code] || show(code) }
function outboundTypeLabel(value?: string | null) { return value === 'ONE_CLICK_DROPSHIP' ? '一件代发出库' : value === 'RETURN_OUTBOUND' ? '退货出库' : value === 'DISPATCH_DELIVERY' ? '派送出库' : show(value) }
const canCancel = computed(() => access.can(permission.outboundCancel) && detail.value?.outbound.status === 'PICKED')
async function load() { if (!outboundNo.value) { loadError.value = { message: '详情缺少出库单号,请从出库单列表重新进入' }; return } loading.value = true; loadError.value = null; try { detail.value = await getOutbound(outboundNo.value) } catch (cause) { detail.value = null; loadError.value = getApiErrorDetails(cause, '出库单详情加载失败,请重试') } finally { loading.value = false } }
async function cancel() { if (!canCancel.value || cancelling.value) return; let reason = ''; try { const result = await ElMessageBox.prompt(`确认取消出库单 ${outboundNo.value}?`, '取消出库单', { inputType: 'textarea', inputPlaceholder: '请输入取消原因', inputPattern: /\S+/, inputErrorMessage: '请输入取消原因', confirmButtonText: '确认取消', cancelButtonText: '返回', type: 'warning' }); reason = result.value.trim() } catch { return } cancelling.value = true; mutationError.value = null; try { await cancelOutbound(outboundNo.value, reason); ElMessage.success('出库单已取消'); await load() } catch (cause) { mutationError.value = getApiErrorDetails(cause, '取消出库单失败,请重试') } finally { cancelling.value = false } }
@@ -43,7 +47,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><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>
<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="出库类型">{{ outboundTypeLabel(detail.outbound.outboundType) }}</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>
@@ -29,25 +29,37 @@ describe('OutboundsView permissions and navigation', () => {
const wrapper = mount(OutboundsView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('详情')
expect(wrapper.text()).not.toContain('一件代发出库')
expect(wrapper.text()).not.toContain('新建出库单')
expect(wrapper.text()).not.toContain('取消')
expect(OutboundsView.__file).toContain('OutboundsView.vue')
wrapper.unmount()
})
it('shows one-click creation and cancellation only with their exact permissions', async () => {
it('shows outbound creation and cancellation only with their exact permissions', async () => {
canPermission.mockImplementation((code: string) => ['platform:overseas-warehouse:outbound:create', 'platform:overseas-warehouse:outbound:cancel'].includes(code))
const wrapper = mount(OutboundsView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('一件代发出库')
expect(wrapper.text()).toContain('新建出库单')
expect(wrapper.text()).toContain('取消')
wrapper.unmount()
})
it('requires an explicit customer selection before choosing customer inventory', async () => {
it('loads one-click candidates across customers and loads return inventory after customer selection', async () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/overseas-warehouse/pages/OutboundsView.vue'), 'utf8')
expect(source).toContain('v-model="selectedCustomerId"')
expect(source).toContain('availablePositionOptions')
expect(source).toContain('createOneClickDropshipOutbound(selectedCustomerId.value')
expect(source).toContain('listOneClickOutboundCandidates(selectedCustomerId.value || undefined')
expect(source).toContain('if (selectedCustomerId.value && selectedWarehouseId.value) await loadReturnCandidates()')
expect(source).toContain("createOneClickDropshipOutbound([...customerIds][0] || ''")
})
it('applies batched return filters only through explicit search and exposes page sizes', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/overseas-warehouse/pages/OutboundsView.vue'), 'utf8')
expect(source).toContain('returnSkuQueryInput')
expect(source).toContain('returnCartonQueryInput')
expect(source).toContain("item.replace(/\\s+/g, '')")
expect(source).toContain('@click="searchReturnSkus"')
expect(source).toContain('@click="searchReturnCartons"')
expect(source).toContain(':page-sizes="[5, 10, 20, 50]"')
expect(source).toContain('width="min(1320px, calc(100vw - 32px))"')
})
})
@@ -14,27 +14,95 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { cancelOutbound, createOneClickDropshipOutbound, listOutbounds, type OutboundRow } from '../api/outbounds'
import { listInventoryBalances, listInventoryPositions, type InventoryBalanceDto, type InventoryPositionDto } from '../api/inventory'
import { cancelOutbound, createOneClickDropshipOutbound, createReturnOutbound, listOneClickOutboundCandidates, listReturnOutboundCandidates, listOutbounds, type OneClickOutboundCandidate, type OutboundRow, type ReturnOutboundCandidate } from '../api/outbounds'
import { type InventoryBalanceDto, type InventoryPositionDto } from '../api/inventory'
import { formatOverseasDateTime } from '../lastMilePresentation'
import { overseasWarehousePermissions as permission } from '../permissions'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam/api/platformIam'
import { listPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
import { getReturnCarton, listReturnCartons, listReturnStock, type ReturnCartonDetail, type ReturnCartonRow, type ReturnStockRow } from '../api/returnInventory'
const router = useRouter(); const access = useAccessPreviewStore()
const rows = ref<OutboundRow[]>([]); const page = ref(1); const pageSize = ref(20); const total = ref(0); const loading = ref(false); const error = ref<ApiErrorDetails | null>(null); const mutationError = ref<ApiErrorDetails | null>(null)
const filters = ref<QueryModel>({ documentNo: '', lxCode: '', warehouseId: '', status: '' }); const createOpen = ref(false); const saving = ref(false); const recipient = ref(''); const remark = ref(''); const quantity = ref<number | undefined>(); const selectedCustomerId = ref(''); const selectedPositionId = ref(''); const positionOptions = ref<Array<{ balance: InventoryBalanceDto; position: InventoryPositionDto }>>([]); const inventoryLoading = ref(false)
const filters = ref<QueryModel>({ documentNo: '', lxCode: '', warehouseId: '', status: '', outboundType: '' }); const createOpen = ref(false); const saving = ref(false); const recipient = ref(''); const remark = ref(''); const quantity = ref<number | undefined>(); const cartonMarks = ref(''); const selectedReturnMarks = ref<string[]>([]); const selectedCustomerId = ref(''); const selectedPositionId = ref(''); const positionOptions = ref<Array<{ balance: InventoryBalanceDto; position: InventoryPositionDto }>>([]); const dropshipCandidates = ref<OneClickOutboundCandidate[]>([]); const selectedDropshipIds = ref<string[]>([]); const returnCandidates = ref<ReturnOutboundCandidate[]>([]); const selectedWarehouseId = ref(''); const selectedReturnNo = ref(''); const outboundType = ref<'ONE_CLICK_DROPSHIP' | 'RETURN_OUTBOUND'>('ONE_CLICK_DROPSHIP'); const inventoryLoading = ref(false); const dropshipQueryInput = ref(''); const dropshipQuery = ref(''); const dropshipPage = ref(1); const dropshipPageSize = ref(5)
const tenantOptions = ref<PlatformTenantResource[]>([])
const warehouseOptionsSource = ref<PlatformWarehouseSummaryResource[]>([])
const returnStockRows = ref<ReturnStockRow[]>([])
const returnCartonRows = ref<ReturnCartonRow[]>([])
const selectedReturnSkus = ref<string[]>([])
const returnSkuQueryInput = ref(''); const returnSkuQuery = ref('')
const returnCartonQueryInput = ref(''); const returnCartonQuery = ref('')
const returnSkuPage = ref(1); const returnCartonPage = ref(1)
const returnSkuPageSize = ref(5); const returnCartonPageSize = ref(5)
const returnCartonDetails = ref<Record<string, ReturnCartonDetail>>({})
const customerOptions = computed(() => {
const customers = new Map<string, string>()
tenantOptions.value.forEach((item) => {
if (item.status !== 'ACTIVE') return
const name = item.tenantName?.trim() || '客户名称待补充'
const phone = item.contactPhone?.trim()
customers.set(String(item.id), phone ? `${name}-${phone}` : name)
})
positionOptions.value.forEach(({ balance }) => customers.set(balance.customerId, balance.customerName?.trim() || '客户名称待补充'))
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: 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' }] }]
const warehouseOptions = computed(() => warehouseOptionsSource.value.filter((item) => item.status === 'ACTIVE' && item.warehouseType === 'OVERSEAS').map((item) => ({ value: String(item.id), label: item.warehouseName || String(item.id) })))
const selectedReturnStock = computed(() => returnStockRows.value.filter((item) => Number(item.availableQuantity || 0) > 0))
const selectedReturnCartons = computed(() => returnCartonRows.value.filter((item) => item.status === 'AVAILABLE'))
const queryTokens = (value: string) => value.split(/[,,\n]+/).map((item) => item.replace(/\s+/g, '').toLowerCase()).filter(Boolean)
const matchesAnyToken = (value: string, query: string) => { const tokens = queryTokens(query); const normalized = value.replace(/\s+/g, '').toLowerCase(); return !tokens.length || tokens.some((token) => normalized.includes(token)) }
const filteredReturnStocks = computed(() => selectedReturnStock.value.filter((item) => matchesAnyToken(`${item.skuCode},${item.productNameZh || ''}`, returnSkuQuery.value)))
const filteredReturnCartons = computed(() => selectedReturnCartons.value.filter((item) => {
const detailSkus = returnCartonDetails.value[item.cartonMark]?.items?.map((detail) => detail.skuCode).join(',') || ''
return matchesAnyToken(`${item.cartonMark},${item.fbaCartonMark || ''},${detailSkus}`, returnCartonQuery.value)
}))
const pagedReturnStocks = computed(() => filteredReturnStocks.value.slice((returnSkuPage.value - 1) * returnSkuPageSize.value, returnSkuPage.value * returnSkuPageSize.value))
const pagedReturnCartons = computed(() => filteredReturnCartons.value.slice((returnCartonPage.value - 1) * returnCartonPageSize.value, returnCartonPage.value * returnCartonPageSize.value))
const effectiveReturnMarks = computed(() => [...new Set(selectedReturnMarks.value)].filter(Boolean) as string[])
const returnMarksModel = computed({ get: () => selectedReturnMarks.value, set: (value: string[]) => { selectedReturnMarks.value = value } })
const filteredDropshipCandidates = computed(() => dropshipCandidates.value.filter(item => !dropshipQuery.value.trim() || [item.orderNo, item.inboundCode, item.customerName, item.palletNo, item.skuCode, item.productName].join(' ').toLowerCase().includes(dropshipQuery.value.trim().toLowerCase())))
const pagedDropshipCandidates = computed(() => filteredDropshipCandidates.value.slice((dropshipPage.value - 1) * dropshipPageSize.value, dropshipPage.value * dropshipPageSize.value))
const oneClickMarks = computed(() => typeof cartonMarks.value === 'string' ? cartonMarks.value.split(/[,\n,]+/).map(value => value.trim()).filter(Boolean) : [])
const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions' }, { key: 'outboundNo', prop: 'outboundNo', label: '出库单号', minWidth: 180 }, { key: 'outboundType', prop: 'outboundType', label: '出库类型', width: 150, slotName: 'outboundType' }, { 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' }] }, { key: 'outboundType', type: 'select', label: '出库类型', placeholder: '全部出库类型', width: 170, options: [{ label: '全部', value: '' }, { label: '一件代发出库', value: 'ONE_CLICK_DROPSHIP' }, { label: '退货出库', value: 'RETURN_OUTBOUND' }, { label: '派送出库', value: 'DISPATCH_DELIVERY' }] }]
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 } }
async function openCreate() { createOpen.value = true; selectedCustomerId.value = ''; selectedPositionId.value = ''; quantity.value = undefined; inventoryLoading.value = true; try { const balances = await listInventoryBalances({ page: 1, size: 200, inventoryStatus: 'AVAILABLE' }); const groups = await Promise.all(balances.records.map(async balance => (await listInventoryPositions(balance.id, { inventoryStatus: 'AVAILABLE' })).map(position => ({ balance, position })))); positionOptions.value = groups.flat().filter(item => item.position.availablePackages > 0) } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '可用库存加载失败').message) } finally { inventoryLoading.value = false } }
function selectCustomer() { selectedPositionId.value = ''; quantity.value = undefined }
async function submit() { const selected = availablePositionOptions.value.find(item => item.position.id === selectedPositionId.value); if (!selectedCustomerId.value || !recipient.value.trim() || !selected || !quantity.value || quantity.value < 1) { ElMessage.warning('请选择客户,填写收件信息并选择托盘和出库数量'); return }; if (quantity.value > selected.position.availablePackages) { ElMessage.warning('出库数量不能超过可用库存'); return }; saving.value = true; try { await createOneClickDropshipOutbound(selectedCustomerId.value, { recipient: recipient.value.trim(), items: [{ inventoryPositionId: selected.position.id, packages: quantity.value, sku: selected.balance.skuCode || undefined }], remark: remark.value.trim() || undefined, idempotencyKey: `web-dropship-${Date.now()}` }); ElMessage.success('出库单已创建并推送PDA拣货任务'); createOpen.value = false; await load() } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '一件代发出库失败').message) } finally { saving.value = false } }
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, outboundType: String(filters.value.outboundType || '').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 } }
function outboundTypeLabel(value?: string | null) { return value === 'ONE_CLICK_DROPSHIP' ? '一件代发出库' : value === 'RETURN_OUTBOUND' ? '退货出库' : value === 'DISPATCH_DELIVERY' ? '派送出库' : value || '—' }
async function loadInventoryOptions() {
inventoryLoading.value = true
try {
const [tenants, warehouses] = await Promise.allSettled([
listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' }),
listPlatformWarehouses({ page: 1, size: 200, warehouseType: 'OVERSEAS', status: 'ACTIVE' }),
])
if (tenants.status === 'fulfilled') tenantOptions.value = tenants.value.records
else ElMessage.error(getApiErrorDetails(tenants.reason, '客户列表加载失败').message)
if (warehouses.status === 'fulfilled') {
warehouseOptionsSource.value = warehouses.value.records
if (!selectedWarehouseId.value) {
const lax = warehouses.value.records.find((item) => item.warehouseCode === 'US-LAX-01' || item.warehouseName?.includes('洛杉矶'))
selectedWarehouseId.value = String(lax?.id || warehouses.value.records[0]?.id || '')
}
} else ElMessage.error(getApiErrorDetails(warehouses.reason, '海外仓列表加载失败').message)
} finally { inventoryLoading.value = false }
}
async function openCreate() { createOpen.value = true; outboundType.value = 'ONE_CLICK_DROPSHIP'; selectedCustomerId.value = ''; selectedWarehouseId.value = ''; selectedDropshipIds.value = []; dropshipCandidates.value = []; dropshipPage.value = 1; returnSkuQueryInput.value = ''; returnSkuQuery.value = ''; returnCartonQueryInput.value = ''; returnCartonQuery.value = ''; returnSkuPage.value = 1; returnCartonPage.value = 1; await loadInventoryOptions(); await loadDropshipCandidates() }
async function loadDropshipCandidates() { if (!selectedWarehouseId.value) { dropshipCandidates.value = []; return }; dropshipPage.value = 1; inventoryLoading.value = true; try { dropshipCandidates.value = await listOneClickOutboundCandidates(selectedCustomerId.value || undefined, { warehouseId: selectedWarehouseId.value }) } catch (cause) { dropshipCandidates.value = []; ElMessage.error(getApiErrorDetails(cause, '一件代发订单加载失败').message) } finally { inventoryLoading.value = false } }
async function changeDropshipWarehouse() { selectedDropshipIds.value = []; await loadDropshipCandidates() }
async function selectCustomer() { selectedPositionId.value = ''; selectedDropshipIds.value = []; selectedReturnNo.value = ''; selectedReturnMarks.value = []; selectedReturnSkus.value = []; returnCandidates.value = []; returnStockRows.value = []; returnCartonRows.value = []; returnCartonDetails.value = {}; dropshipCandidates.value = []; dropshipPage.value = 1; if (outboundType.value === 'RETURN_OUTBOUND') { if (selectedCustomerId.value && selectedWarehouseId.value) await loadReturnCandidates(); return }; await loadDropshipCandidates() }
async function changeOutboundType() { selectedReturnNo.value = ''; selectedReturnMarks.value = []; selectedReturnSkus.value = []; returnCandidates.value = []; returnStockRows.value = []; returnCartonRows.value = []; returnCartonDetails.value = {}; selectedDropshipIds.value = []; dropshipCandidates.value = []; if (outboundType.value === 'ONE_CLICK_DROPSHIP') await loadDropshipCandidates() }
async function loadReturnCandidates() { if (!selectedCustomerId.value || !selectedWarehouseId.value) return; inventoryLoading.value = true; try { const [candidates, stock, cartons] = await Promise.all([listReturnOutboundCandidates(selectedCustomerId.value, selectedWarehouseId.value), listReturnStock({ page: 1, size: 200, customerId: selectedCustomerId.value, overseasWarehouseId: selectedWarehouseId.value, status: 'AVAILABLE' }), listReturnCartons({ page: 1, size: 200, customerId: selectedCustomerId.value, overseasWarehouseId: selectedWarehouseId.value, status: 'AVAILABLE' })]); returnCandidates.value = candidates; returnStockRows.value = stock.records; returnCartonRows.value = cartons.records; const details = await Promise.all(cartons.records.map(async (row) => [row.cartonMark, await getReturnCarton(row.cartonMark)] as const)); returnCartonDetails.value = Object.fromEntries(details); selectedReturnSkus.value = []; selectedReturnMarks.value = [] } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '客户库存加载失败').message) } finally { inventoryLoading.value = false } }
function dropshipCandidateKey(item: OneClickOutboundCandidate) { return `${item.inventoryPositionId}:${item.skuCode || ''}` }
function isDropshipSelected(item: OneClickOutboundCandidate) { return selectedDropshipIds.value.includes(dropshipCandidateKey(item)) }
function toggleDropshipCandidate(item: OneClickOutboundCandidate, checked: boolean | string | number) { const key = dropshipCandidateKey(item); selectedDropshipIds.value = checked ? [...new Set([...selectedDropshipIds.value, key])] : selectedDropshipIds.value.filter(value => value !== key) }
function searchDropshipCandidates() { dropshipQuery.value = dropshipQueryInput.value.trim(); dropshipPage.value = 1 }
function resetDropshipCandidates() { dropshipQueryInput.value = ''; dropshipQuery.value = ''; dropshipPage.value = 1 }
function normalizeBatchQuery(value: string) { return value.split(/[,,\n]+/).map((item) => item.replace(/\s+/g, '')).filter(Boolean).join(',') }
function searchReturnSkus() { returnSkuQueryInput.value = normalizeBatchQuery(returnSkuQueryInput.value); returnSkuQuery.value = returnSkuQueryInput.value; returnSkuPage.value = 1 }
function searchReturnCartons() { returnCartonQueryInput.value = normalizeBatchQuery(returnCartonQueryInput.value); returnCartonQuery.value = returnCartonQueryInput.value; returnCartonPage.value = 1 }
function toggleReturnSku(sku: string, checked: boolean | string | number) { selectedReturnSkus.value = checked ? [...new Set([...selectedReturnSkus.value, sku])] : selectedReturnSkus.value.filter((value) => value !== sku) }
async function submit() { const marks = String(cartonMarks.value).split(/[,\n,]+/).map(value => value.trim()).filter(Boolean); if (!selectedWarehouseId.value) { ElMessage.warning('请选择海外仓'); return }; if (outboundType.value === 'RETURN_OUTBOUND' && !selectedCustomerId.value) { ElMessage.warning('请选择客户'); return }; saving.value = true; try { if (outboundType.value === 'RETURN_OUTBOUND') { if (!recipient.value.trim() || !selectedReturnNo.value || !selectedReturnMarks.value.length) { ElMessage.warning('请填写收件信息并选择退货单和箱唛'); return }; await createReturnOutbound(selectedCustomerId.value, { recipient: recipient.value.trim(), overseasWarehouseId: selectedWarehouseId.value, returnNo: selectedReturnNo.value, cartonMarks: selectedReturnMarks.value, remark: remark.value.trim() || undefined, idempotencyKey: `web-return-${Date.now()}` }); ElMessage.success('退货出库单已创建并推送PDA箱唛拣货任务') } else { const selected = dropshipCandidates.value.filter(item => selectedDropshipIds.value.includes(dropshipCandidateKey(item))); if (!selected.length) { ElMessage.warning('请至少选择一个已完成入库的一件代发订单'); return }; const customerIds = new Set(selected.map(item => item.customerId).filter(Boolean)); if (customerIds.size !== 1) { ElMessage.warning('一张出库单只能选择同一个客户的订单'); return }; const warehouses = new Set(selected.map(item => item.overseasWarehouseId).filter(Boolean)); if (warehouses.size > 1 || (warehouses.size === 1 && !warehouses.has(selectedWarehouseId.value))) { ElMessage.warning('所选订单必须属于当前海外仓'); return }; const items = selected.map(item => ({ inventoryPositionId: item.inventoryPositionId, packages: item.availablePackages, sku: item.skuCode || '', cartonMarks: [] as string[] })); await createOneClickDropshipOutbound([...customerIds][0] || '', { recipient: '一件代发订单直出', items, remark: remark.value.trim() || undefined, idempotencyKey: `web-dropship-${Date.now()}` }); ElMessage.success(`已按订单生成 ${selected.length} 个托盘出库明细并推送PDA拣货任务`) } createOpen.value = false; await load() } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '出库单创建失败').message) } finally { saving.value = false } }
function openDetail(row: OutboundRow) { void router.push({ name: 'overseas-outbound-detail', params: { outboundNo: row.outboundNo } }) }
function canCancel(row: OutboundRow) { return access.can(permission.outboundCancel) && row.status === 'PICKED' }
async function cancel(row: OutboundRow) { if (!canCancel(row)) return; let reason = ''; try { const result = await ElMessageBox.prompt(`出库单 ${row.outboundNo} · 出库数量 ${row.packageCount ?? 0}`, '取消出库单', { inputType: 'textarea', inputPlaceholder: '请输入取消原因', inputPattern: /\S+/, inputErrorMessage: '请输入取消原因', confirmButtonText: '确认取消', cancelButtonText: '返回', type: 'warning' }); reason = result.value.trim() } catch { return }; mutationError.value = null; try { await cancelOutbound(row.outboundNo, reason); ElMessage.success('出库单已取消,库存及关联单据已回滚'); await load() } catch (cause) { mutationError.value = getApiErrorDetails(cause, '取消出库单失败') } }
@@ -48,14 +116,101 @@ onMounted(load)
<template #toolbar><QueryToolbar v-model="filters" :fields="fields" :loading="loading" ariaLabel="出库单筛选" preference-key="overseas-outbounds-query" :search-on-reset="false" @search="search" @reset="reset" /></template>
<template v-if="error" #state><LoadErrorState title="出库单列表加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template>
<section v-if="!error" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-outbounds" row-key="id" empty-text="暂无出库单" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-actions><el-button v-if="access.can(permission.outboundCreate)" type="primary" @click="openCreate"><Plus :size="16" />一件代发出库</el-button></template>
<template #toolbar-actions><el-button v-if="access.can(permission.outboundCreate)" type="primary" @click="openCreate"><Plus :size="16" />新建出库单</el-button></template>
<template #actions="{ row }"><el-button size="small" type="primary" plain @click="openDetail(row as OutboundRow)"><Eye :size="15" />详情</el-button><el-button v-if="canCancel(row as OutboundRow)" size="small" type="danger" plain @click="cancel(row as OutboundRow)"><XCircle :size="15" />取消</el-button></template>
<template #outboundType="{ row }">{{ outboundTypeLabel(row.outboundType) }}</template>
<template #status="{ row }"><StatusBadge :status="row.status === 'CANCELLED' ? '已取消' : row.status === 'OUTBOUNDED' ? '已出库' : row.status === 'PICKED' ? '已拣货' : '拣货中'" /></template>
</DataTable></section>
</ListPageLayout></AdminPage>
<AppDialog v-model="createOpen" title="一件代发出库" confirm-text="生成出库单并推送PDA" :confirm-loading="saving" @confirm="submit"><el-form class="create-outbound-form" label-position="top"><el-form-item label="客户" required><el-select v-model="selectedCustomerId" :loading="inventoryLoading" filterable placeholder="请选择库存所属客户" @change="selectCustomer"><el-option v-for="item in customerOptions" :key="item.value" :value="item.value" :label="item.label" /></el-select></el-form-item><el-form-item label="收件信息" required><el-input v-model="recipient" placeholder="请输入收件人、电话和地址" /></el-form-item><el-form-item label="出库托盘" required><el-select v-model="selectedPositionId" :loading="inventoryLoading" :disabled="!selectedCustomerId" filterable placeholder="请先选择客户,再选择有可用库存的托盘"><el-option v-for="item in availablePositionOptions" :key="item.position.id" :value="item.position.id" :label="`${item.position.palletNo || '托盘号待补充'} · ${item.balance.skuCode || '无SKU'} · 可用${item.position.availablePackages}`" /></el-select></el-form-item><el-form-item label="出库数量" required><el-input-number v-model="quantity" :min="1" placeholder="请输入出库数量" /></el-form-item><el-alert title="提交仅生成出库单并推送PDA任务,PDA完成托盘拣货后才扣减库存" type="info" :closable="false" /><el-form-item label="备注"><el-input v-model="remark" type="textarea" placeholder="请输入备注(选填)" /></el-form-item></el-form></AppDialog>
<AppDialog v-model="createOpen" title="新建出库单" width="min(1320px, calc(100vw - 32px))" confirm-text="生成出库单并推送PDA" :confirm-loading="saving" @confirm="submit">
<el-form class="create-outbound-form" label-position="top">
<el-form-item label="出库类型" required>
<el-radio-group v-model="outboundType" @change="changeOutboundType">
<el-radio-button label="ONE_CLICK_DROPSHIP">一件代发出库</el-radio-button>
<el-radio-button label="RETURN_OUTBOUND">退货出库</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item v-if="outboundType === 'RETURN_OUTBOUND'" label="客户" required>
<el-select v-model="selectedCustomerId" :loading="inventoryLoading" clearable filterable placeholder="请选择客户" @change="selectCustomer">
<el-option v-for="item in customerOptions" :key="item.value" :value="item.value" :label="item.label" />
</el-select>
</el-form-item>
<template v-if="outboundType !== 'RETURN_OUTBOUND'">
<el-form-item label="海外仓" required>
<el-select v-model="selectedWarehouseId" filterable placeholder="请选择海外仓" @change="changeDropshipWarehouse">
<el-option v-for="item in warehouseOptions" :key="item.value" :value="item.value" :label="item.label" />
</el-select>
</el-form-item>
<el-form-item label="已完成入库的一件代发订单">
<div class="candidate-query">
<el-input v-model="dropshipQueryInput" clearable placeholder="按订单号、入库单号、客户、托盘号、SKU或品名筛选" @keyup.enter="searchDropshipCandidates" />
<el-button type="primary" :loading="inventoryLoading" @click="searchDropshipCandidates">查询</el-button>
<el-button @click="resetDropshipCandidates">重置</el-button>
</div>
<el-table v-loading="inventoryLoading" :data="pagedDropshipCandidates" border stripe class="dropship-candidate-table">
<el-table-column label="选择" width="68" align="center">
<template #default="{ row }"><el-checkbox :model-value="isDropshipSelected(row as OneClickOutboundCandidate)" @change="toggleDropshipCandidate(row as OneClickOutboundCandidate, $event)" /></template>
</el-table-column>
<el-table-column prop="customerName" label="客户" min-width="150" show-overflow-tooltip />
<el-table-column prop="orderNo" label="尾程订单号" min-width="170" show-overflow-tooltip />
<el-table-column prop="inboundCode" label="入库单号" min-width="160" show-overflow-tooltip />
<el-table-column prop="palletNo" label="托盘号" min-width="140" show-overflow-tooltip />
<el-table-column prop="skuCode" label="SKU" min-width="140" show-overflow-tooltip />
<el-table-column prop="productName" label="品名" min-width="140" show-overflow-tooltip />
<el-table-column prop="availablePackages" label="可出箱数" width="95" align="right" />
</el-table>
<div v-if="!inventoryLoading && !filteredDropshipCandidates.length" class="form-hint">暂无可直接出库的已完成入库订单</div>
<div v-if="filteredDropshipCandidates.length" class="candidate-pagination">
<span>已选择 {{ selectedDropshipIds.length }} 条</span>
<el-pagination v-model:current-page="dropshipPage" v-model:page-size="dropshipPageSize" :page-sizes="[5, 10, 20]" :total="filteredDropshipCandidates.length" layout="total, sizes, prev, pager, next" />
</div>
<el-alert title="一件代发按托盘与SKU选择;数量默认使用所选行全部可用箱数,同一张出库单只能选择同一海外仓。" type="info" :closable="false" />
</el-form-item>
</template>
<template v-else>
<el-form-item label="收件信息" required><el-input v-model="recipient" placeholder="请输入收件人、电话和地址" /></el-form-item>
<el-form-item label="海外仓" required><el-select v-model="selectedWarehouseId" :disabled="!selectedCustomerId" filterable placeholder="请选择海外仓" @change="loadReturnCandidates"><el-option v-for="item in warehouseOptions" :key="item.value" :value="item.value" :label="item.label" /></el-select></el-form-item>
<div class="return-lists">
<section class="return-list-card">
<div class="list-title"><span>SKU库存</span><small>支持多个 SKU,用英文逗号分隔</small></div>
<div class="candidate-query"><el-input v-model="returnSkuQueryInput" clearable placeholder="如:SKU001,SKU002" /><el-button type="primary" @click="searchReturnSkus">查询</el-button></div>
<el-table :data="pagedReturnStocks" border stripe class="return-result-table" max-height="400"><el-table-column label="选择" width="60" align="center"><template #default="{ row }"><el-checkbox :model-value="selectedReturnSkus.includes(row.skuCode)" @change="toggleReturnSku(row.skuCode, $event)" /></template></el-table-column><el-table-column prop="skuCode" label="SKU" min-width="150" show-overflow-tooltip /><el-table-column prop="productNameZh" label="品名" min-width="150" show-overflow-tooltip /><el-table-column prop="availableQuantity" label="可用数量" width="100" align="right" /></el-table>
<div class="return-pagination"><span>已选择 {{ selectedReturnSkus.length }} 个 SKU</span><el-pagination v-model:current-page="returnSkuPage" v-model:page-size="returnSkuPageSize" :page-sizes="[5, 10, 20, 50]" :total="filteredReturnStocks.length" layout="total, sizes, prev, pager, next" @size-change="returnSkuPage = 1" /></div>
</section>
<section class="return-list-card">
<div class="list-title"><span>箱号列表</span><small>支持多个箱号或 SKU,用英文逗号分隔</small></div>
<div class="candidate-query"><el-input v-model="returnCartonQueryInput" clearable placeholder="如:RC001,RC002 或 SKU001,SKU002" /><el-button type="primary" @click="searchReturnCartons">查询</el-button></div>
<el-table :data="pagedReturnCartons" border stripe class="return-result-table" max-height="400"><el-table-column label="选择" width="60" align="center"><template #default="{ row }"><el-checkbox :model-value="selectedReturnMarks.includes(row.cartonMark)" @change="(v) => selectedReturnMarks = v ? [...new Set([...selectedReturnMarks, row.cartonMark])] : selectedReturnMarks.filter((m) => m !== row.cartonMark)" /></template></el-table-column><el-table-column prop="cartonMark" label="箱号" min-width="190" show-overflow-tooltip /><el-table-column label="箱内SKU及数量" min-width="230"><template #default="{ row }">{{ returnCartonDetails[row.cartonMark]?.items?.map((item) => `${item.skuCode} × ${item.quantity}`).join(',') || '加载中' }}</template></el-table-column></el-table>
<div class="return-pagination"><span>已选择 {{ selectedReturnMarks.length }} 个箱号</span><el-pagination v-model:current-page="returnCartonPage" v-model:page-size="returnCartonPageSize" :page-sizes="[5, 10, 20, 50]" :total="filteredReturnCartons.length" layout="total, sizes, prev, pager, next" @size-change="returnCartonPage = 1" /></div>
</section>
</div>
<el-alert title="退货出库支持按客户选择单个或多个箱子;SKU数量直接从所选箱唛明细扣减。" type="info" :closable="false" />
</template>
<el-form-item label="备注"><el-input v-model="remark" type="textarea" placeholder="请输入备注(选填)" /></el-form-item>
</el-form>
</AppDialog>
</template>
<style scoped>
.create-outbound-form { padding: 0 4px; }
.create-outbound-form :deep(.el-input), .create-outbound-form :deep(.el-select), .create-outbound-form :deep(.el-input-number) { width: 100%; }
.form-hint { margin-top: 6px; color: var(--cc-muted); font-size: 12px; }
.candidate-list { display: grid; gap: 8px; max-height: 300px; overflow: auto; padding: 8px 0; }
.candidate-list :deep(.el-checkbox) { margin-right: 0; }
.candidate-row { display: grid; grid-template-columns: minmax(120px, 1fr) minmax(150px, 1fr) auto; gap: 12px; align-items: center; width: min(700px, 100%); }
.candidate-row span, .candidate-row em { color: var(--cc-muted); font-style: normal; font-size: 12px; }
.candidate-query { display: flex; width: 100%; gap: 10px; }
.candidate-query :deep(.el-input) { flex: 1; }
.candidate-query :deep(.el-button) { flex: 0 0 auto; min-width: 78px; margin-left: 0; }
.dropship-candidate-table { margin-top: 12px; width: 100%; }
.candidate-pagination { display: flex; width: 100%; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 0; color: var(--cc-muted); font-size: 12px; }
.return-lists { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 20px; width: 100%; margin: 4px 0 16px; }
.return-list-card { min-width: 0; padding: 16px; border: 1px solid var(--el-border-color-light); border-radius: 10px; background: var(--el-fill-color-extra-light); }
.list-title { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
.list-title span { color: var(--el-text-color-primary); font-size: 16px; font-weight: 650; }
.list-title small { color: var(--cc-muted); font-size: 12px; font-weight: 400; }
.return-result-table { width: 100%; margin-top: 12px; background: var(--el-bg-color); }
.return-pagination { display: flex; min-height: 44px; align-items: center; justify-content: space-between; gap: 12px; padding-top: 12px; color: var(--cc-muted); font-size: 12px; }
.return-pagination :deep(.el-pagination) { flex-wrap: wrap; justify-content: flex-end; }
@media (max-width: 980px) { .return-lists { grid-template-columns: 1fr; }.return-pagination { align-items: flex-start; flex-direction: column; } }
</style>
@@ -1,12 +1,15 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, ref, onBeforeUnmount } from 'vue'
import { Eye, FileUp } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getRelabelOrder, type RelabelDetailDto } from '../api/relabelOrders'
import { downloadRelabelCompletionPhoto, downloadRelabelPdf, getRelabelOrder, uploadRelabelPdf, type RelabelDetailDto } from '../api/relabelOrders'
import { formatOverseasDateTime } from '../lastMilePresentation'
import OverseasReadOnlyDetail from '../components/OverseasReadOnlyDetail.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
const route = useRoute()
const router = useRouter()
@@ -14,6 +17,10 @@ const orderId = computed(() => String(route.params.orderId || ''))
const detail = ref<RelabelDetailDto | null>(null)
const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const uploading = ref(false)
const previewUrl = ref('')
const previewTitle = ref('')
const previewOpen = ref(false)
const groups = [
{ title: '单据信息', fields: ['换标单号', '关联订单', '状态', '资金状态', '创建时间', '更新时间'] },
{ title: '商品信息', fields: ['商品SKU', '中文品名', '箱数', '预算总价'] },
@@ -22,7 +29,7 @@ const groups = [
]
function text(value: unknown) { return String(value ?? '').trim() || '—' }
function status(value: unknown) {
const labels: Record<string, string> = { PENDING_PROCESSING: '待处理', PROCESSING: '处理中', COMPLETED: '已完成', REJECTED: '已驳回', CANCELLED: '已取消' }
const labels: Record<string, string> = { PENDING_PROCESSING: '待处理', PROCESSING: '处理中', CUSTOMER_CONFIRM_PENDING: '待客户确认', READY_FOR_OUTBOUND: '待出库', COMPLETED: '已完成', REJECTED: '已驳回', CANCELLED: '已取消' }
return labels[String(value || '')] || text(value)
}
function walletStatus(value: unknown) { return ({ FREEZE_PENDING: '冻结中', FROZEN: '已冻结', SETTLE_PENDING: '结算中', SETTLED: '已结算', RELEASE_PENDING: '释放中', RELEASED: '已释放', FREEZE_FAILED: '冻结失败', SETTLE_FAILED: '结算失败', RELEASE_FAILED: '释放失败' } as Record<string,string>)[String(value || '')] || text(value) }
@@ -57,19 +64,41 @@ async function load() {
}
function back() { void router.push({ name: 'overseas-relabel-orders' }) }
onMounted(load)
onBeforeUnmount(() => { if (previewUrl.value) URL.revokeObjectURL(previewUrl.value) })
async function uploadPdf(event: Event, scope: 'TOTAL' | 'ITEM', itemId?: string) {
const input = event.target as HTMLInputElement; const file = input.files?.[0]; input.value = ''
if (!file || !detail.value) return
if (file.type !== 'application/pdf' && !file.name.toLowerCase().endsWith('.pdf')) { ElMessage.warning('只支持PDF文件'); return }
if (file.size > 50 * 1024 * 1024) { ElMessage.warning('PDF文件不能超过50MB'); return }
uploading.value = true
try { detail.value = await uploadRelabelPdf(orderId.value, file, scope, itemId); ElMessage.success(scope === 'TOTAL' ? '总PDF上传成功' : '明细PDF上传成功') }
catch (error) { ElMessage.error(getApiErrorDetails(error, 'PDF上传失败').message) }
finally { uploading.value = false }
}
async function previewPdf(fileId: string | null | undefined, name: string, scope: 'TOTAL' | 'ITEM', itemId?: string) {
if (!fileId) return
try { const blob = await downloadRelabelPdf(orderId.value, fileId, scope, itemId); if (previewUrl.value) URL.revokeObjectURL(previewUrl.value); previewUrl.value = URL.createObjectURL(blob); previewTitle.value = name; previewOpen.value = true }
catch (error) { ElMessage.error(getApiErrorDetails(error, 'PDF预览加载失败').message) }
}
async function previewCompletionPhoto(fileId: string) {
try { const blob = await downloadRelabelCompletionPhoto(orderId.value, fileId); const url = URL.createObjectURL(blob); window.open(url, '_blank', 'noopener,noreferrer'); window.setTimeout(() => URL.revokeObjectURL(url), 60_000) }
catch (error) { ElMessage.error(getApiErrorDetails(error, '完工照片加载失败').message) }
}
</script>
<template>
<OverseasReadOnlyDetail title="换标单详情" reference-label="换标单号" :reference-value="referenceValue" back-label="返回换标单" domain="换标单详情" :groups="groups" :field-values="fieldValues" :pending="false" :status-text="detail ? status(detail.application.status) : '加载中'" :loading="loading" :show-content="!loadError" @back="back">
<template #state><LoadErrorState v-if="loadError" title="换标单详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
<template #content-after>
<section class="relabel-document-panel" aria-label="PDA换标完工证据"><div class="relabel-mark-list__head"><h2>PDA 换标完工证据</h2><span>{{ detail?.completionEvidence?.reviewerName ? `复核人:${detail.completionEvidence.reviewerName}` : '等待仓库作业' }}</span></div><div v-if="detail?.completionEvidence?.photoFileIds?.length" class="document-row"><strong>共 {{ detail.completionEvidence.photoFileIds.length }} 张完工照片</strong><span class="document-actions"><el-button v-for="(fileId, index) in detail.completionEvidence.photoFileIds" :key="fileId" size="small" plain @click="previewCompletionPhoto(fileId)"><Eye :size="14" />照片 {{ index + 1 }}</el-button></span></div><div v-else class="attachment-empty">暂未上传完工照片</div></section>
<section class="relabel-document-panel" aria-label="总换标文件"><div class="relabel-mark-list__head"><h2>总换标 PDF</h2><label class="file-picker"><FileUp :size="15" />{{ uploading ? '上传中' : (detail?.application.totalFileName ? '更换PDF' : '上传PDF') }}<input type="file" accept="application/pdf,.pdf" :disabled="uploading" @change="uploadPdf($event, 'TOTAL')" /></label></div><div v-if="detail?.application.totalFileId" class="document-row"><strong>{{ detail.application.totalFileName }}</strong><el-button size="small" plain @click="previewPdf(detail.application.totalFileId, detail.application.totalFileName || '总换标PDF', 'TOTAL')"><Eye :size="14" />直接预览</el-button></div><div v-else class="attachment-empty">暂未上传总换标PDF</div></section>
<section class="relabel-mark-list" aria-label="箱唛换标信息">
<div class="relabel-mark-list__head"><h2>箱唛换标信息</h2><span>共 {{ detail?.items.length || 0 }} 条</span></div>
<div class="relabel-mark-list__scroll">
<table>
<thead><tr><th>我方箱唛</th><th>FBA 箱唛</th><th>商品 SKU</th><th>中文品名</th><th>FBA 货件编号</th><th>换标新 SKU</th><th>目的仓</th><th>备注</th></tr></thead>
<thead><tr><th>我方箱唛</th><th>FBA 箱唛</th><th>商品 SKU</th><th>中文品名</th><th>FBA 货件编号</th><th>换标新 SKU</th><th>目的仓</th><th>明细PDF</th></tr></thead>
<tbody>
<tr v-for="item in detail?.items || []" :key="item.id"><td class="mark-code">{{ text(item.cartonMark) }}</td><td class="mark-code">{{ text(item.fbaCartonMark) }}</td><td>{{ text(item.skuCode) }}</td><td>{{ text(item.productNameZh) }}</td><td>{{ text(item.shipmentNo) }}</td><td>{{ text(item.newSkuCode) }}</td><td>{{ text(item.destinationWarehouseId) }}</td><td>{{ text(item.remark) }}</td></tr>
<tr v-for="item in detail?.items || []" :key="item.id"><td class="mark-code">{{ text(item.cartonMark) }}</td><td class="mark-code">{{ text(item.fbaCartonMark) }}</td><td>{{ text(item.skuCode) }}</td><td>{{ text(item.productNameZh) }}</td><td>{{ text(item.shipmentNo) }}</td><td>{{ text(item.newSkuCode) }}</td><td>{{ text(item.destinationWarehouseId) }}</td><td><span v-if="item.relabelFileName" class="document-actions"><strong>{{ item.relabelFileName }}</strong><el-button size="small" link type="primary" @click="previewPdf(item.relabelFileId, item.relabelFileName, 'ITEM', item.id)"><Eye :size="14" />预览</el-button></span><label v-else class="inline-file-picker"><FileUp :size="14" />上传<input type="file" accept="application/pdf,.pdf" :disabled="uploading" @change="uploadPdf($event, 'ITEM', item.id)" /></label></td></tr>
<tr v-if="!detail?.items.length"><td class="empty-cell" colspan="8">暂无箱唛换标信息</td></tr>
</tbody>
</table>
@@ -77,6 +106,7 @@ onMounted(load)
</section>
</template>
</OverseasReadOnlyDetail>
<AppDialog v-if="previewUrl" v-model="previewOpen" :title="previewTitle" width="min(900px, calc(100vw - 28px))" :show-footer="false"><div class="pdf-preview"><iframe :src="previewUrl" :title="`${previewTitle}预览`" /></div></AppDialog>
</template>
<style scoped>
@@ -91,5 +121,12 @@ td { min-height: 46px; padding: 12px; border-bottom: 1px solid var(--cc-border);
tbody tr:last-child td { border-bottom: 0; }
tbody tr:hover td { background: #fafcff; }
.mark-code { color: #245b9e; font-weight: 600; }
.relabel-document-panel { grid-column: 1 / -1; padding: 18px; border-bottom: 1px solid var(--cc-border); }
.document-row { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:12px; border:1px solid var(--cc-border); border-radius:6px; }
.file-picker, .inline-file-picker { display:inline-flex; align-items:center; gap:5px; color:var(--el-color-primary); cursor:pointer; }
.file-picker input, .inline-file-picker input { display:none; }
.document-actions { display:inline-flex; align-items:center; gap:8px; }
.pdf-preview { overflow:hidden; border:1px solid var(--cc-border); border-radius:6px; background:#f5f7fa; }
.pdf-preview iframe { display:block; width:100%; height:min(72vh,760px); border:0; background:#fff; }
.empty-cell { height: 96px; color: var(--cc-muted); text-align: center; }
</style>
@@ -0,0 +1,55 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { Printer, Search } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { listPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
import AdminPage from '@/shared/components/AdminPage.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import { getReturnCarton, getReturnCartonLabel, listReturnCartons, listReturnInventoryMovements, listReturnStock, type ReturnCartonDetail, type ReturnCartonRow, type ReturnInventoryMovement, type ReturnStockRow } from '../api/returnInventory'
const props = defineProps<{ mode: 'inventory' | 'cartons' }>()
const loading = ref(false), total = ref(0), detailOpen = ref(false)
const stockRows = ref<ReturnStockRow[]>([]), cartonRows = ref<ReturnCartonRow[]>([]), detail = ref<ReturnCartonDetail>()
const inventoryTab = ref<'stock' | 'movements'>('stock')
const movementRows = ref<ReturnInventoryMovement[]>([])
const customers = ref<PlatformTenantResource[]>([])
const warehouses = ref<PlatformWarehouseSummaryResource[]>([])
const optionsLoading = ref(false)
const query = reactive({ page: 1, size: 20, customerId: '', overseasWarehouseId: '', skuCode: '', cartonMark: '', returnNo: '', status: 'AVAILABLE' })
function params() { return { ...query, customerId: query.customerId || undefined, overseasWarehouseId: query.overseasWarehouseId || undefined, skuCode: query.skuCode || undefined, cartonMark: query.cartonMark || undefined, returnNo: query.returnNo || undefined, status: query.status || undefined } }
async function load() { loading.value = true; try { if (props.mode === 'inventory') { if (inventoryTab.value === 'stock') { const page = await listReturnStock(params()); stockRows.value = page.records; total.value = page.total } else { const page = await listReturnInventoryMovements(params()); movementRows.value = page.records; total.value = page.total } } else { const page = await listReturnCartons(params()); cartonRows.value = page.records; total.value = page.total } } finally { loading.value = false } }
function search() { query.page = 1; void load() }
function changeInventoryTab() { query.page = 1; void load() }
const customerOptions = computed(() => customers.value.filter((item) => item.status === 'ACTIVE').map((item) => ({ label: item.tenantName, value: String(item.id) })))
const warehouseOptions = computed(() => warehouses.value.filter((item) => item.status === 'ACTIVE' && item.warehouseType === 'OVERSEAS').map((item) => ({ label: item.warehouseName, value: String(item.id) })))
function customerName(customerId?: string) { return customers.value.find((item) => String(item.id) === String(customerId))?.tenantName || '客户名称待补充' }
function warehouseName(warehouseId?: string) { return warehouses.value.find((item) => String(item.id) === String(warehouseId))?.warehouseName || '海外仓名称待补充' }
function statusLabel(status?: string) { return status === 'AVAILABLE' ? '可用' : status === 'MERGED' ? '已合箱' : '—' }
async function loadOptions() {
optionsLoading.value = true
try {
const [customerPage, warehousePage] = await Promise.all([
listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' }),
listPlatformWarehouses({ page: 1, size: 200, warehouseType: 'OVERSEAS', status: 'ACTIVE' }),
])
customers.value = customerPage.records
warehouses.value = warehousePage.records
} catch (error) {
ElMessage.warning(error instanceof Error ? `筛选名称加载失败:${error.message}` : '筛选名称加载失败')
} finally { optionsLoading.value = false }
}
async function openCarton(mark: string) { try { detail.value = await getReturnCarton(mark); detailOpen.value = true } catch (e) { ElMessage.error(e instanceof Error ? e.message : '箱号详情加载失败') } }
async function printLabel(mark: string) { try { const label = await getReturnCartonLabel(mark); const popup = window.open('', '_blank'); if (!popup) return ElMessage.warning('请允许浏览器打开打印窗口'); popup.opener = null; popup.document.open(); popup.document.write(label.printableHtml); popup.document.close(); popup.focus(); popup.print() } catch (e) { ElMessage.error(e instanceof Error ? e.message : '箱唛加载失败') } }
onMounted(() => { void load(); void loadOptions() })
</script>
<template><AdminPage as="div" class="business-page return-inventory-page">
<el-tabs v-if="mode==='inventory'" v-model="inventoryTab" @tab-change="changeInventoryTab"><el-tab-pane label="退货库存" name="stock" /><el-tab-pane label="退货进销存明细" name="movements" /></el-tabs>
<section class="surface-panel query-panel"><el-select v-model="query.customerId" clearable filterable placeholder="全部客户" :loading="optionsLoading"><el-option v-for="item in customerOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select><el-select v-model="query.overseasWarehouseId" clearable filterable placeholder="全部海外仓" :loading="optionsLoading"><el-option v-for="item in warehouseOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select><el-input v-model="query.skuCode" clearable placeholder="SKU" @keyup.enter="search" /><el-input v-if="mode==='cartons'" v-model="query.cartonMark" clearable placeholder="箱号/箱唛" @keyup.enter="search" /><el-input v-if="mode==='cartons'" v-model="query.returnNo" clearable placeholder="退货单号" @keyup.enter="search" /><el-select v-if="mode==='cartons'" v-model="query.status" clearable placeholder="箱状态"><el-option label="可用" value="AVAILABLE" /><el-option label="已合箱" value="MERGED" /></el-select><el-button type="primary" @click="search"><Search :size="16" />查询</el-button></section>
<section class="surface-panel table-panel"><el-table v-if="mode==='inventory' && inventoryTab==='stock'" v-loading="loading" :data="stockRows"><el-table-column label="客户" min-width="180"><template #default="{row}">{{ customerName(row.customerId) }}</template></el-table-column><el-table-column label="海外仓" min-width="180"><template #default="{row}">{{ warehouseName(row.overseasWarehouseId) }}</template></el-table-column><el-table-column prop="skuCode" label="SKU" min-width="170" /><el-table-column prop="productNameZh" label="中文品名" min-width="180" /><el-table-column prop="totalQuantity" label="总库存" align="right" /><el-table-column prop="availableQuantity" label="可用库存" align="right" /><el-table-column prop="updatedAt" label="更新时间" min-width="180" /></el-table>
<el-table v-else-if="mode==='inventory'" v-loading="loading" :data="movementRows"><el-table-column label="客户" min-width="180"><template #default="{row}">{{ customerName(row.customerId) }}</template></el-table-column><el-table-column label="海外仓" min-width="180"><template #default="{row}">{{ warehouseName(row.overseasWarehouseId) }}</template></el-table-column><el-table-column prop="skuCode" label="SKU" min-width="170" /><el-table-column prop="productNameZh" label="中文品名" min-width="180" /><el-table-column label="业务类型" min-width="120"><template #default="{row}">{{ row.movementType === 'RECEIPT' ? '退货签收' : row.movementType === 'MERGE' ? '退货合箱' : '退货出库' }}</template></el-table-column><el-table-column prop="referenceNo" label="关联单号/箱号" min-width="220" /><el-table-column prop="totalDelta" label="总库存变动" align="right" /><el-table-column prop="availableDelta" label="可用库存变动" align="right" /><el-table-column prop="occurredAt" label="发生时间" min-width="180" /></el-table>
<el-table v-else v-loading="loading" :data="cartonRows"><el-table-column label="操作" width="140" fixed="left"><template #default="{row}"><el-button link type="primary" @click="openCarton(row.cartonMark)">详情</el-button><el-button link type="primary" @click="printLabel(row.cartonMark)"><Printer :size="14" />箱唛</el-button></template></el-table-column><el-table-column prop="cartonMark" label="箱号" min-width="220" /><el-table-column label="客户" min-width="180"><template #default="{row}">{{ customerName(row.customerId) }}</template></el-table-column><el-table-column label="海外仓" min-width="180"><template #default="{row}">{{ warehouseName(row.overseasWarehouseId) }}</template></el-table-column><el-table-column prop="sourceReturnNo" label="来源退货单" min-width="220" /><el-table-column prop="fbaCartonMark" label="FBA箱唛" min-width="180" /><el-table-column label="目的仓" min-width="180"><template #default="{row}">{{ row.destinationWarehouseCode || row.destinationWarehouseName || '—' }}</template></el-table-column><el-table-column prop="relabelNo" label="换标单号" min-width="200" /><el-table-column label="换标状态" width="110"><template #default="{row}">{{ row.relabelStatus === 'COMPLETED' ? '已完成' : row.relabelStatus === 'BOUND' ? '已绑定' : row.relabelStatus || '未绑定' }}</template></el-table-column><el-table-column label="状态" width="100"><template #default="{row}">{{ statusLabel(row.status) }}</template></el-table-column><el-table-column prop="skuTypes" label="SKU种类" align="right" /><el-table-column prop="totalQuantity" label="总件数" align="right" /><el-table-column label="箱规" min-width="190"><template #default="{row}">{{ row.length ? `${row.length}×${row.width}×${row.height} cm` : '—' }}</template></el-table-column><el-table-column prop="weight" label="重量(kg)" align="right" /><el-table-column prop="createdAt" label="创建时间" min-width="180" /></el-table>
<el-pagination v-model:current-page="query.page" v-model:page-size="query.size" :total="total" layout="total, sizes, prev, pager, next" :page-sizes="[20,50,100]" @current-change="load" @size-change="query.page=1;load()" /></section>
<AppDialog v-model="detailOpen" title="退货箱号详情" width="min(1080px, calc(100vw - 56px))" :show-footer="false"><template v-if="detail"><el-descriptions :column="2" border><el-descriptions-item label="箱号">{{detail.cartonMark}}</el-descriptions-item><el-descriptions-item label="客户">{{customerName(detail.customerId)}}</el-descriptions-item><el-descriptions-item label="海外仓">{{warehouseName(detail.overseasWarehouseId)}}</el-descriptions-item><el-descriptions-item label="来源退货单">{{detail.sourceReturnNo||'—'}}</el-descriptions-item><el-descriptions-item label="FBA箱唛">{{detail.fbaCartonMark||'—'}}</el-descriptions-item><el-descriptions-item label="目的仓">{{detail.destinationWarehouseCode||detail.destinationWarehouseName||'—'}}</el-descriptions-item><el-descriptions-item label="换标单号">{{detail.relabelNo||'—'}}</el-descriptions-item><el-descriptions-item label="换标状态">{{detail.relabelStatus === 'COMPLETED' ? '已完成' : detail.relabelStatus === 'BOUND' ? '已绑定' : detail.relabelStatus || '未绑定'}}</el-descriptions-item><el-descriptions-item label="状态">{{statusLabel(detail.status)}}</el-descriptions-item><el-descriptions-item label="箱规">{{detail.length?`${detail.length}×${detail.width}×${detail.height} cm`:'—'}}</el-descriptions-item><el-descriptions-item label="重量">{{detail.weight?`${detail.weight} kg`:'—'}}</el-descriptions-item></el-descriptions><el-table :data="detail.items" class="detail-items"><el-table-column prop="skuCode" label="SKU" /><el-table-column prop="productNameZh" label="中文品名" /><el-table-column prop="quantity" label="数量" align="right" /></el-table></template></AppDialog>
</AdminPage></template>
<style scoped>.return-inventory-page{display:grid;gap:16px}.query-panel{display:flex;gap:10px;flex-wrap:wrap;padding:16px}.query-panel .el-input,.query-panel .el-select{width:180px}.table-panel{padding:16px}.el-pagination{justify-content:flex-end;margin-top:16px}.detail-items{margin-top:20px}</style>
@@ -84,9 +84,12 @@ describe('overseas warehouse first-batch prototype contract', () => {
const value = source(fileName)
expect(value).toContain('<OverseasReadOnlyDetail')
expect(value).toContain('@back="back"')
expect(value).not.toContain('AppDialog')
expect(value).not.toContain('AppDrawer')
if (fileName !== 'RelabelOrderDetailView.vue') expect(value).not.toContain('AppDialog')
}
const relabel = source('RelabelOrderDetailView.vue')
expect(relabel).toContain('<AppDialog v-if="previewUrl"')
expect(relabel).toContain('<iframe :src="previewUrl"')
})
it('keeps second-batch forms non-submitting until overseas write contracts exist', () => {
@@ -26,6 +26,8 @@ export const overseasWarehousePermissions = {
inventoryExport: 'platform:overseas-warehouse:inventory:export',
inventoryEdit: 'platform:overseas-warehouse:inventory:edit',
inventoryAdjust: 'platform:overseas-warehouse:inventory:adjust',
returnInventoryList: 'platform:overseas-warehouse:return-inventory:list',
returnCartonsList: 'platform:overseas-warehouse:return-cartons:list',
relabelOrderList: 'platform:overseas-warehouse:relabel-order:list',
relabelOrderCreate: 'platform:overseas-warehouse:relabel-order:create',
relabelOrderStatus: 'platform:overseas-warehouse:relabel-order:status',
@@ -18,6 +18,8 @@ describe('overseas warehouse routes', () => {
['overseas-warehouse/exception-orders', permission.exceptionOrderList],
['overseas-warehouse/customer-visits', permission.customerVisitList],
['overseas-warehouse/inventory', permission.inventoryList],
['overseas-warehouse/return-inventory', permission.returnInventoryList],
['overseas-warehouse/return-cartons', permission.returnCartonsList],
['overseas-warehouse/inventory-transactions', permission.inventoryList],
['overseas-warehouse/inventory/:inventoryId', permission.inventoryList],
['overseas-warehouse/relabel-orders', permission.relabelOrderList],
+14
View File
@@ -80,6 +80,20 @@ export const overseasWarehouseRoutes = [
component: () => import('./pages/InventoryView.vue'),
meta: { title: '库存管理', description: '查看海外仓库存汇总、可用量和库内分布', permission: permission.inventoryList },
},
{
path: 'overseas-warehouse/return-inventory',
name: 'overseas-return-inventory',
component: () => import('./pages/ReturnInventoryManagementView.vue'),
props: { mode: 'inventory' },
meta: { title: '退货库存管理', description: '按客户和 SKU 查询退货签收后的可用库存', permission: permission.returnInventoryList },
},
{
path: 'overseas-warehouse/return-cartons',
name: 'overseas-return-cartons',
component: () => import('./pages/ReturnInventoryManagementView.vue'),
props: { mode: 'cartons' },
meta: { title: '退货箱号管理', description: '查询合箱后的箱号、箱规和箱内 SKU', permission: permission.returnCartonsList },
},
{
path: 'overseas-warehouse/inventory-transactions',
name: 'overseas-inventory-transactions',
@@ -57,7 +57,7 @@ describe('unified orders API contract', () => {
it('forwards every main-status tab with search and pagination unchanged', async () => {
const statuses = [
'PENDING_REVIEW', 'WAIT_MEASURE', 'WAIT_SPLIT',
'PENDING_REVIEW', 'WAIT_MEASURE', 'WAIT_PALLETIZE', 'WAIT_SPLIT',
'LOADING', 'LOADED', 'DELIVERED',
] as const
vi.mocked(http.get).mockResolvedValue({ data: { records: [], page: 3, size: 20, total: 0, pages: 0 } })
@@ -5,6 +5,7 @@ export const unifiedOrdersApiPath = '/platform/v1/orders'
export type UnifiedOrderMainStatus =
| 'PENDING_REVIEW'
| 'WAIT_MEASURE'
| 'WAIT_PALLETIZE'
| 'WAIT_SPLIT'
| 'LOADING'
| 'LOADED'
@@ -13,6 +14,7 @@ export type UnifiedOrderMainStatus =
export const unifiedOrderStatusOptions: Array<{ value: UnifiedOrderMainStatus; label: string }> = [
{ value: 'PENDING_REVIEW', label: '待审核' },
{ value: 'WAIT_MEASURE', label: '待测量数据' },
{ value: 'WAIT_PALLETIZE', label: '待一件代发组托' },
{ value: 'WAIT_SPLIT', label: '待分柜' },
{ value: 'LOADING', label: '装柜中' },
{ value: 'LOADED', label: '装柜完成' },
@@ -18,9 +18,9 @@ describe('UnifiedOrdersView contract', () => {
expect(sendFeeSource).toContain("ElMessage.error(getApiErrorDetails(error, '确认费用发送失败').message)")
})
it('only exposes the six product main statuses', () => {
it('exposes the product main statuses including dropship palletizing', () => {
expect(unifiedOrderStatusOptions.map((item) => item.label)).toEqual([
'待审核', '待测量数据', '待分柜', '装柜中', '装柜完成', '派送完成',
'待审核', '待测量数据', '待一件代发组托', '待分柜', '装柜中', '装柜完成', '派送完成',
])
for (const legacyLabel of ['待客户确认', '差异处理中', '派送中', '进入分柜']) {
expect(source).not.toContain(legacyLabel)
@@ -10,6 +10,7 @@ import {
describe('unified order status labels', () => {
it('maps order, review and loading statuses to domain-specific Chinese', () => {
expect(unifiedOrderStatusLabel('WAIT_PALLETIZE')).toBe('待一件代发组托')
expect(unifiedOrderStatusLabel('WAIT_SPLIT')).toBe('待分柜')
expect(unifiedOrderReviewStatusLabel('APPROVED')).toBe('审核通过')
expect(unifiedOrderLoadingStatusLabel('LOADED')).toBe('已配载')
+1
View File
@@ -9,6 +9,7 @@ const orderStatusLabels: Record<string, string> = {
CANCELLED: '已取消',
PENDING_REVIEW: '待审核',
WAIT_MEASURE: '待测量数据',
WAIT_PALLETIZE: '待一件代发组托',
WAIT_SPLIT: '待分柜',
LOADING: '装柜中',
LOADED: '装柜完成',
@@ -12,6 +12,8 @@ describe('first-leg business labels', () => {
expect(firstLegTaxModeLabel('BUY_DECLARATION')).toBe('买单报关')
expect(firstLegContainerModeLabel('FULL_CONTAINER')).toBe('整柜')
expect(firstLegDestinationTypeLabel('OVERSEAS')).toBe('海外仓')
expect(firstLegBusinessTypeLabel('OVERSEAS_SEA')).toBe('海外仓海运')
expect(firstLegBusinessTypeLabel('PRIVATE_SEA')).toBe('私人地址海运')
expect(firstLegBusinessTypeLabel('EXPORT')).toBe('出口业务')
})
+1 -1
View File
@@ -1,5 +1,5 @@
const businessTypeLabels: Record<string, string> = {
EXPORT: '出口业务', FIRST_LEG: '头程物流', FBA: 'FBA 头程', FBA_SEA: 'FBA 海运',
EXPORT: '出口业务', FIRST_LEG: '头程物流', FBA: 'FBA 头程', FBA_SEA: 'FBA 海运', OVERSEAS_SEA: '海外仓海运', PRIVATE_SEA: '私人地址海运',
}
const transportModeLabels: Record<string, string> = {
SEA: '海运', AIR: '空运', RAIL: '铁路运输', TRUCK: '卡车运输',