优化
This commit is contained in:
@@ -6,13 +6,15 @@
|
||||
|---|---|---|---|---|
|
||||
| 尾程订单列表 | `fba-last-mile-order` | `GET /platform/v1/last-leg/orders` | 已接入 | 显式 `customerId`;支持 `page/size/orderNo/inboundCode/containerNo/status`。原型其余筛选保留但禁用,不发送无效参数。 |
|
||||
| 尾程订单详情 | `fba-last-mile-order-detail` | `GET /platform/v1/last-leg/orders/{id}` | 已接入 | 展示订单、货物、状态历史和既有单据编号;出库/派送编号只读,不提供离仓动作。 |
|
||||
| 入库单列表/详情 | `fba-inbound-order*` | 仅有 `GET /platform/v1/last-leg/inbound-plans/{planNo}` | 阻断 | 没有分页列表,无法由详情接口反推列表;当前页面继续明确显示契约待接入。 |
|
||||
| 入库单列表/详情 | `fba-inbound-order*` | `GET /platform/v1/last-leg/inbound-plans`、`GET /{planNo}` | 已接入只读 | 显式 `customerId`、真实分页/筛选/状态;详情展示 `sourceOrderId`、入仓行和汇总。详情 DTO 未提供预约、排车、创建更新时间和费用,对应原型字段保持空值。 |
|
||||
| 换标单列表/详情 | `fba-relabel-order*` | `GET /platform/v1/last-leg/relabel-applications`、`GET /{relabelNo}` | 已接入只读 | 列表 DTO 可展示单号、SKU、箱数和状态;详情补产品名、标签要求、文件。预算/费用继续显示空值并记录缺口,未伪造金额。 |
|
||||
| 库存/进销存/详情 | `fba-inventory*` | `GET /platform/v1/last-leg/inventory/summary|balances|ledger` | 阻断 | API 是入库单/包裹余额口径,原型是客户/仓库/SKU/产品口径,缺 SKU、产品名及原型指标。 |
|
||||
| 托盘列表/详情 | `fba-pallet*` | `GET /platform/v1/warehouse-admin/{warehouseId}/pallets`、`GET /{id}` | 阻断 | API 强制 `customerId + warehouseId` 且资源客户绑定;产品契约要求平台公共资源、独立查询全部客户/仓库。字段还缺 FBA 仓和采购价。 |
|
||||
| 箱号列表/详情 | `fba-carton*` | 无平台查询接口 | 阻断 | 只有内部接收事件产生快照,浏览器不得调用内部端点。 |
|
||||
| 产品库列表/详情 | `fba-product*` | 无平台查询接口 | 阻断 | 只有内部一件代发快照写入,未提供平台查询/维护端点。 |
|
||||
| 库存/进销存/详情 | `fba-inventory*` | `GET /platform/v1/last-leg/inventory/summary|balances|balances/{id}/positions|ledger` | 已接入只读 | 余额已提供 SKU/中英文品名;列表、汇总、库位/托盘位置与流水已接。流水 DTO 仍缺 SKU、产品名和仓库名,库存详情流水只能按入库单范围展示并明确提示。 |
|
||||
| 托盘列表/详情 | `fba-pallet*` | `GET/POST /platform/v1/warehouse-admin/{warehouseId}/pallets`、`GET/PUT /{id}` | 已接入 | 资源请求不传 `customerId`,按公共 `customer_id=0` 查询/写入;新增/编辑使用真实 DTO。DTO 未提供 FBA 仓和业务绑定汇总,字段保持空值。仓库候选因仓库列表仍按客户分页,当前跨全部 ACTIVE 客户汇总,不受页面客户筛选控制。 |
|
||||
| 箱号列表/详情 | `fba-carton*` | 无平台查询接口 | 本期暂缓 | 本期已从模块正式路由移除;页面、类型、权限常量与测试源码保留,供后续恢复。只有内部接收事件产生快照,浏览器不得调用内部端点。 |
|
||||
| 产品库列表/详情 | `fba-product*` | 无平台查询接口 | 本期暂缓 | 本期已从模块正式路由移除;页面、类型、权限常量与测试源码保留,供后续恢复。只有内部一件代发快照写入,未提供平台查询/维护端点。 |
|
||||
|
||||
状态映射仅使用服务端已有事实:`PENDING_ASSIGNMENT`(待分配)、`DESTINATION_ASSIGNED`(已分配目的仓)、`INBOUND_PLANNED`(已生成入库计划)。未定义的原型状态不发送到接口。
|
||||
|
||||
当前所有上述海外业务 Controller 均未声明与前端 `platform:overseas-warehouse:*` 对应的服务端 `@PreAuthorize`。因此本轮只接入尾程只读查询;已有写端点在服务端权限、幂等和原型字段未一致前不向 Admin 暴露。
|
||||
当前入仓、库存、托盘 Controller 已使用 `platform:overseas-warehouse:*` canonical 权限。Admin 未接 PDA、扫码、卸柜照片、内部事件及 `allocate/unallocate/pick/ship` 等离仓命令;库存页面没有原型承载的写操作,不自行新增按钮。
|
||||
|
||||
当前正式路由范围只保留尾程订单、入库单、库存/进销存、换标单和托盘;箱号、产品库不删除源码,但不属于本期可达页面。
|
||||
|
||||
@@ -5,24 +5,21 @@
|
||||
以下前端适配或页面级契约补齐前,页面不得用演示数据或本地成功提示替代真实调用:
|
||||
|
||||
- 尾程订单导入模板、批量导入、异步导出任务及对应独立权限。现有同步 CSV 导出不等同于 Admin 下载中心异步导出。尾程分页与详情已经接入;“待分配”映射为 `PENDING_ASSIGNMENT`,出库单号、派送单号仅展示既有只读关联事实。
|
||||
- 入库单分页查询、详情、创建、编辑、预约、智能排车接口,以及状态机、幂等键和操作权限。
|
||||
- 库存汇总、分页查询、详情、库位库存、托盘箱号和进销存记录接口。
|
||||
- 入库单分页与详情已接入。详情 DTO 仍缺预约状态/时间、排车状态/时间、创建更新时间、备注和应收费用;创建、预约及补托只在原型承载和完整幂等/打印语义冻结后接入。
|
||||
- 库存汇总、SKU 分页、库位位置和进销存已接入。流水 DTO 仍缺 SKU、产品名、客户名和海外仓名称;详情也没有按 `balanceId` 直接查询余额主记录的端点。
|
||||
- 换标单分页、详情、创建、编辑/审核、文件上传与费用明细接口,以及状态机和幂等约定。
|
||||
- 托盘分页、详情、创建、编辑接口,以及海外仓/FBA 仓候选、编码唯一性与占用状态约束。
|
||||
- 箱号分页、详情、创建、编辑接口,以及产品/托盘/入库单关联校验;“已出库”只作为只读库存事实,本模块不承接出库动作。
|
||||
- 产品分页、详情、创建、编辑接口,以及 SKU 唯一性、品类和包装尺寸精度约定。
|
||||
- 托盘分页、详情、新增、编辑已接入公共资源接口。剩余缺口是独立公共仓库候选接口、FBA 仓字段、业务绑定/关联箱数只读汇总;删除未在原型定义,不新增入口。
|
||||
- 箱号、产品库本期暂缓并已从模块正式路由移除;既有源码保留,相关平台查询/写接口不再作为本期交付阻断。
|
||||
- 四期权限资源在 IAM 初始化数据中的正式登记。前端暂按本模块 `permissions.ts` 的稳定编码装配。
|
||||
- 所有分页响应、错误码、`requestId`/`traceId`、时间/金额精度和长整型 ID 字符串传输约定。
|
||||
|
||||
## 当前缺口的精确请求形状
|
||||
|
||||
- 入库单列表:缺 `GET /api/platform/v1/last-leg/inbound-plans`;至少需要 `customerId,page,size,planNo,inboundCode,warehouseId,fbaWarehouseCode,status,appointmentStatus`,响应需返回 `records/page/size/total/pages` 及原型列表字段。现有状态起点为 `PENDING_ASSIGNMENT`,转换端点 `POST /convert` 不能代替列表。
|
||||
- 产品库:缺平台 `GET/POST/PUT /api/platform/v1/last-leg/products[/{id}]`;查询/响应至少覆盖客户、SKU、中英文品名、品类、长宽高、重量,写入需 SKU 唯一性、精度、幂等和状态约束。
|
||||
- 箱号:缺平台 `GET/POST/PUT /api/platform/v1/last-leg/cartons[/{id}]`;查询/响应至少覆盖客户、箱号、FBA 仓、SKU、产品名、数量、重量、库存状态、托盘、入库单及只读出库关联。内部 `/internal/v1/lastleg/dropship/receive` 不得暴露给浏览器。
|
||||
- 库存:现有 `GET /api/platform/v1/last-leg/inventory/balances|ledger` 需增加 SKU/产品快照维度,或另提供与原型一致的 SKU 聚合端点;响应需覆盖现有量、可用量、暂扣、散板、在途、采购、状态和更新时间。状态流转仍只能由入库/库内事实驱动,不增加离仓操作。
|
||||
- 入库详情:`GET /api/platform/v1/last-leg/inbound-plans/{planNo}` 需补预约/排车/时间/费用,或提供对应只读辅助接口;`sourceOrderId` 已按字符串接入。
|
||||
- 库存流水:`GET /api/platform/v1/last-leg/inventory/ledger` 需补 `skuCode/productName/customerName/overseasWarehouseName`,并支持 SKU 过滤,才能完整还原进销存和 SKU 详情流水。
|
||||
- 换标:当前列表响应需补 `productName,labelRequirement,relabelFile,budgetTotal/relabelCost`,或产品明确调整原型字段;写接口必须补正式 `create/edit` 权限。现有状态机为 `PENDING_PROCESSING -> PROCESSING/REJECTED/CANCELLED`、`PROCESSING -> COMPLETED/REJECTED/CANCELLED`。
|
||||
- 托盘:需先收敛资源作用域。产品要求平台公共查询全部客户/仓库,现接口 `GET /api/platform/v1/warehouse-admin/{warehouseId}/pallets?customerId=...` 强制客户绑定;响应还需明确 FBA 仓与采购价如何对应当前 `locationId/unitPrice`。在此冲突解决前不把客户筛选错误地当作公共资源范围。
|
||||
- 权限:尾程、入库、换标、库存、海外仓、库位、托盘相关 Controller 均需为列表/详情使用各资源 `*:list`,写操作分别使用 `*:create`、`*:edit` 等稳定权限;当前仅有应用范围与客户隔离,不能以页面按钮权限替代服务端鉴权。
|
||||
- 托盘:资源接口已固定公共作用域且不再传客户参数;仍缺无需客户遍历即可获取所有实际海外仓的公共候选接口,以及 FBA 仓和业务绑定汇总字段。
|
||||
- 权限:入仓、库存、仓库资源已切换 canonical `platform:overseas-warehouse:*`;前端已同步本模块权限常量。换标写弹框字段与后端 Create DTO 仍不一致,因此不开放提交。
|
||||
|
||||
范围排除:海外仓出库、退货出库、海外派送和其他库存离仓接口。
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
原型来源:`docs/悦/货代SAAS平台后台/prototype-src/modules/03-overseas-warehouse.html`。
|
||||
|
||||
当前范围冻结:箱号管理、产品库本期暂不实施,已从模块正式路由移除;既有页面、详情、弹框、类型与原型证据保留,后续恢复时继续以本矩阵为准。其余正式范围为尾程订单、入库单、换标单、托盘和库存/进销存。
|
||||
|
||||
| 页面 | 操作 | 原型证据 | 原型承载方式 | 当前实现(开工前) | 冻结结论 |
|
||||
|---|---|---|---|---|---|
|
||||
| 尾程订单 | 列表/筛选 | `page-last-mile-orders`,868–897 行;`lastMileSearch()`、`lastMileReset()` | 独立列表页,状态标签、查询区、横向宽表 | `/prototype/overseas-warehouse-orders/*` 占位 | 建立正式列表路由;操作列第一列,保留原型字段顺序,接口未接时显示明确契约态 |
|
||||
@@ -11,13 +13,13 @@
|
||||
| 尾程订单 | 批量转仓 | `modal-lmo-batch-transfer`,921 行 | 居中弹框,约 720px | 无正式实现 | 属库内操作,但写接口/幂等契约未定义;暂不提供写入口 |
|
||||
| 尾程订单 | 入库计划 | `modal-lmo-inbound-plan`,912 行 | 居中弹框,约 720px | 无正式实现 | 属本期入仓链路;待写接口与状态机冻结后按原型实现 |
|
||||
| 尾程订单 | 出库计划 | `modal-lmo-outbound-plan`,927 行 | 居中弹框,约 760px | 无正式实现 | 明确属于库存离仓,排除,不实现 |
|
||||
| 入库单 | 列表/筛选 | `page-fba-inbound-order`,491–525 行;`fbioSearch()`、`fbioReset()` | 独立列表页,查询区、批量预约/排车、横向宽表 | `/prototype/fba-returns/inbound-orders` 占位 | 建立正式列表路由;保留筛选与列契约,接口未接时不填充演示数据 |
|
||||
| 入库单 | 详情 | `page-fba-inbound-order-detail`,684–691 行;`openFbaInboundOrderDetail()` 8567–8575 行 | 独立详情页;单据信息、仓储信息、预约与排车三组字段,应收费用明细位于正文 | 正式参数路由与 `InboundOrderDetailView.vue`;字段和费用空态已落位 | 保持独立页面,不改成抽屉;真实查询与费用数据等待海外区域接口 |
|
||||
| 入库单 | 列表/筛选 | `page-fba-inbound-order`,491–525 行;`fbioSearch()`、`fbioReset()` | 独立列表页,查询区、批量预约/排车、横向宽表 | `/prototype/fba-returns/inbound-orders` 占位 | 已接真实分页、显式客户、筛选、状态、加载/错误/空态;排车字段后端未提供,保持只读空值 |
|
||||
| 入库单 | 详情 | `page-fba-inbound-order-detail`,684–691 行;`openFbaInboundOrderDetail()` 8567–8575 行 | 独立详情页;单据信息、仓储信息、预约与排车三组字段,应收费用明细位于正文 | 正式参数路由与 `InboundOrderDetailView.vue` | 已接计划详情和 `sourceOrderId`;接口未提供的预约/排车时间、备注和费用继续显示空值 |
|
||||
| 入库单 | 编辑 | `modal-fbio-edit`,527 行 | 居中弹框,约 880px | 无正式实现 | 待海外区域写接口与状态机冻结后实现 |
|
||||
| 入库单 | 预约 | `modal-fbio-appointment`,526 行 | 居中弹框,约 720px | 无正式实现 | 待预约/排车接口与幂等契约冻结后实现 |
|
||||
| 入库单 | 新增 | 页面按钮存在但原型未定义对应可达表单 | 原型未定义 | 无正式实现 | 标记待确认,不自行选弹框/抽屉/独立页 |
|
||||
| 库存管理 | 列表/筛选 | `page-inventory-overview`,51–89 行;`unifiedInventorySearch()`、`unifiedInventoryReset()` | 独立列表页,六项紧凑汇总、查询区、横向宽表 | 无正式页面 | 建立正式列表路由;汇总与列表在无接口时保持零/空且明确说明未接契约 |
|
||||
| 库存管理 | 详情 | `page-warehouse-stock-detail`,938–983 行;`renderWarehouseStockDetail()` 6624–6646 行 | 独立详情页;商品/库存汇总左右并列,库位库存、托盘箱号、进销存明细连续分区 | 正式参数路由与 `InventoryDetailView.vue`;字段和三类明细空态已落位 | 保持独立页面;进销存仅为只读库存事实,不提供出库或派送操作 |
|
||||
| 库存管理 | 列表/筛选 | `page-inventory-overview`,51–89 行;`unifiedInventorySearch()`、`unifiedInventoryReset()` | 独立列表页,六项紧凑汇总、查询区、横向宽表 | 无正式页面 | 已接 SKU 余额和汇总;散板指标后端无独立字段,显示空值,不以 receiving 等桶冒充 |
|
||||
| 库存管理 | 详情 | `page-warehouse-stock-detail`,938–983 行;`renderWarehouseStockDetail()` 6624–6646 行 | 独立详情页;商品/库存汇总左右并列,库位库存、托盘箱号、进销存明细连续分区 | 正式参数路由与 `InventoryDetailView.vue` | 已接余额、库位/托盘位置和订单范围流水;流水缺 SKU 时明确提示,只读展示出库关联且不提供离仓操作 |
|
||||
| 库存管理 | 导出 | 列表页“导出Excel”按钮 | 页面工具栏动作,具体浮层未定义 | 无正式实现 | 待 File 导出类型与海外 API 契约补齐,不模拟成功 |
|
||||
|
||||
第一批明确不实现:`overseas-outbound`、`fba-outbound-order*`、`fba-return-outbound`、尾程订单的出库计划及任何海外派送/库存离仓操作。
|
||||
@@ -34,15 +36,15 @@
|
||||
| 换标单 | 详情 | `page-fba-relabel-order-detail`,696–699 行;`openFbaRelabelOrderDetail()` 8605–8613 行 | 独立详情页;单据信息、商品信息、作业要求、费用明细 | 建立正式参数路由,详情复用 `list` 权限;不改为弹框或抽屉 |
|
||||
| 换标单 | 新增 | `modal-fba-relabel-form`,556–566 行;`openFbaRelabelCreate()` 7994–8001 行 | 居中弹框,最大宽度约 760px;订单信息与作业要求分组 | 按原型建立 `create` 权限入口与弹框;写接口未冻结,保存禁用且不模拟成功 |
|
||||
| 换标单 | 编辑/审核 | 同一 `modal-fba-relabel-form`;`openFbaRelabelEdit()` 8003–8022 行 | 居中弹框,约 760px;显示状态和预算报价,提交文案为“提交审核” | 使用独立 `edit` 权限;仅保留承载和字段契约,端点/状态机未冻结前不提交 |
|
||||
| 托盘管理 | 列表/筛选 | `page-fba-pallet`,626–652 行;`renderFbaPallet()` 8319–8335 行 | 独立列表页;编码、类型、仓库、状态筛选;横向表格 | 建立正式列表;操作列第一列;仓库选项等待海外基础资料接口 |
|
||||
| 托盘管理 | 详情 | `page-fba-pallet-detail`,702–705 行;`openFbaPalletDetail()` 8615–8621 行 | 独立详情页;基础信息、使用信息 | 建立正式参数路由,详情复用 `list` 权限 |
|
||||
| 托盘管理 | 新增/编辑 | `modal-fba-pallet-form`,654 行;`openFbaPalletForm()` 8358–8375 行 | 同一居中弹框,最大宽度约 760px;两列字段 | 分别使用 `create`、`edit` 权限;保存禁用,不发明 URL 或本地写入 |
|
||||
| 箱号管理 | 列表/筛选 | `page-fba-carton`,657–681 行;`renderFbaCarton()` 8428–8448 行 | 独立列表页;箱号、FBA 仓、状态筛选 | 建立正式列表;操作列第一列;“已出库”仅作为只读库存事实,不提供离仓动作 |
|
||||
| 箱号管理 | 详情 | `page-fba-carton-detail`,708–711 行;`openFbaCartonDetail()` 8623–8630 行 | 独立详情页;基础信息、商品信息 | 建立正式参数路由,详情复用 `list` 权限;关联出库单只读展示不链接出库流程 |
|
||||
| 箱号管理 | 新增/编辑 | `overlays.html` 的 `modal-fba-carton-form`,1528–1541 行;`openFbaCartonForm()` 8460–8481 行 | 同一居中弹框,最大宽度约 860px;两列字段;已出库不可编辑 | 分别使用 `create`、`edit` 权限;不实现出库状态变更,保存禁用 |
|
||||
| 产品库 | 列表/筛选 | `page-fba-product`,720–745 行;`renderFbaProduct()` 8695–8711 行 | 独立列表页;SKU、中文品名、品类筛选 | 建立正式列表;操作列第一列 |
|
||||
| 产品库 | 详情 | `page-fba-product-detail`,714–717 行;`openFbaProductDetail()` 8683–8690 行 | 独立详情页;基础信息、包装信息、其他信息 | 建立正式参数路由,详情复用 `list` 权限 |
|
||||
| 产品库 | 新增/编辑 | `modal-fbap-add`,748–771 行;`openFbapModal()`、`openFbapEditModal()` 8753–8767 行 | 同一居中弹框,最大宽度约 560px;单列标签/控件布局 | 分别使用 `create`、`edit` 权限;保存禁用,不在前端生成产品数据 |
|
||||
| 托盘管理 | 列表/筛选 | `page-fba-pallet`,626–652 行;`renderFbaPallet()` 8319–8335 行 | 独立列表页;编码、类型、仓库、状态筛选;横向表格 | 已接公共托盘分页;资源请求不传客户参数,仓库候选独立加载,不受页面客户筛选控制 |
|
||||
| 托盘管理 | 详情 | `page-fba-pallet-detail`,702–705 行;`openFbaPalletDetail()` 8615–8621 行 | 独立详情页;基础信息、使用信息 | 已接真实详情,复用 `list` 权限;后端未提供 FBA 仓和业务绑定汇总,显示空值 |
|
||||
| 托盘管理 | 新增/编辑 | `modal-fba-pallet-form`,654 行;`openFbaPalletForm()` 8358–8375 行 | 同一居中弹框,最大宽度约 760px;两列字段 | 已接真实 POST/PUT 和独立权限;FBA 仓不在 DTO 中,原字段只读提示“接口未提供” |
|
||||
| 箱号管理 | 列表/筛选 | `page-fba-carton`,657–681 行;`renderFbaCarton()` 8428–8448 行 | 独立列表页;箱号、FBA 仓、状态筛选 | 本期暂缓并移除正式路由;保留页面源码和操作列首列约束,后续恢复仍不承接离仓动作 |
|
||||
| 箱号管理 | 详情 | `page-fba-carton-detail`,708–711 行;`openFbaCartonDetail()` 8623–8630 行 | 独立详情页;基础信息、商品信息 | 本期暂缓并移除正式参数路由;保留详情源码,关联出库单始终只读 |
|
||||
| 箱号管理 | 新增/编辑 | `overlays.html` 的 `modal-fba-carton-form`,1528–1541 行;`openFbaCartonForm()` 8460–8481 行 | 同一居中弹框,最大宽度约 860px;两列字段;已出库不可编辑 | 本期暂缓;保留禁用弹框源码,不接写接口 |
|
||||
| 产品库 | 列表/筛选 | `page-fba-product`,720–745 行;`renderFbaProduct()` 8695–8711 行 | 独立列表页;SKU、中文品名、品类筛选 | 本期暂缓并移除正式路由;保留页面源码和操作列首列约束 |
|
||||
| 产品库 | 详情 | `page-fba-product-detail`,714–717 行;`openFbaProductDetail()` 8683–8690 行 | 独立详情页;基础信息、包装信息、其他信息 | 本期暂缓并移除正式参数路由;保留详情源码 |
|
||||
| 产品库 | 新增/编辑 | `modal-fbap-add`,748–771 行;`openFbapModal()`、`openFbapEditModal()` 8753–8767 行 | 同一居中弹框,最大宽度约 560px;单列标签/控件布局 | 本期暂缓;保留禁用弹框源码,不接写接口 |
|
||||
|
||||
第二批所有新增/编辑弹框按原型尺寸和字段顺序承载;接口未冻结时只用于展示已确认的交互容器,不提供可成功提交路径。导出接口同样未定义,本批不显示可用导出动作。
|
||||
|
||||
@@ -50,7 +52,7 @@
|
||||
|
||||
| 页面 | 操作 | 原型证据 | 原型承载方式与布局 | 实施结论 |
|
||||
|---|---|---|---|---|
|
||||
| 进销存记录 | 列表/筛选 | `page-inventory-transactions`,986–1007 行;`renderInventoryTransactions()` 6573–6582 行;`inventoryTransactionSearch()`、`inventoryTransactionReset()` 6584–6599 行 | 从库存管理进入的独立列表页;日期、客户/货主、海外仓、SKU、业务类型及业务/入库/出库单号筛选;约 2050px 横向表格 | 建立无独立菜单的正式路由,复用库存 `list` 权限;出库单号与出库数量仅作为只读库存流水事实,不提供出库详情跳转或离仓操作 |
|
||||
| 进销存记录 | 列表/筛选 | `page-inventory-transactions`,986–1007 行;`renderInventoryTransactions()` 6573–6582 行;`inventoryTransactionSearch()`、`inventoryTransactionReset()` 6584–6599 行 | 从库存管理进入的独立列表页;日期、客户/货主、海外仓、SKU、业务类型及业务/入库/出库单号筛选;约 2050px 横向表格 | 已接真实流水分页,复用库存 `list` 权限;DTO 未提供 SKU/品名时对应筛选禁用、列显示空值,出库事实只读且无跳转 |
|
||||
| 进销存记录 | 返回 | `backToInventoryManagement()` 6606–6609 行 | 返回库存管理列表 | 页面工具栏提供明确返回按钮 |
|
||||
| 进销存记录 | 导出 | 页面 989 行“导出Excel”按钮 | 工具栏动作,原型未定义导出任务载体 | File 导出类型与海外接口未冻结,本批不显示可成功导出入口 |
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { overseasHttp } from '@/shared/api/overseasHttp'
|
||||
import { getInboundPlan, listInboundPlans } from './inboundPlans'
|
||||
|
||||
vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn() } }))
|
||||
describe('inbound plan overseas API', () => {
|
||||
beforeEach(() => vi.mocked(overseasHttp.get).mockReset())
|
||||
it('pages plans with explicit customer scope', async () => {
|
||||
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'ok', data: { records: [], page: 1, size: 20, total: 0, pages: 0 } })
|
||||
await listInboundPlans({ customerId: '190000000000000001', page: 1, size: 20, inboundCode: 'IN-1' })
|
||||
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans', { params: { customerId: '190000000000000001', page: 1, size: 20, inboundCode: 'IN-1' } })
|
||||
})
|
||||
it('gets detail by encoded plan number', async () => {
|
||||
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'ok', data: { id: '1', planNo: 'IP/1', customerId: '2', sourceOrderId: '190000000000000099', lineCount: 0, palletCount: 0, totalVolumeCbm: 0, lines: [], pallets: [], allocations: [] } })
|
||||
await getInboundPlan('IP/1', '2')
|
||||
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans/IP%2F1', { params: { customerId: '2' } })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
import { overseasHttp } from '@/shared/api/overseasHttp'
|
||||
import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type { OverseasPageResult } from './lastMileOrders'
|
||||
|
||||
export interface InboundPlanQuery {
|
||||
customerId: string
|
||||
page: number
|
||||
size: number
|
||||
planNo?: string
|
||||
inboundCode?: string
|
||||
status?: string
|
||||
overseasWarehouseId?: string
|
||||
destinationWarehouseCode?: string
|
||||
fbaWarehouseCode?: string
|
||||
appointmentStatus?: string
|
||||
createdFrom?: string
|
||||
createdTo?: string
|
||||
}
|
||||
|
||||
export interface InboundPlanSummaryDto {
|
||||
id: string
|
||||
planNo: string
|
||||
sourceOrderId: string
|
||||
customerId: string
|
||||
customerName?: string | null
|
||||
orderType?: string | null
|
||||
inboundCode?: string | null
|
||||
containerNo?: string | null
|
||||
overseasWarehouseId?: string | null
|
||||
overseasWarehouseName?: string | null
|
||||
destinationWarehouseId?: string | null
|
||||
destinationWarehouseCode?: string | null
|
||||
destinationWarehouseName?: string | null
|
||||
fbaWarehouseCode?: string | null
|
||||
packageCount?: number | null
|
||||
totalVolumeCbm?: number | null
|
||||
palletCount?: number | null
|
||||
status?: string | null
|
||||
appointmentStatus?: string | null
|
||||
createdAt?: string | null
|
||||
updatedAt?: string | null
|
||||
}
|
||||
|
||||
export interface InboundPlanDetailDto {
|
||||
id: string
|
||||
planNo: string
|
||||
customerId: string
|
||||
sourceOrderId: string
|
||||
containerNo?: string | null
|
||||
status?: string | null
|
||||
lineCount: number
|
||||
palletCount: number
|
||||
totalVolumeCbm: number
|
||||
lines: Array<{ id: string; orderId: string; inboundCode?: string | null; fbaWarehouseCode?: string | null; destinationWarehouseId?: string | null; destinationWarehouseCode?: string | null; destinationWarehouseName?: string | null; volumeCbm?: number | null; requiredPackageCount: number; plannedPalletCount: number }>
|
||||
pallets: Array<{ id: string; palletNo?: string | null; destinationWarehouseId?: string | null; destinationWarehouseCode?: string | null; destinationWarehouseName?: string | null; capacityCbm?: number | null; allocatedVolumeCbm?: number | null; sequenceNo: number; warehousePalletId?: string | null; warehouseLocationId?: string | null; warehouseResourceLockId?: string | null; assignmentStatus?: string | null; lockedAt?: string | null; actualVolumeCbm?: number | null; actualPackageCount: number }>
|
||||
allocations: Array<{ lineId: string; palletId: string; allocatedVolumeCbm?: number | null; plannedPackageCount: number; countedPackageCount: number; pickedPackageCount: number; actualVolumeCbm?: number | null; status?: string | null; version: number }>
|
||||
}
|
||||
|
||||
export async function listInboundPlans(query: InboundPlanQuery) {
|
||||
const { customerId, ...params } = query
|
||||
return unwrapApiResponse(await overseasHttp.get<ApiEnvelope<OverseasPageResult<InboundPlanSummaryDto>>, ApiEnvelope<OverseasPageResult<InboundPlanSummaryDto>>>('/platform/v1/last-leg/inbound-plans', { params: { customerId, ...params } }))
|
||||
}
|
||||
|
||||
export async function getInboundPlan(planNo: string, customerId: string) {
|
||||
return unwrapApiResponse(await overseasHttp.get<ApiEnvelope<InboundPlanDetailDto>, ApiEnvelope<InboundPlanDetailDto>>(`/platform/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}`, { params: { customerId } }))
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { overseasHttp } from '@/shared/api/overseasHttp'
|
||||
import { getInventorySummary, listInventoryBalances, listInventoryLedger, listInventoryPositions } from './inventory'
|
||||
vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn() } }))
|
||||
describe('inventory overseas API', () => {
|
||||
beforeEach(() => vi.mocked(overseasHttp.get).mockReset())
|
||||
it('queries SKU balances with explicit customer scope', async () => { vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'ok', data: { records: [], page: 1, size: 20, total: 0, pages: 0 } }); await listInventoryBalances({ customerId: '9', page: 1, size: 20, skuCode: 'SKU-1', productName: '产品' }); expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/inventory/balances', { params: { customerId: '9', page: 1, size: 20, skuCode: 'SKU-1', productName: '产品' } }) })
|
||||
it('uses read-only summary, positions and ledger endpoints', async () => { vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'ok', data: {} }); await getInventorySummary({ customerId: '9', skuCode: 'SKU-1' }); await listInventoryPositions('190000000000000099', '9'); await listInventoryLedger({ customerId: '9', page: 1, size: 20 }); expect(vi.mocked(overseasHttp.get).mock.calls.map((call) => call[0])).toEqual(['/platform/v1/last-leg/inventory/summary', '/platform/v1/last-leg/inventory/balances/190000000000000099/positions', '/platform/v1/last-leg/inventory/ledger']) })
|
||||
it('does not expose stock-leaving commands', () => { const source = `${listInventoryBalances}${getInventorySummary}${listInventoryPositions}${listInventoryLedger}`; expect(source).not.toMatch(/allocate|unallocate|pick|ship/) })
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
import { overseasHttp } from '@/shared/api/overseasHttp'
|
||||
import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type { OverseasPageResult } from './lastMileOrders'
|
||||
|
||||
export interface InventoryBalanceQuery {
|
||||
customerId: string
|
||||
page: number
|
||||
size: number
|
||||
customerName?: string
|
||||
overseasWarehouseId?: string
|
||||
overseasWarehouseCode?: string
|
||||
destinationWarehouseCode?: string
|
||||
inboundOrderNo?: string
|
||||
inboundCode?: string
|
||||
businessOrderNo?: string
|
||||
skuCode?: string
|
||||
productName?: string
|
||||
inventoryStatus?: string
|
||||
}
|
||||
export interface InventoryBalanceDto {
|
||||
id: string; customerId: string; customerName?: string | null; overseasWarehouseId?: string | null; overseasWarehouseCode?: string | null; overseasWarehouseName?: string | null
|
||||
destinationWarehouseId?: string | null; destinationWarehouseCode?: string | null; destinationWarehouseName?: string | null; inboundOrderId?: string | null; inboundOrderNo?: string | null; inboundCode?: string | null
|
||||
skuCode?: string | null; productNameZh?: string | null; productNameEn?: string | null; totalPackages: number; availablePackages: number; holdPackages: number; allocatedPackages: number; pickedPackages: number
|
||||
inTransitPackages: number; receivingPackages: number; procurementPackages: number; updatedAt?: string | null
|
||||
}
|
||||
export interface InventoryDashboardDto { inboundOrderCount: number; palletCount: number; totalPackages: number; availablePackages: number; holdPackages: number; allocatedPackages: number; pickedPackages: number; inTransitPackages: number; receivingPackages: number; procurementPackages: number }
|
||||
export interface InventoryPositionDto { id: string; balanceId: string; customerId: string; overseasWarehouseId?: string | null; destinationWarehouseCode?: string | null; inboundOrderId?: string | null; inboundOrderNo?: string | null; inboundCode?: string | null; palletId?: string | null; palletNo?: string | null; batchNo?: string | null; warehouseLocationId?: string | null; warehouseLocationCode?: string | null; totalPackages: number; availablePackages: number; holdPackages: number; allocatedPackages: number; pickedPackages: number; receivingPackages: number; status?: string | null; updatedAt?: string | null }
|
||||
export interface InventoryLedgerDto { id: string; customerId: string; overseasWarehouseId?: string | null; destinationWarehouseCode?: string | null; inboundOrderId?: string | null; inboundOrderNo?: string | null; inboundCode?: string | null; palletNo?: string | null; batchNo?: string | null; warehouseLocationCode?: string | null; businessType?: string | null; businessOrderNo?: string | null; outboundOrderNo?: string | null; statusFrom?: string | null; statusTo?: string | null; beforePackages: number; deltaPackages: number; afterPackages: number; operatorId?: string | null; remark?: string | null; occurredAt?: string | null }
|
||||
|
||||
export async function listInventoryBalances(query: InventoryBalanceQuery) { const { customerId, ...params } = query; return unwrapApiResponse(await overseasHttp.get<ApiEnvelope<OverseasPageResult<InventoryBalanceDto>>, ApiEnvelope<OverseasPageResult<InventoryBalanceDto>>>('/platform/v1/last-leg/inventory/balances', { params: { customerId, ...params } })) }
|
||||
export async function getInventorySummary(query: Omit<InventoryBalanceQuery, 'page' | 'size'>) { const { customerId, ...params } = query; return unwrapApiResponse(await overseasHttp.get<ApiEnvelope<InventoryDashboardDto>, ApiEnvelope<InventoryDashboardDto>>('/platform/v1/last-leg/inventory/summary', { params: { customerId, ...params } })) }
|
||||
export async function listInventoryPositions(balanceId: string, customerId: string, query: { palletNo?: string; batchNo?: string; inventoryStatus?: string } = {}) { return unwrapApiResponse(await overseasHttp.get<ApiEnvelope<InventoryPositionDto[]>, ApiEnvelope<InventoryPositionDto[]>>(`/platform/v1/last-leg/inventory/balances/${encodeURIComponent(balanceId)}/positions`, { params: { customerId, ...query } })) }
|
||||
export async function listInventoryLedger(query: { customerId: string; page: number; size: number; overseasWarehouseId?: string; destinationWarehouseCode?: string; businessType?: string; businessOrderNo?: string; inboundOrderNo?: string; inboundCode?: string; outboundOrderNo?: string; palletNo?: string; batchNo?: string; occurredFrom?: string; occurredTo?: string }) { const { customerId, ...params } = query; return unwrapApiResponse(await overseasHttp.get<ApiEnvelope<OverseasPageResult<InventoryLedgerDto>>, ApiEnvelope<OverseasPageResult<InventoryLedgerDto>>>('/platform/v1/last-leg/inventory/ledger', { params: { customerId, ...params } })) }
|
||||
@@ -0,0 +1,9 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { overseasHttp } from '@/shared/api/overseasHttp'
|
||||
import { createPallet, getPallet, listPallets, updatePallet } from './pallets'
|
||||
vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn(), post: vi.fn(), put: vi.fn() } }))
|
||||
describe('public overseas pallet API', () => {
|
||||
beforeEach(() => { vi.mocked(overseasHttp.get).mockReset(); vi.mocked(overseasHttp.post).mockReset(); vi.mocked(overseasHttp.put).mockReset() })
|
||||
it('does not send a customer isolation parameter for public pallet reads', async () => { vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'ok', data: { records: [], page: 1, size: 20, total: 0, pages: 0 } }); await listPallets('190000000000000001', { page: 1, size: 20, status: 'IDLE' }); expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/warehouse-admin/190000000000000001/pallets', { params: { page: 1, size: 20, status: 'IDLE' } }); expect(JSON.stringify(vi.mocked(overseasHttp.get).mock.calls)).not.toContain('customerId') })
|
||||
it('uses the public resource path for detail, create and update', async () => { vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'ok', data: {} }); vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', message: 'ok', data: '8' }); vi.mocked(overseasHttp.put).mockResolvedValue({ code: '200', message: 'ok', data: null }); const payload = { palletType: '木托盘', capacityCbm: 2, unitPrice: 10, status: 'IDLE' }; await getPallet('1', '2'); await createPallet('1', payload); await updatePallet('1', '2', payload); expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/warehouse-admin/1/pallets/2'); expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/warehouse-admin/1/pallets', payload); expect(overseasHttp.put).toHaveBeenCalledWith('/platform/v1/warehouse-admin/1/pallets/2', payload) })
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
import { overseasHttp } from '@/shared/api/overseasHttp'
|
||||
import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type { OverseasPageResult } from './lastMileOrders'
|
||||
|
||||
export interface OverseasWarehouseDto { id: string; customerId: string; warehouseCode: string; warehouseName: string; warehouseType?: string | null; countryCode?: string | null; status?: string | null }
|
||||
export interface PalletDto { id: string; customerId: string; warehouseId: string; locationId?: string | null; palletCode: string; palletType: string; capacityCbm: number; unitPrice: number; status: string; remark?: string | null; createdAt?: string | null; updatedAt?: string | null }
|
||||
export interface PalletSave { palletCode?: string; palletType: string; capacityCbm?: number; unitPrice: number; status: string; locationId?: string; remark?: string }
|
||||
export async function listOverseasWarehouses(customerId: string, page: number, size = 200) { return unwrapApiResponse(await overseasHttp.get<ApiEnvelope<OverseasPageResult<OverseasWarehouseDto>>, ApiEnvelope<OverseasPageResult<OverseasWarehouseDto>>>('/platform/v1/warehouse-admin', { params: { customerId, page, size, status: 'ACTIVE' } })) }
|
||||
export async function listPallets(warehouseId: string, query: { page: number; size: number; palletType?: string; status?: string; locationId?: string; keyword?: string }) { return unwrapApiResponse(await overseasHttp.get<ApiEnvelope<OverseasPageResult<PalletDto>>, ApiEnvelope<OverseasPageResult<PalletDto>>>(`/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets`, { params: query })) }
|
||||
export async function getPallet(warehouseId: string, palletId: string) { return unwrapApiResponse(await overseasHttp.get<ApiEnvelope<PalletDto>, ApiEnvelope<PalletDto>>(`/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}`)) }
|
||||
export async function createPallet(warehouseId: string, payload: PalletSave) { return unwrapApiResponse(await overseasHttp.post<ApiEnvelope<string>, ApiEnvelope<string>>(`/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets`, payload)) }
|
||||
export async function updatePallet(warehouseId: string, palletId: string, payload: PalletSave) { unwrapApiResponse(await overseasHttp.put<ApiEnvelope<null>, ApiEnvelope<null>>(`/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}`, payload)) }
|
||||
@@ -1,62 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { getInboundPlan, type InboundPlanDetailDto } from '../api/inboundPlans'
|
||||
import OverseasDetailShell from '../components/OverseasDetailShell.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const orderId = computed(() => String(route.params.orderId || '—'))
|
||||
|
||||
const route = useRoute(); const router = useRouter()
|
||||
const planNo = computed(() => String(route.params.orderId || ''))
|
||||
const customerId = computed(() => String(route.query.customerId || ''))
|
||||
const detail = ref<InboundPlanDetailDto | null>(null); const loading = ref(false); const loadError = ref<ApiErrorDetails | null>(null)
|
||||
function text(value: unknown) { return String(value ?? '').trim() || '—' }
|
||||
function status(value: unknown) { const labels: Record<string, string> = { PENDING_ASSIGNMENT: '待分配', COMPLETED: '已完成' }; return labels[String(value || '')] || text(value) }
|
||||
const firstLine = computed(() => detail.value?.lines[0])
|
||||
const fieldValues = computed<Record<string, string | number>>(() => ({
|
||||
入库单号: text(detail.value?.planNo), 入仓码: text(firstLine.value?.inboundCode), 类型: '—', 货主: '—', 关联订单: text(detail.value?.sourceOrderId),
|
||||
海外仓: text(firstLine.value?.destinationWarehouseName), FBA仓库: text(firstLine.value?.fbaWarehouseCode), 总箱数: detail.value?.lines.reduce((sum, line) => sum + line.requiredPackageCount, 0) || 0,
|
||||
'计费体积(m³)': Number(detail.value?.totalVolumeCbm || 0), 总托盘数: Number(detail.value?.palletCount || 0), 状态: status(detail.value?.status),
|
||||
预约状态: '—', 预约送仓时间: '—', 预计装车时间: '—', 排车状态: '—', 创建时间: '—', 更新时间: '—', 备注: '—',
|
||||
}))
|
||||
const groups = [
|
||||
{ title: '单据信息', fields: ['入库单号', '入仓码', '类型', '货主', '关联订单'] },
|
||||
{ title: '仓储信息', fields: ['海外仓', 'FBA仓库', '总箱数', '计费体积(m³)', '总托盘数'] },
|
||||
{ title: '预约与排车', fields: ['状态', '预约状态', '预约送仓时间', '预计装车时间', '排车状态', '创建时间', '更新时间', '备注'] },
|
||||
]
|
||||
const feeColumns: DataTableColumn[] = [
|
||||
{ key: 'index', label: '序号', width: 70, align: 'center' },
|
||||
{ key: 'name', label: '费用项目', minWidth: 180 },
|
||||
{ key: 'currency', label: '币种', width: 100 },
|
||||
{ key: 'amount', label: '金额', width: 140, align: 'right' },
|
||||
{ key: 'remark', label: '备注', minWidth: 200 },
|
||||
]
|
||||
|
||||
function back() {
|
||||
void router.push({ name: 'overseas-inbound-orders' })
|
||||
}
|
||||
const feeColumns: DataTableColumn[] = [{ key: 'index', label: '序号', width: 70, align: 'center' }, { key: 'name', label: '费用项目', minWidth: 180 }, { key: 'currency', label: '币种', width: 100 }, { key: 'amount', label: '金额', width: 140, align: 'right' }, { key: 'remark', label: '备注', minWidth: 200 }]
|
||||
async function load() { if (!planNo.value || !customerId.value) { loadError.value = { message: '详情缺少客户范围,请从入库单列表重新进入' }; return }; loading.value = true; loadError.value = null; try { detail.value = await getInboundPlan(planNo.value, customerId.value) } catch (error) { loadError.value = getApiErrorDetails(error, '入库单详情加载失败,请重试') } finally { loading.value = false } }
|
||||
function back() { void router.push({ name: 'overseas-inbound-orders' }) }
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OverseasDetailShell title="入库单详情" reference-label="入库单号" :reference-value="orderId" back-label="返回入库单" domain="入库单详情" @back="back">
|
||||
<section class="detail-section" aria-label="入库单基本信息">
|
||||
<div class="detail-groups">
|
||||
<section v-for="group in groups" :key="group.title" class="detail-group" :aria-label="group.title">
|
||||
<h2>{{ group.title }}</h2>
|
||||
<dl class="detail-fields"><div v-for="label in group.fields" :key="label"><dt>{{ label }}</dt><dd>—</dd></div></dl>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
<section class="detail-section fee-section" aria-labelledby="inbound-fees-heading">
|
||||
<h2 id="inbound-fees-heading">应收费用明细</h2>
|
||||
<DataTable :data="[]" :columns="feeColumns" column-settings-mode="hide" empty-text="海外区域应收费用契约待接入" />
|
||||
<footer class="fee-total"><span>应收费用合计(USD)</span><strong>—</strong></footer>
|
||||
</section>
|
||||
</OverseasDetailShell>
|
||||
</template>
|
||||
<template><OverseasDetailShell title="入库单详情" reference-label="入库单号" :reference-value="detail?.planNo || planNo" back-label="返回入库单" domain="入库单详情" :pending="false" :status-text="detail ? status(detail.status) : '加载中'" @back="back">
|
||||
<LoadErrorState v-if="loadError" title="入库单详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
||||
<template v-else><section v-loading="loading" class="detail-section" aria-label="入库单基本信息"><div class="detail-groups"><section v-for="group in groups" :key="group.title" class="detail-group" :aria-label="group.title"><h2>{{ group.title }}</h2><dl class="detail-fields"><div v-for="label in group.fields" :key="label"><dt>{{ label }}</dt><dd>{{ fieldValues[label] }}</dd></div></dl></section></div></section>
|
||||
<section class="detail-section fee-section" aria-labelledby="inbound-fees-heading"><h2 id="inbound-fees-heading">应收费用明细</h2><DataTable :data="[]" :columns="feeColumns" column-settings-mode="hide" empty-text="当前入库计划详情接口未提供应收费用" /><footer class="fee-total"><span>应收费用合计(USD)</span><strong>—</strong></footer></section></template>
|
||||
</OverseasDetailShell></template>
|
||||
|
||||
<style scoped>
|
||||
.detail-section { min-width: 0; border: 1px solid var(--cc-border); background: #fff; }
|
||||
.detail-groups { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.detail-group { min-width: 0; padding: 16px 18px 18px; border-right: 1px solid var(--cc-border); }
|
||||
.detail-group:last-child { border-right: 0; }
|
||||
.detail-group h2, .fee-section h2 { margin: 0 0 12px; color: var(--cc-text); font-size: 15px; }
|
||||
.detail-fields { margin: 0; border-top: 1px solid var(--cc-border); border-left: 1px solid var(--cc-border); }
|
||||
.detail-fields > div { display: grid; min-width: 0; min-height: 46px; grid-template-columns: 120px minmax(0, 1fr); border-right: 1px solid var(--cc-border); border-bottom: 1px solid var(--cc-border); }
|
||||
.detail-fields dt, .detail-fields dd { display: flex; min-width: 0; align-items: center; padding: 8px 10px; }
|
||||
.detail-fields dt { background: #f8fafc; color: var(--cc-muted); font-size: 12px; }
|
||||
.detail-fields dd { margin: 0; color: var(--cc-text); overflow-wrap: anywhere; }
|
||||
.fee-section { overflow: hidden; padding-top: 16px; }
|
||||
.fee-section h2 { margin-right: 18px; margin-left: 18px; }
|
||||
.fee-total { display: flex; min-height: 48px; align-items: center; justify-content: flex-end; gap: 40px; padding: 10px 18px; border-top: 1px solid var(--cc-border); color: var(--cc-text); }
|
||||
.fee-total strong { min-width: 120px; font-size: 16px; }
|
||||
.detail-section { min-width: 0; border: 1px solid var(--cc-border); background: #fff; }.detail-groups { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); }.detail-group { min-width: 0; padding: 16px 18px 18px; border-right: 1px solid var(--cc-border); }.detail-group:last-child { border-right: 0; }.detail-group h2,.fee-section h2 { margin: 0 0 12px; color: var(--cc-text); font-size: 15px; }.detail-fields { margin: 0; border-top: 1px solid var(--cc-border); border-left: 1px solid var(--cc-border); }.detail-fields>div { display: grid; min-width: 0; min-height: 46px; grid-template-columns: 120px minmax(0,1fr); border-right: 1px solid var(--cc-border); border-bottom: 1px solid var(--cc-border); }.detail-fields dt,.detail-fields dd { display:flex; min-width:0; align-items:center; padding:8px 10px; }.detail-fields dt { background:#f8fafc; color:var(--cc-muted); font-size:12px; }.detail-fields dd { margin:0; color:var(--cc-text); overflow-wrap:anywhere; }.fee-section { overflow:hidden; padding-top:16px; }.fee-section h2 { margin-right:18px; margin-left:18px; }.fee-total { display:flex; min-height:48px; align-items:center; justify-content:flex-end; gap:40px; padding:10px 18px; border-top:1px solid var(--cc-border); }.fee-total strong { min-width:120px; font-size:16px; }
|
||||
</style>
|
||||
|
||||
@@ -1,58 +1,85 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { Eye } from '@lucide/vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import ContractPendingNotice from '../components/ContractPendingNotice.vue'
|
||||
import { listInboundPlans } from '../api/inboundPlans'
|
||||
import type { InboundOrderListItem } from '../types'
|
||||
|
||||
const rows = ref<InboundOrderListItem[]>([])
|
||||
const customers = ref<PlatformTenantResource[]>([])
|
||||
const router = useRouter()
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const filters = ref<QueryModel>({ orderNo: '', inboundCode: '', status: '', warehouse: '', fbaWarehouse: '', appointmentStatus: '', dispatchStatus: '', inboundDate: [] })
|
||||
const fields: QueryField[] = [
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const customersLoading = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const customersError = ref<ApiErrorDetails | null>(null)
|
||||
const filters = ref<QueryModel>({ customerId: '', orderNo: '', inboundCode: '', status: '', warehouse: '', fbaWarehouse: '', appointmentStatus: '', dispatchStatus: '', inboundDate: [] })
|
||||
const warehouseOptions = computed(() => [...new Map(rows.value.filter((row) => row.warehouseId).map((row) => [row.warehouseId, row.warehouse])).entries()].map(([value, label]) => ({ label, value })))
|
||||
const fbaWarehouseOptions = computed(() => [...new Set(rows.value.map((row) => row.fbaWarehouse).filter((value) => value !== '—'))].map((value) => ({ label: value, value })))
|
||||
const fields = computed<QueryField[]>(() => [
|
||||
{ key: 'customerId', type: 'select', label: '客户名称', placeholder: '请选择客户', width: 240, props: { filterable: true, clearable: true, loading: customersLoading.value }, options: customers.value.map((item) => ({ label: item.tenantName, value: String(item.id) })) },
|
||||
{ key: 'orderNo', type: 'input', label: '入库单号', placeholder: '请输入入库单号' },
|
||||
{ key: 'inboundCode', type: 'input', label: '入仓码', placeholder: '请输入入仓码' },
|
||||
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', options: ['待审核', '已审核', '已入库'].map((value) => ({ label: value, value })) },
|
||||
{ key: 'warehouse', type: 'select', label: '海外仓', placeholder: '全部海外仓', options: [] },
|
||||
{ key: 'fbaWarehouse', type: 'select', label: 'FBA仓库', placeholder: '全部FBA仓库', options: [] },
|
||||
{ key: 'appointmentStatus', type: 'select', label: '预约状态', placeholder: '全部预约状态', options: ['未预约', '预约中', '预约成功'].map((value) => ({ label: value, value })) },
|
||||
{ key: 'dispatchStatus', type: 'select', label: '排车状态', placeholder: '全部排车状态', options: ['未排车', '已排车'].map((value) => ({ label: value, value })) },
|
||||
{ key: 'inboundDate', type: 'daterange', label: '入库时间' },
|
||||
]
|
||||
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', options: [{ label: '待分配', value: 'PENDING_ASSIGNMENT' }, { label: '已完成', value: 'COMPLETED' }] },
|
||||
{ key: 'warehouse', type: 'select', label: '海外仓', placeholder: '全部海外仓', options: warehouseOptions.value },
|
||||
{ key: 'fbaWarehouse', type: 'select', label: 'FBA仓库', placeholder: '全部FBA仓库', options: fbaWarehouseOptions.value },
|
||||
{ key: 'appointmentStatus', type: 'select', label: '预约状态', placeholder: '全部预约状态', options: [{ label: '待预约', value: 'PENDING' }, { label: '预约成功', value: 'SUCCESS' }, { label: '预约失败', value: 'FAILED' }, { label: '已取消', value: 'CANCELLED' }] },
|
||||
{ key: 'dispatchStatus', type: 'select', label: '排车状态', placeholder: '接口未提供', options: [], props: { disabled: true } },
|
||||
{ key: 'inboundDate', type: 'daterange', label: '创建时间' },
|
||||
])
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 120, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'orderNo', prop: 'orderNo', label: '入库单号', minWidth: 170 },
|
||||
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 140 },
|
||||
{ key: 'type', prop: 'type', label: '类型', width: 100 },
|
||||
{ key: 'warehouse', prop: 'warehouse', label: '海外仓', minWidth: 170 },
|
||||
{ key: 'fbaWarehouse', prop: 'fbaWarehouse', label: 'FBA仓库', minWidth: 130 },
|
||||
{ key: 'owner', prop: 'owner', label: '货主', minWidth: 150 },
|
||||
{ key: 'cartons', prop: 'cartons', label: '总箱数', width: 90, align: 'right' },
|
||||
{ key: 'volume', prop: 'volume', label: '计费体积(m³)', width: 125, align: 'right' },
|
||||
{ key: 'pallets', prop: 'pallets', label: '总托盘数', width: 100, align: 'right' },
|
||||
{ key: 'status', prop: 'status', label: '状态', width: 100 },
|
||||
{ key: 'appointmentStatus', prop: 'appointmentStatus', label: 'FBA预约', width: 110 },
|
||||
{ key: 'dispatchStatus', prop: 'dispatchStatus', label: '排车状态', width: 110 },
|
||||
{ key: 'orderNo', prop: 'orderNo', label: '入库单号', minWidth: 170 }, { key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 140 },
|
||||
{ key: 'type', prop: 'type', label: '类型', width: 100 }, { key: 'warehouse', prop: 'warehouse', label: '海外仓', minWidth: 170 },
|
||||
{ key: 'fbaWarehouse', prop: 'fbaWarehouse', label: 'FBA仓库', minWidth: 130 }, { key: 'owner', prop: 'owner', label: '货主', minWidth: 150 },
|
||||
{ key: 'cartons', prop: 'cartons', label: '总箱数', width: 90, align: 'right' }, { key: 'volume', prop: 'volume', label: '计费体积(m³)', width: 125, align: 'right' },
|
||||
{ key: 'pallets', prop: 'pallets', label: '总托盘数', width: 100, align: 'right' }, { key: 'status', prop: 'status', label: '状态', width: 100 },
|
||||
{ key: 'appointmentStatus', prop: 'appointmentStatus', label: 'FBA预约', width: 110 }, { key: 'dispatchStatus', prop: 'dispatchStatus', label: '排车状态', width: 110 },
|
||||
]
|
||||
|
||||
function openDetail(source: object) {
|
||||
const row = source as InboundOrderListItem
|
||||
void router.push({ name: 'overseas-inbound-order-detail', params: { orderId: row.orderNo } })
|
||||
function text(value: unknown) { return String(value ?? '').trim() || '—' }
|
||||
function statusLabel(value: unknown) { const labels: Record<string, string> = { PENDING_ASSIGNMENT: '待分配', COMPLETED: '已完成' }; return labels[String(value || '')] || text(value) }
|
||||
function appointmentLabel(value: unknown) { const labels: Record<string, string> = { PENDING: '待预约', SUCCESS: '预约成功', FAILED: '预约失败', CANCELLED: '已取消' }; return labels[String(value || '')] || text(value) }
|
||||
function orderType(value: unknown) { const labels: Record<string, string> = { ONE_CLICK_DROPSHIP: '一件代发', FIRST_LEG: '头程', RETURN_INBOUND: '退货入库' }; return labels[String(value || '')] || text(value) }
|
||||
async function loadCustomers() {
|
||||
customersLoading.value = true; customersError.value = null
|
||||
try { const records: PlatformTenantResource[] = []; let current = 1; let pages = 1; do { const result = await listPlatformTenants({ page: current, size: 200, status: 'ACTIVE' }); records.push(...result.records); pages = Math.max(1, result.pages); current += 1 } while (current <= pages); customers.value = records }
|
||||
catch (error) { customersError.value = getApiErrorDetails(error, '客户列表加载失败,请重试') }
|
||||
finally { customersLoading.value = false }
|
||||
}
|
||||
async function load(model: QueryModel = filters.value) {
|
||||
Object.assign(filters.value, model)
|
||||
const customerId = String(filters.value.customerId || '').trim()
|
||||
if (!customerId) { rows.value = []; total.value = 0; loadError.value = null; return }
|
||||
const range = Array.isArray(filters.value.inboundDate) ? filters.value.inboundDate.map(String) : []
|
||||
loading.value = true; loadError.value = null
|
||||
try {
|
||||
const result = await listInboundPlans({ customerId, page: page.value, size: pageSize.value, planNo: String(filters.value.orderNo || '').trim() || undefined, inboundCode: String(filters.value.inboundCode || '').trim() || undefined, status: String(filters.value.status || '').trim() || undefined, overseasWarehouseId: String(filters.value.warehouse || '').trim() || undefined, fbaWarehouseCode: String(filters.value.fbaWarehouse || '').trim() || undefined, appointmentStatus: String(filters.value.appointmentStatus || '').trim() || undefined, createdFrom: range[0] ? `${range[0]}T00:00:00` : undefined, createdTo: range[1] ? `${range[1]}T23:59:59` : undefined })
|
||||
rows.value = result.records.map((item) => ({ id: String(item.id), customerId, sourceOrderId: String(item.sourceOrderId), warehouseId: String(item.overseasWarehouseId || ''), destinationWarehouseCode: String(item.destinationWarehouseCode || ''), orderNo: text(item.planNo), inboundCode: text(item.inboundCode), type: orderType(item.orderType), warehouse: text(item.overseasWarehouseName || item.destinationWarehouseName), fbaWarehouse: text(item.fbaWarehouseCode), owner: text(item.customerName), cartons: Number(item.packageCount || 0), volume: Number(item.totalVolumeCbm || 0), pallets: Number(item.palletCount || 0), status: statusLabel(item.status), appointmentStatus: appointmentLabel(item.appointmentStatus), dispatchStatus: '—' }))
|
||||
total.value = result.total
|
||||
} catch (error) { rows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, '入库单加载失败,请重试') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
function search(model: QueryModel) { page.value = 1; void load(model) }
|
||||
function reset(model: QueryModel) { page.value = 1; void load(model) }
|
||||
function openDetail(source: object) { const row = source as InboundOrderListItem; void router.push({ name: 'overseas-inbound-order-detail', params: { orderId: row.orderNo }, query: { customerId: row.customerId } }) }
|
||||
onMounted(loadCustomers)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage><ListPageLayout aria-label="海外仓入库单列表">
|
||||
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" ariaLabel="入库单筛选" preference-key="overseas-inbound-orders-query" @search="page = 1" /></template>
|
||||
<template #state><ContractPendingNotice domain="入库单" /></template>
|
||||
<section class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="0" preference-key="overseas-inbound-orders" row-key="orderNo" empty-text="海外区域入库单查询契约待接入">
|
||||
<template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button></template>
|
||||
<template #pagination-left>共 0 条入库单</template>
|
||||
</DataTable></section>
|
||||
</ListPageLayout></AdminPage>
|
||||
</template>
|
||||
<template><AdminPage><ListPageLayout aria-label="海外仓入库单列表">
|
||||
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :loading="loading" ariaLabel="入库单筛选" preference-key="overseas-inbound-orders-query" @search="search" @reset="reset" /></template>
|
||||
<template v-if="customersError || loadError" #state><LoadErrorState title="入库单加载失败" :message="(customersError || loadError)?.message" :loading="customersLoading || loading" @retry="customersError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="(customersError || loadError)!" /></template></LoadErrorState></template>
|
||||
<section v-if="!customersError && !loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-inbound-orders" row-key="id" :empty-text="filters.customerId ? '暂无入库单' : '请先选择客户后查询'" @update:page="load()" @update:page-size="page = 1; load()">
|
||||
<template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button></template><template #pagination-left>共 {{ total }} 条入库单</template>
|
||||
</DataTable></section>
|
||||
</ListPageLayout></AdminPage></template>
|
||||
|
||||
@@ -1,88 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import OverseasDetailShell from '../components/OverseasDetailShell.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'; import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'; import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { listInventoryBalances, listInventoryLedger, listInventoryPositions, type InventoryBalanceDto, type InventoryLedgerDto, type InventoryPositionDto } from '../api/inventory'; import { formatOverseasDateTime } from '../lastMilePresentation'; import OverseasDetailShell from '../components/OverseasDetailShell.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const inventoryId = computed(() => String(route.params.inventoryId || '—'))
|
||||
|
||||
const productFields = ['客户', '海外仓', 'SKU编码', '中文品名', '英文品名', '更新时间']
|
||||
const stockFields = ['在库总量', '可用库存', '暂扣库存', '美本土散版', '在途库存', '采购库存', '预计总量']
|
||||
const locationColumns: DataTableColumn[] = [
|
||||
{ key: 'location', label: '库位', minWidth: 130 },
|
||||
{ key: 'status', label: '库存状态', width: 120 },
|
||||
{ key: 'palletNo', label: '托盘号', minWidth: 150 },
|
||||
{ key: 'cartons', label: '箱数', width: 90, align: 'right' },
|
||||
{ key: 'quantity', label: '库存数量', width: 110, align: 'right' },
|
||||
{ key: 'available', label: '可用数量', width: 110, align: 'right' },
|
||||
{ key: 'inboundDate', label: '入库日期', width: 130 },
|
||||
{ key: 'ageDays', label: '库龄(天)', width: 100, align: 'right' },
|
||||
]
|
||||
const palletColumns: DataTableColumn[] = [
|
||||
{ key: 'palletNo', label: '托盘号', minWidth: 150 },
|
||||
{ key: 'cartons', label: '关联箱数', width: 110, align: 'right' },
|
||||
{ key: 'cartonRange', label: '箱号范围', minWidth: 200 },
|
||||
{ key: 'location', label: '库位', minWidth: 130 },
|
||||
{ key: 'quantity', label: '库存数量', width: 110, align: 'right' },
|
||||
]
|
||||
const ledgerColumns: DataTableColumn[] = [
|
||||
{ key: 'occurredAt', label: '发生时间', minWidth: 160 },
|
||||
{ key: 'businessType', label: '业务类型', width: 120 },
|
||||
{ key: 'businessOrderNo', label: '业务订单号', minWidth: 170 },
|
||||
{ key: 'inboundOrderNo', label: '入库单号', minWidth: 160 },
|
||||
{ key: 'outboundOrderNo', label: '出库单号', minWidth: 160 },
|
||||
{ key: 'location', label: '库位', minWidth: 120 },
|
||||
{ key: 'inboundQuantity', label: '入库数量', width: 110, align: 'right' },
|
||||
{ key: 'outboundQuantity', label: '出库数量', width: 110, align: 'right' },
|
||||
{ key: 'balance', label: '结存', width: 100, align: 'right' },
|
||||
{ key: 'operator', label: '操作人', minWidth: 120 },
|
||||
{ key: 'remark', label: '备注', minWidth: 180 },
|
||||
]
|
||||
|
||||
function back() {
|
||||
void router.push({ name: 'overseas-inventory' })
|
||||
}
|
||||
const route = useRoute(); const router = useRouter(); const inventoryId = computed(() => String(route.params.inventoryId || '')); const customerId = computed(() => String(route.query.customerId || ''))
|
||||
const balance = ref<InventoryBalanceDto | null>(null); const positions = ref<InventoryPositionDto[]>([]); const ledger = ref<InventoryLedgerDto[]>([]); const loading = ref(false); const loadError = ref<ApiErrorDetails | null>(null)
|
||||
function text(value: unknown) { return String(value ?? '').trim() || '—' }
|
||||
const productValues = computed(() => ({ 客户: text(balance.value?.customerName), 海外仓: text(balance.value?.overseasWarehouseName), SKU编码: text(balance.value?.skuCode), 中文品名: text(balance.value?.productNameZh), 英文品名: text(balance.value?.productNameEn), 更新时间: formatOverseasDateTime(balance.value?.updatedAt) }))
|
||||
const stockValues = computed(() => ({ 在库总量: Number(balance.value?.totalPackages || 0), 可用库存: Number(balance.value?.availablePackages || 0), 暂扣库存: Number(balance.value?.holdPackages || 0), 美本土散版: '—', 在途库存: Number(balance.value?.inTransitPackages || 0), 采购库存: Number(balance.value?.procurementPackages || 0), 预计总量: Number(balance.value?.totalPackages || 0) + Number(balance.value?.inTransitPackages || 0) + Number(balance.value?.procurementPackages || 0) }))
|
||||
const productFields = ['客户', '海外仓', 'SKU编码', '中文品名', '英文品名', '更新时间']; const stockFields = ['在库总量', '可用库存', '暂扣库存', '美本土散版', '在途库存', '采购库存', '预计总量']
|
||||
const locationRows = computed(() => positions.value.map((item) => ({ id: String(item.id), location: text(item.warehouseLocationCode), status: text(item.status), palletNo: text(item.palletNo), cartons: Number(item.totalPackages || 0), quantity: Number(item.totalPackages || 0), available: Number(item.availablePackages || 0), inboundDate: formatOverseasDateTime(item.updatedAt), ageDays: '—' })))
|
||||
const palletRows = computed(() => positions.value.map((item) => ({ id: String(item.id), palletNo: text(item.palletNo), cartons: Number(item.totalPackages || 0), cartonRange: '—', location: text(item.warehouseLocationCode), quantity: Number(item.totalPackages || 0) })))
|
||||
const ledgerRows = computed(() => ledger.value.map((item) => ({ id: String(item.id), occurredAt: formatOverseasDateTime(item.occurredAt), businessType: text(item.businessType), businessOrderNo: text(item.businessOrderNo), inboundOrderNo: text(item.inboundOrderNo), outboundOrderNo: text(item.outboundOrderNo), location: text(item.warehouseLocationCode), inboundQuantity: item.deltaPackages > 0 ? item.deltaPackages : 0, outboundQuantity: item.deltaPackages < 0 ? Math.abs(item.deltaPackages) : 0, balance: Number(item.afterPackages || 0), operator: text(item.operatorId), remark: text(item.remark) })))
|
||||
const locationColumns: DataTableColumn[] = [{ key: 'location', prop: 'location', label: '库位', minWidth: 130 }, { key: 'status', prop: 'status', label: '库存状态', width: 120 }, { key: 'palletNo', prop: 'palletNo', label: '托盘号', minWidth: 150 }, { key: 'cartons', prop: 'cartons', label: '箱数', width: 90, align: 'right' }, { key: 'quantity', prop: 'quantity', label: '库存数量', width: 110, align: 'right' }, { key: 'available', prop: 'available', label: '可用数量', width: 110, align: 'right' }, { key: 'inboundDate', prop: 'inboundDate', label: '最近更新', width: 160 }, { key: 'ageDays', prop: 'ageDays', label: '库龄(天)', width: 100, align: 'right' }]
|
||||
const palletColumns: DataTableColumn[] = [{ key: 'palletNo', prop: 'palletNo', label: '托盘号', minWidth: 150 }, { key: 'cartons', prop: 'cartons', label: '关联箱数', width: 110, align: 'right' }, { key: 'cartonRange', prop: 'cartonRange', label: '箱号范围', minWidth: 200 }, { key: 'location', prop: 'location', label: '库位', minWidth: 130 }, { key: 'quantity', prop: 'quantity', label: '库存数量', width: 110, align: 'right' }]
|
||||
const ledgerColumns: DataTableColumn[] = [{ key: 'occurredAt', prop: 'occurredAt', label: '发生时间', minWidth: 160 }, { key: 'businessType', prop: 'businessType', label: '业务类型', width: 120 }, { key: 'businessOrderNo', prop: 'businessOrderNo', label: '业务订单号', minWidth: 170 }, { key: 'inboundOrderNo', prop: 'inboundOrderNo', label: '入库单号', minWidth: 160 }, { key: 'outboundOrderNo', prop: 'outboundOrderNo', label: '出库单号', minWidth: 160 }, { key: 'location', prop: 'location', label: '库位', minWidth: 120 }, { key: 'inboundQuantity', prop: 'inboundQuantity', label: '入库数量', width: 110, align: 'right' }, { key: 'outboundQuantity', prop: 'outboundQuantity', label: '出库数量', width: 110, align: 'right' }, { key: 'balance', prop: 'balance', label: '结存', width: 100, align: 'right' }, { key: 'operator', prop: 'operator', label: '操作人', minWidth: 120 }, { key: 'remark', prop: 'remark', label: '备注', minWidth: 180 }]
|
||||
async function load() { if (!inventoryId.value || !customerId.value) { loadError.value = { message: '详情缺少客户范围,请从库存列表重新进入' }; return }; loading.value = true; loadError.value = null; try { const inboundOrderNo = String(route.query.inboundOrderNo || ''); const inboundCode = String(route.query.inboundCode || ''); const skuCode = String(route.query.skuCode || ''); const overseasWarehouseId = String(route.query.overseasWarehouseId || ''); const result = await listInventoryBalances({ customerId: customerId.value, page: 1, size: 200, inboundOrderNo: inboundOrderNo || undefined, inboundCode: inboundCode || undefined, skuCode: skuCode || undefined, overseasWarehouseId: overseasWarehouseId || undefined }); balance.value = result.records.find((item) => String(item.id) === inventoryId.value) || null; if (!balance.value) throw new Error('Inventory balance not found'); const [positionRows, ledgerPage] = await Promise.all([listInventoryPositions(inventoryId.value, customerId.value), listInventoryLedger({ customerId: customerId.value, page: 1, size: 200, inboundOrderNo: balance.value.inboundOrderNo || undefined, inboundCode: balance.value.inboundCode || undefined })]); positions.value = positionRows; ledger.value = ledgerPage.records } catch (error) { loadError.value = getApiErrorDetails(error, '库存详情加载失败,请重试') } finally { loading.value = false } }
|
||||
function back() { void router.push({ name: 'overseas-inventory' }) }; onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OverseasDetailShell title="库存详情" reference-label="库存记录" :reference-value="inventoryId" back-label="返回库存管理" domain="库存详情" @back="back">
|
||||
<section class="overview-panel" aria-label="库存概览">
|
||||
<section aria-labelledby="inventory-product-heading">
|
||||
<h2 id="inventory-product-heading">商品信息</h2>
|
||||
<dl class="detail-fields"><div v-for="label in productFields" :key="label"><dt>{{ label }}</dt><dd>—</dd></div></dl>
|
||||
</section>
|
||||
<section aria-labelledby="inventory-summary-heading">
|
||||
<h2 id="inventory-summary-heading">库存状态汇总</h2>
|
||||
<dl class="detail-fields"><div v-for="label in stockFields" :key="label"><dt>{{ label }}</dt><dd>—</dd></div></dl>
|
||||
</section>
|
||||
</section>
|
||||
<section class="ledger-section" aria-labelledby="inventory-location-heading">
|
||||
<h2 id="inventory-location-heading">库位库存</h2>
|
||||
<DataTable :data="[]" :columns="locationColumns" column-settings-mode="hide" empty-text="海外区域库位库存契约待接入" />
|
||||
</section>
|
||||
<section class="ledger-section" aria-labelledby="inventory-pallet-heading">
|
||||
<h2 id="inventory-pallet-heading">托盘箱号信息</h2>
|
||||
<DataTable :data="[]" :columns="palletColumns" column-settings-mode="hide" empty-text="海外区域托盘箱号契约待接入" />
|
||||
</section>
|
||||
<section class="ledger-section" aria-labelledby="inventory-ledger-heading">
|
||||
<h2 id="inventory-ledger-heading">进销存单据明细</h2>
|
||||
<DataTable :data="[]" :columns="ledgerColumns" column-settings-mode="hide" empty-text="海外区域进销存契约待接入" />
|
||||
</section>
|
||||
</OverseasDetailShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.overview-panel { display: grid; min-width: 0; grid-template-columns: repeat(2, minmax(0, 1fr)); border: 1px solid var(--cc-border); background: #fff; }
|
||||
.overview-panel > section { min-width: 0; padding: 16px 18px 18px; border-right: 1px solid var(--cc-border); }
|
||||
.overview-panel > section:last-child { border-right: 0; }
|
||||
.overview-panel h2, .ledger-section h2 { margin: 0 0 12px; color: var(--cc-text); font-size: 15px; }
|
||||
.detail-fields { margin: 0; border-top: 1px solid var(--cc-border); border-left: 1px solid var(--cc-border); }
|
||||
.detail-fields > div { display: grid; min-width: 0; min-height: 46px; grid-template-columns: 120px minmax(0, 1fr); border-right: 1px solid var(--cc-border); border-bottom: 1px solid var(--cc-border); }
|
||||
.detail-fields dt, .detail-fields dd { display: flex; min-width: 0; align-items: center; padding: 8px 10px; }
|
||||
.detail-fields dt { background: #f8fafc; color: var(--cc-muted); font-size: 12px; }
|
||||
.detail-fields dd { margin: 0; color: var(--cc-text); }
|
||||
.ledger-section { min-width: 0; overflow: hidden; padding-top: 16px; border: 1px solid var(--cc-border); background: #fff; }
|
||||
.ledger-section h2 { margin-right: 18px; margin-left: 18px; }
|
||||
</style>
|
||||
<template><OverseasDetailShell title="库存详情" reference-label="库存记录" :reference-value="inventoryId" back-label="返回库存管理" domain="库存详情" :pending="false" :status-text="balance ? '库存事实' : '加载中'" @back="back"><LoadErrorState v-if="loadError" title="库存详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
||||
<template v-else><section v-loading="loading" class="overview-panel" aria-label="库存概览"><section aria-labelledby="inventory-product-heading"><h2 id="inventory-product-heading">商品信息</h2><dl class="detail-fields"><div v-for="label in productFields" :key="label"><dt>{{ label }}</dt><dd>{{ productValues[label as keyof typeof productValues] }}</dd></div></dl></section><section aria-labelledby="inventory-summary-heading"><h2 id="inventory-summary-heading">库存状态汇总</h2><dl class="detail-fields"><div v-for="label in stockFields" :key="label"><dt>{{ label }}</dt><dd>{{ stockValues[label as keyof typeof stockValues] }}</dd></div></dl></section></section>
|
||||
<section class="ledger-section" aria-labelledby="inventory-location-heading"><h2 id="inventory-location-heading">库位库存</h2><DataTable :data="locationRows" :columns="locationColumns" column-settings-mode="hide" row-key="id" empty-text="暂无库位库存" /></section><section class="ledger-section" aria-labelledby="inventory-pallet-heading"><h2 id="inventory-pallet-heading">托盘箱号信息</h2><DataTable :data="palletRows" :columns="palletColumns" column-settings-mode="hide" row-key="id" empty-text="暂无托盘库存" /></section><section class="ledger-section" aria-labelledby="inventory-ledger-heading"><h2 id="inventory-ledger-heading">进销存单据明细</h2><p class="ledger-note">当前流水接口未返回 SKU,按该库存记录的入库单范围展示,出库字段仅为只读事实。</p><DataTable :data="ledgerRows" :columns="ledgerColumns" column-settings-mode="hide" row-key="id" empty-text="暂无进销存明细" /></section></template>
|
||||
</OverseasDetailShell></template>
|
||||
<style scoped>.overview-panel{display:grid;min-width:0;grid-template-columns:repeat(2,minmax(0,1fr));border:1px solid var(--cc-border);background:#fff}.overview-panel>section{min-width:0;padding:16px 18px 18px;border-right:1px solid var(--cc-border)}.overview-panel>section:last-child{border-right:0}.overview-panel h2,.ledger-section h2{margin:0 0 12px;color:var(--cc-text);font-size:15px}.detail-fields{margin:0;border-top:1px solid var(--cc-border);border-left:1px solid var(--cc-border)}.detail-fields>div{display:grid;min-width:0;min-height:46px;grid-template-columns:120px minmax(0,1fr);border-right:1px solid var(--cc-border);border-bottom:1px solid var(--cc-border)}.detail-fields dt,.detail-fields dd{display:flex;min-width:0;align-items:center;padding:8px 10px}.detail-fields dt{background:#f8fafc;color:var(--cc-muted);font-size:12px}.detail-fields dd{margin:0;color:var(--cc-text)}.ledger-section{min-width:0;overflow:hidden;padding-top:16px;border:1px solid var(--cc-border);background:#fff}.ledger-section h2,.ledger-note{margin-right:18px;margin-left:18px}.ledger-note{color:var(--cc-muted);font-size:12px}</style>
|
||||
|
||||
@@ -1,60 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { ArrowLeft } from '@lucide/vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import ContractPendingNotice from '../components/ContractPendingNotice.vue'
|
||||
import type { InventoryTransactionListItem } from '../types'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'; import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'; import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'; import ListPageLayout from '@/shared/components/ListPageLayout.vue'; import LoadErrorState from '@/shared/components/LoadErrorState.vue'; import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import { listInventoryLedger } from '../api/inventory'; import { formatOverseasDateTime } from '../lastMilePresentation'; import type { InventoryTransactionListItem } from '../types'
|
||||
|
||||
const router = useRouter()
|
||||
const rows = ref<InventoryTransactionListItem[]>([])
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const filters = ref<QueryModel>({ occurredDate: [], owner: '', warehouse: '', sku: '', businessType: '', businessOrderNo: '', inboundOrderNo: '', outboundOrderNo: '' })
|
||||
const fields: QueryField[] = [
|
||||
{ key: 'occurredDate', type: 'daterange', label: '发生日期', width: 260 },
|
||||
{ key: 'owner', type: 'input', label: '客户/货主', placeholder: '请输入客户或货主', width: 190 },
|
||||
{ key: 'warehouse', type: 'select', label: '海外仓', placeholder: '全部海外仓', width: 190, options: [] },
|
||||
{ key: 'sku', type: 'input', label: 'SKU编码', placeholder: '请输入SKU编码', width: 170 },
|
||||
{ key: 'businessType', type: 'select', label: '业务类型', placeholder: '全部业务类型', width: 170, options: ['采购库存', '头程入库', '尾程入库', '退货入库', '暂扣库存', '一件代发', '头程出库', '派送出库', '调拨转仓', '库存调整'].map((value) => ({ label: value, value })) },
|
||||
{ key: 'businessOrderNo', type: 'input', label: '业务订单号', placeholder: '请输入业务订单号', width: 180 },
|
||||
{ key: 'inboundOrderNo', type: 'input', label: '入库单号', placeholder: '请输入入库单号', width: 180 },
|
||||
{ key: 'outboundOrderNo', type: 'input', label: '出库单号', placeholder: '请输入出库单号', width: 180 },
|
||||
]
|
||||
const route = useRoute(); const router = useRouter(); const rows = ref<InventoryTransactionListItem[]>([]); const customers = ref<PlatformTenantResource[]>([]); const page = ref(1); const pageSize = ref(20); const total = ref(0); const loading = ref(false); const customersLoading = ref(false); const loadError = ref<ApiErrorDetails | null>(null); const customersError = ref<ApiErrorDetails | null>(null)
|
||||
const filters = ref<QueryModel>({ occurredDate: [], customerId: String(route.query.customerId || ''), warehouse: '', sku: '', businessType: '', businessOrderNo: '', inboundOrderNo: '', outboundOrderNo: '' })
|
||||
const fields = computed<QueryField[]>(() => [
|
||||
{ key: 'occurredDate', type: 'daterange', label: '发生日期', width: 260 }, { key: 'customerId', type: 'select', label: '客户名称', placeholder: '请选择客户', width: 240, props: { filterable: true, clearable: true, loading: customersLoading.value }, options: customers.value.map((item) => ({ label: item.tenantName, value: String(item.id) })) },
|
||||
{ key: 'warehouse', type: 'input', label: '目的仓代码', placeholder: '请输入目的仓代码', width: 190 }, { key: 'sku', type: 'input', label: 'SKU编码', placeholder: '流水DTO暂未提供', width: 170, props: { disabled: true } },
|
||||
{ key: 'businessType', type: 'input', label: '业务类型', placeholder: '请输入业务类型代码', width: 170 }, { key: 'businessOrderNo', type: 'input', label: '业务订单号', placeholder: '请输入业务订单号', width: 180 },
|
||||
{ key: 'inboundOrderNo', type: 'input', label: '入库单号', placeholder: '请输入入库单号', width: 180 }, { key: 'outboundOrderNo', type: 'input', label: '出库单号', placeholder: '只读关联筛选', width: 180 },
|
||||
])
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'occurredAt', label: '发生时间', minWidth: 160 },
|
||||
{ key: 'owner', label: '客户', minWidth: 160 },
|
||||
{ key: 'warehouse', label: '海外仓', minWidth: 180 },
|
||||
{ key: 'sku', label: 'SKU编码', minWidth: 150 },
|
||||
{ key: 'productName', label: '中文品名', minWidth: 180 },
|
||||
{ key: 'businessType', label: '业务类型', width: 120 },
|
||||
{ key: 'businessOrderNo', label: '业务订单号', minWidth: 170 },
|
||||
{ key: 'inboundOrderNo', label: '入库单号', minWidth: 160 },
|
||||
{ key: 'outboundOrderNo', label: '出库单号', minWidth: 160 },
|
||||
{ key: 'inboundQuantity', label: '入库数量', width: 110, align: 'right' },
|
||||
{ key: 'outboundQuantity', label: '出库数量', width: 110, align: 'right' },
|
||||
{ key: 'balance', label: '结存', width: 100, align: 'right' },
|
||||
{ key: 'operator', label: '操作人', minWidth: 120 },
|
||||
{ key: 'remark', label: '备注', minWidth: 180 },
|
||||
{ key: 'occurredAt', prop: 'occurredAt', label: '发生时间', minWidth: 160 }, { key: 'owner', prop: 'owner', label: '客户', minWidth: 160 }, { key: 'warehouse', prop: 'warehouse', label: '海外仓', minWidth: 180 },
|
||||
{ key: 'sku', prop: 'sku', label: 'SKU编码', minWidth: 150 }, { key: 'productName', prop: 'productName', label: '中文品名', minWidth: 180 }, { key: 'businessType', prop: 'businessType', label: '业务类型', width: 120 },
|
||||
{ key: 'businessOrderNo', prop: 'businessOrderNo', label: '业务订单号', minWidth: 170 }, { key: 'inboundOrderNo', prop: 'inboundOrderNo', label: '入库单号', minWidth: 160 }, { key: 'outboundOrderNo', prop: 'outboundOrderNo', label: '出库单号', minWidth: 160 },
|
||||
{ key: 'inboundQuantity', prop: 'inboundQuantity', label: '入库数量', width: 110, align: 'right' }, { key: 'outboundQuantity', prop: 'outboundQuantity', label: '出库数量', width: 110, align: 'right' }, { key: 'balance', prop: 'balance', label: '结存', width: 100, align: 'right' },
|
||||
{ key: 'operator', prop: 'operator', label: '操作人', minWidth: 120 }, { key: 'remark', prop: 'remark', label: '备注', minWidth: 180 },
|
||||
]
|
||||
|
||||
function back() { void router.push({ name: 'overseas-inventory' }) }
|
||||
function text(value: unknown) { return String(value ?? '').trim() || '—' }
|
||||
function customerName(id: string) { return customers.value.find((item) => String(item.id) === id)?.tenantName || '—' }
|
||||
async function loadCustomers() { customersLoading.value = true; customersError.value = null; try { const records: PlatformTenantResource[] = []; let current = 1; let pages = 1; do { const result = await listPlatformTenants({ page: current, size: 200, status: 'ACTIVE' }); records.push(...result.records); pages = Math.max(1, result.pages); current += 1 } while (current <= pages); customers.value = records; if (filters.value.customerId) void load() } catch (error) { customersError.value = getApiErrorDetails(error, '客户列表加载失败,请重试') } finally { customersLoading.value = false } }
|
||||
async function load(model: QueryModel = filters.value) { Object.assign(filters.value, model); const customerId = String(filters.value.customerId || '').trim(); if (!customerId) { rows.value = []; total.value = 0; loadError.value = null; return }; const range = Array.isArray(filters.value.occurredDate) ? filters.value.occurredDate.map(String) : []; loading.value = true; loadError.value = null; try { const result = await listInventoryLedger({ customerId, page: page.value, size: pageSize.value, destinationWarehouseCode: String(filters.value.warehouse || '').trim() || undefined, businessType: String(filters.value.businessType || '').trim() || undefined, businessOrderNo: String(filters.value.businessOrderNo || '').trim() || undefined, inboundOrderNo: String(filters.value.inboundOrderNo || '').trim() || undefined, outboundOrderNo: String(filters.value.outboundOrderNo || '').trim() || undefined, occurredFrom: range[0] ? `${range[0]}T00:00:00` : undefined, occurredTo: range[1] ? `${range[1]}T23:59:59` : undefined }); rows.value = result.records.map((item) => ({ id: String(item.id), occurredAt: formatOverseasDateTime(item.occurredAt), owner: customerName(customerId), warehouse: text(item.destinationWarehouseCode), sku: '—', productName: '—', businessType: text(item.businessType), businessOrderNo: text(item.businessOrderNo), inboundOrderNo: text(item.inboundOrderNo), outboundOrderNo: text(item.outboundOrderNo), inboundQuantity: item.deltaPackages > 0 ? item.deltaPackages : 0, outboundQuantity: item.deltaPackages < 0 ? Math.abs(item.deltaPackages) : 0, balance: Number(item.afterPackages || 0), operator: text(item.operatorId), remark: text(item.remark) })); total.value = result.total } catch (error) { rows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, '进销存记录加载失败,请重试') } finally { loading.value = false } }
|
||||
function search(model: QueryModel) { page.value = 1; void load(model) }; function reset(model: QueryModel) { page.value = 1; void load(model) }; function back() { void router.push({ name: 'overseas-inventory' }) }; onMounted(loadCustomers)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage><ListPageLayout aria-label="进销存记录列表">
|
||||
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" ariaLabel="进销存记录筛选" preference-key="overseas-inventory-transactions-query" @search="page = 1" /></template>
|
||||
<template #state><ContractPendingNotice domain="进销存记录" /><p class="read-only-boundary">出库相关字段仅展示既有库存流水事实;本页面不提供出库单跳转、库存扣减或其他离仓操作。</p></template>
|
||||
<section class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="0" preference-key="overseas-inventory-transactions" row-key="id" empty-text="海外区域进销存查询契约待接入">
|
||||
<template #toolbar-actions><el-button @click="back"><ArrowLeft :size="16" />返回库存管理</el-button></template>
|
||||
<template #pagination-left>共 0 条进销存记录</template>
|
||||
</DataTable></section>
|
||||
</ListPageLayout></AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.read-only-boundary { margin: 8px 2px 0; color: var(--cc-muted); font-size: 12px; line-height: 1.5; }
|
||||
</style>
|
||||
<template><AdminPage><ListPageLayout aria-label="进销存记录列表"><template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :loading="loading" ariaLabel="进销存记录筛选" preference-key="overseas-inventory-transactions-query" @search="search" @reset="reset" /></template>
|
||||
<template v-if="customersError || loadError" #state><LoadErrorState title="进销存记录加载失败" :message="(customersError || loadError)?.message" :loading="customersLoading || loading" @retry="customersError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="(customersError || loadError)!" /></template></LoadErrorState></template>
|
||||
<template v-else #state><p class="read-only-boundary">出库相关字段仅展示既有库存流水事实;本页面不提供出库单跳转、库存扣减或其他离仓操作。当前流水 DTO 未提供 SKU、产品名称与海外仓名称,对应列显示空值。</p></template>
|
||||
<section v-if="!customersError && !loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-inventory-transactions" row-key="id" :empty-text="filters.customerId ? '暂无进销存记录' : '请先选择客户后查询'" @update:page="load()" @update:page-size="page = 1; load()"><template #toolbar-actions><el-button @click="back"><ArrowLeft :size="16" />返回库存管理</el-button></template><template #pagination-left>共 {{ total }} 条进销存记录</template></DataTable></section>
|
||||
</ListPageLayout></AdminPage></template>
|
||||
<style scoped>.read-only-boundary{margin:8px 2px 0;color:var(--cc-muted);font-size:12px;line-height:1.5}</style>
|
||||
|
||||
@@ -1,75 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { BookOpen, Eye } from '@lucide/vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import ContractPendingNotice from '../components/ContractPendingNotice.vue'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'; import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'; import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'; import ListPageLayout from '@/shared/components/ListPageLayout.vue'; import LoadErrorState from '@/shared/components/LoadErrorState.vue'; import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import { getInventorySummary, listInventoryBalances, type InventoryDashboardDto } from '../api/inventory'
|
||||
import { formatOverseasDateTime } from '../lastMilePresentation'
|
||||
import type { InventoryListItem } from '../types'
|
||||
|
||||
const rows = ref<InventoryListItem[]>([])
|
||||
const router = useRouter()
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const filters = ref<QueryModel>({ warehouse: '', owner: '', sku: '', productName: '', batchNo: '', location: '', status: '' })
|
||||
const fields: QueryField[] = [
|
||||
{ key: 'warehouse', type: 'select', label: '海外仓', placeholder: '全部海外仓', options: [] },
|
||||
{ key: 'owner', type: 'input', label: '客户/货主', placeholder: '请输入客户或货主' },
|
||||
{ key: 'sku', type: 'input', label: 'SKU编码', placeholder: '请输入SKU编码' },
|
||||
{ key: 'productName', type: 'input', label: '中文品名', placeholder: '请输入中文品名' },
|
||||
{ key: 'batchNo', type: 'input', label: '批次号', placeholder: '请输入批次号' },
|
||||
{ key: 'location', type: 'input', label: '库位', placeholder: '请输入库位' },
|
||||
{ key: 'status', type: 'select', label: '库存状态', placeholder: '全部库存状态', options: ['正常', '库存不足', '无可用库存', '存在暂扣', '存在美本土散版'].map((value) => ({ label: value, value })) },
|
||||
]
|
||||
const metrics = [
|
||||
['在库总量', 0, 'primary'], ['可用库存', 0, 'success'], ['暂扣库存', 0, 'danger'],
|
||||
['美本土散版', 0, 'neutral'], ['在途库存', 0, 'warning'], ['采购库存', 0, 'neutral'],
|
||||
] as const
|
||||
const rows = ref<InventoryListItem[]>([]); const customers = ref<PlatformTenantResource[]>([]); const router = useRouter(); const page = ref(1); const pageSize = ref(20); const total = ref(0)
|
||||
const loading = ref(false); const customersLoading = ref(false); const loadError = ref<ApiErrorDetails | null>(null); const customersError = ref<ApiErrorDetails | null>(null); const summary = ref<InventoryDashboardDto | null>(null)
|
||||
const filters = ref<QueryModel>({ customerId: '', warehouse: '', owner: '', sku: '', productName: '', batchNo: '', location: '', status: '' })
|
||||
const warehouseOptions = computed(() => { const values = new Map<string, string>(); for (const row of rows.value) if (row.warehouseId && row.warehouse !== '—') values.set(row.warehouseId, row.warehouse); return [...values].map(([value, label]) => ({ label, value })) })
|
||||
const fields = computed<QueryField[]>(() => [
|
||||
{ key: 'customerId', type: 'select', label: '客户名称', placeholder: '请选择客户', width: 240, props: { filterable: true, clearable: true, loading: customersLoading.value }, options: customers.value.map((item) => ({ label: item.tenantName, value: String(item.id) })) },
|
||||
{ key: 'warehouse', type: 'select', label: '海外仓', placeholder: '全部海外仓', options: warehouseOptions.value }, { key: 'owner', type: 'input', label: '客户/货主', placeholder: '请输入客户或货主' },
|
||||
{ key: 'sku', type: 'input', label: 'SKU编码', placeholder: '请输入SKU编码' }, { key: 'productName', type: 'input', label: '中文品名', placeholder: '请输入中文品名' },
|
||||
{ key: 'batchNo', type: 'input', label: '批次号', placeholder: '详情接口支持', props: { disabled: true } }, { key: 'location', type: 'input', label: '库位', placeholder: '详情接口支持', props: { disabled: true } },
|
||||
{ key: 'status', type: 'select', label: '库存状态', placeholder: '全部库存状态', options: [{ label: '可用', value: 'AVAILABLE' }, { label: '暂扣', value: 'HOLD' }, { label: '已分配', value: 'ALLOCATED' }, { label: '已拣货', value: 'PICKED' }, { label: '在途', value: 'IN_TRANSIT' }, { label: '收货中', value: 'RECEIVING' }, { label: '采购库存', value: 'PROCUREMENT' }] },
|
||||
])
|
||||
const metrics = computed(() => [['在库总量', summary.value?.totalPackages ?? 0, 'primary'], ['可用库存', summary.value?.availablePackages ?? 0, 'success'], ['暂扣库存', summary.value?.holdPackages ?? 0, 'danger'], ['美本土散版', '—', 'neutral'], ['在途库存', summary.value?.inTransitPackages ?? 0, 'warning'], ['采购库存', summary.value?.procurementPackages ?? 0, 'neutral']] as const)
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'owner', prop: 'owner', label: '客户', minWidth: 160 },
|
||||
{ key: 'warehouse', prop: 'warehouse', label: '海外仓', minWidth: 170 },
|
||||
{ key: 'sku', prop: 'sku', label: 'SKU编码', minWidth: 150 },
|
||||
{ key: 'productName', prop: 'productName', label: '中文品名', minWidth: 180 },
|
||||
{ key: 'onHand', prop: 'onHand', label: '在库总量', width: 105, align: 'right' },
|
||||
{ key: 'available', prop: 'available', label: '可用库存', width: 105, align: 'right' },
|
||||
{ key: 'held', prop: 'held', label: '暂扣库存', width: 105, align: 'right' },
|
||||
{ key: 'looseboard', prop: 'looseboard', label: '美本土散版', width: 115, align: 'right' },
|
||||
{ key: 'inTransit', prop: 'inTransit', label: '在途库存', width: 105, align: 'right' },
|
||||
{ key: 'purchase', prop: 'purchase', label: '采购库存', width: 105, align: 'right' },
|
||||
{ key: 'status', prop: 'status', label: '库存状态', width: 110 },
|
||||
{ key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' }, { key: 'owner', prop: 'owner', label: '客户', minWidth: 160 }, { key: 'warehouse', prop: 'warehouse', label: '海外仓', minWidth: 170 },
|
||||
{ key: 'sku', prop: 'sku', label: 'SKU编码', minWidth: 150 }, { key: 'productName', prop: 'productName', label: '中文品名', minWidth: 180 }, { key: 'onHand', prop: 'onHand', label: '在库总量', width: 105, align: 'right' },
|
||||
{ key: 'available', prop: 'available', label: '可用库存', width: 105, align: 'right' }, { key: 'held', prop: 'held', label: '暂扣库存', width: 105, align: 'right' }, { key: 'looseboard', prop: 'looseboard', label: '美本土散版', width: 115, align: 'right' },
|
||||
{ key: 'inTransit', prop: 'inTransit', label: '在途库存', width: 105, align: 'right' }, { key: 'purchase', prop: 'purchase', label: '采购库存', width: 105, align: 'right' }, { key: 'status', prop: 'status', label: '库存状态', width: 110 },
|
||||
{ key: 'updatedAt', prop: 'updatedAt', label: '更新时间', minWidth: 150 },
|
||||
]
|
||||
|
||||
function openDetail(source: object) {
|
||||
const row = source as InventoryListItem
|
||||
void router.push({ name: 'overseas-inventory-detail', params: { inventoryId: row.id } })
|
||||
}
|
||||
function text(value: unknown) { return String(value ?? '').trim() || '—' }
|
||||
function inventoryStatus(row: { holdPackages: number; availablePackages: number; totalPackages: number }) { if (row.holdPackages > 0) return '存在暂扣'; if (row.availablePackages <= 0) return '无可用库存'; return row.totalPackages > 0 ? '正常' : '库存不足' }
|
||||
async function loadCustomers() { customersLoading.value = true; customersError.value = null; try { const records: PlatformTenantResource[] = []; let current = 1; let pages = 1; do { const result = await listPlatformTenants({ page: current, size: 200, status: 'ACTIVE' }); records.push(...result.records); pages = Math.max(1, result.pages); current += 1 } while (current <= pages); customers.value = records } catch (error) { customersError.value = getApiErrorDetails(error, '客户列表加载失败,请重试') } finally { customersLoading.value = false } }
|
||||
function query() { return { customerId: String(filters.value.customerId || '').trim(), customerName: String(filters.value.owner || '').trim() || undefined, overseasWarehouseId: String(filters.value.warehouse || '').trim() || undefined, skuCode: String(filters.value.sku || '').trim() || undefined, productName: String(filters.value.productName || '').trim() || undefined, inventoryStatus: String(filters.value.status || '').trim() || undefined } }
|
||||
async function load(model: QueryModel = filters.value) { Object.assign(filters.value, model); const params = query(); if (!params.customerId) { rows.value = []; total.value = 0; summary.value = null; loadError.value = null; return }; loading.value = true; loadError.value = null; try { const [result, dashboard] = await Promise.all([listInventoryBalances({ ...params, page: page.value, size: pageSize.value }), getInventorySummary(params)]); rows.value = result.records.map((item) => ({ id: String(item.id), customerId: params.customerId, warehouseId: text(item.overseasWarehouseId) === '—' ? '' : String(item.overseasWarehouseId), inboundOrderNo: text(item.inboundOrderNo), inboundCode: text(item.inboundCode), owner: text(item.customerName), warehouse: text(item.overseasWarehouseName), sku: text(item.skuCode), productName: text(item.productNameZh), onHand: Number(item.totalPackages || 0), available: Number(item.availablePackages || 0), held: Number(item.holdPackages || 0), looseboard: null, inTransit: Number(item.inTransitPackages || 0), purchase: Number(item.procurementPackages || 0), status: inventoryStatus(item), updatedAt: formatOverseasDateTime(item.updatedAt) })); total.value = result.total; summary.value = dashboard } catch (error) { rows.value = []; total.value = 0; summary.value = null; loadError.value = getApiErrorDetails(error, '库存加载失败,请重试') } finally { loading.value = false } }
|
||||
function search(model: QueryModel) { page.value = 1; void load(model) }; function reset(model: QueryModel) { page.value = 1; void load(model) }
|
||||
function openDetail(source: object) { const row = source as InventoryListItem; void router.push({ name: 'overseas-inventory-detail', params: { inventoryId: row.id }, query: { customerId: row.customerId, inboundOrderNo: row.inboundOrderNo, inboundCode: row.inboundCode, skuCode: row.sku, overseasWarehouseId: row.warehouseId } }) }
|
||||
function openTransactions() { const customerId = String(filters.value.customerId || ''); void router.push({ name: 'overseas-inventory-transactions', query: customerId ? { customerId } : {} }) }
|
||||
onMounted(loadCustomers)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage><ListPageLayout aria-label="海外仓库存管理">
|
||||
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" ariaLabel="库存筛选" preference-key="overseas-inventory-query" @search="page = 1" /></template>
|
||||
<template #metrics><section class="inventory-metrics" aria-label="库存汇总"><div v-for="metric in metrics" :key="metric[0]" class="inventory-metric" :class="`is-${metric[2]}`"><span>{{ metric[0] }}</span><strong>{{ metric[1] }}</strong></div></section></template>
|
||||
<template #state><ContractPendingNotice domain="库存管理" /></template>
|
||||
<section class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="0" preference-key="overseas-inventory" row-key="id" empty-text="海外区域库存查询契约待接入">
|
||||
<template #toolbar-actions><el-button @click="router.push({ name: 'overseas-inventory-transactions' })"><BookOpen :size="16" />进销存记录</el-button></template>
|
||||
<template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button></template>
|
||||
<template #pagination-left>共 0 条库存记录</template>
|
||||
</DataTable></section>
|
||||
</ListPageLayout></AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.inventory-metrics { display: grid; grid-template-columns: repeat(6, minmax(130px, 1fr)); overflow: hidden; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }
|
||||
.inventory-metric { display: flex; min-width: 0; flex-direction: column; gap: 5px; padding: 13px 16px; border-right: 1px solid var(--cc-border); }
|
||||
.inventory-metric:last-child { border-right: 0; }
|
||||
.inventory-metric span { color: #64748b; font-size: 12px; }
|
||||
.inventory-metric strong { color: #334155; font-size: 22px; line-height: 1; }
|
||||
.inventory-metric.is-primary strong { color: var(--cc-primary); }
|
||||
.inventory-metric.is-success strong { color: #16a34a; }
|
||||
.inventory-metric.is-danger strong { color: #dc2626; }
|
||||
.inventory-metric.is-warning strong { color: #d97706; }
|
||||
</style>
|
||||
<template><AdminPage><ListPageLayout aria-label="海外仓库存管理"><template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :loading="loading" ariaLabel="库存筛选" preference-key="overseas-inventory-query" @search="search" @reset="reset" /></template>
|
||||
<template #metrics><section class="inventory-metrics" aria-label="库存汇总"><div v-for="metric in metrics" :key="metric[0]" class="inventory-metric" :class="`is-${metric[2]}`"><span>{{ metric[0] }}</span><strong>{{ metric[1] }}</strong></div></section></template>
|
||||
<template v-if="customersError || loadError" #state><LoadErrorState title="库存加载失败" :message="(customersError || loadError)?.message" :loading="customersLoading || loading" @retry="customersError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="(customersError || loadError)!" /></template></LoadErrorState></template>
|
||||
<section v-if="!customersError && !loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-inventory" row-key="id" :empty-text="filters.customerId ? '暂无库存记录' : '请先选择客户后查询'" @update:page="load()" @update:page-size="page = 1; load()"><template #toolbar-actions><el-button @click="openTransactions"><BookOpen :size="16" />进销存记录</el-button></template><template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button></template><template #pagination-left>共 {{ total }} 条库存记录</template></DataTable></section>
|
||||
</ListPageLayout></AdminPage></template>
|
||||
<style scoped>.inventory-metrics{display:grid;grid-template-columns:repeat(6,minmax(130px,1fr));overflow:hidden;border:1px solid var(--cc-border);border-radius:8px;background:#fff}.inventory-metric{display:flex;min-width:0;flex-direction:column;gap:5px;padding:13px 16px;border-right:1px solid var(--cc-border)}.inventory-metric:last-child{border-right:0}.inventory-metric span{color:#64748b;font-size:12px}.inventory-metric strong{color:#334155;font-size:22px;line-height:1}.inventory-metric.is-primary strong{color:var(--cc-primary)}.inventory-metric.is-success strong{color:#16a34a}.inventory-metric.is-danger strong{color:#dc2626}.inventory-metric.is-warning strong{color:#d97706}</style>
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import OverseasReadOnlyDetail from '../components/OverseasReadOnlyDetail.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const palletId = computed(() => String(route.params.palletId || '—'))
|
||||
const groups = [
|
||||
{ title: '基础信息', fields: ['托盘编码', '托盘类型', '仓库', '容积(m³)', '采购价格', '所属FBA仓', '状态'] },
|
||||
{ title: '使用信息', fields: ['关联入库单', '关联箱数', '创建时间', '更新时间', '备注'] },
|
||||
]
|
||||
function back() { void router.push({ name: 'overseas-pallets' }) }
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'; import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { getPallet, type PalletDto } from '../api/pallets'; import { formatOverseasDateTime } from '../lastMilePresentation'; import OverseasReadOnlyDetail from '../components/OverseasReadOnlyDetail.vue'
|
||||
const route = useRoute(); const router = useRouter(); const palletId = computed(() => String(route.params.palletId || '')); const warehouseId = computed(() => String(route.query.warehouseId || '')); const pallet = ref<PalletDto | null>(null); const loading = ref(false); const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const groups = [{ title: '基础信息', fields: ['托盘编码', '托盘类型', '仓库', '容积(m³)', '采购价格', '所属FBA仓', '状态'] }, { title: '使用信息', fields: ['关联入库单', '关联箱数', '创建时间', '更新时间', '备注'] }]
|
||||
function text(value: unknown) { return String(value ?? '').trim() || '—' }; function status(value: unknown) { const labels: Record<string, string> = { IDLE: '闲置', OCCUPIED: '占用', SCRAPPED: '已报废' }; return labels[String(value || '')] || text(value) }
|
||||
const values = computed<Record<string, string | number>>(() => ({ 托盘编码: text(pallet.value?.palletCode), 托盘类型: text(pallet.value?.palletType), 仓库: text(route.query.warehouseName || warehouseId.value), '容积(m³)': Number(pallet.value?.capacityCbm || 0), 采购价格: Number(pallet.value?.unitPrice || 0), 所属FBA仓: '—', 状态: status(pallet.value?.status), 关联入库单: '—', 关联箱数: '—', 创建时间: formatOverseasDateTime(pallet.value?.createdAt), 更新时间: formatOverseasDateTime(pallet.value?.updatedAt), 备注: text(pallet.value?.remark) }))
|
||||
async function load() { if (!warehouseId.value || !palletId.value) { loadError.value = { message: '详情缺少仓库范围,请从托盘列表重新进入' }; return }; loading.value = true; loadError.value = null; try { pallet.value = await getPallet(warehouseId.value, palletId.value) } catch (error) { loadError.value = getApiErrorDetails(error, '托盘详情加载失败,请重试') } finally { loading.value = false } }
|
||||
function back() { void router.push({ name: 'overseas-pallets' }) }; onMounted(load)
|
||||
</script>
|
||||
|
||||
<template><OverseasReadOnlyDetail title="托盘详情" reference-label="托盘编码" :reference-value="palletId" back-label="返回托盘管理" domain="托盘详情" :groups="groups" @back="back" /></template>
|
||||
<template><OverseasReadOnlyDetail title="托盘详情" reference-label="托盘编码" :reference-value="pallet?.palletCode || palletId" back-label="返回托盘管理" domain="托盘详情" :groups="groups" :field-values="values" :pending="false" :status-text="pallet ? status(pallet.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></OverseasReadOnlyDetail></template>
|
||||
|
||||
@@ -1,66 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { Eye, Pencil, Plus } from '@lucide/vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import ContractPendingNotice from '../components/ContractPendingNotice.vue'
|
||||
import ContractPendingFormDialog from '../components/ContractPendingFormDialog.vue'
|
||||
import { overseasWarehousePermissions as permission } from '../permissions'
|
||||
import type { PalletListItem } from '../types'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'; import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'; import AppDialog from '@/shared/components/AppDialog.vue'; import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'; import ListPageLayout from '@/shared/components/ListPageLayout.vue'; import LoadErrorState from '@/shared/components/LoadErrorState.vue'; import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import { createPallet, listOverseasWarehouses, listPallets, updatePallet, type OverseasWarehouseDto } from '../api/pallets'; import { overseasWarehousePermissions as permission } from '../permissions'; import type { PalletListItem } from '../types'
|
||||
|
||||
const access = useAccessPreviewStore()
|
||||
const router = useRouter()
|
||||
const rows = ref<PalletListItem[]>([])
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const access = useAccessPreviewStore(); const router = useRouter(); const rows = ref<PalletListItem[]>([]); const warehouses = ref<OverseasWarehouseDto[]>([]); const page = ref(1); const pageSize = ref(20); const total = ref(0); const loading = ref(false); const warehousesLoading = ref(false); const loadError = ref<ApiErrorDetails | null>(null); const warehousesError = ref<ApiErrorDetails | null>(null)
|
||||
const filters = ref<QueryModel>({ code: '', type: '', warehouse: '', status: '' })
|
||||
const fields: QueryField[] = [
|
||||
{ key: 'code', type: 'input', label: '托盘编码', placeholder: '请输入托盘编码', width: 180 },
|
||||
{ key: 'type', type: 'select', label: '托盘类型', placeholder: '全部类型', width: 160, options: ['木托盘', '塑料托盘', '金属托盘', '纸托盘', '免熏蒸'].map((value) => ({ label: value, value })) },
|
||||
{ key: 'warehouse', type: 'select', label: '仓库', placeholder: '全部仓库', width: 190, options: [] },
|
||||
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 140, options: ['闲置', '占用'].map((value) => ({ label: value, value })) },
|
||||
]
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'code', label: '托盘编码', minWidth: 160 }, { key: 'type', label: '托盘类型', minWidth: 130 },
|
||||
{ key: 'warehouse', label: '仓库', minWidth: 180 }, { key: 'volume', label: '容积(m³)', width: 110, align: 'right' },
|
||||
{ key: 'purchasePrice', label: '采购价格', width: 120, align: 'right' }, { key: 'fbaWarehouse', label: '所属FBA仓', minWidth: 140 },
|
||||
{ key: 'status', label: '状态', width: 100 }, { key: 'remark', label: '备注', minWidth: 180 },
|
||||
]
|
||||
const dialogOpen = ref(false)
|
||||
const dialogMode = ref<'create' | 'edit'>('create')
|
||||
const form = reactive({ code: '', type: '木托盘', volume: undefined as number | undefined, purchasePrice: undefined as number | undefined, warehouse: '', fbaWarehouse: '', status: '闲置', remark: '' })
|
||||
|
||||
function optionalNumber(value: unknown) { const number = Number(value); return value === '' || value == null || !Number.isFinite(number) ? undefined : number }
|
||||
function openForm(mode: 'create' | 'edit', source?: object) { const row = source as PalletListItem | undefined; dialogMode.value = mode; Object.assign(form, { code: '', type: '木托盘', volume: undefined, purchasePrice: undefined, warehouse: '', fbaWarehouse: '', status: '闲置', remark: '' }); if (mode === 'edit' && row) Object.assign(form, { code: String(row.code || ''), type: String(row.type || '木托盘'), volume: optionalNumber(row.volume), purchasePrice: optionalNumber(row.purchasePrice), warehouse: String(row.warehouse || ''), fbaWarehouse: String(row.fbaWarehouse || ''), status: String(row.status || '闲置'), remark: String(row.remark || '') }); dialogOpen.value = true }
|
||||
function openDetail(source: object) { const row = source as PalletListItem; void router.push({ name: 'overseas-pallet-detail', params: { palletId: String(row.code) } }) }
|
||||
const warehouseOptions = computed(() => warehouses.value.map((item) => ({ label: item.warehouseName, value: String(item.id) })))
|
||||
const fields = computed<QueryField[]>(() => [{ key: 'code', type: 'input', label: '托盘编码', placeholder: '请输入托盘编码', width: 180 }, { key: 'type', type: 'select', label: '托盘类型', placeholder: '全部类型', width: 160, options: ['木托盘', '塑料托盘', '金属托盘', '纸托盘', '免熏蒸'].map((value) => ({ label: value, value })) }, { key: 'warehouse', type: 'select', label: '仓库', placeholder: '请选择仓库', width: 220, props: { filterable: true, loading: warehousesLoading.value }, options: warehouseOptions.value }, { key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 140, options: [{ label: '闲置', value: 'IDLE' }, { label: '占用', value: 'OCCUPIED' }, { label: '已报废', value: 'SCRAPPED' }] }])
|
||||
const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions' }, { key: 'code', prop: 'code', label: '托盘编码', minWidth: 160 }, { key: 'type', prop: 'type', label: '托盘类型', minWidth: 130 }, { key: 'warehouse', prop: 'warehouse', label: '仓库', minWidth: 180 }, { key: 'volume', prop: 'volume', label: '容积(m³)', width: 110, align: 'right' }, { key: 'purchasePrice', prop: 'purchasePrice', label: '采购价格', width: 120, align: 'right' }, { key: 'fbaWarehouse', prop: 'fbaWarehouse', label: '所属FBA仓', minWidth: 140 }, { key: 'status', prop: 'status', label: '状态', width: 100 }, { key: 'remark', prop: 'remark', label: '备注', minWidth: 180 }]
|
||||
const dialogOpen = ref(false); const dialogMode = ref<'create' | 'edit'>('create'); const saving = ref(false); const selected = ref<PalletListItem | null>(null)
|
||||
const form = reactive({ code: '', type: '木托盘', volume: undefined as number | undefined, purchasePrice: undefined as number | undefined, warehouse: '', fbaWarehouse: '', status: 'IDLE', remark: '' })
|
||||
function text(value: unknown) { return String(value ?? '').trim() || '—' }; function statusLabel(value: unknown) { const labels: Record<string, string> = { IDLE: '闲置', OCCUPIED: '占用', SCRAPPED: '已报废' }; return labels[String(value || '')] || text(value) }
|
||||
async function loadWarehouses() { if (!access.can(permission.warehouseList)) { warehousesError.value = { message: '缺少海外仓列表权限,无法选择托盘所属仓库' }; return }; warehousesLoading.value = true; warehousesError.value = null; try { const tenants: PlatformTenantResource[] = []; let current = 1; let pages = 1; do { const result = await listPlatformTenants({ page: current, size: 200, status: 'ACTIVE' }); tenants.push(...result.records); pages = Math.max(1, result.pages); current += 1 } while (current <= pages); const resolved: OverseasWarehouseDto[] = []; for (const tenant of tenants) { let warehousePage = 1; let warehousePages = 1; do { const result = await listOverseasWarehouses(String(tenant.id), warehousePage); resolved.push(...result.records); warehousePages = Math.max(1, result.pages); warehousePage += 1 } while (warehousePage <= warehousePages) }; warehouses.value = [...new Map(resolved.map((item) => [String(item.id), item])).values()] } catch (error) { warehousesError.value = getApiErrorDetails(error, '海外仓候选加载失败,请重试') } finally { warehousesLoading.value = false } }
|
||||
async function load(model: QueryModel = filters.value) { Object.assign(filters.value, model); const warehouseId = String(filters.value.warehouse || '').trim(); if (!warehouseId) { rows.value = []; total.value = 0; loadError.value = null; return }; loading.value = true; loadError.value = null; try { const result = await listPallets(warehouseId, { page: page.value, size: pageSize.value, keyword: String(filters.value.code || '').trim() || undefined, palletType: String(filters.value.type || '').trim() || undefined, status: String(filters.value.status || '').trim() || undefined }); const warehouseName = warehouses.value.find((item) => String(item.id) === warehouseId)?.warehouseName || warehouseId; rows.value = result.records.map((item) => ({ id: String(item.id), warehouseId, locationId: item.locationId ? String(item.locationId) : '', code: text(item.palletCode), type: text(item.palletType), warehouse: warehouseName, volume: Number(item.capacityCbm || 0), purchasePrice: Number(item.unitPrice || 0), fbaWarehouse: '—', status: statusLabel(item.status), remark: text(item.remark) })); total.value = result.total } catch (error) { rows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, '托盘加载失败,请重试') } finally { loading.value = false } }
|
||||
function search(model: QueryModel) { page.value = 1; void load(model) }; function reset(model: QueryModel) { page.value = 1; void load(model) }
|
||||
function openForm(mode: 'create' | 'edit', source?: object) { const row = source as PalletListItem | undefined; dialogMode.value = mode; selected.value = row || null; Object.assign(form, { code: row?.code === '—' ? '' : row?.code || '', type: row?.type || '木托盘', volume: row?.volume, purchasePrice: row?.purchasePrice, warehouse: row?.warehouseId || String(filters.value.warehouse || ''), fbaWarehouse: '', status: row?.status === '占用' ? 'OCCUPIED' : row?.status === '已报废' ? 'SCRAPPED' : 'IDLE', remark: row?.remark === '—' ? '' : row?.remark || '' }); dialogOpen.value = true }
|
||||
async function save() { if (!form.warehouse || !form.type || form.volume == null || form.purchasePrice == null || !form.status) { ElMessage.warning('请完整填写托盘必填字段'); return }; saving.value = true; try { const payload = { palletCode: form.code.trim() || undefined, palletType: form.type, capacityCbm: form.volume, unitPrice: form.purchasePrice, status: form.status, locationId: selected.value?.locationId || undefined, remark: form.remark.trim() || undefined }; if (dialogMode.value === 'create') await createPallet(form.warehouse, payload); else if (selected.value) await updatePallet(selected.value.warehouseId, selected.value.id, payload); ElMessage.success(dialogMode.value === 'create' ? '托盘已创建' : '托盘已更新'); dialogOpen.value = false; filters.value.warehouse = form.warehouse; await load() } catch (error) { ElMessage.error(getApiErrorMessage(error, '托盘保存失败,请重试')) } finally { saving.value = false } }
|
||||
function openDetail(source: object) { const row = source as PalletListItem; void router.push({ name: 'overseas-pallet-detail', params: { palletId: row.id }, query: { warehouseId: row.warehouseId, warehouseName: row.warehouse } }) }
|
||||
onMounted(loadWarehouses)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage><ListPageLayout aria-label="托盘管理列表">
|
||||
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" ariaLabel="托盘筛选" preference-key="overseas-pallets-query" @search="page = 1" /></template>
|
||||
<template #state><ContractPendingNotice domain="托盘管理" /></template>
|
||||
<section class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="0" preference-key="overseas-pallets" row-key="code" empty-text="海外区域托盘查询契约待接入">
|
||||
<template #toolbar-actions><el-button v-if="access.can(permission.palletCreate)" type="primary" @click="openForm('create')"><Plus :size="16" />新增托盘</el-button></template>
|
||||
<template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="access.can(permission.palletEdit)" size="small" plain @click="openForm('edit', row)"><Pencil :size="15" />编辑</el-button></template>
|
||||
<template #pagination-left>共 0 条托盘</template>
|
||||
</DataTable></section>
|
||||
</ListPageLayout>
|
||||
<ContractPendingFormDialog v-model="dialogOpen" :title="dialogMode === 'create' ? '新增托盘' : '编辑托盘'" width="min(760px, calc(100vw - 28px))" domain="托盘">
|
||||
<el-form label-position="top"><el-row :gutter="16">
|
||||
<el-col :span="12"><el-form-item label="托盘编码" required><el-input v-model="form.code" :readonly="dialogMode === 'edit'" placeholder="请输入托盘编码" /></el-form-item></el-col>
|
||||
<el-col :span="12"><el-form-item label="托盘类型" required><el-select v-model="form.type"><el-option v-for="value in ['木托盘', '塑料托盘', '金属托盘', '纸托盘', '免熏蒸']" :key="value" :label="value" :value="value" /></el-select></el-form-item></el-col>
|
||||
<el-col :span="12"><el-form-item label="容积(m³)" required><el-input-number v-model="form.volume" :min="0.01" :precision="2" controls-position="right" /></el-form-item></el-col>
|
||||
<el-col :span="12"><el-form-item label="采购价格(USD)" required><el-input-number v-model="form.purchasePrice" :min="0" :precision="2" controls-position="right" /></el-form-item></el-col>
|
||||
<el-col :span="12"><el-form-item label="仓库" required><el-select v-model="form.warehouse" placeholder="海外仓候选接口待接入" disabled /></el-form-item></el-col>
|
||||
<el-col :span="12"><el-form-item label="所属 FBA 仓" required><el-select v-model="form.fbaWarehouse" placeholder="FBA仓候选接口待接入" disabled /></el-form-item></el-col>
|
||||
<el-col :span="12"><el-form-item label="状态" required><el-select v-model="form.status"><el-option label="闲置" value="闲置" /><el-option label="占用" value="占用" /></el-select></el-form-item></el-col>
|
||||
<el-col :span="24"><el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" /></el-form-item></el-col>
|
||||
</el-row></el-form>
|
||||
</ContractPendingFormDialog>
|
||||
</AdminPage>
|
||||
</template>
|
||||
<template><AdminPage><ListPageLayout aria-label="托盘管理列表"><template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :loading="loading" ariaLabel="托盘筛选" preference-key="overseas-pallets-query" @search="search" @reset="reset" /></template><template v-if="warehousesError || loadError" #state><LoadErrorState title="托盘加载失败" :message="(warehousesError || loadError)?.message" :loading="warehousesLoading || loading" @retry="warehousesError ? loadWarehouses() : load()"><template #details><ApiErrorMeta :details="(warehousesError || loadError)!" /></template></LoadErrorState></template>
|
||||
<section v-if="!warehousesError && !loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-pallets" row-key="id" :empty-text="filters.warehouse ? '暂无托盘' : '请先选择仓库后查询'" @update:page="load()" @update:page-size="page = 1; load()"><template #toolbar-actions><el-button v-if="access.can(permission.palletCreate)" type="primary" :disabled="!warehouses.length" @click="openForm('create')"><Plus :size="16" />新增托盘</el-button></template><template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="access.can(permission.palletEdit)" size="small" plain @click="openForm('edit', row)"><Pencil :size="15" />编辑</el-button></template><template #pagination-left>共 {{ total }} 条托盘</template></DataTable></section>
|
||||
</ListPageLayout><AppDialog v-model="dialogOpen" :title="dialogMode === 'create' ? '新增托盘' : '编辑托盘'" width="min(760px, calc(100vw - 28px))" :confirm-loading="saving" @confirm="save"><el-form label-position="top"><el-row :gutter="16"><el-col :span="12"><el-form-item label="托盘编码"><el-input v-model="form.code" :readonly="dialogMode === 'edit'" placeholder="留空由系统生成" /></el-form-item></el-col><el-col :span="12"><el-form-item label="托盘类型" required><el-select v-model="form.type"><el-option v-for="value in ['木托盘','塑料托盘','金属托盘','纸托盘','免熏蒸']" :key="value" :label="value" :value="value" /></el-select></el-form-item></el-col><el-col :span="12"><el-form-item label="容积(m³)" required><el-input-number v-model="form.volume" :min="0.01" :precision="2" controls-position="right" /></el-form-item></el-col><el-col :span="12"><el-form-item label="采购价格(USD)" required><el-input-number v-model="form.purchasePrice" :min="0" :precision="2" controls-position="right" /></el-form-item></el-col><el-col :span="12"><el-form-item label="仓库" required><el-select v-model="form.warehouse" filterable :disabled="dialogMode === 'edit'"><el-option v-for="item in warehouses" :key="String(item.id)" :label="item.warehouseName" :value="String(item.id)" /></el-select></el-form-item></el-col><el-col :span="12"><el-form-item label="所属 FBA 仓"><el-input model-value="接口未提供" readonly /></el-form-item></el-col><el-col :span="12"><el-form-item label="状态" required><el-select v-model="form.status"><el-option label="闲置" value="IDLE" /><el-option label="占用" value="OCCUPIED" /><el-option label="已报废" value="SCRAPPED" /></el-select></el-form-item></el-col><el-col :span="24"><el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" /></el-form-item></el-col></el-row></el-form></AppDialog></AdminPage></template>
|
||||
|
||||
@@ -101,7 +101,7 @@ onMounted(loadCustomers)
|
||||
<template v-if="customersError || loadError" #state><LoadErrorState title="换标单加载失败" :message="(customersError || loadError)?.message" :loading="customersLoading || loading" @retry="customersError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="(customersError || loadError)!" /></template></LoadErrorState></template>
|
||||
<section v-if="!customersError && !loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-relabel-orders" row-key="orderNo" :empty-text="filters.customerId ? '暂无换标单' : '请先选择客户后查询'" @update:page="load()" @update:page-size="page = 1; load()">
|
||||
<template #toolbar-actions><el-button v-if="access.can(permission.relabelOrderCreate)" type="primary" @click="openForm('create')"><Plus :size="16" />新增换标单</el-button></template>
|
||||
<template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="access.can(permission.relabelOrderEdit)" size="small" plain @click="openForm('edit', row)"><Pencil :size="15" />审核</el-button></template>
|
||||
<template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="access.can(permission.relabelOrderStatus)" size="small" plain @click="openForm('edit', row)"><Pencil :size="15" />审核</el-button></template>
|
||||
<template #pagination-left>共 {{ total }} 条换标单</template>
|
||||
</DataTable></section>
|
||||
</ListPageLayout>
|
||||
|
||||
@@ -50,7 +50,6 @@ describe('overseas warehouse first-batch prototype contract', () => {
|
||||
it('preserves the prototype dialog carriers and desktop widths', () => {
|
||||
const expected = [
|
||||
['RelabelOrdersView.vue', '760px', '新增换标单', '审核换标单'],
|
||||
['PalletsView.vue', '760px', '新增托盘', '编辑托盘'],
|
||||
['CartonsView.vue', '860px', '新增箱号', '编辑箱号'],
|
||||
['ProductsView.vue', '560px', '新增产品', '编辑产品'],
|
||||
]
|
||||
@@ -62,6 +61,12 @@ describe('overseas warehouse first-batch prototype contract', () => {
|
||||
expect(value).toContain(editTitle)
|
||||
expect(value).not.toContain('AppDrawer')
|
||||
}
|
||||
const pallets = source('PalletsView.vue')
|
||||
expect(pallets).toContain('<AppDialog')
|
||||
expect(pallets).toContain('760px')
|
||||
expect(pallets).toContain('新增托盘')
|
||||
expect(pallets).toContain('编辑托盘')
|
||||
expect(pallets).not.toContain('AppDrawer')
|
||||
})
|
||||
|
||||
it('implements all second-batch details as formal parameterized pages', () => {
|
||||
@@ -79,11 +84,15 @@ describe('overseas warehouse first-batch prototype contract', () => {
|
||||
expect(dialog).toContain('confirm-disabled')
|
||||
expect(dialog).toContain('不可提交')
|
||||
expect(dialog).not.toContain('@confirm=')
|
||||
for (const fileName of ['RelabelOrdersView.vue', 'PalletsView.vue', 'CartonsView.vue', 'ProductsView.vue']) {
|
||||
for (const fileName of ['RelabelOrdersView.vue', 'CartonsView.vue', 'ProductsView.vue']) {
|
||||
const value = source(fileName)
|
||||
expect(value).not.toContain('ElMessage.success')
|
||||
expect(value).not.toMatch(/https?:\/\//)
|
||||
}
|
||||
const pallets = source('PalletsView.vue')
|
||||
expect(pallets).toContain('createPallet')
|
||||
expect(pallets).toContain('updatePallet')
|
||||
expect(pallets).toContain('ElMessage.success')
|
||||
})
|
||||
|
||||
it('implements three prototype-defined details as formal pages', () => {
|
||||
@@ -110,12 +119,15 @@ describe('overseas warehouse first-batch prototype contract', () => {
|
||||
expect(tailLeg).toContain(':data="cargo"')
|
||||
expect(tailLeg).toContain('<LoadErrorState')
|
||||
expect(tailLeg).not.toContain('ElMessage.success')
|
||||
for (const fileName of ['InboundOrderDetailView.vue', 'InventoryDetailView.vue']) {
|
||||
const value = source(fileName)
|
||||
expect(value).toContain(':data="[]"')
|
||||
expect(value).toContain('契约待接入')
|
||||
expect(value).not.toContain('ElMessage.success')
|
||||
}
|
||||
const inbound = source('InboundOrderDetailView.vue')
|
||||
expect(inbound).toContain('getInboundPlan')
|
||||
expect(inbound).toContain('sourceOrderId')
|
||||
expect(inbound).toContain('<LoadErrorState')
|
||||
const inventory = source('InventoryDetailView.vue')
|
||||
expect(inventory).toContain('listInventoryPositions')
|
||||
expect(inventory).toContain(':data="locationRows"')
|
||||
expect(inventory).toContain('当前流水接口未返回 SKU')
|
||||
expect(inventory).not.toContain('ElMessage.success')
|
||||
})
|
||||
|
||||
it('adds the prototype inventory transaction page as a read-only inventory route', () => {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { listPlatformTenants } from '@/modules/iam'
|
||||
import { getInboundPlan } from '../api/inboundPlans'
|
||||
import { listInventoryLedger } from '../api/inventory'
|
||||
import InboundOrderDetailView from './InboundOrderDetailView.vue'
|
||||
import InventoryTransactionsView from './InventoryTransactionsView.vue'
|
||||
|
||||
const routing = vi.hoisted(() => ({ push: vi.fn(), route: { params: {} as Record<string, string>, query: {} as Record<string, string> } }))
|
||||
vi.mock('vue-router', () => ({ useRoute: () => routing.route, useRouter: () => ({ push: routing.push }) }))
|
||||
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() }))
|
||||
vi.mock('../api/inboundPlans', () => ({ getInboundPlan: vi.fn() }))
|
||||
vi.mock('../api/inventory', () => ({ listInventoryLedger: vi.fn() }))
|
||||
class ResizeObserverStub implements ResizeObserver { observe() {} unobserve() {} disconnect() {} }
|
||||
function memoryStorage(): Storage { const values = new Map<string, string>(); return { get length() { return values.size }, clear: () => values.clear(), getItem: (key) => values.get(key) ?? null, key: (index) => [...values.keys()][index] ?? null, removeItem: (key) => values.delete(key), setItem: (key, value) => values.set(key, value) } }
|
||||
enableAutoUnmount(afterEach); afterAll(() => vi.unstubAllGlobals())
|
||||
|
||||
describe('overseas warehouse mounted real API pages', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub); vi.stubGlobal('localStorage', memoryStorage()); routing.push.mockReset(); routing.route.params = {}; routing.route.query = {}
|
||||
vi.mocked(listPlatformTenants).mockReset().mockResolvedValue({ records: [{ id: '10001', tenantName: '客户甲', status: 'ACTIVE' } as never], page: 1, size: 200, total: 1, pages: 1 })
|
||||
vi.mocked(getInboundPlan).mockReset().mockResolvedValue({ id: '9007199254740993', planNo: 'IP-1', customerId: '10001', sourceOrderId: '9007199254740995', status: 'PENDING_ASSIGNMENT', lineCount: 1, palletCount: 1, totalVolumeCbm: 2, lines: [{ id: '1', orderId: '9007199254740995', inboundCode: 'IN-1', fbaWarehouseCode: 'ONT8', destinationWarehouseName: '洛杉矶仓', volumeCbm: 2, requiredPackageCount: 10, plannedPalletCount: 1 }], pallets: [], allocations: [] })
|
||||
vi.mocked(listInventoryLedger).mockReset().mockResolvedValue({ records: [{ id: '9007199254740997', customerId: '10001', destinationWarehouseCode: 'LAX', inboundOrderNo: 'IP-1', businessType: 'STOCK_IN', beforePackages: 0, deltaPackages: 10, afterPackages: 10, occurredAt: '2026-08-20T10:00:00' }], page: 1, size: 20, total: 1, pages: 1 })
|
||||
})
|
||||
it('mounts inbound detail with a string-safe source order id', async () => { routing.route.params = { orderId: 'IP-1' }; routing.route.query = { customerId: '10001' }; const wrapper = mount(InboundOrderDetailView, { global: { plugins: [ElementPlus] } }); await flushPromises(); expect(getInboundPlan).toHaveBeenCalledWith('IP-1', '10001'); expect(wrapper.text()).toContain('9007199254740995'); expect(wrapper.text()).toContain('洛杉矶仓') })
|
||||
it('mounts inventory ledger as read-only facts', async () => { routing.route.query = { customerId: '10001' }; const wrapper = mount(InventoryTransactionsView, { global: { plugins: [ElementPlus] } }); await flushPromises(); expect(listInventoryLedger).toHaveBeenCalledWith(expect.objectContaining({ customerId: '10001', page: 1, size: 20 })); expect(wrapper.text()).toContain('IP-1'); expect(wrapper.text()).toContain('10'); expect(wrapper.text()).toContain('不提供出库单跳转') })
|
||||
})
|
||||
@@ -1,13 +1,27 @@
|
||||
export const overseasWarehousePermissions = {
|
||||
lastMileOrderList: 'platform:overseas-warehouse:last-mile-order:list',
|
||||
lastMileOrderEdit: 'platform:overseas-warehouse:last-mile-order:edit',
|
||||
lastMileOrderExport: 'platform:overseas-warehouse:last-mile-order:export',
|
||||
inboundOrderList: 'platform:overseas-warehouse:inbound-order:list',
|
||||
inboundOrderCreate: 'platform:overseas-warehouse:inbound-order:create',
|
||||
inboundOrderEdit: 'platform:overseas-warehouse:inbound-order:edit',
|
||||
inventoryList: 'platform:overseas-warehouse:inventory:list',
|
||||
inventoryExport: 'platform:overseas-warehouse:inventory:export',
|
||||
inventoryEdit: 'platform:overseas-warehouse:inventory:edit',
|
||||
inventoryAdjust: 'platform:overseas-warehouse:inventory:adjust',
|
||||
relabelOrderList: 'platform:overseas-warehouse:relabel-order:list',
|
||||
relabelOrderCreate: 'platform:overseas-warehouse:relabel-order:create',
|
||||
relabelOrderEdit: 'platform:overseas-warehouse:relabel-order:edit',
|
||||
relabelOrderStatus: 'platform:overseas-warehouse:relabel-order:status',
|
||||
locationList: 'platform:overseas-warehouse:location:list',
|
||||
locationCreate: 'platform:overseas-warehouse:location:create',
|
||||
locationEdit: 'platform:overseas-warehouse:location:edit',
|
||||
locationDelete: 'platform:overseas-warehouse:location:delete',
|
||||
warehouseList: 'platform:overseas-warehouse:warehouse:list',
|
||||
palletList: 'platform:overseas-warehouse:pallet:list',
|
||||
palletCreate: 'platform:overseas-warehouse:pallet:create',
|
||||
palletEdit: 'platform:overseas-warehouse:pallet:edit',
|
||||
palletDelete: 'platform:overseas-warehouse:pallet:delete',
|
||||
// 箱号、产品库本期暂缓;权限常量随保留源码留存,但不再由模块正式路由引用。
|
||||
cartonList: 'platform:overseas-warehouse:carton:list',
|
||||
cartonCreate: 'platform:overseas-warehouse:carton:create',
|
||||
cartonEdit: 'platform:overseas-warehouse:carton:edit',
|
||||
|
||||
@@ -16,22 +16,24 @@ describe('overseas warehouse routes', () => {
|
||||
['overseas-warehouse/relabel-orders/:orderId', permission.relabelOrderList],
|
||||
['overseas-warehouse/pallets', permission.palletList],
|
||||
['overseas-warehouse/pallets/:palletId', permission.palletList],
|
||||
['overseas-warehouse/cartons', permission.cartonList],
|
||||
['overseas-warehouse/cartons/:cartonId', permission.cartonList],
|
||||
['overseas-warehouse/products', permission.productList],
|
||||
['overseas-warehouse/products/:productId', permission.productList],
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps all details as formal parameterized pages', () => {
|
||||
const details = overseasWarehouseRoutes.filter((route) => route.path.includes('/:'))
|
||||
expect(details).toHaveLength(7)
|
||||
expect(details).toHaveLength(5)
|
||||
expect(details.every((route) => route.meta.showPageHeading === false)).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps create and edit permissions independent for second-batch resources', () => {
|
||||
it('keeps deferred carton and product page sources outside the formal route set', () => {
|
||||
const paths = overseasWarehouseRoutes.map((route) => route.path)
|
||||
expect(paths.some((path) => path.includes('/cartons'))).toBe(false)
|
||||
expect(paths.some((path) => path.includes('/products'))).toBe(false)
|
||||
})
|
||||
|
||||
it('retains independent write permissions for current and deferred page sources', () => {
|
||||
expect(new Set([
|
||||
permission.relabelOrderCreate, permission.relabelOrderEdit,
|
||||
permission.relabelOrderCreate, permission.relabelOrderStatus,
|
||||
permission.palletCreate, permission.palletEdit,
|
||||
permission.cartonCreate, permission.cartonEdit,
|
||||
permission.productCreate, permission.productEdit,
|
||||
|
||||
@@ -68,28 +68,4 @@ export const overseasWarehouseRoutes = [
|
||||
component: () => import('./pages/PalletDetailView.vue'),
|
||||
meta: { title: '托盘详情', description: '查看托盘基础信息与使用信息', permission: permission.palletList, showPageHeading: false },
|
||||
},
|
||||
{
|
||||
path: 'overseas-warehouse/cartons',
|
||||
name: 'overseas-cartons',
|
||||
component: () => import('./pages/CartonsView.vue'),
|
||||
meta: { title: '箱号管理', description: '管理海外仓箱号、商品与托盘关联', permission: permission.cartonList },
|
||||
},
|
||||
{
|
||||
path: 'overseas-warehouse/cartons/:cartonId',
|
||||
name: 'overseas-carton-detail',
|
||||
component: () => import('./pages/CartonDetailView.vue'),
|
||||
meta: { title: '箱号详情', description: '查看箱号基础信息与商品信息', permission: permission.cartonList, showPageHeading: false },
|
||||
},
|
||||
{
|
||||
path: 'overseas-warehouse/products',
|
||||
name: 'overseas-products',
|
||||
component: () => import('./pages/ProductsView.vue'),
|
||||
meta: { title: '产品库', description: '管理海外仓商品与包装信息', permission: permission.productList },
|
||||
},
|
||||
{
|
||||
path: 'overseas-warehouse/products/:productId',
|
||||
name: 'overseas-product-detail',
|
||||
component: () => import('./pages/ProductDetailView.vue'),
|
||||
meta: { title: '产品详情', description: '查看商品基础信息与包装信息', permission: permission.productList, showPageHeading: false },
|
||||
},
|
||||
] satisfies RouteRecordRaw[]
|
||||
|
||||
@@ -42,6 +42,11 @@ export interface LastMileOrderListItem {
|
||||
}
|
||||
|
||||
export interface InboundOrderListItem {
|
||||
id: string
|
||||
customerId: string
|
||||
sourceOrderId: string
|
||||
warehouseId: string
|
||||
destinationWarehouseCode: string
|
||||
orderNo: string
|
||||
inboundCode: string
|
||||
type: string
|
||||
@@ -58,6 +63,10 @@ export interface InboundOrderListItem {
|
||||
|
||||
export interface InventoryListItem {
|
||||
id: string
|
||||
customerId: string
|
||||
warehouseId: string
|
||||
inboundOrderNo: string
|
||||
inboundCode: string
|
||||
owner: string
|
||||
warehouse: string
|
||||
sku: string
|
||||
@@ -65,7 +74,7 @@ export interface InventoryListItem {
|
||||
onHand: number
|
||||
available: number
|
||||
held: number
|
||||
looseboard: number
|
||||
looseboard: number | null
|
||||
inTransit: number
|
||||
purchase: number
|
||||
status: string
|
||||
@@ -88,6 +97,9 @@ export interface RelabelOrderListItem {
|
||||
}
|
||||
|
||||
export interface PalletListItem {
|
||||
id: string
|
||||
warehouseId: string
|
||||
locationId: string
|
||||
code: string
|
||||
type: string
|
||||
warehouse: string
|
||||
|
||||
Reference in New Issue
Block a user