feat: complete phase 5 overseas fulfillment contracts
This commit is contained in:
@@ -45,6 +45,9 @@
|
||||
| 柜级入仓计划进度与整柜转换 | `lastleg-service/` | `orders/container-summaries` 按 `sourceContainerBookingId` 跨客户聚合全柜进度与 `allPlansReady`,详情返回柜内全部订单/入仓码/计划且不受订单分页截断;`plannedArrivalAt` 只取装柜完成事件 `estimatedWarehouseArrivalAt` 冻结事实;`inbound-plans/convert-container` 锁定同柜订单,每票生成独立计划,整批原子且幂等 |
|
||||
| 海外入仓分页、FBA 预约、PDA 柜号签收与入仓 | `lastleg-service/` | 平台入口 `inboundplan/controller/InboundPlanController.java`、`InboundPlanQueryMapper.java`、`InboundFbaAppointmentService.java`;PDA 公网入口 `inboundplan/pda/controller/ReceivingContainerController.java`、`ReceivingContainerService.java`,旧 planNo/allocationId Controller 已移除 |
|
||||
| 海外仓排车查询、候选与智能预排 | `lastleg-service/` | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`;草稿柜、托盘锁和预排归 `dispatch/`,只读排车和预排创建独立开关,不自动开放编辑、取消、发车、PDA 出库或派送 |
|
||||
| 五期海外排车执行、PDA 出库、派送与一件代发 | `lastleg-service/` | `dispatch/controller/DispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`、`delivery/controller/DeliveryTransferController.java`、`OutboundPdaController.java`、`MerchantDeliveryController.java`,一件代发库存归 `dropship/`;统一经 `gateway-overseas/`,设备端是否已有正式入口继续查 `../apps/pda-overseas-uniapp/AGENTS.md` |
|
||||
| 五期海外异常退回与客户回访 | `lastleg-service/` | `exceptionorder/`、`customervisit/`;平台异常建档和关系解析、派送退回入仓、派送完成/状态、回访资料与附件均以 Lastleg 自身客户范围和状态为准 |
|
||||
| 五期一件代发 SKU/箱唛与暂扣跨区协同 | `order/`、`firstleg/`、`common-event-contract/`、`lastleg-service/` | Order `InternalOrderCargoController` 提供强制 SKU;Firstleg `InboundCartonLabelService` 冻结 SKU、`LoadingWorkPalletService` 维护装柜工作托盘并消费 `OverseasInboundOrderDetainedChangedV1`;Lastleg `dropship/` 建立库存并发布暂扣变更 |
|
||||
| 客户海外仓贴标、转运、废弃与退货换标 | `lastleg-service/` | `relabel/`、`transfer/`、`discard/` 的 Merchant Controller/Service;Client 消费端 `../apps/client-web/src/modules/overseas-warehouse/`,统一经 `gateway-overseas/` |
|
||||
| 海外仓 PDA 盘点、移库、托盘、换标与查询 | `lastleg-service/`、`warehouse-overseas-service/` | Lastleg `pda/stocktake/`、`pda/move/`、`pda/warehouse/`、`palletops/`、`relabel/pda/`;契约 `lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml`;物理托盘流转与补偿进入 Warehouse `InternalPdaPalletTransitionController`,设备端入口是否开放以 `../apps/pda-overseas-uniapp/AGENTS.md` 为准 |
|
||||
| 国内船节点向海外接柜门禁事件 | `firstleg/`、`common-event-contract/`、`lastleg-service/` | Firstleg `portschedule/service/CarrierScheduleNodeOutboxService.java` 发布 `FirstLegContainerShippingNodeRecordedV1`;Lastleg `shipping/service/ContainerShippingNodeConsumer.java` 单调消费,只有实际 `PICKUP_READY` 及以后节点允许海外 PDA 候选进入可收货 |
|
||||
@@ -107,6 +110,7 @@
|
||||
- 二期:`../docs/二期/README.md`、`../docs/二期/二期需求优化版-v2-测量计费入库主流程.md`、需求确认清单及联调报告
|
||||
- 三期:`../docs/三期/README.md`、`../docs/三期/三期开工门禁.md`、`../docs/三期/三期开发规划.md`、`../docs/三期/三期需求确认表.md`、页面与原型清单
|
||||
- 四期:`../docs/四期/README.md`;海外入口 `gateway-overseas/`,尾程与入仓库存 `lastleg-service/`,海外仓储资源 `warehouse-overseas-service/`
|
||||
- 五期:当前以本轮确认的正式 Controller、前端路由、权限、SQL 与跨区事件契约为范围依据;海外排车执行、出库、派送、一件代发、异常退回和客户回访进入 `lastleg-service/`,国内 SKU/箱唛及暂扣协同进入 `order/`、`firstleg/`、`common-event-contract/`
|
||||
- 财务:`../docs/财务管理/README.md` 中已确认的范围、对象、状态和接口约束
|
||||
|
||||
全量验收任务再读取对应阶段进度表、验收报告和证据矩阵;普通代码定位只读取当前链路涉及的范围、契约、模块指引和代码。
|
||||
|
||||
+15
-1
@@ -225,4 +225,18 @@ SELECT
|
||||
|
||||
- 固定入口已同步 `iam/src/main/resources/db/lastleg-permissions.sql` 的 `platform:overseas-*` 权限,并默认授予平台系统管理员。
|
||||
- 旧 `platform:lastleg:*` 权限会撤销角色关联并停用;不保留旧码兼容映射。
|
||||
- 本批不创建箱号、产品库、海外出库、退货出库或海外派送权限。
|
||||
- 四期目录保留海外出库、派送列表入口;五期写操作和客户派送权限由后续五期区块直接收敛,不使用旧码兼容映射。
|
||||
|
||||
## 五期海外排车、出库与派送权限
|
||||
|
||||
- 国内固定入口已在四期 `lastleg-permissions.sql` 权限段之后完整内联 `iam/src/main/resources/db/phase5-delivery-permissions.sql`;测试和部署只执行固定入口,不再单独执行模块脚本。
|
||||
- 平台端固定使用排车编辑/取消/转派送、出库列表/创建/取消和派送列表七项权限;列表权限包含详情只读,写操作保持独立授权。
|
||||
- 客户端固定使用 `merchant:warehouse:delivery:list` 和 `merchant:warehouse:delivery:create`;客户默认管理员获得两项权限,其他客户角色不自动扩权。
|
||||
- `WAREHOUSE_OPERATOR` 获得七项后台作业权限,`WAREHOUSE_TALLY` 只获得出库和派送列表只读;国内、海外 PDA 岗位不得获得上述后台权限。
|
||||
|
||||
## 五期海外排车与出库结构升级
|
||||
|
||||
- 五期数据库部署仍只执行 `海外区域统一增量-MySQL8.sql`;空库先执行 Lastleg 与 Warehouse Overseas 当前基线,再完整执行固定入口并确认最末 `migration_status = PASS`。
|
||||
- 固定入口只补目标字段并做 fail-fast 校验,不回填、改号、清理或从其他表推断开发旧行。`ll_dispatch_pda_task.warehouse_id` 存在 `NULL`、空值或 `0`,或任一待收敛全局业务号/任务号、完成幂等键发生冲突时,脚本立即 `SIGNAL`;应重新造数,重置或清理仍需用户另行授权。
|
||||
- 数据门禁通过后,脚本原位把 `dispatch_no`、`outbound_no`、排车 PDA `task_no`、出库 PDA `task_no` 收敛为全局唯一,并建立 `(customer_id, completion_idempotency_key)` 完成命令唯一约束;旧客户复合索引不保留。
|
||||
- 必须在启动或重启 Lastleg Service 前完成固定入口;不得单独执行模块基线补丁、日期脚本或手工兼容 SQL。
|
||||
|
||||
@@ -16340,6 +16340,204 @@ WHERE role.tenant_id=0 AND role.app_code='PLATFORM'
|
||||
AND UPPER(role.role_code)='PLATFORM_CUSTOM_ADMIN'
|
||||
AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0;
|
||||
|
||||
-- 五期海外排车、出库与派送权限(来源:iam/db/phase5-delivery-permissions.sql)
|
||||
USE baiyue_iam;
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
-- 五期海外排车、出库与派送权限。列表权限同时覆盖列表和详情只读,写操作保持独立授权。
|
||||
DROP PROCEDURE IF EXISTS guard_phase5_delivery_permission_ids;
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE guard_phase5_delivery_permission_ids()
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM iam_permission
|
||||
WHERE (id = 5107016 AND permission_code <> 'platform:overseas-warehouse:dispatch:edit')
|
||||
OR (id = 5107017 AND permission_code <> 'platform:overseas-warehouse:dispatch:cancel')
|
||||
OR (id = 5107018 AND permission_code <> 'platform:overseas-warehouse:dispatch:transfer-delivery')
|
||||
OR (id = 5107090 AND permission_code <> 'platform:overseas-warehouse:outbound:list')
|
||||
OR (id = 5107091 AND permission_code <> 'platform:overseas-warehouse:outbound:create')
|
||||
OR (id = 5107092 AND permission_code <> 'platform:overseas-warehouse:delivery:list')
|
||||
OR (id = 5107093 AND permission_code <> 'platform:overseas-warehouse:outbound:cancel')
|
||||
OR (id = 5306000 AND permission_code <> 'merchant:warehouse:delivery:list')
|
||||
OR (id = 5306001 AND permission_code <> 'merchant:warehouse:delivery:create')
|
||||
) OR EXISTS (
|
||||
SELECT 1 FROM iam_permission
|
||||
WHERE (permission_code = 'platform:overseas-warehouse:dispatch:edit' AND id <> 5107016)
|
||||
OR (permission_code = 'platform:overseas-warehouse:dispatch:cancel' AND id <> 5107017)
|
||||
OR (permission_code = 'platform:overseas-warehouse:dispatch:transfer-delivery' AND id <> 5107018)
|
||||
OR (permission_code = 'platform:overseas-warehouse:outbound:list' AND id <> 5107090)
|
||||
OR (permission_code = 'platform:overseas-warehouse:outbound:create' AND id <> 5107091)
|
||||
OR (permission_code = 'platform:overseas-warehouse:delivery:list' AND id <> 5107092)
|
||||
OR (permission_code = 'platform:overseas-warehouse:outbound:cancel' AND id <> 5107093)
|
||||
OR (permission_code = 'merchant:warehouse:delivery:list' AND id <> 5306000)
|
||||
OR (permission_code = 'merchant:warehouse:delivery:create' AND id <> 5306001)
|
||||
) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:权限ID或编码冲突';
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
CALL guard_phase5_delivery_permission_ids();
|
||||
DROP PROCEDURE guard_phase5_delivery_permission_ids;
|
||||
|
||||
INSERT INTO iam_permission
|
||||
(id, tenant_id, app_code, permission_type, parent_id, permission_code,
|
||||
permission_name, route_path, http_method, sort_no, status, built_in, deleted)
|
||||
VALUES
|
||||
(5107016,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:dispatch:edit','编辑海外排车',NULL,'PUT',716,'ACTIVE',1,0),
|
||||
(5107017,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:dispatch:cancel','取消海外排车',NULL,'POST',717,'ACTIVE',1,0),
|
||||
(5107018,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:dispatch:transfer-delivery','排车转派送',NULL,'POST',718,'ACTIVE',1,0),
|
||||
(5107090,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:outbound:list','海外出库单','/overseas-warehouse/outbounds','GET',765,'ACTIVE',1,0),
|
||||
(5107091,0,'PLATFORM','BUTTON',5107090,'platform:overseas-warehouse:outbound:create','创建海外出库单',NULL,'POST',766,'ACTIVE',1,0),
|
||||
(5107092,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:delivery:list','海外派送单','/overseas-warehouse/delivery-orders','GET',767,'ACTIVE',1,0),
|
||||
(5107093,0,'PLATFORM','BUTTON',5107090,'platform:overseas-warehouse:outbound:cancel','取消海外出库单',NULL,'POST',768,'ACTIVE',1,0),
|
||||
(5306000,0,'MERCHANT','MENU',0,'merchant:warehouse:delivery:list','海外仓派送单','/overseas-warehouse/delivery-orders','GET',60,'ACTIVE',1,0),
|
||||
(5306001,0,'MERCHANT','BUTTON',5306000,'merchant:warehouse:delivery:create','创建海外仓派送单',NULL,'POST',10,'ACTIVE',1,0)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
permission_type=VALUES(permission_type), parent_id=VALUES(parent_id),
|
||||
permission_name=VALUES(permission_name), route_path=VALUES(route_path),
|
||||
http_method=VALUES(http_method), sort_no=VALUES(sort_no), status='ACTIVE',
|
||||
built_in=1, deleted=0;
|
||||
|
||||
-- 平台管理员默认拥有五期全部后台动作;已有的平台自定义管理员保持同等五期操作能力。
|
||||
INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at)
|
||||
SELECT 0, role.id, permission.id, CURRENT_TIMESTAMP(3)
|
||||
FROM iam_role role
|
||||
JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM'
|
||||
WHERE role.tenant_id=0 AND role.app_code='PLATFORM'
|
||||
AND UPPER(role.role_code) IN ('PLATFORM_ADMIN','PLATFORM_CUSTOM_ADMIN')
|
||||
AND role.status='ACTIVE' AND role.deleted=0
|
||||
AND permission.permission_code IN (
|
||||
'platform:overseas-warehouse:dispatch:edit',
|
||||
'platform:overseas-warehouse:dispatch:cancel',
|
||||
'platform:overseas-warehouse:dispatch:transfer-delivery',
|
||||
'platform:overseas-warehouse:outbound:list',
|
||||
'platform:overseas-warehouse:outbound:create',
|
||||
'platform:overseas-warehouse:outbound:cancel',
|
||||
'platform:overseas-warehouse:delivery:list')
|
||||
AND permission.status='ACTIVE' AND permission.deleted=0;
|
||||
|
||||
-- 仓库后台岗位按最小职责授权;PDA 岗位不得获得后台排车、出库或派送权限。
|
||||
DROP TEMPORARY TABLE IF EXISTS tmp_phase5_delivery_role_permission;
|
||||
CREATE TEMPORARY TABLE tmp_phase5_delivery_role_permission (
|
||||
role_code VARCHAR(64) NOT NULL,
|
||||
permission_code VARCHAR(160) NOT NULL,
|
||||
PRIMARY KEY (role_code, permission_code)
|
||||
) ENGINE=MEMORY;
|
||||
INSERT INTO tmp_phase5_delivery_role_permission (role_code, permission_code) VALUES
|
||||
('WAREHOUSE_OPERATOR','platform:overseas-warehouse:dispatch:edit'),
|
||||
('WAREHOUSE_OPERATOR','platform:overseas-warehouse:dispatch:cancel'),
|
||||
('WAREHOUSE_OPERATOR','platform:overseas-warehouse:dispatch:transfer-delivery'),
|
||||
('WAREHOUSE_OPERATOR','platform:overseas-warehouse:outbound:list'),
|
||||
('WAREHOUSE_OPERATOR','platform:overseas-warehouse:outbound:create'),
|
||||
('WAREHOUSE_OPERATOR','platform:overseas-warehouse:outbound:cancel'),
|
||||
('WAREHOUSE_OPERATOR','platform:overseas-warehouse:delivery:list'),
|
||||
('WAREHOUSE_TALLY','platform:overseas-warehouse:outbound:list'),
|
||||
('WAREHOUSE_TALLY','platform:overseas-warehouse:delivery:list');
|
||||
|
||||
DELETE grant_row FROM iam_role_permission grant_row
|
||||
JOIN iam_role role ON role.id=grant_row.role_id AND role.tenant_id=grant_row.tenant_id
|
||||
JOIN iam_permission permission ON permission.id=grant_row.permission_id AND permission.tenant_id=grant_row.tenant_id
|
||||
LEFT JOIN tmp_phase5_delivery_role_permission expected
|
||||
ON expected.role_code=role.role_code AND expected.permission_code=permission.permission_code
|
||||
WHERE role.tenant_id=0 AND role.app_code='PLATFORM'
|
||||
AND role.role_code IN ('WAREHOUSE_OPERATOR','WAREHOUSE_TALLY','OVERSEAS_WAREHOUSE_PDA_OPERATOR','CN_FIRSTLEG_PDA_OPERATOR')
|
||||
AND permission.permission_code IN (
|
||||
'platform:overseas-warehouse:dispatch:edit',
|
||||
'platform:overseas-warehouse:dispatch:cancel',
|
||||
'platform:overseas-warehouse:dispatch:transfer-delivery',
|
||||
'platform:overseas-warehouse:outbound:list',
|
||||
'platform:overseas-warehouse:outbound:create',
|
||||
'platform:overseas-warehouse:outbound:cancel',
|
||||
'platform:overseas-warehouse:delivery:list')
|
||||
AND expected.permission_code IS NULL;
|
||||
|
||||
INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at)
|
||||
SELECT 0, role.id, permission.id, CURRENT_TIMESTAMP(3)
|
||||
FROM tmp_phase5_delivery_role_permission expected
|
||||
JOIN iam_role role ON role.tenant_id=0 AND role.app_code='PLATFORM'
|
||||
AND role.role_code=expected.role_code AND role.status='ACTIVE' AND role.deleted=0
|
||||
JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM'
|
||||
AND permission.permission_code=expected.permission_code
|
||||
AND permission.status='ACTIVE' AND permission.deleted=0;
|
||||
|
||||
-- 客户默认管理员获得派送列表/详情只读及独立创建;其他客户角色不自动扩权。
|
||||
INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at)
|
||||
SELECT 0, role.id, permission.id, CURRENT_TIMESTAMP(3)
|
||||
FROM iam_role role
|
||||
JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='MERCHANT'
|
||||
WHERE role.tenant_id=0 AND role.app_code='MERCHANT' AND role.role_code='MERCHANT_OWNER'
|
||||
AND role.status='ACTIVE' AND role.deleted=0
|
||||
AND permission.permission_code IN (
|
||||
'merchant:warehouse:delivery:list',
|
||||
'merchant:warehouse:delivery:create')
|
||||
AND permission.status='ACTIVE' AND permission.deleted=0;
|
||||
|
||||
DROP PROCEDURE IF EXISTS assert_phase5_delivery_permissions;
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE assert_phase5_delivery_permissions()
|
||||
BEGIN
|
||||
IF (SELECT COUNT(*) FROM iam_permission
|
||||
WHERE tenant_id=0 AND status='ACTIVE' AND deleted=0
|
||||
AND permission_code IN (
|
||||
'platform:overseas-warehouse:dispatch:edit',
|
||||
'platform:overseas-warehouse:dispatch:cancel',
|
||||
'platform:overseas-warehouse:dispatch:transfer-delivery',
|
||||
'platform:overseas-warehouse:outbound:list',
|
||||
'platform:overseas-warehouse:outbound:create',
|
||||
'platform:overseas-warehouse:outbound:cancel',
|
||||
'platform:overseas-warehouse:delivery:list',
|
||||
'merchant:warehouse:delivery:list',
|
||||
'merchant:warehouse:delivery:create')) <> 9 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:权限目录不完整';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM iam_role_permission grant_row
|
||||
JOIN iam_role role ON role.id=grant_row.role_id AND role.tenant_id=grant_row.tenant_id
|
||||
JOIN iam_permission permission ON permission.id=grant_row.permission_id
|
||||
WHERE role.role_code='PLATFORM_ADMIN'
|
||||
AND permission.permission_code IN (
|
||||
'platform:overseas-warehouse:dispatch:edit',
|
||||
'platform:overseas-warehouse:dispatch:cancel',
|
||||
'platform:overseas-warehouse:dispatch:transfer-delivery',
|
||||
'platform:overseas-warehouse:outbound:list',
|
||||
'platform:overseas-warehouse:outbound:create',
|
||||
'platform:overseas-warehouse:outbound:cancel',
|
||||
'platform:overseas-warehouse:delivery:list')) <> 7 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:平台管理员授权不完整';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM tmp_phase5_delivery_role_permission WHERE role_code='WAREHOUSE_OPERATOR') <> 7
|
||||
OR (SELECT COUNT(*) FROM tmp_phase5_delivery_role_permission WHERE role_code='WAREHOUSE_TALLY') <> 2 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:仓库岗位最小权限矩阵异常';
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM iam_role_permission grant_row
|
||||
JOIN iam_role role ON role.id=grant_row.role_id AND role.tenant_id=grant_row.tenant_id
|
||||
JOIN iam_permission permission ON permission.id=grant_row.permission_id
|
||||
WHERE role.role_code IN ('OVERSEAS_WAREHOUSE_PDA_OPERATOR','CN_FIRSTLEG_PDA_OPERATOR')
|
||||
AND permission.permission_code IN (
|
||||
'platform:overseas-warehouse:dispatch:edit',
|
||||
'platform:overseas-warehouse:dispatch:cancel',
|
||||
'platform:overseas-warehouse:dispatch:transfer-delivery',
|
||||
'platform:overseas-warehouse:outbound:list',
|
||||
'platform:overseas-warehouse:outbound:create',
|
||||
'platform:overseas-warehouse:outbound:cancel',
|
||||
'platform:overseas-warehouse:delivery:list')) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:PDA岗位错误获得后台权限';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM iam_role_permission grant_row
|
||||
JOIN iam_role role ON role.id=grant_row.role_id AND role.tenant_id=grant_row.tenant_id
|
||||
JOIN iam_permission permission ON permission.id=grant_row.permission_id
|
||||
WHERE role.role_code='MERCHANT_OWNER' AND role.app_code='MERCHANT'
|
||||
AND permission.permission_code IN (
|
||||
'merchant:warehouse:delivery:list',
|
||||
'merchant:warehouse:delivery:create')) <> 2 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:客户默认管理员授权不完整';
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
CALL assert_phase5_delivery_permissions();
|
||||
DROP PROCEDURE assert_phase5_delivery_permissions;
|
||||
DROP TEMPORARY TABLE tmp_phase5_delivery_role_permission;
|
||||
|
||||
|
||||
-- 海外智能排车草稿锁:拖入即锁定,移除或关闭草稿即释放。
|
||||
CREATE DATABASE IF NOT EXISTS baiyue_lastleg
|
||||
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
|
||||
|
||||
@@ -400,7 +400,7 @@ CREATE TABLE IF NOT EXISTS ll_dispatch_order (
|
||||
shipped_package_count INT NOT NULL DEFAULT 0 COMMENT '累计已出库箱数', partial_completion_reason VARCHAR(1000) NULL COMMENT '部分完成原因',
|
||||
created_by VARCHAR(128) NULL COMMENT '创建人ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_ll_dispatch_order_no(customer_id,dispatch_no),
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_ll_dispatch_order_no(dispatch_no),
|
||||
KEY idx_ll_dispatch_order_list(customer_id,status,depart_at), KEY idx_ll_dispatch_order_vehicle(customer_id,vehicle_plate)
|
||||
) ENGINE=InnoDB COMMENT='尾程派车单';
|
||||
|
||||
@@ -1097,9 +1097,11 @@ CREATE TABLE IF NOT EXISTS ll_order_operation_log (
|
||||
CREATE TABLE IF NOT EXISTS ll_outbound_pda_task (
|
||||
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', outbound_order_id BIGINT NOT NULL COMMENT '出库单ID',
|
||||
task_no VARCHAR(64) NOT NULL COMMENT 'PDA任务号', status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '任务状态',
|
||||
completion_idempotency_key VARCHAR(255) NULL COMMENT '完成命令幂等键',
|
||||
pushed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '推送时间', completed_at DATETIME(3) NULL COMMENT '完成时间',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_ll_outbound_pda_task_order(customer_id,outbound_order_id), UNIQUE KEY uk_ll_outbound_pda_task_no(customer_id,task_no)
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_ll_outbound_pda_task_order(customer_id,outbound_order_id), UNIQUE KEY uk_ll_outbound_pda_task_no(task_no),
|
||||
UNIQUE KEY uk_ll_outbound_pda_complete_key(customer_id,completion_idempotency_key)
|
||||
) ENGINE=InnoDB COMMENT='一件代发出库PDA拣货任务';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_outbound_pda_pick (
|
||||
@@ -1109,6 +1111,180 @@ CREATE TABLE IF NOT EXISTS ll_outbound_pda_pick (
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_ll_outbound_pda_pick_key(customer_id,idempotency_key)
|
||||
) ENGINE=InnoDB COMMENT='一件代发出库PDA托盘拣货记录';
|
||||
|
||||
-- 五期排车、出库与海外PDA正式业务号/幂等约束。只补结构并 fail-fast 校验开发数据,不回填或修复旧行。
|
||||
SET @ddl = (SELECT IF(EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_task' AND column_name='warehouse_id'
|
||||
), 'SELECT 1',
|
||||
'ALTER TABLE ll_dispatch_pda_task ADD COLUMN warehouse_id VARCHAR(128) NULL COMMENT ''Token绑定的海外仓库ID'' AFTER operator_id'));
|
||||
PREPARE phase5_pda_scope_ddl FROM @ddl; EXECUTE phase5_pda_scope_ddl; DEALLOCATE PREPARE phase5_pda_scope_ddl;
|
||||
|
||||
SET @ddl = (SELECT IF(EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema=DATABASE() AND table_name='ll_outbound_pda_task' AND column_name='completion_idempotency_key'
|
||||
), 'SELECT 1',
|
||||
'ALTER TABLE ll_outbound_pda_task ADD COLUMN completion_idempotency_key VARCHAR(255) NULL COMMENT ''完成命令幂等键'' AFTER status'));
|
||||
PREPARE phase5_pda_scope_ddl FROM @ddl; EXECUTE phase5_pda_scope_ddl; DEALLOCATE PREPARE phase5_pda_scope_ddl;
|
||||
|
||||
DROP PROCEDURE IF EXISTS assert_phase5_lastleg_identifiers_migratable;
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE assert_phase5_lastleg_identifiers_migratable()
|
||||
BEGIN
|
||||
IF (SELECT COUNT(*) FROM information_schema.tables
|
||||
WHERE table_schema='baiyue_lastleg' AND table_name IN (
|
||||
'll_dispatch_order','ll_outbound_order','ll_dispatch_pda_task','ll_outbound_pda_task')) <> 4 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期排车、出库或PDA任务表未完整部署';
|
||||
END IF;
|
||||
IF EXISTS (SELECT dispatch_no FROM ll_dispatch_order GROUP BY dispatch_no HAVING COUNT(*)>1) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='ll_dispatch_order存在跨客户重复dispatch_no,无法收敛全局唯一';
|
||||
END IF;
|
||||
IF EXISTS (SELECT outbound_no FROM ll_outbound_order GROUP BY outbound_no HAVING COUNT(*)>1) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='ll_outbound_order存在跨客户重复outbound_no,无法收敛全局唯一';
|
||||
END IF;
|
||||
IF EXISTS (SELECT task_no FROM ll_dispatch_pda_task GROUP BY task_no HAVING COUNT(*)>1) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='ll_dispatch_pda_task存在跨客户重复task_no,无法收敛全局唯一';
|
||||
END IF;
|
||||
IF EXISTS (SELECT task_no FROM ll_outbound_pda_task GROUP BY task_no HAVING COUNT(*)>1) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='ll_outbound_pda_task存在跨客户重复task_no,无法收敛全局唯一';
|
||||
END IF;
|
||||
IF EXISTS (SELECT customer_id,completion_idempotency_key FROM ll_outbound_pda_task
|
||||
WHERE completion_idempotency_key IS NOT NULL
|
||||
GROUP BY customer_id,completion_idempotency_key HAVING COUNT(*)>1) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='ll_outbound_pda_task存在重复完成幂等键,无法创建客户范围唯一约束';
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM ll_dispatch_pda_task
|
||||
WHERE warehouse_id IS NULL OR TRIM(warehouse_id)='' OR TRIM(warehouse_id)='0') THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='ll_dispatch_pda_task存在空或0仓库范围;请重新造数后重跑,脚本不回填旧行';
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
CALL assert_phase5_lastleg_identifiers_migratable();
|
||||
DROP PROCEDURE assert_phase5_lastleg_identifiers_migratable;
|
||||
|
||||
SET @ddl = (SELECT IF(EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_task' AND column_name='warehouse_id'
|
||||
AND is_nullable='NO' AND column_type='varchar(128)' AND column_comment='Token绑定的海外仓库ID'
|
||||
), 'SELECT 1',
|
||||
'ALTER TABLE ll_dispatch_pda_task MODIFY COLUMN warehouse_id VARCHAR(128) NOT NULL COMMENT ''Token绑定的海外仓库ID'''));
|
||||
PREPARE phase5_pda_scope_ddl FROM @ddl; EXECUTE phase5_pda_scope_ddl; DEALLOCATE PREPARE phase5_pda_scope_ddl;
|
||||
|
||||
SET @index_ready = (SELECT COUNT(*)=1 AND MIN(non_unique)=0
|
||||
AND MAX(seq_in_index=1 AND column_name='dispatch_no')=1
|
||||
FROM information_schema.statistics WHERE table_schema=DATABASE()
|
||||
AND table_name='ll_dispatch_order' AND index_name='uk_ll_dispatch_order_no');
|
||||
SET @index_exists = (SELECT COUNT(*)>0 FROM information_schema.statistics WHERE table_schema=DATABASE()
|
||||
AND table_name='ll_dispatch_order' AND index_name='uk_ll_dispatch_order_no');
|
||||
SET @ddl = IF(@index_ready, 'SELECT 1', IF(@index_exists,
|
||||
'ALTER TABLE ll_dispatch_order DROP INDEX uk_ll_dispatch_order_no, ADD UNIQUE KEY uk_ll_dispatch_order_no(dispatch_no)',
|
||||
'ALTER TABLE ll_dispatch_order ADD UNIQUE KEY uk_ll_dispatch_order_no(dispatch_no)'));
|
||||
PREPARE phase5_identifier_ddl FROM @ddl; EXECUTE phase5_identifier_ddl; DEALLOCATE PREPARE phase5_identifier_ddl;
|
||||
|
||||
SET @index_ready = (SELECT COUNT(*)=1 AND MIN(non_unique)=0
|
||||
AND MAX(seq_in_index=1 AND column_name='outbound_no')=1
|
||||
FROM information_schema.statistics WHERE table_schema=DATABASE()
|
||||
AND table_name='ll_outbound_order' AND index_name='uk_ll_outbound_no');
|
||||
SET @index_exists = (SELECT COUNT(*)>0 FROM information_schema.statistics WHERE table_schema=DATABASE()
|
||||
AND table_name='ll_outbound_order' AND index_name='uk_ll_outbound_no');
|
||||
SET @ddl = IF(@index_ready, 'SELECT 1', IF(@index_exists,
|
||||
'ALTER TABLE ll_outbound_order DROP INDEX uk_ll_outbound_no, ADD UNIQUE KEY uk_ll_outbound_no(outbound_no)',
|
||||
'ALTER TABLE ll_outbound_order ADD UNIQUE KEY uk_ll_outbound_no(outbound_no)'));
|
||||
PREPARE phase5_identifier_ddl FROM @ddl; EXECUTE phase5_identifier_ddl; DEALLOCATE PREPARE phase5_identifier_ddl;
|
||||
|
||||
SET @index_ready = (SELECT COUNT(*)=1 AND MIN(non_unique)=0
|
||||
AND MAX(seq_in_index=1 AND column_name='task_no')=1
|
||||
FROM information_schema.statistics WHERE table_schema=DATABASE()
|
||||
AND table_name='ll_dispatch_pda_task' AND index_name='uk_ll_dispatch_pda_task_no');
|
||||
SET @index_exists = (SELECT COUNT(*)>0 FROM information_schema.statistics WHERE table_schema=DATABASE()
|
||||
AND table_name='ll_dispatch_pda_task' AND index_name='uk_ll_dispatch_pda_task_no');
|
||||
SET @ddl = IF(@index_ready, 'SELECT 1', IF(@index_exists,
|
||||
'ALTER TABLE ll_dispatch_pda_task DROP INDEX uk_ll_dispatch_pda_task_no, ADD UNIQUE KEY uk_ll_dispatch_pda_task_no(task_no)',
|
||||
'ALTER TABLE ll_dispatch_pda_task ADD UNIQUE KEY uk_ll_dispatch_pda_task_no(task_no)'));
|
||||
PREPARE phase5_identifier_ddl FROM @ddl; EXECUTE phase5_identifier_ddl; DEALLOCATE PREPARE phase5_identifier_ddl;
|
||||
|
||||
SET @index_ready = (SELECT COUNT(*)=1 AND MIN(non_unique)=0
|
||||
AND MAX(seq_in_index=1 AND column_name='task_no')=1
|
||||
FROM information_schema.statistics WHERE table_schema=DATABASE()
|
||||
AND table_name='ll_outbound_pda_task' AND index_name='uk_ll_outbound_pda_task_no');
|
||||
SET @index_exists = (SELECT COUNT(*)>0 FROM information_schema.statistics WHERE table_schema=DATABASE()
|
||||
AND table_name='ll_outbound_pda_task' AND index_name='uk_ll_outbound_pda_task_no');
|
||||
SET @ddl = IF(@index_ready, 'SELECT 1', IF(@index_exists,
|
||||
'ALTER TABLE ll_outbound_pda_task DROP INDEX uk_ll_outbound_pda_task_no, ADD UNIQUE KEY uk_ll_outbound_pda_task_no(task_no)',
|
||||
'ALTER TABLE ll_outbound_pda_task ADD UNIQUE KEY uk_ll_outbound_pda_task_no(task_no)'));
|
||||
PREPARE phase5_identifier_ddl FROM @ddl; EXECUTE phase5_identifier_ddl; DEALLOCATE PREPARE phase5_identifier_ddl;
|
||||
|
||||
SET @index_ready = (SELECT COUNT(*)=2 AND MIN(non_unique)=0
|
||||
AND SUM(seq_in_index=1 AND column_name='customer_id')=1
|
||||
AND SUM(seq_in_index=2 AND column_name='completion_idempotency_key')=1
|
||||
FROM information_schema.statistics WHERE table_schema=DATABASE()
|
||||
AND table_name='ll_outbound_pda_task' AND index_name='uk_ll_outbound_pda_complete_key');
|
||||
SET @index_exists = (SELECT COUNT(*)>0 FROM information_schema.statistics WHERE table_schema=DATABASE()
|
||||
AND table_name='ll_outbound_pda_task' AND index_name='uk_ll_outbound_pda_complete_key');
|
||||
SET @ddl = IF(@index_ready, 'SELECT 1', IF(@index_exists,
|
||||
'ALTER TABLE ll_outbound_pda_task DROP INDEX uk_ll_outbound_pda_complete_key, ADD UNIQUE KEY uk_ll_outbound_pda_complete_key(customer_id,completion_idempotency_key)',
|
||||
'ALTER TABLE ll_outbound_pda_task ADD UNIQUE KEY uk_ll_outbound_pda_complete_key(customer_id,completion_idempotency_key)'));
|
||||
PREPARE phase5_identifier_ddl FROM @ddl; EXECUTE phase5_identifier_ddl; DEALLOCATE PREPARE phase5_identifier_ddl;
|
||||
|
||||
DROP PROCEDURE IF EXISTS assert_phase5_lastleg_identifiers_ready;
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE assert_phase5_lastleg_identifiers_ready()
|
||||
BEGIN
|
||||
IF (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='baiyue_lastleg'
|
||||
AND table_name='ll_dispatch_pda_task' AND column_name='warehouse_id' AND is_nullable='NO') <> 1 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='ll_dispatch_pda_task.warehouse_id未收敛为非空';
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM ll_dispatch_pda_task
|
||||
WHERE warehouse_id IS NULL OR TRIM(warehouse_id)='' OR TRIM(warehouse_id)='0') THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='ll_dispatch_pda_task仍存在空或0仓库范围';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='baiyue_lastleg'
|
||||
AND table_name='ll_outbound_pda_task' AND column_name='completion_idempotency_key'
|
||||
AND column_type='varchar(255)' AND is_nullable='YES') <> 1 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='ll_outbound_pda_task完成幂等字段未部署';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema='baiyue_lastleg'
|
||||
AND table_name='ll_dispatch_order' AND index_name='uk_ll_dispatch_order_no') <> 1
|
||||
OR (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema='baiyue_lastleg'
|
||||
AND table_name='ll_dispatch_order' AND index_name='uk_ll_dispatch_order_no'
|
||||
AND non_unique=0 AND seq_in_index=1 AND column_name='dispatch_no') <> 1 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='dispatch_no全局唯一索引未部署';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema='baiyue_lastleg'
|
||||
AND table_name='ll_outbound_order' AND index_name='uk_ll_outbound_no') <> 1
|
||||
OR (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema='baiyue_lastleg'
|
||||
AND table_name='ll_outbound_order' AND index_name='uk_ll_outbound_no'
|
||||
AND non_unique=0 AND seq_in_index=1 AND column_name='outbound_no') <> 1 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='outbound_no全局唯一索引未部署';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema='baiyue_lastleg'
|
||||
AND table_name='ll_dispatch_pda_task' AND index_name='uk_ll_dispatch_pda_task_no') <> 1
|
||||
OR (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema='baiyue_lastleg'
|
||||
AND table_name='ll_dispatch_pda_task' AND index_name='uk_ll_dispatch_pda_task_no'
|
||||
AND non_unique=0 AND seq_in_index=1 AND column_name='task_no') <> 1 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='排车PDA task_no全局唯一索引未部署';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema='baiyue_lastleg'
|
||||
AND table_name='ll_outbound_pda_task' AND index_name='uk_ll_outbound_pda_task_no') <> 1
|
||||
OR (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema='baiyue_lastleg'
|
||||
AND table_name='ll_outbound_pda_task' AND index_name='uk_ll_outbound_pda_task_no'
|
||||
AND non_unique=0 AND seq_in_index=1 AND column_name='task_no') <> 1 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='出库PDA task_no全局唯一索引未部署';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema='baiyue_lastleg'
|
||||
AND table_name='ll_outbound_pda_task' AND index_name='uk_ll_outbound_pda_complete_key') <> 2
|
||||
OR (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema='baiyue_lastleg'
|
||||
AND table_name='ll_outbound_pda_task' AND index_name='uk_ll_outbound_pda_complete_key'
|
||||
AND non_unique=0 AND seq_in_index=1 AND column_name='customer_id') <> 1
|
||||
OR (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema='baiyue_lastleg'
|
||||
AND table_name='ll_outbound_pda_task' AND index_name='uk_ll_outbound_pda_complete_key'
|
||||
AND non_unique=0 AND seq_in_index=2 AND column_name='completion_idempotency_key') <> 1 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='出库PDA客户范围完成幂等唯一索引未部署';
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
CALL assert_phase5_lastleg_identifiers_ready();
|
||||
DROP PROCEDURE assert_phase5_lastleg_identifiers_ready;
|
||||
|
||||
USE baiyue_lastleg;
|
||||
SET @ddl = (SELECT IF(EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
|
||||
+16
@@ -38,4 +38,20 @@ class OverseasLastlegRouteContractTest {
|
||||
.doesNotContain("event-bridge-service/v3/api-docs/pda");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void lastlegPdaRouteCoversFrozenDispatchAndOutboundTaskPaths() throws IOException {
|
||||
try (InputStream input = getClass().getResourceAsStream("/application-overseas.yml")) {
|
||||
assertThat(input).isNotNull();
|
||||
String yaml = new String(input.readAllBytes(), StandardCharsets.UTF_8);
|
||||
assertThat(yaml).contains("/api/pda/v1/last-leg/**");
|
||||
for (String path : new String[] {
|
||||
"/api/pda/v1/last-leg/dispatches/DSP-1",
|
||||
"/api/pda/v1/last-leg/dispatches/DSP-1/depart",
|
||||
"/api/pda/v1/last-leg/outbound-tasks/PDA-1/complete"
|
||||
}) {
|
||||
assertThat(path).startsWith("/api/pda/v1/last-leg/");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
USE baiyue_iam;
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
-- 五期海外排车、出库与派送权限。列表权限同时覆盖列表和详情只读,写操作保持独立授权。
|
||||
DROP PROCEDURE IF EXISTS guard_phase5_delivery_permission_ids;
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE guard_phase5_delivery_permission_ids()
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM iam_permission
|
||||
WHERE (id = 5107016 AND permission_code <> 'platform:overseas-warehouse:dispatch:edit')
|
||||
OR (id = 5107017 AND permission_code <> 'platform:overseas-warehouse:dispatch:cancel')
|
||||
OR (id = 5107018 AND permission_code <> 'platform:overseas-warehouse:dispatch:transfer-delivery')
|
||||
OR (id = 5107090 AND permission_code <> 'platform:overseas-warehouse:outbound:list')
|
||||
OR (id = 5107091 AND permission_code <> 'platform:overseas-warehouse:outbound:create')
|
||||
OR (id = 5107092 AND permission_code <> 'platform:overseas-warehouse:delivery:list')
|
||||
OR (id = 5107093 AND permission_code <> 'platform:overseas-warehouse:outbound:cancel')
|
||||
OR (id = 5306000 AND permission_code <> 'merchant:warehouse:delivery:list')
|
||||
OR (id = 5306001 AND permission_code <> 'merchant:warehouse:delivery:create')
|
||||
) OR EXISTS (
|
||||
SELECT 1 FROM iam_permission
|
||||
WHERE (permission_code = 'platform:overseas-warehouse:dispatch:edit' AND id <> 5107016)
|
||||
OR (permission_code = 'platform:overseas-warehouse:dispatch:cancel' AND id <> 5107017)
|
||||
OR (permission_code = 'platform:overseas-warehouse:dispatch:transfer-delivery' AND id <> 5107018)
|
||||
OR (permission_code = 'platform:overseas-warehouse:outbound:list' AND id <> 5107090)
|
||||
OR (permission_code = 'platform:overseas-warehouse:outbound:create' AND id <> 5107091)
|
||||
OR (permission_code = 'platform:overseas-warehouse:delivery:list' AND id <> 5107092)
|
||||
OR (permission_code = 'platform:overseas-warehouse:outbound:cancel' AND id <> 5107093)
|
||||
OR (permission_code = 'merchant:warehouse:delivery:list' AND id <> 5306000)
|
||||
OR (permission_code = 'merchant:warehouse:delivery:create' AND id <> 5306001)
|
||||
) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:权限ID或编码冲突';
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
CALL guard_phase5_delivery_permission_ids();
|
||||
DROP PROCEDURE guard_phase5_delivery_permission_ids;
|
||||
|
||||
INSERT INTO iam_permission
|
||||
(id, tenant_id, app_code, permission_type, parent_id, permission_code,
|
||||
permission_name, route_path, http_method, sort_no, status, built_in, deleted)
|
||||
VALUES
|
||||
(5107016,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:dispatch:edit','编辑海外排车',NULL,'PUT',716,'ACTIVE',1,0),
|
||||
(5107017,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:dispatch:cancel','取消海外排车',NULL,'POST',717,'ACTIVE',1,0),
|
||||
(5107018,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:dispatch:transfer-delivery','排车转派送',NULL,'POST',718,'ACTIVE',1,0),
|
||||
(5107090,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:outbound:list','海外出库单','/overseas-warehouse/outbounds','GET',765,'ACTIVE',1,0),
|
||||
(5107091,0,'PLATFORM','BUTTON',5107090,'platform:overseas-warehouse:outbound:create','创建海外出库单',NULL,'POST',766,'ACTIVE',1,0),
|
||||
(5107092,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:delivery:list','海外派送单','/overseas-warehouse/delivery-orders','GET',767,'ACTIVE',1,0),
|
||||
(5107093,0,'PLATFORM','BUTTON',5107090,'platform:overseas-warehouse:outbound:cancel','取消海外出库单',NULL,'POST',768,'ACTIVE',1,0),
|
||||
(5306000,0,'MERCHANT','MENU',0,'merchant:warehouse:delivery:list','海外仓派送单','/overseas-warehouse/delivery-orders','GET',60,'ACTIVE',1,0),
|
||||
(5306001,0,'MERCHANT','BUTTON',5306000,'merchant:warehouse:delivery:create','创建海外仓派送单',NULL,'POST',10,'ACTIVE',1,0)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
permission_type=VALUES(permission_type), parent_id=VALUES(parent_id),
|
||||
permission_name=VALUES(permission_name), route_path=VALUES(route_path),
|
||||
http_method=VALUES(http_method), sort_no=VALUES(sort_no), status='ACTIVE',
|
||||
built_in=1, deleted=0;
|
||||
|
||||
-- 平台管理员默认拥有五期全部后台动作;已有的平台自定义管理员保持同等五期操作能力。
|
||||
INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at)
|
||||
SELECT 0, role.id, permission.id, CURRENT_TIMESTAMP(3)
|
||||
FROM iam_role role
|
||||
JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM'
|
||||
WHERE role.tenant_id=0 AND role.app_code='PLATFORM'
|
||||
AND UPPER(role.role_code) IN ('PLATFORM_ADMIN','PLATFORM_CUSTOM_ADMIN')
|
||||
AND role.status='ACTIVE' AND role.deleted=0
|
||||
AND permission.permission_code IN (
|
||||
'platform:overseas-warehouse:dispatch:edit',
|
||||
'platform:overseas-warehouse:dispatch:cancel',
|
||||
'platform:overseas-warehouse:dispatch:transfer-delivery',
|
||||
'platform:overseas-warehouse:outbound:list',
|
||||
'platform:overseas-warehouse:outbound:create',
|
||||
'platform:overseas-warehouse:outbound:cancel',
|
||||
'platform:overseas-warehouse:delivery:list')
|
||||
AND permission.status='ACTIVE' AND permission.deleted=0;
|
||||
|
||||
-- 仓库后台岗位按最小职责授权;PDA 岗位不得获得后台排车、出库或派送权限。
|
||||
DROP TEMPORARY TABLE IF EXISTS tmp_phase5_delivery_role_permission;
|
||||
CREATE TEMPORARY TABLE tmp_phase5_delivery_role_permission (
|
||||
role_code VARCHAR(64) NOT NULL,
|
||||
permission_code VARCHAR(160) NOT NULL,
|
||||
PRIMARY KEY (role_code, permission_code)
|
||||
) ENGINE=MEMORY;
|
||||
INSERT INTO tmp_phase5_delivery_role_permission (role_code, permission_code) VALUES
|
||||
('WAREHOUSE_OPERATOR','platform:overseas-warehouse:dispatch:edit'),
|
||||
('WAREHOUSE_OPERATOR','platform:overseas-warehouse:dispatch:cancel'),
|
||||
('WAREHOUSE_OPERATOR','platform:overseas-warehouse:dispatch:transfer-delivery'),
|
||||
('WAREHOUSE_OPERATOR','platform:overseas-warehouse:outbound:list'),
|
||||
('WAREHOUSE_OPERATOR','platform:overseas-warehouse:outbound:create'),
|
||||
('WAREHOUSE_OPERATOR','platform:overseas-warehouse:outbound:cancel'),
|
||||
('WAREHOUSE_OPERATOR','platform:overseas-warehouse:delivery:list'),
|
||||
('WAREHOUSE_TALLY','platform:overseas-warehouse:outbound:list'),
|
||||
('WAREHOUSE_TALLY','platform:overseas-warehouse:delivery:list');
|
||||
|
||||
DELETE grant_row FROM iam_role_permission grant_row
|
||||
JOIN iam_role role ON role.id=grant_row.role_id AND role.tenant_id=grant_row.tenant_id
|
||||
JOIN iam_permission permission ON permission.id=grant_row.permission_id AND permission.tenant_id=grant_row.tenant_id
|
||||
LEFT JOIN tmp_phase5_delivery_role_permission expected
|
||||
ON expected.role_code=role.role_code AND expected.permission_code=permission.permission_code
|
||||
WHERE role.tenant_id=0 AND role.app_code='PLATFORM'
|
||||
AND role.role_code IN ('WAREHOUSE_OPERATOR','WAREHOUSE_TALLY','OVERSEAS_WAREHOUSE_PDA_OPERATOR','CN_FIRSTLEG_PDA_OPERATOR')
|
||||
AND permission.permission_code IN (
|
||||
'platform:overseas-warehouse:dispatch:edit',
|
||||
'platform:overseas-warehouse:dispatch:cancel',
|
||||
'platform:overseas-warehouse:dispatch:transfer-delivery',
|
||||
'platform:overseas-warehouse:outbound:list',
|
||||
'platform:overseas-warehouse:outbound:create',
|
||||
'platform:overseas-warehouse:outbound:cancel',
|
||||
'platform:overseas-warehouse:delivery:list')
|
||||
AND expected.permission_code IS NULL;
|
||||
|
||||
INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at)
|
||||
SELECT 0, role.id, permission.id, CURRENT_TIMESTAMP(3)
|
||||
FROM tmp_phase5_delivery_role_permission expected
|
||||
JOIN iam_role role ON role.tenant_id=0 AND role.app_code='PLATFORM'
|
||||
AND role.role_code=expected.role_code AND role.status='ACTIVE' AND role.deleted=0
|
||||
JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM'
|
||||
AND permission.permission_code=expected.permission_code
|
||||
AND permission.status='ACTIVE' AND permission.deleted=0;
|
||||
|
||||
-- 客户默认管理员获得派送列表/详情只读及独立创建;其他客户角色不自动扩权。
|
||||
INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at)
|
||||
SELECT 0, role.id, permission.id, CURRENT_TIMESTAMP(3)
|
||||
FROM iam_role role
|
||||
JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='MERCHANT'
|
||||
WHERE role.tenant_id=0 AND role.app_code='MERCHANT' AND role.role_code='MERCHANT_OWNER'
|
||||
AND role.status='ACTIVE' AND role.deleted=0
|
||||
AND permission.permission_code IN (
|
||||
'merchant:warehouse:delivery:list',
|
||||
'merchant:warehouse:delivery:create')
|
||||
AND permission.status='ACTIVE' AND permission.deleted=0;
|
||||
|
||||
DROP PROCEDURE IF EXISTS assert_phase5_delivery_permissions;
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE assert_phase5_delivery_permissions()
|
||||
BEGIN
|
||||
IF (SELECT COUNT(*) FROM iam_permission
|
||||
WHERE tenant_id=0 AND status='ACTIVE' AND deleted=0
|
||||
AND permission_code IN (
|
||||
'platform:overseas-warehouse:dispatch:edit',
|
||||
'platform:overseas-warehouse:dispatch:cancel',
|
||||
'platform:overseas-warehouse:dispatch:transfer-delivery',
|
||||
'platform:overseas-warehouse:outbound:list',
|
||||
'platform:overseas-warehouse:outbound:create',
|
||||
'platform:overseas-warehouse:outbound:cancel',
|
||||
'platform:overseas-warehouse:delivery:list',
|
||||
'merchant:warehouse:delivery:list',
|
||||
'merchant:warehouse:delivery:create')) <> 9 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:权限目录不完整';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM iam_role_permission grant_row
|
||||
JOIN iam_role role ON role.id=grant_row.role_id AND role.tenant_id=grant_row.tenant_id
|
||||
JOIN iam_permission permission ON permission.id=grant_row.permission_id
|
||||
WHERE role.role_code='PLATFORM_ADMIN'
|
||||
AND permission.permission_code IN (
|
||||
'platform:overseas-warehouse:dispatch:edit',
|
||||
'platform:overseas-warehouse:dispatch:cancel',
|
||||
'platform:overseas-warehouse:dispatch:transfer-delivery',
|
||||
'platform:overseas-warehouse:outbound:list',
|
||||
'platform:overseas-warehouse:outbound:create',
|
||||
'platform:overseas-warehouse:outbound:cancel',
|
||||
'platform:overseas-warehouse:delivery:list')) <> 7 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:平台管理员授权不完整';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM tmp_phase5_delivery_role_permission WHERE role_code='WAREHOUSE_OPERATOR') <> 7
|
||||
OR (SELECT COUNT(*) FROM tmp_phase5_delivery_role_permission WHERE role_code='WAREHOUSE_TALLY') <> 2 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:仓库岗位最小权限矩阵异常';
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM iam_role_permission grant_row
|
||||
JOIN iam_role role ON role.id=grant_row.role_id AND role.tenant_id=grant_row.tenant_id
|
||||
JOIN iam_permission permission ON permission.id=grant_row.permission_id
|
||||
WHERE role.role_code IN ('OVERSEAS_WAREHOUSE_PDA_OPERATOR','CN_FIRSTLEG_PDA_OPERATOR')
|
||||
AND permission.permission_code IN (
|
||||
'platform:overseas-warehouse:dispatch:edit',
|
||||
'platform:overseas-warehouse:dispatch:cancel',
|
||||
'platform:overseas-warehouse:dispatch:transfer-delivery',
|
||||
'platform:overseas-warehouse:outbound:list',
|
||||
'platform:overseas-warehouse:outbound:create',
|
||||
'platform:overseas-warehouse:outbound:cancel',
|
||||
'platform:overseas-warehouse:delivery:list')) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:PDA岗位错误获得后台权限';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM iam_role_permission grant_row
|
||||
JOIN iam_role role ON role.id=grant_row.role_id AND role.tenant_id=grant_row.tenant_id
|
||||
JOIN iam_permission permission ON permission.id=grant_row.permission_id
|
||||
WHERE role.role_code='MERCHANT_OWNER' AND role.app_code='MERCHANT'
|
||||
AND permission.permission_code IN (
|
||||
'merchant:warehouse:delivery:list',
|
||||
'merchant:warehouse:delivery:create')) <> 2 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:客户默认管理员授权不完整';
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
CALL assert_phase5_delivery_permissions();
|
||||
DROP PROCEDURE assert_phase5_delivery_permissions;
|
||||
DROP TEMPORARY TABLE tmp_phase5_delivery_role_permission;
|
||||
@@ -74,7 +74,7 @@ paths:
|
||||
get:
|
||||
tags: [IAM Resources / IAM 资源]
|
||||
summary: 查询当前用户有效权限
|
||||
description: 返回当前有效平台成员权限编码;只要求有效 PLATFORM JWT,不依赖 `platform:role:list`。
|
||||
description: 返回当前有效平台成员权限编码;只要求有效 PLATFORM JWT,不依赖 `platform:role:list`。五期海外排车、出库与派送分别返回 `platform:overseas-warehouse:dispatch:edit`、`platform:overseas-warehouse:dispatch:cancel`、`platform:overseas-warehouse:dispatch:transfer-delivery`、`platform:overseas-warehouse:outbound:list`、`platform:overseas-warehouse:outbound:create`、`platform:overseas-warehouse:outbound:cancel`、`platform:overseas-warehouse:delivery:list`;列表权限包含详情只读,写操作独立授权。
|
||||
responses:
|
||||
'200':
|
||||
description: 当前平台成员权限查询成功
|
||||
@@ -276,7 +276,7 @@ paths:
|
||||
get:
|
||||
tags: [IAM Resources / IAM 资源]
|
||||
summary: 查询当前用户有效权限
|
||||
description: 返回当前有效商户成员权限编码。
|
||||
description: 返回当前有效客户成员权限编码。五期客户派送返回 `merchant:warehouse:delivery:list` 和 `merchant:warehouse:delivery:create`;列表权限包含详情只读,创建权限独立授权。
|
||||
responses:
|
||||
'200':
|
||||
description: 当前商户成员权限查询成功
|
||||
|
||||
@@ -20,6 +20,11 @@ class IamOpenApiContractTest {
|
||||
|
||||
assertThat(document).contains("/api/platform/v1/iam/authorization/permissions:")
|
||||
.contains("/api/merchant/v1/iam/authorization/permissions:")
|
||||
.contains("platform:overseas-warehouse:dispatch:transfer-delivery")
|
||||
.contains("platform:overseas-warehouse:outbound:cancel")
|
||||
.contains("merchant:warehouse:delivery:list")
|
||||
.contains("merchant:warehouse:delivery:create")
|
||||
.contains("列表权限包含详情只读")
|
||||
.contains("PermissionCodesApiResponse:")
|
||||
.contains("TenantPageApiResponse:")
|
||||
.contains("TenantApiResponse:")
|
||||
|
||||
+4
@@ -96,6 +96,10 @@ class OverseasPhase4PermissionSqlContractTest {
|
||||
void servicePermissionConstantsExactlyMatchTheIamCatalog() throws Exception {
|
||||
Set<String> moduleCatalog = codes(new ClassPathResource("db/lastleg-permissions.sql")
|
||||
.getContentAsString(StandardCharsets.UTF_8));
|
||||
codes(new ClassPathResource("db/phase5-delivery-permissions.sql")
|
||||
.getContentAsString(StandardCharsets.UTF_8)).stream()
|
||||
.filter(code -> code.startsWith("platform:"))
|
||||
.forEach(moduleCatalog::add);
|
||||
Set<String> serviceCatalog = new LinkedHashSet<>();
|
||||
serviceCatalog.addAll(codes(repositoryFile(
|
||||
"lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java")));
|
||||
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
package com.baiyue.iam.repository;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class Phase5DeliveryPermissionSqlContractTest {
|
||||
private static final Pattern CODE = Pattern.compile("(?:platform:overseas-warehouse|merchant:warehouse):[a-z0-9:-]+");
|
||||
private static final Set<String> REQUIRED = Set.of(
|
||||
"platform:overseas-warehouse:dispatch:edit",
|
||||
"platform:overseas-warehouse:dispatch:cancel",
|
||||
"platform:overseas-warehouse:dispatch:transfer-delivery",
|
||||
"platform:overseas-warehouse:outbound:list",
|
||||
"platform:overseas-warehouse:outbound:create",
|
||||
"platform:overseas-warehouse:outbound:cancel",
|
||||
"platform:overseas-warehouse:delivery:list",
|
||||
"merchant:warehouse:delivery:list",
|
||||
"merchant:warehouse:delivery:create");
|
||||
|
||||
@Test
|
||||
void definesOnlyTheFrozenPhase5DeliveryCatalogWithoutAliases() throws Exception {
|
||||
String sql = sql();
|
||||
|
||||
assertThat(codes(sql)).containsExactlyInAnyOrderElementsOf(REQUIRED);
|
||||
assertThat(codes(sql)).noneMatch(code -> code.endsWith(":view"));
|
||||
assertThat(sql).contains(
|
||||
"guard_phase5_delivery_permission_ids",
|
||||
"权限ID或编码冲突",
|
||||
"permission_type=VALUES(permission_type)",
|
||||
"列表权限同时覆盖列表和详情只读");
|
||||
}
|
||||
|
||||
@Test
|
||||
void grantsAdministratorsAndWarehouseRolesByTheFrozenMinimumMatrix() throws Exception {
|
||||
String sql = sql();
|
||||
|
||||
assertThat(sql).contains(
|
||||
"UPPER(role.role_code) IN ('PLATFORM_ADMIN','PLATFORM_CUSTOM_ADMIN')",
|
||||
"tmp_phase5_delivery_role_permission",
|
||||
"('WAREHOUSE_OPERATOR','platform:overseas-warehouse:dispatch:edit')",
|
||||
"('WAREHOUSE_OPERATOR','platform:overseas-warehouse:outbound:create')",
|
||||
"('WAREHOUSE_TALLY','platform:overseas-warehouse:outbound:list')",
|
||||
"('WAREHOUSE_TALLY','platform:overseas-warehouse:delivery:list')",
|
||||
"role.role_code IN ('OVERSEAS_WAREHOUSE_PDA_OPERATOR','CN_FIRSTLEG_PDA_OPERATOR')",
|
||||
"PDA岗位错误获得后台权限");
|
||||
assertThat(sql).doesNotContain(
|
||||
"('WAREHOUSE_TALLY','platform:overseas-warehouse:dispatch:edit')",
|
||||
"('WAREHOUSE_TALLY','platform:overseas-warehouse:outbound:create')",
|
||||
"('OVERSEAS_WAREHOUSE_PDA_OPERATOR','platform:overseas-warehouse:",
|
||||
"('CN_FIRSTLEG_PDA_OPERATOR','platform:overseas-warehouse:");
|
||||
}
|
||||
|
||||
@Test
|
||||
void grantsOnlyTheDefaultCustomerAdministratorAutomatically() throws Exception {
|
||||
String sql = sql();
|
||||
int marker = sql.indexOf("-- 客户默认管理员获得派送列表/详情只读及独立创建");
|
||||
int assertion = sql.indexOf("DROP PROCEDURE IF EXISTS assert_phase5_delivery_permissions", marker);
|
||||
assertThat(marker).isGreaterThanOrEqualTo(0);
|
||||
assertThat(assertion).isGreaterThan(marker);
|
||||
|
||||
String grant = sql.substring(marker, assertion);
|
||||
assertThat(grant).contains(
|
||||
"role.role_code='MERCHANT_OWNER'",
|
||||
"merchant:warehouse:delivery:list",
|
||||
"merchant:warehouse:delivery:create");
|
||||
assertThat(grant).doesNotContain("MERCHANT_OPERATOR", "ORDER_VIEWER", "role.role_code IN");
|
||||
}
|
||||
|
||||
@Test
|
||||
void domesticUnifiedEntryInlinesTheCompleteModuleSqlAfterPhase4Permissions() throws Exception {
|
||||
String moduleSql = sql().trim();
|
||||
String unifiedSql = repositoryFile("deploy/sql/头程数据库统一增量-MySQL8.sql");
|
||||
int phase4 = unifiedSql.indexOf("-- 四期海外权限统一目录");
|
||||
int phase5 = unifiedSql.indexOf("-- 五期海外排车、出库与派送权限(来源:iam/db/phase5-delivery-permissions.sql)");
|
||||
int bodyStart = unifiedSql.indexOf("USE baiyue_iam;", phase5);
|
||||
int nextSection = unifiedSql.indexOf("-- 海外智能排车草稿锁", bodyStart);
|
||||
|
||||
assertThat(phase4).isGreaterThanOrEqualTo(0);
|
||||
assertThat(phase5).isGreaterThan(phase4);
|
||||
assertThat(bodyStart).isGreaterThan(phase5);
|
||||
assertThat(nextSection).isGreaterThan(bodyStart);
|
||||
assertThat(unifiedSql.substring(bodyStart, nextSection).trim()).isEqualTo(moduleSql);
|
||||
assertThat(codes(unifiedSql.substring(phase5, nextSection)))
|
||||
.containsExactlyInAnyOrderElementsOf(REQUIRED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void deploymentGuideUsesOnlyTheDomesticUnifiedEntry() throws Exception {
|
||||
String readme = repositoryFile("deploy/sql/README.md");
|
||||
|
||||
assertThat(readme).contains(
|
||||
"五期海外排车、出库与派送权限",
|
||||
"测试和部署只执行固定入口,不再单独执行模块脚本",
|
||||
"merchant:warehouse:delivery:list",
|
||||
"merchant:warehouse:delivery:create",
|
||||
"PDA 岗位不得获得上述后台权限");
|
||||
}
|
||||
|
||||
private static String sql() throws Exception {
|
||||
return new ClassPathResource("db/phase5-delivery-permissions.sql")
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private static Set<String> codes(String source) {
|
||||
Set<String> values = new LinkedHashSet<>();
|
||||
CODE.matcher(source).results().map(result -> result.group()).forEach(values::add);
|
||||
return values;
|
||||
}
|
||||
|
||||
private static String repositoryFile(String relativePath) throws Exception {
|
||||
Path path = Path.of(relativePath);
|
||||
if (!Files.exists(path)) path = Path.of("..").resolve(relativePath);
|
||||
return Files.readString(path, StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
@@ -74,6 +74,22 @@ class AuthorizationSnapshotServiceTest {
|
||||
verify(mapper).findPermissions(42L, 0L, 9L, "PLATFORM");
|
||||
}
|
||||
|
||||
@Test
|
||||
void exposesPhase5DeliveryPermissionsFromCurrentRoleAssignments() {
|
||||
when(mapper.countActiveIdentity(42L, 7L, 9L)).thenReturn(1);
|
||||
when(mapper.findRoles(42L, 7L, 9L, "MERCHANT")).thenReturn(List.of("MERCHANT_OWNER"));
|
||||
when(mapper.findPermissions(42L, 7L, 9L, "MERCHANT")).thenReturn(List.of(
|
||||
"merchant:warehouse:delivery:list",
|
||||
"merchant:warehouse:delivery:create"));
|
||||
|
||||
var result = service.currentPermissions(new AuthenticatedUser(42L, 7L, 7L, 9L,
|
||||
"owner", "MERCHANT", "MERCHANT", java.util.Set.of()), "MERCHANT");
|
||||
|
||||
assertThat(result).containsExactlyInAnyOrder(
|
||||
"merchant:warehouse:delivery:create",
|
||||
"merchant:warehouse:delivery:list");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsIncompleteCurrentIdentityAsBusinessError() {
|
||||
assertThatThrownBy(() -> service
|
||||
|
||||
@@ -50,10 +50,11 @@
|
||||
| 测试 | `src/test/java/com/baiyue/lastleg/` |
|
||||
| 真实事件消费验收 | `src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java`;覆盖真实 V2 建单、独立入仓单快照及下游转运/换标可消费性 |
|
||||
|
||||
## 四期开发范围路由
|
||||
## 四期/五期开发范围路由
|
||||
|
||||
- 尾程服务覆盖尾程订单、入仓计划/PDA 入仓、换标、产品与箱唛快照、库存、海外采购、本土卡车、本土散板、排车、出库、派送、客户拜访及异常退回等完整业务域。
|
||||
- 排车编辑/取消/出库、PDA 出库和 `delivery/` Controller 与其他尾程业务接口一同注册,不受功能开关控制。平台排车列表与详情仍由 `lastleg.dispatch-query-api.enabled=true` 独立启用;入库单智能排车候选、草稿锁定/解锁/释放和预排创建仍由 `lastleg.smart-dispatch-api.enabled=true` 独立启用。这些排车平台入口均不接收 `customerId`:排车列表跨客户分页,详情按全局排车单号定位后使用记录自身客户范围加载明细;智能排车候选未传计划 ID 时跨客户查询,传入时根据计划 ID 解析范围;托盘移入草稿时立即以操作人和随机草稿标识锁定,移除或关闭时释放,草稿锁超时失效;预排创建根据所选托盘 ID 在服务端解析并校验唯一客户范围,并将当前操作人的草稿锁原子转换为正式排车锁。
|
||||
- 四期范围止于尾程订单、入仓计划/PDA 入仓、换标、产品与箱唛快照、库存、海外采购、本土卡车、本土散板以及排车查询/智能预排;不把出库、派送、异常退回或回访倒灌为四期验收项。
|
||||
- 五期范围承接排车编辑/取消/发车、PDA 排车拣货与出库任务、一件代发出库、排车转派送、客户派送单、异常退回和客户回访。服务端正式入口为 `dispatch/controller/DispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`、`delivery/controller/DeliveryTransferController.java`、`delivery/controller/OutboundPdaController.java`、`delivery/controller/MerchantDeliveryController.java`、`exceptionorder/` 与 `customervisit/`;海外 PDA 前端工作台尚未接入时只视为后端能力,不得声称设备页面可用。
|
||||
- 排车执行与 `delivery/` Controller 和其他尾程业务接口一同注册,不受功能开关控制。平台排车列表与详情仍由 `lastleg.dispatch-query-api.enabled=true` 独立启用;入库单智能排车候选、草稿锁定/解锁/释放和预排创建仍由 `lastleg.smart-dispatch-api.enabled=true` 独立启用。这些排车平台入口均不接收 `customerId`:排车列表跨客户分页,详情按全局排车单号定位后使用记录自身客户范围加载明细;智能排车候选未传计划 ID 时跨客户查询,传入时根据计划 ID 解析范围;托盘移入草稿时立即以操作人和随机草稿标识锁定,移除或关闭时释放,草稿锁超时失效;预排创建根据所选托盘 ID 在服务端解析并校验唯一客户范围,并将当前操作人的草稿锁原子转换为正式排车锁。
|
||||
- 仓库 ID、类型、状态、地址和能力唯一权威归国内 `warehouse-service`;海外库位、实物托盘和资源锁归 `warehouse-overseas-service/` 且只引用同一国内 `warehouseId`。Lastleg PDA 通过 Warehouse Overseas 内部资源入口校验该国内共享收货仓,不得要求海外数据库重复建仓;浏览器 API 统一经 `gateway-overseas/`。
|
||||
|
||||
## 数据库表索引与注释约束
|
||||
|
||||
@@ -66,6 +66,7 @@ com.baiyue.lastleg.transfer.service
|
||||
|
||||
| 来源 | 目标 | 类边数 |
|
||||
|---|---|---:|
|
||||
| dispatch-delivery | dropship | 5 |
|
||||
| dispatch-delivery | inbound-pda | 6 |
|
||||
| dispatch-delivery | inventory | 4 |
|
||||
| dispatch-delivery | shared-technical | 2 |
|
||||
@@ -88,12 +89,13 @@ com.baiyue.lastleg.transfer.service
|
||||
| relabel-transfer-discard | shared-technical | 3 |
|
||||
| relabel-transfer-discard | tailleg-handoff | 14 |
|
||||
| security-projection | shared-technical | 1 |
|
||||
| tailleg-handoff | dispatch-delivery | 8 |
|
||||
| tailleg-handoff | dropship | 2 |
|
||||
| tailleg-handoff | inbound-pda | 6 |
|
||||
| tailleg-handoff | inventory | 2 |
|
||||
| tailleg-handoff | shared-technical | 2 |
|
||||
|
||||
当前 26 条切片边冻结在 `src/test/resources/architecture/lastleg-slice-dependencies.json`。合同允许删除历史边,不允许增加新边;海外主数据继续锁定为只允许出向 `shared-technical`,只允许采购/卡车/散板切片入向;安全投影只允许出向 `shared-technical` 且禁止任何业务切片入向依赖。
|
||||
当前 28 条切片边冻结在 `src/test/resources/architecture/lastleg-slice-dependencies.json`。合同允许删除历史边,不允许增加新边;海外主数据继续锁定为只允许出向 `shared-technical`,只允许采购/卡车/散板切片入向;安全投影只允许出向 `shared-technical` 且禁止任何业务切片入向依赖。
|
||||
|
||||
## 5. Entity/DTO/Mapper 泄漏基线
|
||||
|
||||
@@ -208,3 +210,9 @@ node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set all -
|
||||
- MySQL 表固定使用 Lastleg 前缀 `ll_security_event_inbox`、`ll_security_projection`;没有 `overseas_security_*` 表或独立数据库归属。
|
||||
- `loading`、`shipping`、`inboundplan` 及其他业务切片不得直接依赖 `security/controller|dto|service`;当前 Java 类型图零入向,Node 与 ArchUnit 双重锁定。
|
||||
- 安全投影不进入主逻辑 SCC,唯一出向为内部 Controller 复用共享令牌守卫,不注册为 loading/shipping/inbound 的模型、Mapper 或应用服务。
|
||||
|
||||
## 12. 五期出库派送稳定边界
|
||||
|
||||
- `dispatch-delivery -> dropship` 是一件代发出库的稳定业务依赖:出库应用服务按客户范围读取一件代发托盘、箱唛和 SKU 库存,并在 PDA 出库事务中完成扣减。
|
||||
- `tailleg-handoff -> dispatch-delivery` 是异常退回与客户回访的稳定业务依赖:异常单和回访应用服务按派送业务号读取派送单及操作记录,不引入反向兼容入口或跨服务共享模型。
|
||||
- 两条边均属于同一 `lastleg-service` 部署单元内的五期正式链路;本次只冻结已存在的生产依赖,不扩大海外主数据或安全投影的允许方向。
|
||||
|
||||
+29
-20
@@ -24,43 +24,50 @@ public class DeliveryTransferController {
|
||||
@Resource private DeliveryTransferService service;
|
||||
|
||||
@PostMapping("/dispatches/{dispatchNo}/transfer-delivery")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_TRANSFER_DELIVERY + "')")
|
||||
@Operation(summary = "Create one pending outbound order and one pending delivery order")
|
||||
public ApiResponse<DeliveryResponses.TransferResult> transfer(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String dispatchNo,
|
||||
@RequestParam(required = false) String customerId,
|
||||
@Valid @RequestBody DeliveryRequests.Transfer request) {
|
||||
return ApiResponse.success(service.transfer(scope(user, customerId), dispatchNo, operator(user), request));
|
||||
requirePlatform(user);
|
||||
return ApiResponse.success(service.platformTransfer(dispatchNo, operator(user), request));
|
||||
}
|
||||
|
||||
@PostMapping("/outbound-orders/one-click-dropship")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.OUTBOUND_CREATE + "')")
|
||||
@Operation(summary = "Create one-click dropship outbound and push a PDA picking task")
|
||||
public ApiResponse<DeliveryResponses.PdaTask> createOneClick(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId,
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId,
|
||||
@Valid @RequestBody DeliveryRequests.OneClickOutbound request) {
|
||||
return ApiResponse.success(service.createOneClick(scope(user, customerId), operator(user), request));
|
||||
return ApiResponse.success(service.createOneClick(requiredCustomer(user, customerId), operator(user), request));
|
||||
}
|
||||
|
||||
@GetMapping("/outbound-orders")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.OUTBOUND_LIST + "')")
|
||||
public ApiResponse<PageResult<DeliveryResponses.OutboundRow>> outboundPage(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId,
|
||||
@Valid DeliveryRequests.Query query) {
|
||||
requirePlatform(user);
|
||||
return ApiResponse.success(service.outboundPage(listScope(user, customerId), query));
|
||||
}
|
||||
|
||||
@GetMapping("/outbound-orders/{outboundNo}")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.OUTBOUND_LIST + "')")
|
||||
public ApiResponse<DeliveryResponses.OutboundDetail> outboundDetail(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId,
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String outboundNo, @Valid DeliveryRequests.PalletQuery query) {
|
||||
return ApiResponse.success(service.outboundDetail(scope(user, customerId), outboundNo, query));
|
||||
requirePlatform(user);
|
||||
return ApiResponse.success(service.outboundDetail(outboundNo, query));
|
||||
}
|
||||
|
||||
@PostMapping("/outbound-orders/{outboundNo}/cancel")
|
||||
@Operation(summary = "Cancel outbound and linked delivery, restoring inventory")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.OUTBOUND_CANCEL + "')")
|
||||
@Operation(summary = "Cancel an unexecuted outbound order and its linked delivery")
|
||||
public ApiResponse<Void> cancelOutbound(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String customerId,
|
||||
@PathVariable String outboundNo,
|
||||
@Valid @RequestBody DeliveryRequests.Cancel request) {
|
||||
service.cancelOutbound(scope(user, customerId), operator(user), outboundNo, request);
|
||||
requirePlatform(user);
|
||||
service.cancelOutbound(outboundNo, request);
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
@@ -77,25 +84,27 @@ public class DeliveryTransferController {
|
||||
public ApiResponse<DeliveryResponses.DeliveryDetail> deliveryDetail(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId,
|
||||
@PathVariable String deliveryNo) {
|
||||
return ApiResponse.success(service.deliveryDetail(scope(user, customerId), deliveryNo));
|
||||
return ApiResponse.success(service.deliveryDetail(requiredCustomer(user, customerId), deliveryNo));
|
||||
}
|
||||
|
||||
private String scope(AuthenticatedUser user, String requestedCustomer) {
|
||||
private String requiredCustomer(AuthenticatedUser user, String requestedCustomer) {
|
||||
requirePlatform(user);
|
||||
if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Platform request must specify target customer");
|
||||
}
|
||||
return requestedCustomer.trim();
|
||||
}
|
||||
private void requirePlatform(AuthenticatedUser user) {
|
||||
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
|
||||
if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) {
|
||||
if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Platform request must specify target tenant");
|
||||
}
|
||||
return requestedCustomer.trim();
|
||||
if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Platform application required");
|
||||
}
|
||||
return String.valueOf(user.tenantId());
|
||||
}
|
||||
private String operator(AuthenticatedUser user) {
|
||||
return user.userId() == null ? user.username() : String.valueOf(user.userId());
|
||||
}
|
||||
private String listScope(AuthenticatedUser user, String requestedCustomer) {
|
||||
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
|
||||
if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) return requestedCustomer == null || requestedCustomer.isBlank() ? null : requestedCustomer.trim();
|
||||
return String.valueOf(user.tenantId());
|
||||
requirePlatform(user);
|
||||
return requestedCustomer == null || requestedCustomer.isBlank() ? null : requestedCustomer.trim();
|
||||
}
|
||||
}
|
||||
|
||||
+6
-1
@@ -6,9 +6,11 @@ import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.delivery.dto.DeliveryRequests;
|
||||
import com.baiyue.lastleg.delivery.dto.DeliveryResponses;
|
||||
import com.baiyue.lastleg.delivery.service.DeliveryTransferService;
|
||||
import com.baiyue.lastleg.security.OverseasPermissions;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
@@ -19,19 +21,22 @@ public class MerchantDeliveryController {
|
||||
@Resource private DeliveryTransferService service;
|
||||
|
||||
@GetMapping
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DELIVERY_LIST + "')")
|
||||
public ApiResponse<PageResult<DeliveryResponses.DeliveryRow>> page(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid DeliveryRequests.Query query) {
|
||||
return ApiResponse.success(service.deliveryPage(customer(user), query));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DELIVERY_CREATE + "')")
|
||||
public ApiResponse<DeliveryResponses.TransferResult> create(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam String dispatchNo,
|
||||
@Valid @RequestBody DeliveryRequests.Transfer request) {
|
||||
return ApiResponse.success(service.transfer(customer(user), dispatchNo, operator(user), request));
|
||||
return ApiResponse.success(service.merchantTransfer(customer(user), dispatchNo, operator(user), request));
|
||||
}
|
||||
|
||||
@GetMapping("/{deliveryNo}")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DELIVERY_LIST + "')")
|
||||
public ApiResponse<DeliveryResponses.DeliveryDetail> detail(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String deliveryNo) {
|
||||
return ApiResponse.success(service.deliveryDetail(customer(user), deliveryNo));
|
||||
|
||||
+23
-14
@@ -5,37 +5,46 @@ import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.delivery.dto.DeliveryRequests;
|
||||
import com.baiyue.lastleg.delivery.dto.DeliveryResponses;
|
||||
import com.baiyue.lastleg.delivery.service.DeliveryTransferService;
|
||||
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/pda/v1/last-leg/outbound-tasks")
|
||||
@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')")
|
||||
public class OutboundPdaController {
|
||||
@Resource private DeliveryTransferService service;
|
||||
|
||||
@PostMapping("/{taskNo}/pick")
|
||||
public ApiResponse<DeliveryResponses.PdaTask> pick(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String taskNo, @RequestParam(required=false) String customerId,
|
||||
@PathVariable String taskNo,
|
||||
@Valid @RequestBody DeliveryRequests.PdaPick request) {
|
||||
return ApiResponse.success(service.pdaPick(scope(user, customerId), operator(user), taskNo, request));
|
||||
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
|
||||
return ApiResponse.success(service.pdaPick(String.valueOf(identity.overseasWarehouseId()),
|
||||
identity.operatorKey(), taskNo, request));
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public ApiResponse<java.util.List<DeliveryResponses.PdaTask>> list(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required=false) String customerId, @RequestParam(required=false) String status) {
|
||||
return ApiResponse.success(service.pdaTasks(scope(user, customerId), status));
|
||||
@RequestParam(required=false) String status) {
|
||||
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
|
||||
return ApiResponse.success(service.pdaTasks(String.valueOf(identity.overseasWarehouseId()), status));
|
||||
}
|
||||
|
||||
@GetMapping("/{taskNo}")
|
||||
public ApiResponse<DeliveryResponses.PdaTaskDetail> detail(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String taskNo) {
|
||||
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
|
||||
return ApiResponse.success(service.pdaTaskDetail(String.valueOf(identity.overseasWarehouseId()), taskNo));
|
||||
}
|
||||
|
||||
@PostMapping("/{taskNo}/complete")
|
||||
public ApiResponse<DeliveryResponses.PdaTask> complete(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String taskNo, @RequestParam(required=false) String customerId) {
|
||||
return ApiResponse.success(service.completePda(scope(user, customerId), taskNo));
|
||||
@PathVariable String taskNo, @Valid @RequestBody DeliveryRequests.PdaComplete request) {
|
||||
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
|
||||
return ApiResponse.success(service.completePda(String.valueOf(identity.overseasWarehouseId()), taskNo, request));
|
||||
}
|
||||
private String scope(AuthenticatedUser user, String requested) {
|
||||
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
|
||||
if (requested != null && !requested.isBlank()) return requested.trim();
|
||||
return String.valueOf(user.tenantId());
|
||||
}
|
||||
private String operator(AuthenticatedUser user) { return user.userId() == null ? user.username() : String.valueOf(user.userId()); }
|
||||
}
|
||||
|
||||
@@ -56,5 +56,6 @@ public final class DeliveryRequests {
|
||||
@NotBlank @Size(max=128) String sku) {}
|
||||
public record PdaPick(@NotNull Long outboundPalletItemId, @NotNull Long inventoryPositionId,
|
||||
@NotNull @Min(1) Long packages, @NotBlank @Size(max=255) String idempotencyKey) {}
|
||||
public record PdaComplete(@NotBlank @Size(max=255) String idempotencyKey) {}
|
||||
public record Cancel(@NotBlank @Size(max=1000) String reason) {}
|
||||
}
|
||||
|
||||
@@ -42,4 +42,11 @@ public final class DeliveryResponses {
|
||||
|
||||
public record OutboundDetail(OutboundRow outbound, DeliveryRow delivery) {}
|
||||
public record PdaTask(Long id, String taskNo, String status, String outboundNo, LocalDateTime pushedAt, LocalDateTime completedAt) {}
|
||||
public record PdaPickPosition(Long inventoryPositionId, String palletNo, String cartonMark,
|
||||
String skuCode, String inboundCode, long availablePackages,
|
||||
long pickedPackages) {}
|
||||
public record PdaTaskItem(Long outboundPalletItemId, String palletNo, String skuCode, String inboundCode,
|
||||
int packageCount, int pickedPackageCount, String status,
|
||||
List<PdaPickPosition> positions) {}
|
||||
public record PdaTaskDetail(PdaTask task, List<PdaTaskItem> items) {}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ public class OutboundPdaTask {
|
||||
private Long outboundOrderId;
|
||||
private String taskNo;
|
||||
private String status;
|
||||
private String completionIdempotencyKey;
|
||||
private LocalDateTime pushedAt;
|
||||
private LocalDateTime completedAt;
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
+4
@@ -10,10 +10,14 @@ import org.apache.ibatis.annotations.Select;
|
||||
public interface OutboundOrderMapper extends BaseMapper<OutboundOrder> {
|
||||
@Select("SELECT * FROM ll_outbound_order WHERE customer_id=#{customerId} AND outbound_no=#{outboundNo} LIMIT 1")
|
||||
OutboundOrder findByNo(@Param("customerId") String customerId, @Param("outboundNo") String outboundNo);
|
||||
@Select("SELECT * FROM ll_outbound_order WHERE outbound_no=#{outboundNo} LIMIT 1")
|
||||
OutboundOrder findGlobalByNo(@Param("outboundNo") String outboundNo);
|
||||
@Select("SELECT * FROM ll_outbound_order WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1")
|
||||
OutboundOrder findByIdempotency(@Param("customerId") String customerId, @Param("key") String key);
|
||||
@Select("SELECT * FROM ll_outbound_order WHERE customer_id=#{customerId} AND dispatch_order_id=#{dispatchOrderId} LIMIT 1 FOR UPDATE")
|
||||
OutboundOrder lockByDispatch(@Param("customerId") String customerId, @Param("dispatchOrderId") Long dispatchOrderId);
|
||||
@Select("SELECT * FROM ll_outbound_order WHERE customer_id=#{customerId} AND outbound_no=#{outboundNo} LIMIT 1 FOR UPDATE")
|
||||
OutboundOrder lockByNo(@Param("customerId") String customerId, @Param("outboundNo") String outboundNo);
|
||||
@Select("SELECT * FROM ll_outbound_order WHERE outbound_no=#{outboundNo} LIMIT 1 FOR UPDATE")
|
||||
OutboundOrder lockGlobalByNo(@Param("outboundNo") String outboundNo);
|
||||
}
|
||||
|
||||
+18
@@ -13,4 +13,22 @@ public interface OutboundPdaTaskMapper extends BaseMapper<OutboundPdaTask> {
|
||||
OutboundPdaTask lockByNo(@Param("customerId") String customerId, @Param("taskNo") String taskNo);
|
||||
@Select("SELECT * FROM ll_outbound_pda_task WHERE customer_id=#{customerId} AND (#{status} IS NULL OR status=#{status}) ORDER BY pushed_at DESC LIMIT 200")
|
||||
List<OutboundPdaTask> list(@Param("customerId") String customerId, @Param("status") String status);
|
||||
|
||||
@Select("SELECT t.* FROM ll_outbound_pda_task t JOIN ll_outbound_order o ON o.id=t.outbound_order_id " +
|
||||
"AND o.customer_id=t.customer_id WHERE t.task_no=#{taskNo} AND o.overseas_warehouse_id=#{warehouseId} " +
|
||||
"LIMIT 1 FOR UPDATE")
|
||||
OutboundPdaTask lockByNoAndWarehouse(@Param("taskNo") String taskNo, @Param("warehouseId") String warehouseId);
|
||||
|
||||
@Select("SELECT t.* FROM ll_outbound_pda_task t JOIN ll_outbound_order o ON o.id=t.outbound_order_id " +
|
||||
"AND o.customer_id=t.customer_id WHERE t.task_no=#{taskNo} AND o.overseas_warehouse_id=#{warehouseId} LIMIT 1")
|
||||
OutboundPdaTask findByNoAndWarehouse(@Param("taskNo") String taskNo, @Param("warehouseId") String warehouseId);
|
||||
|
||||
@Select("SELECT t.* FROM ll_outbound_pda_task t JOIN ll_outbound_order o ON o.id=t.outbound_order_id " +
|
||||
"AND o.customer_id=t.customer_id WHERE o.overseas_warehouse_id=#{warehouseId} " +
|
||||
"AND (#{status} IS NULL OR t.status=#{status}) ORDER BY t.pushed_at DESC LIMIT 200")
|
||||
List<OutboundPdaTask> listByWarehouse(@Param("warehouseId") String warehouseId, @Param("status") String status);
|
||||
|
||||
@Select("SELECT * FROM ll_outbound_pda_task WHERE customer_id=#{customerId} " +
|
||||
"AND completion_idempotency_key=#{key} LIMIT 1")
|
||||
OutboundPdaTask findByCompletionKey(@Param("customerId") String customerId, @Param("key") String key);
|
||||
}
|
||||
|
||||
+172
-29
@@ -34,6 +34,8 @@ import com.baiyue.lastleg.delivery.mapper.OutboundPdaPickMapper;
|
||||
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests;
|
||||
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
|
||||
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
|
||||
import com.baiyue.lastleg.inventory.entity.InventoryBalance;
|
||||
import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper;
|
||||
import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper;
|
||||
import com.baiyue.lastleg.dropship.entity.DropshipOrderProfile;
|
||||
import com.baiyue.lastleg.dropship.entity.DropshipCarton;
|
||||
@@ -69,6 +71,7 @@ public class DeliveryTransferService {
|
||||
@Resource private OutboundPdaTaskMapper pdaTasks;
|
||||
@Resource private OutboundPdaPickMapper pdaPicks;
|
||||
@Resource private InventoryPositionMapper inventoryPositions;
|
||||
@Resource private InventoryBalanceMapper inventoryBalances;
|
||||
@Resource private InventoryCommandService inventoryCommands;
|
||||
@Resource private DropshipOrderProfileMapper dropshipProfiles;
|
||||
@Resource private DropshipCartonMapper dropshipCartons;
|
||||
@@ -81,11 +84,26 @@ public class DeliveryTransferService {
|
||||
@Resource private LastlegOrderNoGenerator orderNoGenerator;
|
||||
|
||||
@Transactional
|
||||
public DeliveryResponses.TransferResult transfer(String customerId, String dispatchNo, String operatorId,
|
||||
DeliveryRequests.Transfer request) {
|
||||
public DeliveryResponses.TransferResult platformTransfer(String dispatchNo, String operatorId,
|
||||
DeliveryRequests.Transfer request) {
|
||||
DispatchOrder dispatch = dispatchOrders.lockGlobalByDispatchNo(trim(dispatchNo));
|
||||
if (dispatch == null) throw notFound("Dispatch order not found");
|
||||
return transferResolved(dispatch, operatorId, request);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public DeliveryResponses.TransferResult merchantTransfer(String customerId, String dispatchNo, String operatorId,
|
||||
DeliveryRequests.Transfer request) {
|
||||
DispatchOrder found = dispatchOrders.findByDispatchNo(customerId, dispatchNo);
|
||||
if (found == null) throw notFound("Dispatch order not found");
|
||||
DispatchOrder dispatch = dispatchOrders.lockById(customerId, found.getId());
|
||||
if (dispatch == null) throw notFound("Dispatch order not found");
|
||||
return transferResolved(dispatch, operatorId, request);
|
||||
}
|
||||
|
||||
private DeliveryResponses.TransferResult transferResolved(DispatchOrder dispatch, String operatorId,
|
||||
DeliveryRequests.Transfer request) {
|
||||
String customerId = dispatch.getCustomerId();
|
||||
OutboundOrder replay = outboundOrders.findByIdempotency(customerId, request.idempotencyKey().trim());
|
||||
if (replay != null) {
|
||||
if (!dispatch.getId().equals(replay.getDispatchOrderId())) throw conflict("Idempotency key belongs to another dispatch");
|
||||
@@ -228,15 +246,42 @@ public class DeliveryTransferService {
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public DeliveryResponses.PdaTask pdaPick(String customerId, String operatorId, String taskNo, DeliveryRequests.PdaPick request) {
|
||||
OutboundPdaTask task = pdaTasks.lockByNo(customerId, taskNo); if (task == null) throw notFound("PDA task not found");
|
||||
OutboundPdaPick replay = pdaPicks.findByKey(customerId, request.idempotencyKey().trim()); if (replay != null) return taskResult(outboundOrders.selectById(task.getOutboundOrderId()), task);
|
||||
OutboundOrder outbound = outboundOrders.lockByNo(customerId, outboundNo(task.getOutboundOrderId()));
|
||||
public DeliveryResponses.PdaTask pdaPick(String warehouseId, String operatorId, String taskNo,
|
||||
DeliveryRequests.PdaPick request) {
|
||||
OutboundPdaTask task = pdaTasks.lockByNoAndWarehouse(required(taskNo, "PDA task number is required"),
|
||||
required(warehouseId, "PDA warehouse binding is required"));
|
||||
if (task == null) throw notFound("PDA task not found in the bound warehouse");
|
||||
String customerId = task.getCustomerId();
|
||||
OutboundPdaPick replay = pdaPicks.findByKey(customerId, request.idempotencyKey().trim());
|
||||
if (replay != null) {
|
||||
if (!task.getId().equals(replay.getTaskId())
|
||||
|| !request.outboundPalletItemId().equals(replay.getOutboundPalletItemId())
|
||||
|| !request.inventoryPositionId().equals(replay.getInventoryPositionId())
|
||||
|| !request.packages().equals(replay.getPackages())) {
|
||||
throw conflict("Idempotency key was reused with a different outbound pick request");
|
||||
}
|
||||
return taskResult(requireOutbound(task), task);
|
||||
}
|
||||
OutboundOrder existingOutbound = requireOutbound(task);
|
||||
OutboundOrder outbound = outboundOrders.lockByNo(customerId, existingOutbound.getOutboundNo());
|
||||
if (outbound == null || !required(warehouseId, "PDA warehouse binding is required")
|
||||
.equals(outbound.getOverseasWarehouseId())) throw notFound("Outbound order not found in the bound warehouse");
|
||||
if (!Set.of("PENDING", "IN_PROGRESS").contains(task.getStatus())) {
|
||||
throw conflict("PDA task is not available for picking");
|
||||
}
|
||||
OutboundPalletItem item = outboundPallets.selectById(request.outboundPalletItemId());
|
||||
if (item == null || !task.getOutboundOrderId().equals(item.getOutboundOrderId())) throw notFound("Outbound pallet item not found");
|
||||
if (item == null || !customerId.equals(item.getCustomerId())
|
||||
|| !task.getOutboundOrderId().equals(item.getOutboundOrderId())) throw notFound("Outbound pallet item not found");
|
||||
if (request.packages() > item.getPackageCount() - value(item.getPickedPackageCount())) throw conflict("Picked quantity exceeds outbound pallet quantity");
|
||||
InventoryPosition selectedPosition = inventoryPositions.selectById(request.inventoryPositionId());
|
||||
if (selectedPosition == null || !customerId.equals(selectedPosition.getCustomerId()) || !Objects.equals(selectedPosition.getPalletId(), item.getWarehousePalletId())) throw conflict("Scanned inventory position does not belong to the outbound pallet");
|
||||
if (!warehouseId.equals(selectedPosition.getOverseasWarehouseId())) {
|
||||
throw conflict("Scanned inventory position does not belong to the bound warehouse");
|
||||
}
|
||||
InventoryBalance selectedBalance = inventoryBalances.selectById(selectedPosition.getBalanceId());
|
||||
if (!matchesOutboundItem(customerId, warehouseId, item, selectedBalance)) {
|
||||
throw conflict("Scanned inventory position does not belong to the outbound item");
|
||||
}
|
||||
DropshipOrderProfile profile = dropshipProfiles.findPosition(customerId, request.inventoryPositionId());
|
||||
if (profile == null) throw conflict("Scanned pallet is not one-click dropship inventory");
|
||||
List<DropshipCarton> cartons = dropshipCartons.lockAvailable(customerId, profile.getId(), item.getSkuCode(), request.packages());
|
||||
@@ -258,37 +303,60 @@ public class DeliveryTransferService {
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public DeliveryResponses.PdaTask completePda(String customerId, String taskNo) {
|
||||
OutboundPdaTask task = pdaTasks.lockByNo(customerId, taskNo); if (task == null) throw notFound("PDA task not found");
|
||||
OutboundOrder outbound = outboundOrders.selectById(task.getOutboundOrderId());
|
||||
public DeliveryResponses.PdaTask completePda(String warehouseId, String taskNo,
|
||||
DeliveryRequests.PdaComplete request) {
|
||||
OutboundPdaTask task = pdaTasks.lockByNoAndWarehouse(required(taskNo, "PDA task number is required"),
|
||||
required(warehouseId, "PDA warehouse binding is required"));
|
||||
if (task == null) throw notFound("PDA task not found in the bound warehouse");
|
||||
String customerId = task.getCustomerId();
|
||||
OutboundOrder outbound = requireOutbound(task);
|
||||
String completionKey = request.idempotencyKey().trim();
|
||||
OutboundPdaTask completionReplay = pdaTasks.findByCompletionKey(customerId, completionKey);
|
||||
if (completionReplay != null && !task.getId().equals(completionReplay.getId())) {
|
||||
throw conflict("Idempotency key belongs to another outbound PDA task");
|
||||
}
|
||||
if ("COMPLETED".equals(task.getStatus())) {
|
||||
if (completionKey.equals(task.getCompletionIdempotencyKey())) {
|
||||
return taskResult(outbound, task);
|
||||
}
|
||||
throw conflict("PDA task was already completed with another idempotency key");
|
||||
}
|
||||
if (!Set.of("PENDING", "IN_PROGRESS").contains(task.getStatus())) {
|
||||
throw conflict("PDA task is not available for completion");
|
||||
}
|
||||
List<OutboundPalletItem> items = outboundPallets.findByOutbound(customerId, outbound.getId());
|
||||
if (items.stream().anyMatch(i -> value(i.getPickedPackageCount()) < value(i.getPackageCount()))) throw conflict("All outbound pallets must be picked before completion");
|
||||
task.setStatus("COMPLETED"); task.setCompletedAt(LocalDateTime.now()); task.setUpdatedAt(LocalDateTime.now()); pdaTasks.updateById(task); outbound.setStatus("OUTBOUNDED"); outbound.setUpdatedAt(LocalDateTime.now()); outboundOrders.updateById(outbound); return taskResult(outbound, task);
|
||||
if (items.isEmpty() || items.stream().anyMatch(i -> value(i.getPickedPackageCount()) != value(i.getPackageCount()))) {
|
||||
throw conflict("All outbound pallets and packages must be picked before completion");
|
||||
}
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
task.setStatus("COMPLETED"); task.setCompletionIdempotencyKey(completionKey);
|
||||
task.setCompletedAt(now); task.setUpdatedAt(now); pdaTasks.updateById(task);
|
||||
outbound.setStatus("OUTBOUNDED"); outbound.setUpdatedAt(now); outboundOrders.updateById(outbound);
|
||||
return taskResult(outbound, task);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void cancelOutbound(String customerId, String operatorId, String outboundNo, DeliveryRequests.Cancel request) {
|
||||
OutboundOrder outbound = outboundOrders.lockByNo(customerId, outboundNo);
|
||||
public void cancelOutbound(String outboundNo, DeliveryRequests.Cancel request) {
|
||||
OutboundOrder outbound = outboundOrders.lockGlobalByNo(trim(outboundNo));
|
||||
if (outbound == null) throw notFound("Outbound order not found");
|
||||
if ("CANCELLED".equals(outbound.getStatus())) return;
|
||||
if (!Set.of("WAIT_OUTBOUND", "WAIT_PICK").contains(outbound.getStatus())) {
|
||||
throw conflict("Only an unexecuted outbound order can be cancelled");
|
||||
}
|
||||
String customerId = outbound.getCustomerId();
|
||||
List<OutboundPalletItem> outboundItems = outboundPallets.findByOutbound(customerId, outbound.getId());
|
||||
if (outboundItems.isEmpty() || outboundItems.stream().anyMatch(i -> !"PICKED".equals(i.getStatus())))
|
||||
throw conflict("Outbound order can only be cancelled after picking is completed");
|
||||
if (outboundItems.isEmpty() || outboundItems.stream().anyMatch(i -> value(i.getPickedPackageCount()) > 0
|
||||
|| !Set.of("WAIT_PICK", "WAIT_OUTBOUND").contains(i.getStatus()))) {
|
||||
throw conflict("Outbound order cannot be cancelled after execution has started");
|
||||
}
|
||||
DeliveryOrder existingDelivery = deliveryOrders.lockByOutbound(customerId, outbound.getId());
|
||||
if (existingDelivery != null && !"WAIT_DELIVERY".equals(existingDelivery.getStatus()))
|
||||
throw conflict("Outbound order cannot be cancelled while delivery is in progress");
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
for (OutboundPdaPick pick : pdaPicks.findByOutbound(customerId, outbound.getId())) {
|
||||
inventoryCommands.adjust(customerId, operatorId,
|
||||
new InventoryCommandRequests.Adjustment(pick.getInventoryPositionId(), pick.getPackages(), "OUTBOUND_CANCEL",
|
||||
outbound.getOutboundNo(), outbound.getOutboundNo() + ":CANCEL:" + pick.getId(), request.reason()));
|
||||
OutboundPalletItem pickedItem = outboundItems.stream().filter(i -> Objects.equals(i.getId(), pick.getOutboundPalletItemId())).findFirst().orElse(null);
|
||||
DropshipOrderProfile profile = dropshipProfiles.findPosition(customerId, pick.getInventoryPositionId());
|
||||
if (profile != null && pickedItem != null) dropshipSkuInventory.restore(customerId, profile.getId(), pickedItem.getSkuCode(), pick.getPackages());
|
||||
}
|
||||
dropshipCartons.restoreOutbound(customerId, outbound.getOutboundNo());
|
||||
DeliveryOrder delivery = existingDelivery;
|
||||
if (delivery != null) { delivery.setStatus("CANCELLED"); delivery.setUpdatedAt(now); deliveryOrders.updateById(delivery); }
|
||||
OutboundPdaTask task = pdaTasks.lockByOutbound(customerId, outbound.getId());
|
||||
if (task != null) { task.setStatus("CANCELLED"); task.setUpdatedAt(now); pdaTasks.updateById(task); }
|
||||
outbound.setStatus("CANCELLED"); outbound.setUpdatedAt(now); outboundOrders.updateById(outbound);
|
||||
for (OutboundPalletItem item : outboundItems) { item.setStatus("CANCELLED"); outboundPallets.updateById(item); }
|
||||
if (delivery != null) {
|
||||
@@ -299,10 +367,79 @@ public class DeliveryTransferService {
|
||||
}
|
||||
}
|
||||
|
||||
public List<DeliveryResponses.PdaTask> pdaTasks(String customerId, String status) { return pdaTasks.list(customerId, StringUtils.hasText(status) ? status : null).stream().map(task -> taskResult(outboundOrders.selectById(task.getOutboundOrderId()), task)).toList(); }
|
||||
public List<DeliveryResponses.PdaTask> pdaTasks(String warehouseId, String status) {
|
||||
String boundWarehouseId = required(warehouseId, "PDA warehouse binding is required");
|
||||
return pdaTasks.listByWarehouse(boundWarehouseId, StringUtils.hasText(status) ? status.trim() : null).stream()
|
||||
.map(task -> taskResult(requireOutboundInWarehouse(task, boundWarehouseId), task)).toList();
|
||||
}
|
||||
|
||||
public DeliveryResponses.PdaTaskDetail pdaTaskDetail(String warehouseId, String taskNo) {
|
||||
String boundWarehouseId = required(warehouseId, "PDA warehouse binding is required");
|
||||
OutboundPdaTask task = pdaTasks.findByNoAndWarehouse(required(taskNo, "PDA task number is required"),
|
||||
boundWarehouseId);
|
||||
if (task == null) throw notFound("PDA task not found in the bound warehouse");
|
||||
OutboundOrder outbound = requireOutboundInWarehouse(task, boundWarehouseId);
|
||||
List<DeliveryResponses.PdaTaskItem> items = outboundPallets.findByOutbound(task.getCustomerId(),
|
||||
task.getOutboundOrderId()).stream().map(item -> new DeliveryResponses.PdaTaskItem(item.getId(),
|
||||
item.getPalletNo(), item.getSkuCode(), item.getInboundCode(), value(item.getPackageCount()),
|
||||
value(item.getPickedPackageCount()), item.getStatus(),
|
||||
pdaPickPositions(task.getCustomerId(), boundWarehouseId, item))).toList();
|
||||
return new DeliveryResponses.PdaTaskDetail(taskResult(outbound, task), items);
|
||||
}
|
||||
|
||||
private List<DeliveryResponses.PdaPickPosition> pdaPickPositions(String customerId, String warehouseId,
|
||||
OutboundPalletItem item) {
|
||||
if (item.getWarehousePalletId() == null) return List.of();
|
||||
List<DeliveryResponses.PdaPickPosition> result = new ArrayList<>();
|
||||
for (InventoryPosition position : inventoryPositions.listPallet(customerId, item.getWarehousePalletId())) {
|
||||
long available = value(position.getAvailablePackages());
|
||||
if (available <= 0 || !warehouseId.equals(position.getOverseasWarehouseId())) continue;
|
||||
InventoryBalance balance = inventoryBalances.selectById(position.getBalanceId());
|
||||
if (!matchesOutboundItem(customerId, warehouseId, item, balance)) continue;
|
||||
DropshipOrderProfile profile = dropshipProfiles.findPosition(customerId, position.getId());
|
||||
List<String> marks = profile == null ? List.of() : dropshipCartons.listProfile(customerId, profile.getId())
|
||||
.stream().filter(carton -> "AVAILABLE".equals(carton.getStatus()))
|
||||
.filter(carton -> Objects.equals(item.getSkuCode(), carton.getSkuCode()))
|
||||
.map(DropshipCarton::getCartonMark).filter(StringUtils::hasText).distinct().limit(available).toList();
|
||||
if (marks.isEmpty()) {
|
||||
result.add(pdaPosition(position, item, balance, null, available, value(position.getPickedPackages())));
|
||||
} else {
|
||||
for (String mark : marks) result.add(pdaPosition(position, item, balance, mark, 1, 0));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private DeliveryResponses.PdaPickPosition pdaPosition(InventoryPosition position, OutboundPalletItem item,
|
||||
InventoryBalance balance, String cartonMark,
|
||||
long available, long picked) {
|
||||
return new DeliveryResponses.PdaPickPosition(position.getId(), position.getPalletNo(), cartonMark,
|
||||
balance.getSkuCode(), balance.getInboundCode(), available, picked);
|
||||
}
|
||||
|
||||
private boolean matchesOutboundItem(String customerId, String warehouseId, OutboundPalletItem item,
|
||||
InventoryBalance balance) {
|
||||
return balance != null && customerId.equals(balance.getCustomerId())
|
||||
&& warehouseId.equals(balance.getOverseasWarehouseId())
|
||||
&& Objects.equals(item.getSkuCode(), balance.getSkuCode())
|
||||
&& Objects.equals(item.getInboundCode(), balance.getInboundCode());
|
||||
}
|
||||
|
||||
private DeliveryResponses.PdaTask taskResult(OutboundOrder outbound, OutboundPdaTask task) { return new DeliveryResponses.PdaTask(task.getId(), task.getTaskNo(), task.getStatus(), outbound.getOutboundNo(), task.getPushedAt(), task.getCompletedAt()); }
|
||||
private String outboundNo(Long id) { OutboundOrder value = outboundOrders.selectById(id); return value == null ? "" : value.getOutboundNo(); }
|
||||
private OutboundOrder requireOutbound(OutboundPdaTask task) {
|
||||
OutboundOrder outbound = outboundOrders.selectById(task.getOutboundOrderId());
|
||||
if (outbound == null || !task.getCustomerId().equals(outbound.getCustomerId())) {
|
||||
throw new IllegalStateException("Outbound PDA task has no scoped outbound order");
|
||||
}
|
||||
return outbound;
|
||||
}
|
||||
private OutboundOrder requireOutboundInWarehouse(OutboundPdaTask task, String warehouseId) {
|
||||
OutboundOrder outbound = requireOutbound(task);
|
||||
if (!warehouseId.equals(outbound.getOverseasWarehouseId())) {
|
||||
throw notFound("PDA task not found in the bound warehouse");
|
||||
}
|
||||
return outbound;
|
||||
}
|
||||
private int value(Integer value) { return value == null ? 0 : value; }
|
||||
private long value(Long value) { return value == null ? 0L : value; }
|
||||
|
||||
@@ -316,10 +453,11 @@ public class DeliveryTransferService {
|
||||
return page(page, page.getRecords().stream().map(this::outboundRow).toList());
|
||||
}
|
||||
|
||||
public DeliveryResponses.OutboundDetail outboundDetail(String customerId, String outboundNo,
|
||||
public DeliveryResponses.OutboundDetail outboundDetail(String outboundNo,
|
||||
DeliveryRequests.PalletQuery query) {
|
||||
OutboundOrder outbound = outboundOrders.findByNo(customerId, outboundNo);
|
||||
OutboundOrder outbound = outboundOrders.findGlobalByNo(trim(outboundNo));
|
||||
if (outbound == null) throw notFound("Outbound order not found");
|
||||
String customerId = outbound.getCustomerId();
|
||||
DeliveryOrder delivery = requireDelivery(customerId, outbound.getId());
|
||||
return new DeliveryResponses.OutboundDetail(outboundRow(outbound), deliveryRow(delivery));
|
||||
}
|
||||
@@ -404,6 +542,11 @@ public class DeliveryTransferService {
|
||||
private LastlegOrder firstTailOrder(String customerId, List<DispatchItem> items) { return items.stream().map(DispatchItem::getOrderId).filter(Objects::nonNull).map(tailOrders::selectById).filter(Objects::nonNull).filter(order -> customerId.equals(order.getCustomerId())).findFirst().orElse(null); }
|
||||
private BigDecimal totalWeight(String customerId, List<DispatchItem> items) { List<Long> ids = items.stream().map(DispatchItem::getOrderId).filter(Objects::nonNull).distinct().toList(); if (ids.isEmpty()) return BigDecimal.ZERO; return tailOrderDetails.selectList(new LambdaQueryWrapper<LastlegOrderDetail>().eq(LastlegOrderDetail::getCustomerId, customerId).in(LastlegOrderDetail::getOrderId, ids)).stream().map(LastlegOrderDetail::getGrossWeightKg).filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add); }
|
||||
private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; }
|
||||
private static String required(String value, String message) {
|
||||
String normalized = trim(value);
|
||||
if (normalized == null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, message);
|
||||
return normalized;
|
||||
}
|
||||
private static String firstText(String primary, String fallback) { return StringUtils.hasText(primary) ? primary.trim() : trim(fallback); }
|
||||
private static <T> PageResult<T> page(Page<?> page, List<T> records) { return new PageResult<>(records, page.getCurrent(), page.getSize(), page.getTotal(), page.getPages()); }
|
||||
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
|
||||
|
||||
+12
-41
@@ -4,24 +4,20 @@ import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.dispatch.dto.DispatchRequests;
|
||||
import com.baiyue.lastleg.dispatch.dto.DispatchResponse;
|
||||
import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaRequests;
|
||||
import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaResponses;
|
||||
import com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService;
|
||||
import com.baiyue.lastleg.dispatch.pda.controller.DispatchPdaController;
|
||||
import com.baiyue.lastleg.dispatch.service.DispatchService;
|
||||
import com.baiyue.lastleg.security.OverseasPermissions;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
@@ -30,60 +26,35 @@ import org.springframework.web.server.ResponseStatusException;
|
||||
@Tag(name = "Tail-leg vehicle dispatch")
|
||||
public class DispatchController {
|
||||
@Resource private DispatchService service;
|
||||
@Resource private DispatchPdaWorkflowService pdaWorkflow;
|
||||
|
||||
@PutMapping("/{dispatchNo}")
|
||||
@Operation(summary = "Edit vehicle, driver, appointment, route and departure data")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_EDIT + "')")
|
||||
@Operation(summary = "Edit vehicle, driver, appointment and route data")
|
||||
public ApiResponse<DispatchResponse> update(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String customerId,
|
||||
@PathVariable String dispatchNo,
|
||||
@Valid @RequestBody DispatchRequests.Update request) {
|
||||
return ApiResponse.success(service.update(scope(user, customerId), dispatchNo, request));
|
||||
requirePlatform(user);
|
||||
return ApiResponse.success(service.update(dispatchNo, request));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{dispatchNo}")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_CANCEL + "')")
|
||||
@Operation(summary = "Cancel a pending dispatch and release its pallet locks")
|
||||
public ApiResponse<Void> cancel(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String customerId,
|
||||
@PathVariable String dispatchNo) {
|
||||
service.cancel(scope(user, customerId), dispatchNo);
|
||||
requirePlatform(user);
|
||||
service.cancel(dispatchNo);
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
@PostMapping("/{dispatchNo}/depart")
|
||||
@Operation(summary = "Ship all PDA-picked pallets and mark the dispatch departed")
|
||||
public ApiResponse<DispatchPdaResponses.Departed> depart(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String customerId,
|
||||
@PathVariable String dispatchNo,
|
||||
@Valid @RequestBody DispatchPdaRequests.Depart request) {
|
||||
requirePdaOperate(user);
|
||||
String scopedTenant = scope(user, customerId);
|
||||
String operatorId = user.userId() == null ? user.username() : String.valueOf(user.userId());
|
||||
return ApiResponse.success(pdaWorkflow.depart(scopedTenant, dispatchNo, operatorId, request));
|
||||
}
|
||||
|
||||
private String scope(AuthenticatedUser user, String requestedCustomer) {
|
||||
private void requirePlatform(AuthenticatedUser user) {
|
||||
if (user == null || user.tenantId() == null) {
|
||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
|
||||
}
|
||||
if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) {
|
||||
if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
|
||||
"Platform request must specify target tenant");
|
||||
}
|
||||
return requestedCustomer;
|
||||
}
|
||||
return String.valueOf(user.tenantId());
|
||||
}
|
||||
|
||||
private void requirePdaOperate(AuthenticatedUser user) {
|
||||
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
|
||||
if (user.permissions() == null || !user.permissions().contains(DispatchPdaController.OPERATE_PERMISSION)) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN,
|
||||
DispatchPdaController.OPERATE_PERMISSION + " permission is required");
|
||||
if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Platform application required");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,10 +46,10 @@ public final class DispatchRequests {
|
||||
|
||||
public record Update(@Size(max = 64) String vehiclePlate, @Size(max = 128) String driverName,
|
||||
@Size(max = 64) String driverPhone, @Size(max = 255) String carrierName,
|
||||
@Size(max = 255) String route, LocalDateTime departAt,
|
||||
@Size(max = 255) String route,
|
||||
@Size(max = 64) String fbaAppointmentNo,
|
||||
@Size(max = 32) String fbaAppointmentStatus,
|
||||
@Size(max = 32) String vehicleAppointmentStatus,
|
||||
LocalDateTime estimatedArrivalAt, @Size(max = 32) String status) {
|
||||
LocalDateTime estimatedArrivalAt) {
|
||||
}
|
||||
}
|
||||
|
||||
+5
-4
@@ -6,15 +6,16 @@ import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface DispatchOrderMapper extends BaseMapper<DispatchOrder> {
|
||||
@Select("SELECT * FROM ll_dispatch_order WHERE customer_id=#{customerId} AND dispatch_no=#{dispatchNo} LIMIT 1")
|
||||
DispatchOrder findByDispatchNo(@Param("customerId") String customerId, @Param("dispatchNo") String dispatchNo);
|
||||
|
||||
@Select("SELECT * FROM ll_dispatch_order WHERE dispatch_no=#{dispatchNo} ORDER BY customer_id LIMIT 2")
|
||||
List<DispatchOrder> findAcrossCustomersByDispatchNo(@Param("dispatchNo") String dispatchNo);
|
||||
@Select("SELECT * FROM ll_dispatch_order WHERE dispatch_no=#{dispatchNo} LIMIT 1")
|
||||
DispatchOrder findGlobalByDispatchNo(@Param("dispatchNo") String dispatchNo);
|
||||
|
||||
@Select("SELECT * FROM ll_dispatch_order WHERE dispatch_no=#{dispatchNo} LIMIT 1 FOR UPDATE")
|
||||
DispatchOrder lockGlobalByDispatchNo(@Param("dispatchNo") String dispatchNo);
|
||||
|
||||
@Select("SELECT * FROM ll_dispatch_order WHERE customer_id=#{customerId} AND id=#{id} LIMIT 1 FOR UPDATE")
|
||||
DispatchOrder lockById(@Param("customerId") String customerId, @Param("id") Long id);
|
||||
|
||||
+35
-40
@@ -5,65 +5,60 @@ import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaRequests;
|
||||
import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaResponses;
|
||||
import com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService;
|
||||
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/pda/v1/last-leg/dispatches/{dispatchNo}")
|
||||
@RequestMapping("/api/pda/v1/last-leg/dispatches")
|
||||
@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')")
|
||||
public class DispatchPdaController {
|
||||
public static final String OPERATE_PERMISSION = "lastleg:pda:operate";
|
||||
@Resource private DispatchPdaWorkflowService service;
|
||||
|
||||
@PostMapping("/push")
|
||||
public ApiResponse<DispatchPdaResponses.Task> push(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String dispatchNo,
|
||||
@RequestParam(required = false) String customerId,
|
||||
@Valid @RequestBody DispatchPdaRequests.Push request) {
|
||||
requireOperate(user);
|
||||
return ApiResponse.success(service.push(scope(user, customerId), dispatchNo, request));
|
||||
}
|
||||
@GetMapping
|
||||
public ApiResponse<DispatchPdaResponses.Task> task(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String dispatchNo,
|
||||
@RequestParam(required = false) String customerId) {
|
||||
return ApiResponse.success(service.task(scope(user, customerId), dispatchNo));
|
||||
public ApiResponse<List<DispatchPdaResponses.Task>> list(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String status) {
|
||||
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
|
||||
return ApiResponse.success(service.list(String.valueOf(identity.overseasWarehouseId()), status));
|
||||
}
|
||||
@PostMapping("/items/{itemId}/pick")
|
||||
|
||||
@GetMapping("/{dispatchNo}")
|
||||
public ApiResponse<DispatchPdaResponses.Task> task(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String dispatchNo) {
|
||||
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
|
||||
return ApiResponse.success(service.task(String.valueOf(identity.overseasWarehouseId()), dispatchNo));
|
||||
}
|
||||
|
||||
@PostMapping("/{dispatchNo}/items/{itemId}/pick")
|
||||
public ApiResponse<DispatchPdaResponses.Picked> pick(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String dispatchNo, @PathVariable long itemId,
|
||||
@RequestParam(required = false) String customerId,
|
||||
@Valid @RequestBody DispatchPdaRequests.Pick request) {
|
||||
requireOperate(user);
|
||||
return ApiResponse.success(service.pick(scope(user, customerId), dispatchNo, itemId, operator(user), request));
|
||||
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
|
||||
return ApiResponse.success(service.pick(String.valueOf(identity.overseasWarehouseId()), dispatchNo, itemId,
|
||||
identity.operatorKey(), identity.operatorName(), request));
|
||||
}
|
||||
@PostMapping("/complete")
|
||||
|
||||
@PostMapping("/{dispatchNo}/complete")
|
||||
public ApiResponse<DispatchPdaResponses.PickingCompleted> completePicking(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String dispatchNo,
|
||||
@RequestParam(required = false) String customerId,
|
||||
@Valid @RequestBody DispatchPdaRequests.CompletePicking request) {
|
||||
requireOperate(user);
|
||||
return ApiResponse.success(service.completePicking(scope(user, customerId), dispatchNo, request));
|
||||
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
|
||||
return ApiResponse.success(service.completePicking(String.valueOf(identity.overseasWarehouseId()),
|
||||
dispatchNo, request));
|
||||
}
|
||||
private String scope(AuthenticatedUser user, String requestedCustomer) {
|
||||
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
|
||||
if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) {
|
||||
if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Platform request must specify target tenant");
|
||||
}
|
||||
return requestedCustomer.trim();
|
||||
}
|
||||
return String.valueOf(user.tenantId());
|
||||
}
|
||||
private String operator(AuthenticatedUser user) { return user.userId() == null ? user.username() : String.valueOf(user.userId()); }
|
||||
private void requireOperate(AuthenticatedUser user) {
|
||||
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
|
||||
if (user.permissions() == null || !user.permissions().contains(OPERATE_PERMISSION)) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, OPERATE_PERMISSION + " permission is required");
|
||||
}
|
||||
|
||||
@PostMapping("/{dispatchNo}/depart")
|
||||
public ApiResponse<DispatchPdaResponses.Departed> depart(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String dispatchNo,
|
||||
@Valid @RequestBody DispatchPdaRequests.Depart request) {
|
||||
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
|
||||
return ApiResponse.success(service.depart(String.valueOf(identity.overseasWarehouseId()), dispatchNo,
|
||||
identity.operatorKey(), request));
|
||||
}
|
||||
}
|
||||
|
||||
+2
-13
@@ -8,19 +8,8 @@ import jakarta.validation.constraints.Size;
|
||||
|
||||
public final class DispatchPdaRequests {
|
||||
private DispatchPdaRequests() {}
|
||||
public record Push(@NotBlank String idempotencyKey, String operatorId, String operatorName,
|
||||
String warehouseId) {
|
||||
public Push(String idempotencyKey, String operatorId, String operatorName) {
|
||||
this(idempotencyKey, operatorId, operatorName, null);
|
||||
}
|
||||
}
|
||||
public record Pick(@NotNull Long positionId, @Positive long packages, @PositiveOrZero long productQuantity,
|
||||
@NotBlank String idempotencyKey, String remark, String warehouseId) {
|
||||
public Pick(Long positionId, long packages, long productQuantity, String idempotencyKey, String remark) {
|
||||
this(positionId, packages, productQuantity, idempotencyKey, remark, null);
|
||||
}
|
||||
}
|
||||
@NotBlank String idempotencyKey, String remark) {}
|
||||
public record Depart(@NotBlank String idempotencyKey, String remark) {}
|
||||
public record CompletePicking(@NotBlank String idempotencyKey,
|
||||
@NotBlank @Size(max = 1000) String reason) {}
|
||||
public record CompletePicking(@NotBlank String idempotencyKey) {}
|
||||
}
|
||||
|
||||
+5
@@ -8,4 +8,9 @@ import org.apache.ibatis.annotations.*;
|
||||
public interface DispatchPdaOperationMapper extends BaseMapper<DispatchPdaOperation> {
|
||||
@Select("SELECT * FROM ll_dispatch_pda_operation WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1")
|
||||
DispatchPdaOperation findKey(@Param("customerId") String customerId, @Param("key") String key);
|
||||
|
||||
@Select("SELECT * FROM ll_dispatch_pda_operation WHERE customer_id=#{customerId} AND task_id=#{taskId} " +
|
||||
"AND operation_type=#{operationType} AND status='COMPLETED' ORDER BY created_at DESC LIMIT 1")
|
||||
DispatchPdaOperation findCompleted(@Param("customerId") String customerId, @Param("taskId") Long taskId,
|
||||
@Param("operationType") String operationType);
|
||||
}
|
||||
|
||||
+7
@@ -4,6 +4,8 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaTask;
|
||||
import org.apache.ibatis.annotations.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface DispatchPdaTaskMapper extends BaseMapper<DispatchPdaTask> {
|
||||
@Select("SELECT * FROM ll_dispatch_pda_task WHERE customer_id=#{customerId} AND dispatch_order_id=#{dispatchOrderId} LIMIT 1 FOR UPDATE")
|
||||
@@ -11,4 +13,9 @@ public interface DispatchPdaTaskMapper extends BaseMapper<DispatchPdaTask> {
|
||||
|
||||
@Select("SELECT * FROM ll_dispatch_pda_task WHERE customer_id=#{customerId} AND dispatch_order_id=#{dispatchOrderId} LIMIT 1")
|
||||
DispatchPdaTask findByDispatch(@Param("customerId") String customerId, @Param("dispatchOrderId") Long dispatchOrderId);
|
||||
|
||||
@Select("SELECT * FROM ll_dispatch_pda_task WHERE warehouse_id=#{warehouseId} " +
|
||||
"AND (#{status} IS NULL OR status=#{status}) ORDER BY pushed_at DESC LIMIT 200")
|
||||
List<DispatchPdaTask> listByWarehouse(@Param("warehouseId") String warehouseId,
|
||||
@Param("status") String status);
|
||||
}
|
||||
|
||||
+66
-55
@@ -44,59 +44,48 @@ public class DispatchPdaWorkflowService {
|
||||
@Resource private DispatchPickOrderAttributionService pickAttribution;
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
|
||||
@Transactional
|
||||
public DispatchPdaResponses.Task push(String customerId, String dispatchNo, DispatchPdaRequests.Push request) {
|
||||
DispatchOrder order = requireOrder(customerId, dispatchNo);
|
||||
requirePdaStatus(order);
|
||||
requireVehicle(order);
|
||||
String warehouseId = warehouse(request.warehouseId());
|
||||
requireDispatchWarehouse(order, warehouseId);
|
||||
DispatchPdaOperation replay = replay(customerId, "PUSH", request.idempotencyKey(), request);
|
||||
if (replay != null) return read(replay, DispatchPdaResponses.Task.class);
|
||||
items.bindReceivedPallets(customerId, order.getId());
|
||||
List<DispatchItem> dispatchItems = items.findByDispatchOrder(customerId, order.getId());
|
||||
if (dispatchItems.isEmpty()) throw conflict("Dispatch has no pallets to push");
|
||||
if (dispatchItems.stream().anyMatch(item -> item.getWarehousePalletId() == null)) {
|
||||
throw conflict("All pre-arranged pallets must be received before PDA push");
|
||||
@Transactional(readOnly = true)
|
||||
public List<DispatchPdaResponses.Task> list(String warehouseId, String status) {
|
||||
String boundWarehouseId = warehouse(warehouseId);
|
||||
List<DispatchPdaResponses.Task> result = new ArrayList<>();
|
||||
for (DispatchPdaTask pdaTask : tasks.listByWarehouse(boundWarehouseId, trim(status))) {
|
||||
DispatchOrder order = orders.selectById(pdaTask.getDispatchOrderId());
|
||||
if (order == null || !pdaTask.getCustomerId().equals(order.getCustomerId())
|
||||
|| !boundWarehouseId.equals(trim(order.getOverseasWarehouseId()))) {
|
||||
throw conflict("Dispatch PDA task scope is inconsistent");
|
||||
}
|
||||
result.add(task(order.getCustomerId(), order, pdaTask,
|
||||
items.findByDispatchOrder(order.getCustomerId(), order.getId())));
|
||||
}
|
||||
DispatchPdaTask task = tasks.lockByDispatch(customerId, order.getId());
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
if (task == null) {
|
||||
task = new DispatchPdaTask();
|
||||
task.setId(IdWorker.getId()); task.setCustomerId(customerId); task.setDispatchOrderId(order.getId());
|
||||
task.setTaskNo("DPDA" + task.getId()); task.setStatus("PDA_PENDING");
|
||||
task.setWarehouseId(warehouseId);
|
||||
task.setOperatorId(trim(request.operatorId())); task.setOperatorName(trim(request.operatorName()));
|
||||
task.setPushedAt(now); task.setCreatedAt(now); task.setUpdatedAt(now); tasks.insert(task);
|
||||
}
|
||||
if ("PENDING_DEPARTURE".equals(order.getStatus())) { order.setStatus("PICKING"); order.setUpdatedAt(now); orders.updateById(order); }
|
||||
if (!warehouseId.equals(task.getWarehouseId())) throw conflict("PDA task warehouse does not match selected warehouse");
|
||||
DispatchPdaResponses.Task result = task(customerId, order, task, dispatchItems);
|
||||
save(customerId, task.getId(), "PUSH", request.idempotencyKey(), request, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public DispatchPdaResponses.Task task(String customerId, String dispatchNo) {
|
||||
DispatchOrder order = requireOrder(customerId, dispatchNo);
|
||||
@Transactional
|
||||
public DispatchPdaResponses.Task task(String warehouseId, String dispatchNo) {
|
||||
DispatchOrder order = requireWarehouseOrder(warehouseId, dispatchNo);
|
||||
String customerId = order.getCustomerId();
|
||||
items.bindReceivedPallets(customerId, order.getId());
|
||||
DispatchPdaTask task = tasks.findByDispatch(customerId, order.getId());
|
||||
if (task == null) throw notFound("Dispatch PDA task not found");
|
||||
requireTaskWarehouse(task, warehouseId);
|
||||
return task(customerId, order, task, items.findByDispatchOrder(customerId, order.getId()));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public DispatchPdaResponses.Picked pick(String customerId, String dispatchNo, long itemId,
|
||||
String operatorId, DispatchPdaRequests.Pick request) {
|
||||
DispatchOrder order = requireOrder(customerId, dispatchNo);
|
||||
public DispatchPdaResponses.Picked pick(String warehouseId, String dispatchNo, long itemId,
|
||||
String operatorId, String operatorName,
|
||||
DispatchPdaRequests.Pick request) {
|
||||
DispatchOrder order = requireWarehouseOrder(warehouseId, dispatchNo);
|
||||
String customerId = order.getCustomerId();
|
||||
requirePdaStatus(order);
|
||||
requireVehicle(order);
|
||||
String warehouseId = warehouse(request.warehouseId());
|
||||
requireDispatchWarehouse(order, warehouseId);
|
||||
items.bindReceivedPallets(customerId, order.getId());
|
||||
DispatchPdaTask task = tasks.lockByDispatch(customerId, order.getId());
|
||||
if (task == null || !("PDA_PENDING".equals(task.getStatus()) || "PICKING".equals(task.getStatus()))) {
|
||||
throw conflict("Dispatch PDA task is not available for picking");
|
||||
}
|
||||
if (!warehouseId.equals(task.getWarehouseId())) throw conflict("PDA pick warehouse does not match task warehouse");
|
||||
bindOperator(task, operatorId, operatorName);
|
||||
DispatchItem item = items.selectById(itemId);
|
||||
if (item == null || !customerId.equals(item.getCustomerId()) || !order.getId().equals(item.getDispatchOrderId())) {
|
||||
throw notFound("Dispatch pallet item not found");
|
||||
@@ -145,9 +134,10 @@ public class DispatchPdaWorkflowService {
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public DispatchPdaResponses.PickingCompleted completePicking(String customerId, String dispatchNo,
|
||||
public DispatchPdaResponses.PickingCompleted completePicking(String warehouseId, String dispatchNo,
|
||||
DispatchPdaRequests.CompletePicking request) {
|
||||
DispatchOrder found = requireOrder(customerId, dispatchNo);
|
||||
DispatchOrder found = requireWarehouseOrder(warehouseId, dispatchNo);
|
||||
String customerId = found.getCustomerId();
|
||||
DispatchOrder order = orders.lockById(customerId, found.getId());
|
||||
if (order == null) throw notFound("Dispatch order not found");
|
||||
DispatchPdaOperation replay = replay(customerId, "COMPLETE_PICKING", request.idempotencyKey(), request);
|
||||
@@ -159,8 +149,12 @@ public class DispatchPdaWorkflowService {
|
||||
long planned = completedItems.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull)
|
||||
.mapToLong(Integer::longValue).sum();
|
||||
long picked = picks.listTask(customerId, task.getId()).stream().mapToLong(p -> n(p.getPackages())).sum();
|
||||
return new DispatchPdaResponses.PickingCompleted(order.getDispatchNo(), order.getStatus(), planned,
|
||||
picked, Math.max(0, planned - picked), picked < planned, true);
|
||||
DispatchPdaResponses.PickingCompleted result = new DispatchPdaResponses.PickingCompleted(
|
||||
order.getDispatchNo(), order.getStatus(), planned, picked, Math.max(0, planned - picked), false, false);
|
||||
DispatchPdaOperation completed = operations.findCompleted(customerId, task.getId(), "COMPLETE_PICKING");
|
||||
if (completed != null) throw conflict("PDA picking was already completed with another idempotency key");
|
||||
save(customerId, task.getId(), "COMPLETE_PICKING", request.idempotencyKey(), request, result);
|
||||
return result;
|
||||
}
|
||||
if (task == null || !("PDA_PENDING".equals(task.getStatus()) || "PICKING".equals(task.getStatus()))) {
|
||||
throw conflict("Dispatch PDA task is not available for completion");
|
||||
@@ -169,25 +163,27 @@ public class DispatchPdaWorkflowService {
|
||||
long planned = source.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull)
|
||||
.mapToLong(Integer::longValue).sum();
|
||||
long picked = picks.listTask(customerId, task.getId()).stream().mapToLong(p -> n(p.getPackages())).sum();
|
||||
if (picked <= 0 || picked > planned) throw conflict("PDA completion requires a valid picked package count");
|
||||
boolean partial = picked < planned;
|
||||
if (planned <= 0 || picked != planned || !allPicked(customerId, task.getId(), source)) {
|
||||
throw conflict("Every dispatch pallet and package must be fully picked before completion");
|
||||
}
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
task.setStatus("COMPLETED"); task.setCompletedAt(now); task.setUpdatedAt(now); tasks.updateById(task);
|
||||
order.setStatus(partial ? "PARTIAL_READY_TO_DEPART" : "READY_TO_DEPART");
|
||||
order.setPartialCompletionReason(partial ? request.reason().trim() : null);
|
||||
order.setStatus("READY_TO_DEPART");
|
||||
order.setPartialCompletionReason(null);
|
||||
order.setPickedPackageCount((int) picked);
|
||||
order.setUpdatedAt(now); orders.updateById(order);
|
||||
DispatchPdaResponses.PickingCompleted result = new DispatchPdaResponses.PickingCompleted(order.getDispatchNo(),
|
||||
order.getStatus(), planned, picked, planned - picked, partial, false);
|
||||
order.getStatus(), planned, picked, 0, false, false);
|
||||
save(customerId, task.getId(), "COMPLETE_PICKING", request.idempotencyKey(), request, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Final warehouse exit: only fully PDA-picked dispatches can consume picked inventory. */
|
||||
@Transactional
|
||||
public DispatchPdaResponses.Departed depart(String customerId, String dispatchNo, String operatorId,
|
||||
public DispatchPdaResponses.Departed depart(String warehouseId, String dispatchNo, String operatorId,
|
||||
DispatchPdaRequests.Depart request) {
|
||||
DispatchOrder found = requireOrder(customerId, dispatchNo);
|
||||
DispatchOrder found = requireWarehouseOrder(warehouseId, dispatchNo);
|
||||
String customerId = found.getCustomerId();
|
||||
DispatchOrder order = orders.lockById(customerId, found.getId());
|
||||
if (order == null) throw notFound("Dispatch order not found");
|
||||
DispatchPdaOperation replay = replay(customerId, "DEPART", request.idempotencyKey(), request);
|
||||
@@ -199,13 +195,13 @@ public class DispatchPdaWorkflowService {
|
||||
}
|
||||
DispatchPdaTask task = tasks.lockByDispatch(customerId, order.getId());
|
||||
if (task == null || !"COMPLETED".equals(task.getStatus())
|
||||
|| !("READY_TO_DEPART".equals(order.getStatus()) || "PARTIAL_READY_TO_DEPART".equals(order.getStatus()))) {
|
||||
|| !"READY_TO_DEPART".equals(order.getStatus())) {
|
||||
throw conflict("Dispatch must complete PDA picking before departure");
|
||||
}
|
||||
requireTaskWarehouse(task, warehouseId);
|
||||
|
||||
List<DispatchItem> dispatchItems = items.findByDispatchOrder(customerId, order.getId());
|
||||
boolean partial = "PARTIAL_READY_TO_DEPART".equals(order.getStatus());
|
||||
if (!partial && !allPicked(customerId, task.getId(), dispatchItems)) {
|
||||
if (!allPicked(customerId, task.getId(), dispatchItems)) {
|
||||
throw conflict("Every dispatch pallet must be fully picked before departure");
|
||||
}
|
||||
List<DispatchPdaPick> dispatchPicks = picks.listTask(customerId, task.getId());
|
||||
@@ -217,7 +213,7 @@ public class DispatchPdaWorkflowService {
|
||||
outbound.setUpdatedAt(now);
|
||||
outboundOrders.updateById(outbound);
|
||||
order.setShippedPackageCount((int) shipped);
|
||||
order.setStatus(partial ? "PARTIAL_DEPARTED" : "DEPARTED");
|
||||
order.setStatus("DEPARTED");
|
||||
order.setUpdatedAt(now);
|
||||
orders.updateById(order);
|
||||
releasePalletLocks(customerId, order.getId(), now);
|
||||
@@ -258,18 +254,33 @@ public class DispatchPdaWorkflowService {
|
||||
palletLocks.updateById(lock);
|
||||
}
|
||||
}
|
||||
private DispatchOrder requireOrder(String customerId, String dispatchNo) { DispatchOrder order = orders.findByDispatchNo(customerId, dispatchNo); if (order == null) throw notFound("Dispatch order not found"); return order; }
|
||||
private DispatchOrder requireWarehouseOrder(String warehouseId, String dispatchNo) {
|
||||
String boundWarehouseId = warehouse(warehouseId);
|
||||
DispatchOrder order = orders.findGlobalByDispatchNo(required(dispatchNo, "Dispatch number is required"));
|
||||
if (order == null || !boundWarehouseId.equals(trim(order.getOverseasWarehouseId()))) {
|
||||
throw notFound("Dispatch order not found in the bound warehouse");
|
||||
}
|
||||
return order;
|
||||
}
|
||||
private void requirePdaStatus(DispatchOrder order) {
|
||||
if (!("PENDING_DEPARTURE".equals(order.getStatus()) || "PICKING".equals(order.getStatus())
|
||||
|| "READY_TO_DEPART".equals(order.getStatus()))) throw conflict("Dispatch has already departed and cannot be changed by PDA");
|
||||
}
|
||||
private void requireVehicle(DispatchOrder order) { if (trim(order.getVehiclePlate()) == null) throw conflict("Vehicle plate is required before PDA push or picking"); }
|
||||
private static String warehouse(String value) { return required(value, "Warehouse selection is required for PDA work"); }
|
||||
private static void requireDispatchWarehouse(DispatchOrder order, String warehouseId) {
|
||||
if (trim(order.getOverseasWarehouseId()) == null || !warehouseId.equals(order.getOverseasWarehouseId().trim())) {
|
||||
throw conflict("Selected warehouse does not match dispatch warehouse");
|
||||
private static void requireTaskWarehouse(DispatchPdaTask task, String warehouseId) {
|
||||
if (!warehouse(warehouseId).equals(trim(task.getWarehouseId()))) {
|
||||
throw notFound("Dispatch PDA task not found in the bound warehouse");
|
||||
}
|
||||
}
|
||||
private static void bindOperator(DispatchPdaTask task, String operatorId, String operatorName) {
|
||||
String normalizedOperator = required(operatorId, "PDA operator is required");
|
||||
if (trim(task.getOperatorId()) != null && !normalizedOperator.equals(trim(task.getOperatorId()))) {
|
||||
throw conflict("Dispatch PDA task is already assigned to another operator");
|
||||
}
|
||||
task.setOperatorId(normalizedOperator);
|
||||
task.setOperatorName(trim(operatorName));
|
||||
}
|
||||
private DispatchPdaOperation replay(String customerId, String type, String key, Object request) { DispatchPdaOperation op = operations.findKey(customerId, required(key, "Idempotency key is required")); if (op == null) return null; if (!type.equals(op.getOperationType()) || !hash(request).equals(op.getRequestHash())) throw conflict("Idempotency key was reused with a different request"); if (!"COMPLETED".equals(op.getStatus())) throw conflict("PDA operation is still processing"); return op; }
|
||||
private void save(String customerId, Long taskId, String type, String key, Object request, Object result) { DispatchPdaOperation op = new DispatchPdaOperation(); op.setId(IdWorker.getId()); op.setCustomerId(customerId); op.setTaskId(taskId); op.setOperationType(type); op.setIdempotencyKey(required(key,"Idempotency key is required")); op.setRequestHash(hash(request)); op.setResultJson(json(result)); op.setStatus("COMPLETED"); op.setCreatedAt(LocalDateTime.now()); op.setUpdatedAt(op.getCreatedAt()); operations.insert(op); }
|
||||
private <T> T read(DispatchPdaOperation op, Class<T> type) { try { return objectMapper.readValue(op.getResultJson(), type); } catch (Exception e) { throw new IllegalStateException("Cannot read PDA replay", e); } }
|
||||
|
||||
+28
-18
@@ -20,6 +20,8 @@ import com.baiyue.lastleg.dispatch.mapper.DispatchDraftContainerMapper;
|
||||
import com.baiyue.lastleg.dispatch.dto.DispatchContainerResponse;
|
||||
import com.baiyue.lastleg.dispatch.dto.DispatchDraftResponse;
|
||||
import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
|
||||
import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaTask;
|
||||
import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaTaskMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
@@ -49,6 +51,7 @@ public class DispatchService {
|
||||
@Resource private DispatchDraftMapper drafts;
|
||||
@Resource private DispatchDraftContainerMapper draftContainers;
|
||||
@Resource private DispatchCandidateMapper candidates;
|
||||
@Resource private DispatchPdaTaskMapper pdaTasks;
|
||||
@Resource private LastlegOrderNoGenerator orderNoGenerator;
|
||||
|
||||
public String resolveCustomerScopeForPlans(List<Long> planIds) {
|
||||
@@ -315,6 +318,17 @@ public class DispatchService {
|
||||
item.setCreatedAt(now);
|
||||
items.insert(item);
|
||||
}
|
||||
DispatchPdaTask pdaTask = new DispatchPdaTask();
|
||||
pdaTask.setId(IdWorker.getId());
|
||||
pdaTask.setCustomerId(customerId);
|
||||
pdaTask.setDispatchOrderId(dispatch.getId());
|
||||
pdaTask.setTaskNo("DPDA" + pdaTask.getId());
|
||||
pdaTask.setStatus("PDA_PENDING");
|
||||
pdaTask.setWarehouseId(dispatch.getOverseasWarehouseId());
|
||||
pdaTask.setPushedAt(now);
|
||||
pdaTask.setCreatedAt(now);
|
||||
pdaTask.setUpdatedAt(now);
|
||||
pdaTasks.insert(pdaTask);
|
||||
return response(dispatch, items.findByDispatchOrder(customerId, dispatch.getId()));
|
||||
}
|
||||
|
||||
@@ -351,49 +365,45 @@ public class DispatchService {
|
||||
}
|
||||
|
||||
public DispatchResponse platformDetail(String dispatchNo) {
|
||||
List<DispatchOrder> matches = orders.findAcrossCustomersByDispatchNo(dispatchNo);
|
||||
if (matches == null || matches.isEmpty()) {
|
||||
DispatchOrder dispatch = orders.findGlobalByDispatchNo(trimToNull(dispatchNo));
|
||||
if (dispatch == null) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found");
|
||||
}
|
||||
if (matches.size() > 1) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT,
|
||||
"Dispatch number is not unique across customers");
|
||||
}
|
||||
DispatchOrder dispatch = matches.get(0);
|
||||
return response(dispatch, items.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId()));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public DispatchResponse update(String customerId, String dispatchNo, DispatchRequests.Update request) {
|
||||
DispatchOrder dispatch = orders.lockById(customerId, find(customerId, dispatchNo).getId());
|
||||
public DispatchResponse update(String dispatchNo, DispatchRequests.Update request) {
|
||||
DispatchOrder dispatch = orders.lockGlobalByDispatchNo(trimToNull(dispatchNo));
|
||||
if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found");
|
||||
// Vehicle plate is intentionally editable through all dispatch states.
|
||||
if (!PENDING_DEPARTURE.equals(dispatch.getStatus())) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT,
|
||||
"Only a pending dispatch can be edited");
|
||||
}
|
||||
String customerId = dispatch.getCustomerId();
|
||||
dispatch.setVehiclePlate(trimToNull(request.vehiclePlate()));
|
||||
dispatch.setDriverName(trimToNull(request.driverName()));
|
||||
dispatch.setDriverPhone(trimToNull(request.driverPhone()));
|
||||
dispatch.setCarrierName(trimToNull(request.carrierName()));
|
||||
dispatch.setRoute(trimToNull(request.route()));
|
||||
dispatch.setDepartAt(request.departAt());
|
||||
dispatch.setFbaAppointmentNo(trimToNull(request.fbaAppointmentNo()));
|
||||
dispatch.setFbaAppointmentStatus(defaultStatus(request.fbaAppointmentStatus()));
|
||||
dispatch.setVehicleAppointmentStatus(defaultStatus(request.vehicleAppointmentStatus()));
|
||||
dispatch.setEstimatedArrivalAt(request.estimatedArrivalAt());
|
||||
if (StringUtils.hasText(request.status())) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
|
||||
"Dispatch status is PDA-managed and cannot be changed by the edit endpoint");
|
||||
}
|
||||
dispatch.setUpdatedAt(LocalDateTime.now());
|
||||
orders.updateById(dispatch);
|
||||
return response(dispatch, items.findByDispatchOrder(customerId, dispatch.getId()));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void cancel(String customerId, String dispatchNo) {
|
||||
DispatchOrder dispatch = orders.lockById(customerId, find(customerId, dispatchNo).getId());
|
||||
public void cancel(String dispatchNo) {
|
||||
DispatchOrder dispatch = orders.lockGlobalByDispatchNo(trimToNull(dispatchNo));
|
||||
if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found");
|
||||
if (CANCELLED.equals(dispatch.getStatus())) return;
|
||||
if (!(PENDING_DEPARTURE.equals(dispatch.getStatus()))) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "Dispatched order cannot be cancelled");
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "Only a pending dispatch can be cancelled");
|
||||
}
|
||||
String customerId = dispatch.getCustomerId();
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
dispatch.setStatus(CANCELLED);
|
||||
dispatch.setUpdatedAt(now);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package com.baiyue.lastleg.security;
|
||||
|
||||
/** Canonical Admin authorities for fourth-phase overseas APIs. */
|
||||
/** Canonical authorities for overseas browser APIs. */
|
||||
public final class OverseasPermissions {
|
||||
public static final String LAST_MILE_ORDER_LIST = "platform:overseas-warehouse:last-mile-order:list";
|
||||
public static final String LAST_MILE_ORDER_EDIT = "platform:overseas-warehouse:last-mile-order:edit";
|
||||
@@ -20,6 +20,15 @@ public final class OverseasPermissions {
|
||||
public static final String INVENTORY_EDIT = "platform:overseas-warehouse:inventory:edit";
|
||||
public static final String INVENTORY_ADJUST = "platform:overseas-warehouse:inventory:adjust";
|
||||
public static final String DELIVERY_LIST = "platform:overseas-warehouse:delivery:list";
|
||||
public static final String DISPATCH_EDIT = "platform:overseas-warehouse:dispatch:edit";
|
||||
public static final String DISPATCH_CANCEL = "platform:overseas-warehouse:dispatch:cancel";
|
||||
public static final String DISPATCH_TRANSFER_DELIVERY =
|
||||
"platform:overseas-warehouse:dispatch:transfer-delivery";
|
||||
public static final String OUTBOUND_LIST = "platform:overseas-warehouse:outbound:list";
|
||||
public static final String OUTBOUND_CREATE = "platform:overseas-warehouse:outbound:create";
|
||||
public static final String OUTBOUND_CANCEL = "platform:overseas-warehouse:outbound:cancel";
|
||||
public static final String MERCHANT_DELIVERY_LIST = "merchant:warehouse:delivery:list";
|
||||
public static final String MERCHANT_DELIVERY_CREATE = "merchant:warehouse:delivery:create";
|
||||
public static final String PURCHASE_LIST = "platform:overseas-business:purchase:list";
|
||||
public static final String PURCHASE_EDIT = "platform:overseas-business:purchase:edit";
|
||||
public static final String PURCHASE_REVIEW = "platform:overseas-business:purchase:review";
|
||||
|
||||
@@ -1040,7 +1040,7 @@ CREATE TABLE IF NOT EXISTS ll_dispatch_order (
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_ll_dispatch_order_no (customer_id,dispatch_no),
|
||||
UNIQUE KEY uk_ll_dispatch_order_no (dispatch_no),
|
||||
KEY idx_ll_dispatch_order_list (customer_id,status,depart_at),
|
||||
KEY idx_ll_dispatch_order_vehicle (customer_id,vehicle_plate)
|
||||
) ENGINE=InnoDB COMMENT='尾程派车单';
|
||||
@@ -1134,12 +1134,12 @@ PREPARE dispatch_schema_stmt FROM @ddl; EXECUTE dispatch_schema_stmt; DEALLOCATE
|
||||
CREATE TABLE IF NOT EXISTS ll_dispatch_pda_task (
|
||||
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', dispatch_order_id BIGINT NOT NULL COMMENT '派车单ID',
|
||||
task_no VARCHAR(64) NOT NULL COMMENT 'PDA任务号', status VARCHAR(32) NOT NULL COMMENT '业务状态', operator_id VARCHAR(128) NULL COMMENT '操作人ID',
|
||||
warehouse_id VARCHAR(128) NULL COMMENT '仓库ID',
|
||||
warehouse_id VARCHAR(128) NOT NULL COMMENT 'Token绑定的海外仓库ID',
|
||||
operator_name VARCHAR(128) NULL COMMENT '操作人姓名', pushed_at DATETIME(3) NOT NULL COMMENT '任务下发时间', completed_at DATETIME(3) NULL COMMENT '完成时间',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||
PRIMARY KEY (id), UNIQUE KEY uk_ll_dispatch_pda_task_dispatch (customer_id,dispatch_order_id),
|
||||
UNIQUE KEY uk_ll_dispatch_pda_task_no (customer_id,task_no)
|
||||
UNIQUE KEY uk_ll_dispatch_pda_task_no (task_no)
|
||||
) ENGINE=InnoDB COMMENT='派车PDA拣货任务';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_dispatch_pda_pick (
|
||||
@@ -1178,7 +1178,7 @@ CREATE TABLE IF NOT EXISTS ll_outbound_order (
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||
outbound_type VARCHAR(32) NOT NULL DEFAULT 'DISPATCH_DELIVERY' COMMENT '出库类型:派送转出库、一件代发',
|
||||
recipient_info VARCHAR(1000) NULL COMMENT '一件代发收件信息',
|
||||
PRIMARY KEY (id), UNIQUE KEY uk_ll_outbound_no (customer_id,outbound_no),
|
||||
PRIMARY KEY (id), UNIQUE KEY uk_ll_outbound_no (outbound_no),
|
||||
UNIQUE KEY uk_ll_outbound_idempotency (customer_id,idempotency_key), UNIQUE KEY uk_ll_outbound_dispatch (customer_id,dispatch_order_id),
|
||||
KEY idx_ll_outbound_list (customer_id,status,created_at)
|
||||
) ENGINE=InnoDB COMMENT='尾程出库单';
|
||||
@@ -1196,9 +1196,11 @@ CREATE TABLE IF NOT EXISTS ll_outbound_pallet_item (
|
||||
CREATE TABLE IF NOT EXISTS ll_outbound_pda_task (
|
||||
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', outbound_order_id BIGINT NOT NULL COMMENT '出库单ID',
|
||||
task_no VARCHAR(64) NOT NULL COMMENT 'PDA任务号', status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '任务状态:待拣货、拣货中、已完成',
|
||||
completion_idempotency_key VARCHAR(255) NULL COMMENT '完成命令幂等键',
|
||||
pushed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '推送时间', completed_at DATETIME(3) NULL COMMENT '完成时间',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_ll_outbound_pda_task_order(customer_id,outbound_order_id), UNIQUE KEY uk_ll_outbound_pda_task_no(customer_id,task_no)
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_ll_outbound_pda_task_order(customer_id,outbound_order_id), UNIQUE KEY uk_ll_outbound_pda_task_no(task_no),
|
||||
UNIQUE KEY uk_ll_outbound_pda_complete_key(customer_id,completion_idempotency_key)
|
||||
) ENGINE=InnoDB COMMENT='一件代发出库PDA拣货任务';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_outbound_pda_pick (
|
||||
|
||||
@@ -386,7 +386,19 @@ paths:
|
||||
responses:
|
||||
'200': { description: 统一 ApiResponse 包装的 DispatchResponse }
|
||||
'404': { description: 排车单不存在 }
|
||||
'409': { description: 排车单号在不同客户下重复,无法唯一定位 }
|
||||
put:
|
||||
summary: 编辑待发车排车单
|
||||
description: 权限 platform:overseas-warehouse:dispatch:edit;只按全局唯一 dispatchNo 锁定记录并从记录解析客户范围,不接收 customerId。仅允许 PENDING_DEPARTURE,且请求不包含 status/departAt,发车事实只能由 PDA 作业命令产生。
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters: [{ name: dispatchNo, in: path, required: true, schema: { type: string } }]
|
||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/DispatchUpdate' } } } }
|
||||
responses: { '200': { description: 更新后的 DispatchResponse }, '404': { description: 排车单不存在 }, '409': { description: 排车单已执行或不可编辑 } }
|
||||
delete:
|
||||
summary: 取消待发车排车单
|
||||
description: 权限 platform:overseas-warehouse:dispatch:cancel;只按全局唯一 dispatchNo 锁定记录并解析客户范围,不接收 customerId。仅 PENDING_DEPARTURE 可取消并释放正式托盘锁;重复取消幂等成功。
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters: [{ name: dispatchNo, in: path, required: true, schema: { type: string } }]
|
||||
responses: { '200': { description: 取消成功或已取消 }, '404': { description: 排车单不存在 }, '409': { description: 排车已开始执行 } }
|
||||
/api/platform/v1/last-leg/dispatches/candidates:
|
||||
get:
|
||||
summary: 查询智能排车候选托盘
|
||||
@@ -411,7 +423,7 @@ paths:
|
||||
/api/platform/v1/last-leg/dispatches/pre-arrange:
|
||||
post:
|
||||
summary: 创建智能预排车单
|
||||
description: 权限 platform:overseas-warehouse:inbound-order:smart-dispatch;服务端根据所选托盘解析并校验唯一客户范围,锁定同一海外仓的托盘并创建 PENDING_DEPARTURE 排车单,前端不传 customerId;不开放编辑、取消、depart、PDA 出库或派送。
|
||||
description: 权限 platform:overseas-warehouse:inbound-order:smart-dispatch;服务端根据所选托盘解析并校验唯一客户范围,锁定同一海外仓的托盘并创建 PENDING_DEPARTURE 排车单,前端不传 customerId;本接口只负责预排车创建,编辑、取消、转出库派送和 PDA 发车使用各自独立接口与权限。
|
||||
security: [{ bearerAuth: [] }]
|
||||
requestBody:
|
||||
required: true
|
||||
@@ -467,6 +479,50 @@ paths:
|
||||
- { name: draftToken, in: path, required: true, schema: { type: string, maxLength: 64 } }
|
||||
- { name: containerId, in: path, required: true, schema: { type: string } }
|
||||
responses: { '200': { description: 空车厢删除成功 }, '409': { description: 车厢仍绑定托盘或草稿状态冲突 } }
|
||||
/api/platform/v1/last-leg/dispatches/{dispatchNo}/transfer-delivery:
|
||||
post:
|
||||
summary: 排车转出库与派送单
|
||||
description: 权限 platform:overseas-warehouse:dispatch:transfer-delivery;只按全局唯一 dispatchNo 锁定记录并从记录解析客户范围,不接收 customerId;同一幂等键或已转换排车重复调用返回既有单据。
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters: [{ name: dispatchNo, in: path, required: true, schema: { type: string } }]
|
||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/DeliveryTransfer' } } } }
|
||||
responses: { '200': { description: 出库与派送单转换结果 }, '404': { description: 排车单不存在 }, '409': { description: 状态、托盘或幂等冲突 } }
|
||||
/api/platform/v1/last-leg/outbound-orders:
|
||||
get:
|
||||
summary: 分页查询出库单
|
||||
description: 权限 platform:overseas-warehouse:outbound:list;customerId 仅为可选平台筛选条件。
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters:
|
||||
- { name: customerId, in: query, required: false, schema: { type: string, pattern: '^[1-9][0-9]*$' } }
|
||||
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
|
||||
- { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } }
|
||||
- { name: documentNo, in: query, schema: { type: string } }
|
||||
- { name: warehouseId, in: query, schema: { type: string } }
|
||||
- { name: status, in: query, schema: { type: string } }
|
||||
responses: { '200': { description: 出库单分页 } }
|
||||
/api/platform/v1/last-leg/outbound-orders/one-click-dropship:
|
||||
post:
|
||||
summary: 创建一件代发出库单
|
||||
description: 权限 platform:overseas-warehouse:outbound:create;因尚无既有业务单号可解析归属,平台必须显式传 customerId。
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters: [{ name: customerId, in: query, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } }]
|
||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/OneClickOutboundCreate' } } } }
|
||||
responses: { '200': { description: 一件代发出库 PDA 任务 }, '409': { description: 库存或幂等冲突 } }
|
||||
/api/platform/v1/last-leg/outbound-orders/{outboundNo}:
|
||||
get:
|
||||
summary: 查询出库单详情
|
||||
description: 权限 platform:overseas-warehouse:outbound:list;只按全局唯一 outboundNo 定位并从记录解析客户范围,不接收 customerId。
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters: [{ name: outboundNo, in: path, required: true, schema: { type: string } }]
|
||||
responses: { '200': { description: 出库单及关联派送单详情 }, '404': { description: 出库单不存在 } }
|
||||
/api/platform/v1/last-leg/outbound-orders/{outboundNo}/cancel:
|
||||
post:
|
||||
summary: 取消未执行出库单
|
||||
description: 权限 platform:overseas-warehouse:outbound:cancel;只按全局唯一 outboundNo 锁定并解析客户范围,不接收 customerId。仅未开始拣货/出库的单据可取消,重复取消幂等成功。
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters: [{ name: outboundNo, in: path, required: true, schema: { type: string } }]
|
||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/OutboundCancel' } } } }
|
||||
responses: { '200': { description: 取消成功或已取消 }, '404': { description: 出库单不存在 }, '409': { description: 出库已开始执行 } }
|
||||
/api/platform/v1/last-leg/overseas-procurements:
|
||||
get:
|
||||
summary: 分页查询海外采购订单
|
||||
@@ -533,6 +589,39 @@ paths:
|
||||
required: true
|
||||
content: { application/json: { schema: { $ref: '#/components/schemas/ProcurementReview' } } }
|
||||
responses: { '200': { description: 统一 ApiResponse 包装的采购订单详情 } }
|
||||
/api/merchant/v1/last-leg/delivery-orders:
|
||||
get:
|
||||
summary: 客户分页查询自己的派送单
|
||||
description: 权限 merchant:warehouse:delivery:list;客户范围只取认证 Token,不接收 customerId。
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters:
|
||||
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
|
||||
- { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } }
|
||||
- { name: documentNo, in: query, schema: { type: string } }
|
||||
- { name: warehouseId, in: query, schema: { type: string } }
|
||||
- { name: status, in: query, schema: { type: string } }
|
||||
- { name: dispatchNo, in: query, schema: { type: string } }
|
||||
- { name: waybillNo, in: query, schema: { type: string } }
|
||||
- { name: vehiclePlate, in: query, schema: { type: string } }
|
||||
- { name: driverName, in: query, schema: { type: string } }
|
||||
- { name: deliveryMethod, in: query, schema: { type: string } }
|
||||
- { name: plannedFrom, in: query, schema: { type: string, format: date-time } }
|
||||
- { name: plannedTo, in: query, schema: { type: string, format: date-time } }
|
||||
responses: { '200': { description: 当前客户派送单分页 }, '403': { description: 缺少客户派送查看权限 } }
|
||||
post:
|
||||
summary: 客户从自己的排车单创建派送单
|
||||
description: 权限 merchant:warehouse:delivery:create;客户范围只取认证 Token,dispatchNo 必填,不接收 customerId;同一幂等键或已转换排车重复调用返回既有单据。
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters: [{ name: dispatchNo, in: query, required: true, schema: { type: string, minLength: 1 } }]
|
||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/DeliveryTransfer' } } } }
|
||||
responses: { '200': { description: 出库与派送单转换结果 }, '403': { description: 缺少客户派送创建权限 }, '404': { description: 当前客户下排车单不存在 }, '409': { description: 状态、参数或幂等冲突 } }
|
||||
/api/merchant/v1/last-leg/delivery-orders/{deliveryNo}:
|
||||
get:
|
||||
summary: 客户查询自己的派送单详情
|
||||
description: 权限 merchant:warehouse:delivery:list;列表权限包含详情只读,客户范围只取认证 Token,不接收 customerId。
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters: [{ name: deliveryNo, in: path, required: true, schema: { type: string } }]
|
||||
responses: { '200': { description: 当前客户派送单详情 }, '403': { description: 缺少客户派送查看权限 }, '404': { description: 当前客户下派送单不存在 } }
|
||||
/api/merchant/v1/last-leg/overseas-procurements:
|
||||
post:
|
||||
summary: 客户创建海外采购订单
|
||||
@@ -1246,6 +1335,69 @@ components:
|
||||
id: { type: string }
|
||||
capacity: { type: integer }
|
||||
palletIds: { type: array, items: { type: string } }
|
||||
DispatchUpdate:
|
||||
type: object
|
||||
description: 普通编辑字段;不包含 status 或 departAt。
|
||||
properties:
|
||||
vehiclePlate: { type: string, nullable: true, maxLength: 64 }
|
||||
driverName: { type: string, nullable: true, maxLength: 128 }
|
||||
driverPhone: { type: string, nullable: true, maxLength: 64 }
|
||||
carrierName: { type: string, nullable: true, maxLength: 255 }
|
||||
route: { type: string, nullable: true, maxLength: 255 }
|
||||
fbaAppointmentNo: { type: string, nullable: true, maxLength: 64 }
|
||||
fbaAppointmentStatus: { type: string, nullable: true, maxLength: 32 }
|
||||
vehicleAppointmentStatus: { type: string, nullable: true, maxLength: 32 }
|
||||
estimatedArrivalAt: { type: string, format: date-time, nullable: true }
|
||||
DeliveryTransfer:
|
||||
type: object
|
||||
required: [idempotencyKey, plannedDeliveryAt, deliveryMethod, pickupMethod, loadingType, fees]
|
||||
properties:
|
||||
idempotencyKey: { type: string, minLength: 1, maxLength: 255 }
|
||||
plannedDeliveryAt: { type: string, format: date-time }
|
||||
deliveryMethod: { type: string, maxLength: 32 }
|
||||
waybillNo: { type: string, nullable: true, maxLength: 128 }
|
||||
carrierName: { type: string, nullable: true, maxLength: 255 }
|
||||
pickupMethod: { type: string, maxLength: 64 }
|
||||
loadingType: { type: string, maxLength: 64 }
|
||||
vehicleType: { type: string, nullable: true, maxLength: 64 }
|
||||
vehicleSize: { type: string, nullable: true, maxLength: 64 }
|
||||
driverName: { type: string, nullable: true, maxLength: 128 }
|
||||
driverPhone: { type: string, nullable: true, maxLength: 64 }
|
||||
vehiclePlate: { type: string, nullable: true, maxLength: 64 }
|
||||
appointmentAccount: { type: string, nullable: true, maxLength: 128 }
|
||||
sellerName: { type: string, nullable: true, maxLength: 128 }
|
||||
remark: { type: string, nullable: true, maxLength: 1000 }
|
||||
fees: { type: array, minItems: 1, maxItems: 20, items: { $ref: '#/components/schemas/DeliveryFeeInput' } }
|
||||
DeliveryFeeInput:
|
||||
type: object
|
||||
required: [feeType, currency, amount]
|
||||
properties:
|
||||
feeType: { type: string, maxLength: 64 }
|
||||
currency: { type: string, maxLength: 8 }
|
||||
amount: { type: number, minimum: 0 }
|
||||
remark: { type: string, nullable: true, maxLength: 500 }
|
||||
OneClickOutboundCreate:
|
||||
type: object
|
||||
required: [recipient, items, idempotencyKey]
|
||||
properties:
|
||||
recipient: { type: string, maxLength: 1000 }
|
||||
remark: { type: string, nullable: true, maxLength: 1000 }
|
||||
idempotencyKey: { type: string, minLength: 1, maxLength: 255 }
|
||||
items:
|
||||
type: array
|
||||
minItems: 1
|
||||
maxItems: 200
|
||||
items:
|
||||
type: object
|
||||
required: [inventoryPositionId, packages, sku]
|
||||
properties:
|
||||
inventoryPositionId: { type: string, pattern: '^[1-9][0-9]*$' }
|
||||
packages: { type: integer, format: int64, minimum: 1 }
|
||||
sku: { type: string, maxLength: 128 }
|
||||
OutboundCancel:
|
||||
type: object
|
||||
required: [reason]
|
||||
properties: { reason: { type: string, minLength: 1, maxLength: 1000 } }
|
||||
DispatchResponse:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -2,10 +2,71 @@ openapi: 3.0.3
|
||||
info:
|
||||
title: Lastleg Overseas PDA Warehouse Operations API
|
||||
version: 1.0.0
|
||||
description: 海外仓PDA盘点、同仓整托移库、托盘作业与换标现场作业;全部范围从PDA Token解析。
|
||||
description: 海外仓PDA排车拣货发车、一件代发出库、盘点、同仓整托移库、托盘与换标作业;全部范围从PDA Token解析,派送不进入PDA。
|
||||
security:
|
||||
- bearerAuth: []
|
||||
paths:
|
||||
/api/pda/v1/last-leg/dispatches:
|
||||
get:
|
||||
summary: 查询Token绑定仓的排车PDA任务
|
||||
description: 仅 OVERSEAS_WAREHOUSE_PDA_OPERATOR + lastleg:pda:operate;跨客户读取绑定仓任务,不接收customerId或warehouseId。
|
||||
parameters: [{ name: status, in: query, required: false, schema: { type: string } }]
|
||||
responses: { '200': { description: 排车任务列表 }, '403': { description: 角色、权限或仓库绑定缺失 } }
|
||||
/api/pda/v1/last-leg/dispatches/{dispatchNo}:
|
||||
get:
|
||||
summary: 查询绑定仓排车任务详情
|
||||
parameters: [{ name: dispatchNo, in: path, required: true, schema: { type: string } }]
|
||||
responses: { '200': { description: 排车托盘、目的仓与库存位置详情 }, '404': { description: 当前绑定仓无此排车任务 } }
|
||||
/api/pda/v1/last-leg/dispatches/{dispatchNo}/items/{itemId}/pick:
|
||||
post:
|
||||
summary: 扫码拣取排车托盘包裹
|
||||
parameters:
|
||||
- { name: dispatchNo, in: path, required: true, schema: { type: string } }
|
||||
- { name: itemId, in: path, required: true, schema: { type: string, pattern: '^[0-9]+$' } }
|
||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/DispatchPick' } } } }
|
||||
responses: { '200': { description: 本次及累计拣货结果;同键同请求重放既有结果 }, '409': { description: 跨仓、跨客户、库存、数量或幂等冲突 } }
|
||||
/api/pda/v1/last-leg/dispatches/{dispatchNo}/complete:
|
||||
post:
|
||||
summary: 完成排车拣货
|
||||
description: 每个要求托盘及全部包裹必须精确完成;不允许部分完成。
|
||||
parameters: [{ name: dispatchNo, in: path, required: true, schema: { type: string } }]
|
||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/IdempotentAction' } } } }
|
||||
responses: { '200': { description: READY_TO_DEPART结果 }, '409': { description: 未全部拣货或幂等冲突 } }
|
||||
/api/pda/v1/last-leg/dispatches/{dispatchNo}/depart:
|
||||
post:
|
||||
summary: 排车发车并完成出库门禁
|
||||
description: 仅完整拣货且存在待出库单时发车;同事务推进出库与排车状态、订单归属并释放托盘锁,不重复扣减扫码时已扣库存。
|
||||
parameters: [{ name: dispatchNo, in: path, required: true, schema: { type: string } }]
|
||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/DepartAction' } } } }
|
||||
responses: { '200': { description: DEPARTED结果;同键同请求返回既有结果 }, '409': { description: 拣货、出库或幂等门禁未满足 } }
|
||||
/api/pda/v1/last-leg/outbound-tasks:
|
||||
get:
|
||||
summary: 查询Token绑定仓的一件代发出库任务
|
||||
description: 一件代发复用统一出库任务模型;不接收customerId或warehouseId,派送不进入PDA。
|
||||
parameters: [{ name: status, in: query, required: false, schema: { type: string } }]
|
||||
responses: { '200': { description: 出库任务列表 } }
|
||||
/api/pda/v1/last-leg/outbound-tasks/{taskNo}:
|
||||
get:
|
||||
summary: 查询一件代发出库任务详情
|
||||
parameters: [{ name: taskNo, in: path, required: true, schema: { type: string } }]
|
||||
responses:
|
||||
'200':
|
||||
description: 出库任务、出库项及每条可拣库存位置;inventoryPositionId仅作为pick提交键,不作为用户展示编号
|
||||
content: { application/json: { schema: { $ref: '#/components/schemas/PdaOutboundTaskDetail' } } }
|
||||
'404': { description: 当前绑定仓无此任务 }
|
||||
/api/pda/v1/last-leg/outbound-tasks/{taskNo}/pick:
|
||||
post:
|
||||
summary: 扫码拣取一件代发箱件
|
||||
parameters: [{ name: taskNo, in: path, required: true, schema: { type: string } }]
|
||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/OutboundPick' } } } }
|
||||
responses: { '200': { description: 更新后的统一出库任务 }, '409': { description: 库存、数量或幂等冲突 } }
|
||||
/api/pda/v1/last-leg/outbound-tasks/{taskNo}/complete:
|
||||
post:
|
||||
summary: 完成一件代发出库任务
|
||||
description: 全部托盘及箱件精确拣完后完成;同一完成幂等键返回既有结果,其他键明确冲突。
|
||||
parameters: [{ name: taskNo, in: path, required: true, schema: { type: string } }]
|
||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/IdempotentAction' } } } }
|
||||
responses: { '200': { description: COMPLETED任务 }, '409': { description: 未全部拣货或幂等冲突 } }
|
||||
/api/pda/v1/last-leg/stocktakes:
|
||||
get:
|
||||
summary: 查询Token绑定仓盘点任务
|
||||
@@ -128,6 +189,68 @@ components:
|
||||
type: object
|
||||
required: [idempotencyKey]
|
||||
properties: { idempotencyKey: { type: string, minLength: 1, maxLength: 255 } }
|
||||
DepartAction:
|
||||
type: object
|
||||
required: [idempotencyKey]
|
||||
properties:
|
||||
idempotencyKey: { type: string, minLength: 1, maxLength: 255 }
|
||||
remark: { type: string, maxLength: 1000, nullable: true }
|
||||
DispatchPick:
|
||||
type: object
|
||||
required: [positionId, packages, productQuantity, idempotencyKey]
|
||||
properties:
|
||||
positionId: { type: string, pattern: '^[0-9]+$' }
|
||||
packages: { type: integer, format: int64, minimum: 1 }
|
||||
productQuantity: { type: integer, format: int64, minimum: 0 }
|
||||
idempotencyKey: { type: string, minLength: 1, maxLength: 255 }
|
||||
remark: { type: string, maxLength: 1000, nullable: true }
|
||||
OutboundPick:
|
||||
type: object
|
||||
required: [outboundPalletItemId, inventoryPositionId, packages, idempotencyKey]
|
||||
properties:
|
||||
outboundPalletItemId: { type: string, pattern: '^[0-9]+$' }
|
||||
inventoryPositionId: { type: string, pattern: '^[0-9]+$' }
|
||||
packages: { type: integer, format: int64, minimum: 1 }
|
||||
idempotencyKey: { type: string, minLength: 1, maxLength: 255 }
|
||||
PdaOutboundTaskDetail:
|
||||
type: object
|
||||
required: [task, items]
|
||||
properties:
|
||||
task:
|
||||
type: object
|
||||
required: [taskNo, status, outboundNo]
|
||||
properties:
|
||||
taskNo: { type: string }
|
||||
status: { type: string }
|
||||
outboundNo: { type: string }
|
||||
pushedAt: { type: string, format: date-time, nullable: true }
|
||||
completedAt: { type: string, format: date-time, nullable: true }
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
required: [outboundPalletItemId, palletNo, skuCode, inboundCode, packageCount, pickedPackageCount, status, positions]
|
||||
properties:
|
||||
outboundPalletItemId: { type: string, description: pick请求提交键,不作为用户展示编号 }
|
||||
palletNo: { type: string }
|
||||
skuCode: { type: string }
|
||||
inboundCode: { type: string }
|
||||
packageCount: { type: integer }
|
||||
pickedPackageCount: { type: integer }
|
||||
status: { type: string }
|
||||
positions:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
required: [inventoryPositionId, palletNo, cartonMark, skuCode, inboundCode, availablePackages, pickedPackages]
|
||||
properties:
|
||||
inventoryPositionId: { type: string, description: pick请求提交键,不作为用户展示编号 }
|
||||
palletNo: { type: string }
|
||||
cartonMark: { type: string, nullable: true }
|
||||
skuCode: { type: string }
|
||||
inboundCode: { type: string }
|
||||
availablePackages: { type: integer, format: int64, minimum: 0 }
|
||||
pickedPackages: { type: integer, format: int64, minimum: 0 }
|
||||
StocktakeCreate:
|
||||
type: object
|
||||
required: [scopeType, scopeCode, idempotencyKey]
|
||||
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package com.baiyue.lastleg.delivery;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class Phase5PdaDeploymentSqlContractTest {
|
||||
@Test
|
||||
void overseasUnifiedMigrationConvergesPhase5IdentifiersWithoutBackfill() throws Exception {
|
||||
String sql = Files.readString(Path.of("..", "deploy", "sql", "海外区域统一增量-MySQL8.sql"),
|
||||
StandardCharsets.UTF_8);
|
||||
int start = sql.indexOf("-- 五期排车、出库与海外PDA正式业务号/幂等约束");
|
||||
int end = sql.indexOf("USE baiyue_lastleg;", start);
|
||||
assertTrue(start >= 0 && end > start);
|
||||
String phase5 = sql.substring(start, end);
|
||||
|
||||
assertTrue(phase5.contains("assert_phase5_lastleg_identifiers_migratable"));
|
||||
assertTrue(phase5.contains("assert_phase5_lastleg_identifiers_ready"));
|
||||
assertTrue(phase5.contains("warehouse_id IS NULL OR TRIM(warehouse_id)='' OR TRIM(warehouse_id)='0'"));
|
||||
assertTrue(phase5.contains("SIGNAL SQLSTATE '45000'"));
|
||||
assertTrue(phase5.contains("MODIFY COLUMN warehouse_id VARCHAR(128) NOT NULL"));
|
||||
assertTrue(phase5.contains("ADD COLUMN completion_idempotency_key VARCHAR(255) NULL"));
|
||||
assertTrue(phase5.contains("ADD UNIQUE KEY uk_ll_dispatch_order_no(dispatch_no)"));
|
||||
assertTrue(phase5.contains("ADD UNIQUE KEY uk_ll_outbound_no(outbound_no)"));
|
||||
assertTrue(phase5.contains("ADD UNIQUE KEY uk_ll_dispatch_pda_task_no(task_no)"));
|
||||
assertTrue(phase5.contains("ADD UNIQUE KEY uk_ll_outbound_pda_task_no(task_no)"));
|
||||
assertTrue(phase5.contains("ADD UNIQUE KEY uk_ll_outbound_pda_complete_key(customer_id,completion_idempotency_key)"));
|
||||
assertFalse(Pattern.compile("(?im)^\\s*UPDATE\\s+").matcher(phase5).find());
|
||||
assertFalse(phase5.contains("JOIN ll_dispatch_order"));
|
||||
|
||||
assertFalse(sql.contains("uk_ll_dispatch_order_no(customer_id,dispatch_no)"));
|
||||
assertFalse(sql.contains("uk_ll_outbound_no(customer_id,outbound_no)"));
|
||||
assertFalse(sql.contains("uk_ll_dispatch_pda_task_no(customer_id,task_no)"));
|
||||
assertFalse(sql.contains("uk_ll_outbound_pda_task_no(customer_id,task_no)"));
|
||||
}
|
||||
}
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
package com.baiyue.lastleg.delivery;
|
||||
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.delivery.controller.DeliveryTransferController;
|
||||
import com.baiyue.lastleg.delivery.dto.DeliveryRequests;
|
||||
import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
|
||||
import com.baiyue.lastleg.dispatch.controller.DispatchController;
|
||||
import com.baiyue.lastleg.dispatch.dto.DispatchRequests;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper;
|
||||
import com.baiyue.lastleg.security.OverseasPermissions;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Arrays;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class PlatformDispatchOutboundContractTest {
|
||||
@Test
|
||||
void platformActionsHaveIndependentPermissionsAndNoCustomerParameter() throws Exception {
|
||||
Method update = DispatchController.class.getMethod("update", AuthenticatedUser.class,
|
||||
String.class, DispatchRequests.Update.class);
|
||||
Method cancel = DispatchController.class.getMethod("cancel", AuthenticatedUser.class, String.class);
|
||||
Method transfer = DeliveryTransferController.class.getMethod("transfer", AuthenticatedUser.class,
|
||||
String.class, DeliveryRequests.Transfer.class);
|
||||
Method outboundDetail = DeliveryTransferController.class.getMethod("outboundDetail",
|
||||
AuthenticatedUser.class, String.class, DeliveryRequests.PalletQuery.class);
|
||||
Method outboundCancel = DeliveryTransferController.class.getMethod("cancelOutbound",
|
||||
AuthenticatedUser.class, String.class, DeliveryRequests.Cancel.class);
|
||||
|
||||
assertPermission(update, OverseasPermissions.DISPATCH_EDIT);
|
||||
assertPermission(cancel, OverseasPermissions.DISPATCH_CANCEL);
|
||||
assertPermission(transfer, OverseasPermissions.DISPATCH_TRANSFER_DELIVERY);
|
||||
assertPermission(outboundDetail, OverseasPermissions.OUTBOUND_LIST);
|
||||
assertPermission(outboundCancel, OverseasPermissions.OUTBOUND_CANCEL);
|
||||
for (Method method : Set.of(update, cancel, transfer, outboundDetail, outboundCancel)) {
|
||||
assertTrue(Arrays.stream(method.getParameters())
|
||||
.noneMatch(parameter -> parameter.isAnnotationPresent(RequestParam.class)));
|
||||
}
|
||||
assertTrue(Arrays.stream(DispatchController.class.getDeclaredMethods())
|
||||
.noneMatch(method -> "depart".equals(method.getName())));
|
||||
assertEquals(Set.of("vehiclePlate", "driverName", "driverPhone", "carrierName", "route",
|
||||
"fbaAppointmentNo", "fbaAppointmentStatus", "vehicleAppointmentStatus", "estimatedArrivalAt"),
|
||||
Arrays.stream(DispatchRequests.Update.class.getRecordComponents())
|
||||
.map(component -> component.getName()).collect(java.util.stream.Collectors.toSet()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void oneClickRequiresCustomerWhileOutboundListKeepsOptionalFilter() throws Exception {
|
||||
Method create = DeliveryTransferController.class.getMethod("createOneClick", AuthenticatedUser.class,
|
||||
String.class, DeliveryRequests.OneClickOutbound.class);
|
||||
Method page = DeliveryTransferController.class.getMethod("outboundPage", AuthenticatedUser.class,
|
||||
String.class, DeliveryRequests.Query.class);
|
||||
|
||||
assertPermission(create, OverseasPermissions.OUTBOUND_CREATE);
|
||||
assertPermission(page, OverseasPermissions.OUTBOUND_LIST);
|
||||
RequestParam createCustomer = create.getParameters()[1].getAnnotation(RequestParam.class);
|
||||
RequestParam pageCustomer = page.getParameters()[1].getAnnotation(RequestParam.class);
|
||||
assertNotNull(createCustomer);
|
||||
assertTrue(createCustomer.required());
|
||||
assertNotNull(pageCustomer);
|
||||
assertFalse(pageCustomer.required());
|
||||
|
||||
Method deliveryPage = DeliveryTransferController.class.getMethod("deliveryPage", AuthenticatedUser.class,
|
||||
String.class, DeliveryRequests.Query.class);
|
||||
RequestParam deliveryCustomer = deliveryPage.getParameters()[1].getAnnotation(RequestParam.class);
|
||||
assertNotNull(deliveryCustomer);
|
||||
assertFalse(deliveryCustomer.required());
|
||||
}
|
||||
|
||||
@Test
|
||||
void platformActionsRejectMerchantIdentityBeforeBusinessLookup() {
|
||||
AuthenticatedUser merchant = new AuthenticatedUser(7L, 100L, 100L, 8L, "merchant",
|
||||
"MERCHANT", "WEB", Set.of());
|
||||
DispatchController dispatchController = new DispatchController();
|
||||
DeliveryTransferController deliveryController = new DeliveryTransferController();
|
||||
|
||||
ResponseStatusException dispatchRejected = org.junit.jupiter.api.Assertions.assertThrows(
|
||||
ResponseStatusException.class,
|
||||
() -> dispatchController.cancel(merchant, "DSP-101"));
|
||||
ResponseStatusException outboundRejected = org.junit.jupiter.api.Assertions.assertThrows(
|
||||
ResponseStatusException.class,
|
||||
() -> deliveryController.outboundDetail(merchant, "OUT-101",
|
||||
new DeliveryRequests.PalletQuery(1, 20, null)));
|
||||
|
||||
assertEquals(403, dispatchRejected.getStatusCode().value());
|
||||
assertEquals(403, outboundRejected.getStatusCode().value());
|
||||
}
|
||||
|
||||
@Test
|
||||
void globalBusinessNumberMappersAndSchemaDoNotUseCustomerForIdentity() throws Exception {
|
||||
String dispatchFind = select(DispatchOrderMapper.class, "findGlobalByDispatchNo", String.class);
|
||||
String dispatchLock = select(DispatchOrderMapper.class, "lockGlobalByDispatchNo", String.class);
|
||||
String outboundFind = select(OutboundOrderMapper.class, "findGlobalByNo", String.class);
|
||||
String outboundLock = select(OutboundOrderMapper.class, "lockGlobalByNo", String.class);
|
||||
|
||||
for (String sql : Set.of(dispatchFind, dispatchLock, outboundFind, outboundLock)) {
|
||||
assertFalse(sql.contains("customer_id"));
|
||||
}
|
||||
assertTrue(dispatchLock.contains("FOR UPDATE"));
|
||||
assertTrue(outboundLock.contains("FOR UPDATE"));
|
||||
|
||||
String schema = Files.readString(Path.of("src/main/resources/db/lastleg.sql"), StandardCharsets.UTF_8);
|
||||
assertTrue(schema.contains("UNIQUE KEY uk_ll_dispatch_order_no (dispatch_no)"));
|
||||
assertTrue(schema.contains("UNIQUE KEY uk_ll_outbound_no (outbound_no)"));
|
||||
assertFalse(schema.contains("uk_ll_dispatch_order_no (customer_id,dispatch_no)"));
|
||||
assertFalse(schema.contains("uk_ll_outbound_no (customer_id,outbound_no)"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void openApiPublishesOnlyTheFrozenPlatformProtocol() throws Exception {
|
||||
String yaml = Files.readString(Path.of("src/main/resources/openapi/overseas-business-v1.yaml"),
|
||||
StandardCharsets.UTF_8);
|
||||
String dispatchAction = slice(yaml, " /api/platform/v1/last-leg/dispatches/{dispatchNo}:",
|
||||
" /api/platform/v1/last-leg/dispatches/candidates:");
|
||||
String transfer = slice(yaml,
|
||||
" /api/platform/v1/last-leg/dispatches/{dispatchNo}/transfer-delivery:",
|
||||
" /api/platform/v1/last-leg/outbound-orders:");
|
||||
String outboundDetail = slice(yaml,
|
||||
" /api/platform/v1/last-leg/outbound-orders/{outboundNo}:",
|
||||
" /api/platform/v1/last-leg/outbound-orders/{outboundNo}/cancel:");
|
||||
String outboundCancel = slice(yaml,
|
||||
" /api/platform/v1/last-leg/outbound-orders/{outboundNo}/cancel:",
|
||||
" /api/platform/v1/last-leg/overseas-procurements:");
|
||||
|
||||
for (String action : Set.of(dispatchAction, transfer, outboundDetail, outboundCancel)) {
|
||||
assertFalse(action.contains("name: customerId"));
|
||||
}
|
||||
assertTrue(yaml.contains("platform:overseas-warehouse:dispatch:edit"));
|
||||
assertTrue(yaml.contains("platform:overseas-warehouse:dispatch:cancel"));
|
||||
assertTrue(yaml.contains("platform:overseas-warehouse:dispatch:transfer-delivery"));
|
||||
assertTrue(yaml.contains("platform:overseas-warehouse:outbound:list"));
|
||||
assertTrue(yaml.contains("platform:overseas-warehouse:outbound:create"));
|
||||
assertTrue(yaml.contains("platform:overseas-warehouse:outbound:cancel"));
|
||||
}
|
||||
|
||||
private static void assertPermission(Method method, String permission) {
|
||||
PreAuthorize annotation = method.getAnnotation(PreAuthorize.class);
|
||||
assertNotNull(annotation);
|
||||
assertEquals("hasAuthority('" + permission + "')", annotation.value());
|
||||
}
|
||||
|
||||
private static String select(Class<?> mapper, String method, Class<?>... parameterTypes) throws Exception {
|
||||
Select annotation = mapper.getMethod(method, parameterTypes).getAnnotation(Select.class);
|
||||
assertNotNull(annotation);
|
||||
return String.join("", annotation.value());
|
||||
}
|
||||
|
||||
private static String slice(String source, String start, String end) {
|
||||
int from = source.indexOf(start);
|
||||
int to = source.indexOf(end, from + start.length());
|
||||
assertTrue(from >= 0 && to > from);
|
||||
return source.substring(from, to);
|
||||
}
|
||||
}
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
package com.baiyue.lastleg.delivery.controller;
|
||||
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.delivery.dto.DeliveryRequests;
|
||||
import com.baiyue.lastleg.delivery.service.DeliveryTransferService;
|
||||
import com.baiyue.lastleg.security.OverseasPermissions;
|
||||
import jakarta.validation.Validation;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
class MerchantDeliveryControllerTest {
|
||||
private final DeliveryTransferService service = mock(DeliveryTransferService.class);
|
||||
private final MerchantDeliveryController controller = controller();
|
||||
private final DeliveryRequests.Query query = new DeliveryRequests.Query(
|
||||
1, 20, null, null, null, null, null, null, null, null, null, null);
|
||||
|
||||
@Test
|
||||
void fixedRoutesUseIndependentListAndCreatePermissionsWithoutCustomerParameter() throws Exception {
|
||||
RequestMapping mapping = MerchantDeliveryController.class.getAnnotation(RequestMapping.class);
|
||||
assertEquals(List.of("/api/merchant/v1/last-leg/delivery-orders"), List.of(mapping.value()));
|
||||
|
||||
Method page = MerchantDeliveryController.class.getMethod("page", AuthenticatedUser.class,
|
||||
DeliveryRequests.Query.class);
|
||||
Method create = MerchantDeliveryController.class.getMethod("create", AuthenticatedUser.class,
|
||||
String.class, DeliveryRequests.Transfer.class);
|
||||
Method detail = MerchantDeliveryController.class.getMethod("detail", AuthenticatedUser.class, String.class);
|
||||
|
||||
assertPermission(page, OverseasPermissions.MERCHANT_DELIVERY_LIST);
|
||||
assertPermission(detail, OverseasPermissions.MERCHANT_DELIVERY_LIST);
|
||||
assertPermission(create, OverseasPermissions.MERCHANT_DELIVERY_CREATE);
|
||||
assertNotEquals(OverseasPermissions.MERCHANT_DELIVERY_LIST,
|
||||
OverseasPermissions.MERCHANT_DELIVERY_CREATE);
|
||||
assertTrue(Arrays.stream(page.getParameters()).noneMatch(this::isCustomerParameter));
|
||||
assertTrue(Arrays.stream(detail.getParameters()).noneMatch(this::isCustomerParameter));
|
||||
assertTrue(Arrays.stream(create.getParameters()).noneMatch(this::isCustomerParameter));
|
||||
assertTrue(Arrays.stream(DeliveryRequests.Transfer.class.getRecordComponents())
|
||||
.noneMatch(component -> "customerId".equals(component.getName())));
|
||||
}
|
||||
|
||||
@Test
|
||||
void allOperationsUseOnlyAuthenticatedCustomerScope() {
|
||||
AuthenticatedUser merchant = user(100L, "MERCHANT");
|
||||
DeliveryRequests.Transfer request = transferRequest();
|
||||
|
||||
controller.page(merchant, query);
|
||||
controller.detail(merchant, "DEL-100");
|
||||
controller.create(merchant, "DSP-100", request);
|
||||
|
||||
verify(service).deliveryPage("100", query);
|
||||
verify(service).deliveryDetail("100", "DEL-100");
|
||||
verify(service).merchantTransfer("100", "DSP-100", "1", request);
|
||||
}
|
||||
|
||||
@Test
|
||||
void merchantEndpointsRejectPlatformIdentity() {
|
||||
AuthenticatedUser platform = user(0L, "PLATFORM");
|
||||
|
||||
assertThrows(ResponseStatusException.class, () -> controller.page(platform, query));
|
||||
assertThrows(ResponseStatusException.class, () -> controller.detail(platform, "DEL-100"));
|
||||
assertThrows(ResponseStatusException.class, () -> controller.create(platform, "DSP-100", transferRequest()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidCreatePayloadIsRejectedByBeanValidationContract() {
|
||||
DeliveryRequests.Transfer invalid = new DeliveryRequests.Transfer(" ", null, " ", null, null,
|
||||
" ", " ", null, null, null, null, null, null, null, null, null);
|
||||
try (var factory = Validation.buildDefaultValidatorFactory()) {
|
||||
assertTrue(factory.getValidator().validate(invalid).size() >= 6);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void openApiPublishesOnlyTokenScopedMerchantDeliveryProtocol() throws Exception {
|
||||
String yaml = Files.readString(Path.of("src/main/resources/openapi/overseas-business-v1.yaml"),
|
||||
StandardCharsets.UTF_8);
|
||||
String contract = slice(yaml, " /api/merchant/v1/last-leg/delivery-orders:",
|
||||
" /api/merchant/v1/last-leg/overseas-procurements:");
|
||||
|
||||
assertTrue(contract.contains("merchant:warehouse:delivery:list"));
|
||||
assertTrue(contract.contains("merchant:warehouse:delivery:create"));
|
||||
assertFalse(contract.contains("name: customerId"));
|
||||
}
|
||||
|
||||
private boolean isCustomerParameter(java.lang.reflect.Parameter parameter) {
|
||||
RequestParam annotation = parameter.getAnnotation(RequestParam.class);
|
||||
return annotation != null && ("customerId".equals(annotation.name())
|
||||
|| "customerId".equals(annotation.value()));
|
||||
}
|
||||
|
||||
private static void assertPermission(Method method, String permission) {
|
||||
PreAuthorize annotation = method.getAnnotation(PreAuthorize.class);
|
||||
assertEquals("hasAuthority('" + permission + "')", annotation.value());
|
||||
}
|
||||
|
||||
private static String slice(String source, String start, String end) {
|
||||
int from = source.indexOf(start);
|
||||
int to = source.indexOf(end, from + start.length());
|
||||
assertTrue(from >= 0 && to > from);
|
||||
return source.substring(from, to);
|
||||
}
|
||||
|
||||
private static AuthenticatedUser user(long tenantId, String appCode) {
|
||||
return new AuthenticatedUser(1L, tenantId, tenantId, 9L, "merchant", appCode, "WEB", Set.of());
|
||||
}
|
||||
|
||||
private MerchantDeliveryController controller() {
|
||||
MerchantDeliveryController value = new MerchantDeliveryController();
|
||||
ReflectionTestUtils.setField(value, "service", service);
|
||||
return value;
|
||||
}
|
||||
|
||||
private static DeliveryRequests.Transfer transferRequest() {
|
||||
return new DeliveryRequests.Transfer("merchant-key", LocalDateTime.now(), "SELF_DELIVERY", null, null,
|
||||
"ONE_PICKUP_ONE_DROP", "PALLET", null, null, null, null, null, null, null, null,
|
||||
List.of(new DeliveryRequests.FeeItem("DELIVERY_FEE", "USD", BigDecimal.ZERO, null)));
|
||||
}
|
||||
}
|
||||
+244
-26
@@ -6,11 +6,14 @@ import com.baiyue.lastleg.delivery.dto.DeliveryResponses;
|
||||
import com.baiyue.lastleg.delivery.entity.DeliveryOrder;
|
||||
import com.baiyue.lastleg.delivery.entity.OutboundOrder;
|
||||
import com.baiyue.lastleg.delivery.entity.OutboundPalletItem;
|
||||
import com.baiyue.lastleg.delivery.entity.OutboundPdaTask;
|
||||
import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper;
|
||||
import com.baiyue.lastleg.delivery.mapper.DeliveryFeeMapper;
|
||||
import com.baiyue.lastleg.delivery.mapper.DeliveryOperationMapper;
|
||||
import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
|
||||
import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper;
|
||||
import com.baiyue.lastleg.delivery.mapper.OutboundPdaTaskMapper;
|
||||
import com.baiyue.lastleg.delivery.mapper.OutboundPdaPickMapper;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchOrder;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper;
|
||||
@@ -23,6 +26,14 @@ import com.baiyue.lastleg.mapper.LastlegOrderMapper;
|
||||
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
|
||||
import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper;
|
||||
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
|
||||
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
|
||||
import com.baiyue.lastleg.inventory.entity.InventoryBalance;
|
||||
import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper;
|
||||
import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper;
|
||||
import com.baiyue.lastleg.dropship.entity.DropshipOrderProfile;
|
||||
import com.baiyue.lastleg.dropship.entity.DropshipCarton;
|
||||
import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper;
|
||||
import com.baiyue.lastleg.dropship.mapper.DropshipCartonMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
@@ -30,6 +41,7 @@ import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
@@ -59,6 +71,12 @@ class DeliveryTransferServiceTest {
|
||||
@Mock LastlegOrderMapper tailOrders;
|
||||
@Mock LastlegOrderStatusHistoryMapper histories;
|
||||
@Mock LastlegOrderDetailMapper tailOrderDetails;
|
||||
@Mock OutboundPdaTaskMapper pdaTasks;
|
||||
@Mock OutboundPdaPickMapper pdaPicks;
|
||||
@Mock InventoryPositionMapper inventoryPositions;
|
||||
@Mock InventoryBalanceMapper inventoryBalances;
|
||||
@Mock DropshipOrderProfileMapper dropshipProfiles;
|
||||
@Mock DropshipCartonMapper dropshipCartons;
|
||||
|
||||
private DeliveryTransferService service;
|
||||
|
||||
@@ -78,19 +96,24 @@ class DeliveryTransferServiceTest {
|
||||
ReflectionTestUtils.setField(service, "tailOrders", tailOrders);
|
||||
ReflectionTestUtils.setField(service, "histories", histories);
|
||||
ReflectionTestUtils.setField(service, "tailOrderDetails", tailOrderDetails);
|
||||
ReflectionTestUtils.setField(service, "pdaTasks", pdaTasks);
|
||||
ReflectionTestUtils.setField(service, "pdaPicks", pdaPicks);
|
||||
ReflectionTestUtils.setField(service, "inventoryPositions", inventoryPositions);
|
||||
ReflectionTestUtils.setField(service, "inventoryBalances", inventoryBalances);
|
||||
ReflectionTestUtils.setField(service, "dropshipProfiles", dropshipProfiles);
|
||||
ReflectionTestUtils.setField(service, "dropshipCartons", dropshipCartons);
|
||||
}
|
||||
|
||||
@Test
|
||||
void firstTransferCreatesOneOutboundOneDeliveryAndAllPalletSnapshots() {
|
||||
DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A");
|
||||
when(dispatchOrders.findByDispatchNo("TENANT-A", "DSP-101")).thenReturn(dispatch);
|
||||
when(dispatchOrders.lockById("TENANT-A", 101L)).thenReturn(dispatch);
|
||||
when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch);
|
||||
when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of(
|
||||
item(201L, 501L, "PLT-501", "ONT8", "Ontario", "IN-A", 12),
|
||||
item(202L, 502L, "PLT-502", "LAX9", "Los Angeles", "IN-B", 8)
|
||||
));
|
||||
|
||||
DeliveryResponses.TransferResult result = service.transfer("TENANT-A", "DSP-101", "U-1",
|
||||
DeliveryResponses.TransferResult result = service.platformTransfer("DSP-101", "U-1",
|
||||
transferRequest("transfer-101", "ready"));
|
||||
|
||||
ArgumentCaptor<OutboundOrder> outbound = ArgumentCaptor.forClass(OutboundOrder.class);
|
||||
@@ -122,12 +145,11 @@ class DeliveryTransferServiceTest {
|
||||
DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A");
|
||||
OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "transfer-101");
|
||||
DeliveryOrder delivery = delivery(401L, outbound, "DEL-401");
|
||||
when(dispatchOrders.findByDispatchNo("TENANT-A", "DSP-101")).thenReturn(dispatch);
|
||||
when(dispatchOrders.lockById("TENANT-A", 101L)).thenReturn(dispatch);
|
||||
when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch);
|
||||
when(outboundOrders.findByIdempotency("TENANT-A", "transfer-101")).thenReturn(outbound);
|
||||
when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(delivery);
|
||||
|
||||
DeliveryResponses.TransferResult result = service.transfer("TENANT-A", "DSP-101", "U-1",
|
||||
DeliveryResponses.TransferResult result = service.platformTransfer("DSP-101", "U-1",
|
||||
transferRequest(" transfer-101 ", null));
|
||||
|
||||
assertTrue(result.replayed());
|
||||
@@ -142,12 +164,11 @@ class DeliveryTransferServiceTest {
|
||||
void idempotencyKeyCannotBeReusedForAnotherDispatch() {
|
||||
DispatchOrder dispatch = dispatch(102L, "DSP-102", "TENANT-A");
|
||||
OutboundOrder other = outbound(301L, 101L, "DSP-101", "OUT-301", "shared-key");
|
||||
when(dispatchOrders.findByDispatchNo("TENANT-A", "DSP-102")).thenReturn(dispatch);
|
||||
when(dispatchOrders.lockById("TENANT-A", 102L)).thenReturn(dispatch);
|
||||
when(dispatchOrders.lockGlobalByDispatchNo("DSP-102")).thenReturn(dispatch);
|
||||
when(outboundOrders.findByIdempotency("TENANT-A", "shared-key")).thenReturn(other);
|
||||
|
||||
ResponseStatusException exception = assertThrows(ResponseStatusException.class, () ->
|
||||
service.transfer("TENANT-A", "DSP-102", "U-1", transferRequest("shared-key", null)));
|
||||
service.platformTransfer("DSP-102", "U-1", transferRequest("shared-key", null)));
|
||||
|
||||
assertEquals(409, exception.getStatusCode().value());
|
||||
verify(outboundOrders, never()).insert((OutboundOrder) any());
|
||||
@@ -158,15 +179,14 @@ class DeliveryTransferServiceTest {
|
||||
DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A");
|
||||
OutboundOrder winner = outbound(301L, 101L, "DSP-101", "OUT-301", "transfer-101");
|
||||
DeliveryOrder delivery = delivery(401L, winner, "DEL-401");
|
||||
when(dispatchOrders.findByDispatchNo("TENANT-A", "DSP-101")).thenReturn(dispatch);
|
||||
when(dispatchOrders.lockById("TENANT-A", 101L)).thenReturn(dispatch);
|
||||
when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch);
|
||||
when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of(
|
||||
item(201L, 501L, "PLT-501", "ONT8", "Ontario", "IN-A", 12)));
|
||||
when(outboundOrders.insert(any(OutboundOrder.class))).thenThrow(new org.springframework.dao.DuplicateKeyException("duplicate"));
|
||||
when(outboundOrders.findByIdempotency("TENANT-A", "transfer-101")).thenReturn(null, winner);
|
||||
when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(delivery);
|
||||
|
||||
DeliveryResponses.TransferResult result = service.transfer("TENANT-A", "DSP-101", "U-1",
|
||||
DeliveryResponses.TransferResult result = service.platformTransfer("DSP-101", "U-1",
|
||||
transferRequest("transfer-101", null));
|
||||
|
||||
assertTrue(result.replayed());
|
||||
@@ -183,12 +203,11 @@ class DeliveryTransferServiceTest {
|
||||
source.setOrderId(901L);
|
||||
LastlegOrder tailOrder = new LastlegOrder();
|
||||
tailOrder.setId(901L); tailOrder.setCustomerId("TENANT-A"); tailOrder.setCurrentStatus("PENDING_ASSIGNMENT");
|
||||
when(dispatchOrders.findByDispatchNo("TENANT-A", "DSP-101")).thenReturn(dispatch);
|
||||
when(dispatchOrders.lockById("TENANT-A", 101L)).thenReturn(dispatch);
|
||||
when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch);
|
||||
when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of(source));
|
||||
when(tailOrders.selectList(any())).thenReturn(List.of(tailOrder));
|
||||
|
||||
service.transfer("TENANT-A", "DSP-101", "U-1", transferRequest("direct-order", null));
|
||||
service.platformTransfer("DSP-101", "U-1", transferRequest("direct-order", null));
|
||||
|
||||
assertEquals("WAIT_OUTBOUND", tailOrder.getCurrentStatus());
|
||||
assertNotNull(tailOrder.getOutboundOrderNo());
|
||||
@@ -204,12 +223,11 @@ class DeliveryTransferServiceTest {
|
||||
DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A");
|
||||
DispatchItem planned = item(201L, null, "PLAN-1", "ONT8", "Ontario", "IN-A", 12);
|
||||
planned.setSourceStage("PLAN");
|
||||
when(dispatchOrders.findByDispatchNo("TENANT-A", "DSP-101")).thenReturn(dispatch);
|
||||
when(dispatchOrders.lockById("TENANT-A", 101L)).thenReturn(dispatch);
|
||||
when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch);
|
||||
when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of(planned));
|
||||
|
||||
ResponseStatusException exception = assertThrows(ResponseStatusException.class, () ->
|
||||
service.transfer("TENANT-A", "DSP-101", "U-1", transferRequest("transfer-101", null)));
|
||||
service.platformTransfer("DSP-101", "U-1", transferRequest("transfer-101", null)));
|
||||
|
||||
assertEquals(409, exception.getStatusCode().value());
|
||||
assertTrue(exception.getReason().contains("received"));
|
||||
@@ -220,10 +238,10 @@ class DeliveryTransferServiceTest {
|
||||
void outboundDetailDoesNotExposeExcludedPalletOrOutboundFulfillmentRows() {
|
||||
OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "transfer-101");
|
||||
DeliveryOrder delivery = delivery(401L, outbound, "DEL-401");
|
||||
when(outboundOrders.findByNo("TENANT-A", "OUT-301")).thenReturn(outbound);
|
||||
when(outboundOrders.findGlobalByNo("OUT-301")).thenReturn(outbound);
|
||||
when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(delivery);
|
||||
|
||||
DeliveryResponses.OutboundDetail detail = service.outboundDetail("TENANT-A", "OUT-301",
|
||||
DeliveryResponses.OutboundDetail detail = service.outboundDetail("OUT-301",
|
||||
new DeliveryRequests.PalletQuery(1, 20, null));
|
||||
|
||||
assertEquals("OUT-301", detail.outbound().outboundNo());
|
||||
@@ -234,16 +252,216 @@ class DeliveryTransferServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void detailLookupCannotCrossTenantBoundary() {
|
||||
when(outboundOrders.findByNo("TENANT-B", "OUT-301")).thenReturn(null);
|
||||
void globalOutboundDetailUsesTheCustomerResolvedFromTheBusinessNumber() {
|
||||
OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "transfer-101");
|
||||
DeliveryOrder delivery = delivery(401L, outbound, "DEL-401");
|
||||
when(outboundOrders.findGlobalByNo("OUT-301")).thenReturn(outbound);
|
||||
when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(delivery);
|
||||
|
||||
DeliveryResponses.OutboundDetail detail = service.outboundDetail("OUT-301",
|
||||
new DeliveryRequests.PalletQuery(1, 20, null));
|
||||
|
||||
assertEquals("OUT-301", detail.outbound().outboundNo());
|
||||
verify(deliveryOrders).lockByOutbound("TENANT-A", 301L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void merchantTransferCannotResolveAnotherCustomersDispatch() {
|
||||
when(dispatchOrders.findByDispatchNo("TENANT-B", "DSP-101")).thenReturn(null);
|
||||
|
||||
ResponseStatusException exception = assertThrows(ResponseStatusException.class, () ->
|
||||
service.outboundDetail("TENANT-B", "OUT-301", new DeliveryRequests.PalletQuery(1, 20, null)));
|
||||
service.merchantTransfer("TENANT-B", "DSP-101", "U-2", transferRequest("merchant-key", null)));
|
||||
|
||||
assertEquals(404, exception.getStatusCode().value());
|
||||
verify(outboundOrders).findByNo("TENANT-B", "OUT-301");
|
||||
verify(deliveryOrders, never()).lockByOutbound(any(), any());
|
||||
verify(outboundPallets, never()).selectPage(any(), any());
|
||||
verify(dispatchOrders, never()).lockGlobalByDispatchNo(any());
|
||||
verify(outboundOrders, never()).insert((OutboundOrder) any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void merchantTransferReplaysExistingDocumentsWithinAuthenticatedCustomer() {
|
||||
DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A");
|
||||
OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "merchant-key");
|
||||
DeliveryOrder delivery = delivery(401L, outbound, "DEL-401");
|
||||
when(dispatchOrders.findByDispatchNo("TENANT-A", "DSP-101")).thenReturn(dispatch);
|
||||
when(dispatchOrders.lockById("TENANT-A", 101L)).thenReturn(dispatch);
|
||||
when(outboundOrders.findByIdempotency("TENANT-A", "merchant-key")).thenReturn(outbound);
|
||||
when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(delivery);
|
||||
|
||||
DeliveryResponses.TransferResult result = service.merchantTransfer("TENANT-A", "DSP-101", "U-2",
|
||||
transferRequest(" merchant-key ", null));
|
||||
|
||||
assertTrue(result.replayed());
|
||||
assertEquals("DEL-401", result.deliveryNo());
|
||||
verify(outboundOrders, never()).insert((OutboundOrder) any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void merchantTransferRejectsCancelledDispatch() {
|
||||
DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A");
|
||||
dispatch.setStatus("CANCELLED");
|
||||
when(dispatchOrders.findByDispatchNo("TENANT-A", "DSP-101")).thenReturn(dispatch);
|
||||
when(dispatchOrders.lockById("TENANT-A", 101L)).thenReturn(dispatch);
|
||||
|
||||
ResponseStatusException exception = assertThrows(ResponseStatusException.class, () ->
|
||||
service.merchantTransfer("TENANT-A", "DSP-101", "U-2", transferRequest("merchant-key", null)));
|
||||
|
||||
assertEquals(409, exception.getStatusCode().value());
|
||||
verify(outboundOrders, never()).insert((OutboundOrder) any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void merchantDeliveryDetailCannotCrossCustomerBoundary() {
|
||||
when(deliveryOrders.findByNo("TENANT-B", "DEL-401")).thenReturn(null);
|
||||
|
||||
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
|
||||
() -> service.deliveryDetail("TENANT-B", "DEL-401"));
|
||||
|
||||
assertEquals(404, exception.getStatusCode().value());
|
||||
verify(dispatchItems, never()).findByDispatchOrder(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void globalOutboundCancelIsIdempotentForAnUnexecutedOrder() {
|
||||
OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "one-click-key");
|
||||
outbound.setStatus("WAIT_PICK");
|
||||
OutboundPalletItem item = pallet(501L, "PLT-501", "ONT8", "Ontario", "IN-A", 12);
|
||||
item.setStatus("WAIT_PICK"); item.setPickedPackageCount(0);
|
||||
OutboundPdaTask task = new OutboundPdaTask();
|
||||
task.setId(701L); task.setCustomerId("TENANT-A"); task.setOutboundOrderId(301L); task.setStatus("PENDING");
|
||||
when(outboundOrders.lockGlobalByNo("OUT-301")).thenReturn(outbound);
|
||||
when(outboundPallets.findByOutbound("TENANT-A", 301L)).thenReturn(List.of(item));
|
||||
when(pdaTasks.lockByOutbound("TENANT-A", 301L)).thenReturn(task);
|
||||
|
||||
service.cancelOutbound("OUT-301", new DeliveryRequests.Cancel("cancelled by platform"));
|
||||
service.cancelOutbound("OUT-301", new DeliveryRequests.Cancel("duplicate command"));
|
||||
|
||||
assertEquals("CANCELLED", outbound.getStatus());
|
||||
assertEquals("CANCELLED", item.getStatus());
|
||||
assertEquals("CANCELLED", task.getStatus());
|
||||
verify(outboundPallets, org.mockito.Mockito.times(1)).findByOutbound("TENANT-A", 301L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void globalOutboundCancelRejectsAnExecutedOrderWithoutDrift() {
|
||||
OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "transfer-key");
|
||||
outbound.setStatus("PICKED");
|
||||
when(outboundOrders.lockGlobalByNo("OUT-301")).thenReturn(outbound);
|
||||
|
||||
ResponseStatusException exception = assertThrows(ResponseStatusException.class, () ->
|
||||
service.cancelOutbound("OUT-301", new DeliveryRequests.Cancel("too late")));
|
||||
|
||||
assertEquals(409, exception.getStatusCode().value());
|
||||
assertEquals("PICKED", outbound.getStatus());
|
||||
verify(outboundOrders, never()).updateById(any(OutboundOrder.class));
|
||||
verify(outboundPallets, never()).findByOutbound(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void outboundPdaListAndDetailUseOnlyBoundWarehouse() throws Exception {
|
||||
assertNotNull(DeliveryTransferService.class.getMethod("pdaPick", String.class, String.class,
|
||||
String.class, DeliveryRequests.PdaPick.class).getAnnotation(Transactional.class));
|
||||
OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "one-click-key");
|
||||
outbound.setOverseasWarehouseId("WH-1");
|
||||
OutboundPdaTask task = new OutboundPdaTask();
|
||||
task.setId(701L); task.setCustomerId("TENANT-A"); task.setOutboundOrderId(301L);
|
||||
task.setTaskNo("PDA-701"); task.setStatus("PENDING");
|
||||
when(pdaTasks.listByWarehouse("WH-1", null)).thenReturn(List.of(task));
|
||||
when(pdaTasks.findByNoAndWarehouse("PDA-701", "WH-1")).thenReturn(task);
|
||||
when(outboundOrders.selectById(301L)).thenReturn(outbound);
|
||||
when(outboundPallets.findByOutbound("TENANT-A", 301L)).thenReturn(List.of());
|
||||
|
||||
assertEquals(1, service.pdaTasks("WH-1", null).size());
|
||||
assertEquals("PDA-701", service.pdaTaskDetail("WH-1", "PDA-701").task().taskNo());
|
||||
verify(pdaTasks).listByWarehouse("WH-1", null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void outboundPdaDetailReturnsEveryPickablePositionWithStableBusinessCodes() {
|
||||
OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "one-click-key");
|
||||
outbound.setOverseasWarehouseId("WH-1");
|
||||
OutboundPdaTask task = new OutboundPdaTask();
|
||||
task.setId(701L); task.setCustomerId("TENANT-A"); task.setOutboundOrderId(301L);
|
||||
task.setTaskNo("PDA-701"); task.setStatus("PENDING");
|
||||
OutboundPalletItem item = pallet(501L, "PLT-501", "ONT8", "Ontario", "IN-A", 2);
|
||||
item.setSkuCode("SKU-1"); item.setInboundCode("IN-A");
|
||||
InventoryPosition position = new InventoryPosition();
|
||||
position.setId(901L); position.setCustomerId("TENANT-A"); position.setBalanceId(801L);
|
||||
position.setOverseasWarehouseId("WH-1"); position.setPalletId(501L); position.setPalletNo("PLT-501");
|
||||
position.setAvailablePackages(2L); position.setPickedPackages(0L);
|
||||
InventoryBalance balance = new InventoryBalance();
|
||||
balance.setId(801L); balance.setCustomerId("TENANT-A"); balance.setOverseasWarehouseId("WH-1");
|
||||
balance.setSkuCode("SKU-1"); balance.setInboundCode("IN-A");
|
||||
DropshipOrderProfile profile = new DropshipOrderProfile(); profile.setId(1001L);
|
||||
DropshipCarton first = new DropshipCarton(); first.setStatus("AVAILABLE"); first.setSkuCode("SKU-1"); first.setCartonMark("CTN-A");
|
||||
DropshipCarton second = new DropshipCarton(); second.setStatus("AVAILABLE"); second.setSkuCode("SKU-1"); second.setCartonMark("CTN-B");
|
||||
when(pdaTasks.findByNoAndWarehouse("PDA-701", "WH-1")).thenReturn(task);
|
||||
when(outboundOrders.selectById(301L)).thenReturn(outbound);
|
||||
when(outboundPallets.findByOutbound("TENANT-A", 301L)).thenReturn(List.of(item));
|
||||
when(inventoryPositions.listPallet("TENANT-A", 501L)).thenReturn(List.of(position));
|
||||
when(inventoryBalances.selectById(801L)).thenReturn(balance);
|
||||
when(dropshipProfiles.findPosition("TENANT-A", 901L)).thenReturn(profile);
|
||||
when(dropshipCartons.listProfile("TENANT-A", 1001L)).thenReturn(List.of(first, second));
|
||||
|
||||
DeliveryResponses.PdaTaskDetail detail = service.pdaTaskDetail("WH-1", "PDA-701");
|
||||
|
||||
assertEquals(2, detail.items().get(0).positions().size());
|
||||
assertEquals(List.of("CTN-A", "CTN-B"), detail.items().get(0).positions().stream()
|
||||
.map(DeliveryResponses.PdaPickPosition::cartonMark).toList());
|
||||
DeliveryResponses.PdaPickPosition projected = detail.items().get(0).positions().get(0);
|
||||
assertEquals(901L, projected.inventoryPositionId());
|
||||
assertEquals("PLT-501", projected.palletNo());
|
||||
assertEquals("SKU-1", projected.skuCode());
|
||||
assertEquals("IN-A", projected.inboundCode());
|
||||
assertEquals(1L, projected.availablePackages());
|
||||
}
|
||||
|
||||
@Test
|
||||
void outboundPdaPickReplayDoesNotDeductInventoryAgainAndRejectsChangedRequest() {
|
||||
OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "one-click-key");
|
||||
outbound.setOverseasWarehouseId("WH-1");
|
||||
OutboundPdaTask task = new OutboundPdaTask();
|
||||
task.setId(701L); task.setCustomerId("TENANT-A"); task.setOutboundOrderId(301L);
|
||||
task.setTaskNo("PDA-701"); task.setStatus("IN_PROGRESS");
|
||||
com.baiyue.lastleg.delivery.entity.OutboundPdaPick replay = new com.baiyue.lastleg.delivery.entity.OutboundPdaPick();
|
||||
replay.setTaskId(701L); replay.setOutboundPalletItemId(801L); replay.setInventoryPositionId(901L);
|
||||
replay.setPackages(2L);
|
||||
when(pdaTasks.lockByNoAndWarehouse("PDA-701", "WH-1")).thenReturn(task);
|
||||
when(pdaPicks.findByKey("TENANT-A", "pick-1")).thenReturn(replay);
|
||||
when(outboundOrders.selectById(301L)).thenReturn(outbound);
|
||||
|
||||
service.pdaPick("WH-1", "7", "PDA-701", new DeliveryRequests.PdaPick(801L, 901L, 2L, "pick-1"));
|
||||
verify(outboundPallets, never()).selectById(any());
|
||||
|
||||
ResponseStatusException conflict = assertThrows(ResponseStatusException.class,
|
||||
() -> service.pdaPick("WH-1", "7", "PDA-701",
|
||||
new DeliveryRequests.PdaPick(801L, 901L, 3L, "pick-1")));
|
||||
assertEquals(409, conflict.getStatusCode().value());
|
||||
}
|
||||
|
||||
@Test
|
||||
void outboundPdaCompleteRequiresAllPackagesAndReplaysOnlySameKey() {
|
||||
OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "one-click-key");
|
||||
outbound.setOverseasWarehouseId("WH-1");
|
||||
OutboundPdaTask task = new OutboundPdaTask();
|
||||
task.setId(701L); task.setCustomerId("TENANT-A"); task.setOutboundOrderId(301L);
|
||||
task.setTaskNo("PDA-701"); task.setStatus("PENDING");
|
||||
OutboundPalletItem item = pallet(501L, "PLT-501", "ONT8", "Ontario", "IN-A", 2);
|
||||
item.setPickedPackageCount(1);
|
||||
when(pdaTasks.lockByNoAndWarehouse("PDA-701", "WH-1")).thenReturn(task);
|
||||
when(outboundOrders.selectById(301L)).thenReturn(outbound);
|
||||
when(outboundPallets.findByOutbound("TENANT-A", 301L)).thenReturn(List.of(item));
|
||||
|
||||
assertEquals(409, assertThrows(ResponseStatusException.class,
|
||||
() -> service.completePda("WH-1", "PDA-701", new DeliveryRequests.PdaComplete("done-1")))
|
||||
.getStatusCode().value());
|
||||
|
||||
item.setPickedPackageCount(2);
|
||||
service.completePda("WH-1", "PDA-701", new DeliveryRequests.PdaComplete("done-1"));
|
||||
assertEquals("COMPLETED", task.getStatus());
|
||||
service.completePda("WH-1", "PDA-701", new DeliveryRequests.PdaComplete("done-1"));
|
||||
assertEquals(409, assertThrows(ResponseStatusException.class,
|
||||
() -> service.completePda("WH-1", "PDA-701", new DeliveryRequests.PdaComplete("done-2")))
|
||||
.getStatusCode().value());
|
||||
}
|
||||
|
||||
private static DispatchOrder dispatch(Long id, String dispatchNo, String customerId) {
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ class SmartDispatchOpenApiContractTest {
|
||||
yaml.indexOf("name: orderNo", yaml.indexOf("name: planIds")));
|
||||
assertTrue(planIdsParameter.contains("required: false"));
|
||||
String dispatchContract = yaml.substring(yaml.indexOf("/api/platform/v1/last-leg/dispatches:"),
|
||||
yaml.indexOf("/api/platform/v1/last-leg/overseas-procurements:"));
|
||||
yaml.indexOf("/api/platform/v1/last-leg/outbound-orders:"));
|
||||
assertFalse(dispatchContract.contains("name: customerId"));
|
||||
assertFalse(yaml.contains("/api/platform/v1/last-leg/dispatches/{dispatchNo}/depart:"));
|
||||
}
|
||||
|
||||
+179
-112
@@ -1,10 +1,12 @@
|
||||
package com.baiyue.lastleg.dispatch.pda.service;
|
||||
|
||||
import com.baiyue.lastleg.delivery.entity.OutboundOrder;
|
||||
import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchOrder;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchOrder;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchPalletLockMapper;
|
||||
import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaRequests;
|
||||
import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaResponses;
|
||||
import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaOperation;
|
||||
@@ -13,7 +15,10 @@ import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaTask;
|
||||
import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaOperationMapper;
|
||||
import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaPickMapper;
|
||||
import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaTaskMapper;
|
||||
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
|
||||
import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
@@ -21,21 +26,52 @@ import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class DispatchPdaWorkflowServiceTest {
|
||||
@Mock private DispatchOrderMapper orders;
|
||||
@Mock private DispatchPdaOperationMapper operations;
|
||||
@Mock private OutboundOrderMapper outboundOrders;
|
||||
@Mock private DispatchItemMapper items;
|
||||
@Mock private DispatchPdaTaskMapper tasks;
|
||||
@Mock private DispatchPdaPickMapper picks;
|
||||
@Mock DispatchOrderMapper orders;
|
||||
@Mock OutboundOrderMapper outboundOrders;
|
||||
@Mock DispatchItemMapper items;
|
||||
@Mock DispatchPdaTaskMapper tasks;
|
||||
@Mock DispatchPdaPickMapper picks;
|
||||
@Mock DispatchPdaOperationMapper operations;
|
||||
@Mock DispatchPalletLockMapper palletLocks;
|
||||
@Mock InventoryPositionMapper positions;
|
||||
@Mock InventoryCommandService inventory;
|
||||
@Mock DispatchPickOrderAttributionService attribution;
|
||||
private DispatchPdaWorkflowService service;
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new DispatchPdaWorkflowService();
|
||||
objectMapper = new ObjectMapper();
|
||||
ReflectionTestUtils.setField(service, "orders", orders);
|
||||
ReflectionTestUtils.setField(service, "outboundOrders", outboundOrders);
|
||||
ReflectionTestUtils.setField(service, "items", items);
|
||||
ReflectionTestUtils.setField(service, "tasks", tasks);
|
||||
ReflectionTestUtils.setField(service, "picks", picks);
|
||||
ReflectionTestUtils.setField(service, "operations", operations);
|
||||
ReflectionTestUtils.setField(service, "palletLocks", palletLocks);
|
||||
ReflectionTestUtils.setField(service, "positions", positions);
|
||||
ReflectionTestUtils.setField(service, "inventory", inventory);
|
||||
ReflectionTestUtils.setField(service, "pickAttribution", attribution);
|
||||
ReflectionTestUtils.setField(service, "objectMapper", objectMapper);
|
||||
}
|
||||
|
||||
@Test
|
||||
void labelsTransferredPalletsWithOriginalAndCurrentFbaWarehouses() {
|
||||
@@ -45,126 +81,157 @@ class DispatchPdaWorkflowServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void pushRequiresVehiclePlateBeforeCreatingPdaTask() {
|
||||
DispatchPdaWorkflowService service = new DispatchPdaWorkflowService();
|
||||
ReflectionTestUtils.setField(service, "orders", orders);
|
||||
DispatchOrder order = new DispatchOrder();
|
||||
order.setId(1L); order.setCustomerId("100"); order.setDispatchNo("PC1"); order.setVehiclePlate(" ");
|
||||
when(orders.findByDispatchNo("100", "PC1")).thenReturn(order);
|
||||
void taskListUsesOnlyTokenBoundWarehouseAndResolvedCustomer() {
|
||||
DispatchOrder order = order("100", "DSP-1", "WH-1", "PENDING_DEPARTURE");
|
||||
DispatchPdaTask task = task(11L, "100", "WH-1", "PDA_PENDING");
|
||||
when(tasks.listByWarehouse("WH-1", null)).thenReturn(List.of(task));
|
||||
when(orders.selectById(1L)).thenReturn(order);
|
||||
when(items.findByDispatchOrder("100", 1L)).thenReturn(List.of());
|
||||
|
||||
List<DispatchPdaResponses.Task> result = service.list("WH-1", null);
|
||||
|
||||
assertEquals(1, result.size());
|
||||
assertEquals("DSP-1", result.get(0).dispatchNo());
|
||||
verify(tasks).listByWarehouse("WH-1", null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void detailRejectsDispatchFromAnotherWarehouseBeforeCustomerLookup() {
|
||||
when(orders.findGlobalByDispatchNo("DSP-1"))
|
||||
.thenReturn(order("100", "DSP-1", "WH-2", "PENDING_DEPARTURE"));
|
||||
|
||||
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
|
||||
() -> service.push("100", "PC1", new DispatchPdaRequests.Push("push-1", null, null)));
|
||||
() -> service.task("WH-1", "DSP-1"));
|
||||
|
||||
assertEquals(409, exception.getStatusCode().value());
|
||||
assertEquals(404, exception.getStatusCode().value());
|
||||
verify(tasks, never()).findByDispatch(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void pushRequiresWarehouseSelection() {
|
||||
DispatchPdaWorkflowService service = new DispatchPdaWorkflowService();
|
||||
ReflectionTestUtils.setField(service, "orders", orders);
|
||||
DispatchOrder order = new DispatchOrder();
|
||||
order.setId(1L); order.setCustomerId("100"); order.setDispatchNo("PC1");
|
||||
order.setStatus("PENDING_DEPARTURE"); order.setVehiclePlate("CA-100");
|
||||
order.setOverseasWarehouseId("WH-1");
|
||||
when(orders.findByDispatchNo("100", "PC1")).thenReturn(order);
|
||||
void exactPickRetryReturnsPersistedResultWithoutInventoryDeduction() throws Exception {
|
||||
DispatchOrder order = order("100", "DSP-1", "WH-1", "PICKING");
|
||||
order.setVehiclePlate("CA-100");
|
||||
DispatchPdaTask task = task(11L, "100", "WH-1", "PICKING");
|
||||
task.setOperatorId("7");
|
||||
DispatchItem item = item(21L, "100", 5);
|
||||
DispatchPdaRequests.Pick request = new DispatchPdaRequests.Pick(31L, 2, 2, "pick-1", null);
|
||||
DispatchPdaResponses.Picked persisted = new DispatchPdaResponses.Picked(21L, 31L, 2, 2, false, false);
|
||||
DispatchPdaOperation replay = operation("PICK", request, persisted);
|
||||
when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order);
|
||||
when(tasks.lockByDispatch("100", 1L)).thenReturn(task);
|
||||
when(items.selectById(21L)).thenReturn(item);
|
||||
when(operations.findKey("100", "pick-1")).thenReturn(replay);
|
||||
|
||||
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
|
||||
() -> service.push("100", "PC1", new DispatchPdaRequests.Push("push-1", null, null)));
|
||||
DispatchPdaResponses.Picked result = service.pick("WH-1", "DSP-1", 21L, "7", "Picker", request);
|
||||
|
||||
assertEquals(400, exception.getStatusCode().value());
|
||||
assertEquals(2, result.packages());
|
||||
verify(inventory, never()).allocate(any(), any(), any());
|
||||
verify(picks, never()).insert((DispatchPdaPick) any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void departRequiresVehiclePlate() {
|
||||
DispatchPdaWorkflowService service = new DispatchPdaWorkflowService();
|
||||
ReflectionTestUtils.setField(service, "orders", orders);
|
||||
ReflectionTestUtils.setField(service, "operations", operations);
|
||||
DispatchOrder order = new DispatchOrder();
|
||||
order.setId(1L); order.setCustomerId("100"); order.setDispatchNo("PC1");
|
||||
order.setStatus("READY_TO_DEPART"); order.setVehiclePlate(" ");
|
||||
when(orders.findByDispatchNo("100", "PC1")).thenReturn(order);
|
||||
void completionRejectsPartialPickingWithoutStatusDrift() {
|
||||
DispatchOrder order = order("100", "DSP-1", "WH-1", "PICKING");
|
||||
order.setVehiclePlate("CA-100");
|
||||
DispatchPdaTask task = task(11L, "100", "WH-1", "PICKING");
|
||||
DispatchItem item = item(21L, "100", 6);
|
||||
DispatchPdaPick pick = new DispatchPdaPick(); pick.setPackages(3L);
|
||||
when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order);
|
||||
when(orders.lockById("100", 1L)).thenReturn(order);
|
||||
when(operations.findKey("100", "depart-1")).thenReturn(null);
|
||||
|
||||
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
|
||||
() -> service.depart("100", "PC1", "operator", new DispatchPdaRequests.Depart("depart-1", null)));
|
||||
|
||||
assertEquals(409, exception.getStatusCode().value());
|
||||
}
|
||||
|
||||
@Test
|
||||
void departRequiresPendingOutboundOrder() {
|
||||
DispatchPdaWorkflowService service = new DispatchPdaWorkflowService();
|
||||
ReflectionTestUtils.setField(service, "orders", orders);
|
||||
ReflectionTestUtils.setField(service, "operations", operations);
|
||||
ReflectionTestUtils.setField(service, "outboundOrders", outboundOrders);
|
||||
DispatchOrder order = new DispatchOrder();
|
||||
order.setId(1L); order.setCustomerId("100"); order.setDispatchNo("PC1");
|
||||
order.setStatus("READY_TO_DEPART"); order.setVehiclePlate("CA-100");
|
||||
when(orders.findByDispatchNo("100", "PC1")).thenReturn(order);
|
||||
when(orders.lockById("100", 1L)).thenReturn(order);
|
||||
when(operations.findKey("100", "depart-1")).thenReturn(null);
|
||||
when(outboundOrders.lockByDispatch("100", 1L)).thenReturn(null);
|
||||
|
||||
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
|
||||
() -> service.depart("100", "PC1", "operator", new DispatchPdaRequests.Depart("depart-1", null)));
|
||||
|
||||
assertEquals(409, exception.getStatusCode().value());
|
||||
}
|
||||
|
||||
@Test
|
||||
void departedRequestReplaysAfterStatusChanged() throws Exception {
|
||||
DispatchPdaWorkflowService service = new DispatchPdaWorkflowService();
|
||||
ObjectMapper objectMapper = new ObjectMapper();
|
||||
ReflectionTestUtils.setField(service, "orders", orders);
|
||||
ReflectionTestUtils.setField(service, "operations", operations);
|
||||
ReflectionTestUtils.setField(service, "objectMapper", objectMapper);
|
||||
DispatchOrder order = new DispatchOrder();
|
||||
order.setId(1L); order.setCustomerId("100"); order.setDispatchNo("PC1"); order.setStatus("DEPARTED");
|
||||
when(orders.findByDispatchNo("100", "PC1")).thenReturn(order);
|
||||
when(orders.lockById("100", 1L)).thenReturn(order);
|
||||
DispatchPdaOperation operation = new DispatchPdaOperation();
|
||||
operation.setOperationType("DEPART"); operation.setStatus("COMPLETED");
|
||||
operation.setResultJson(objectMapper.writeValueAsString(new DispatchPdaResponses.Departed("PC1", "DEPARTED", 12, false)));
|
||||
when(operations.findKey("100", "depart-1")).thenReturn(operation);
|
||||
|
||||
// This exact key/hash pair models the persisted first execution result.
|
||||
DispatchPdaRequests.Depart request = new DispatchPdaRequests.Depart("depart-1", null);
|
||||
operation.setRequestHash(java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256")
|
||||
.digest(objectMapper.writeValueAsString(request).getBytes(java.nio.charset.StandardCharsets.UTF_8))));
|
||||
DispatchPdaResponses.Departed result = service.depart("100", "PC1", "operator", request);
|
||||
|
||||
assertEquals("DEPARTED", result.status());
|
||||
assertEquals(true, result.replayed());
|
||||
}
|
||||
|
||||
@Test
|
||||
void completingAnAutomaticallyCompletedTaskReturnsIdempotentSuccess() {
|
||||
DispatchPdaWorkflowService service = new DispatchPdaWorkflowService();
|
||||
ReflectionTestUtils.setField(service, "orders", orders);
|
||||
ReflectionTestUtils.setField(service, "operations", operations);
|
||||
ReflectionTestUtils.setField(service, "items", items);
|
||||
ReflectionTestUtils.setField(service, "tasks", tasks);
|
||||
ReflectionTestUtils.setField(service, "picks", picks);
|
||||
DispatchOrder order = new DispatchOrder();
|
||||
order.setId(1L); order.setCustomerId("100"); order.setDispatchNo("PC1");
|
||||
order.setStatus("READY_TO_DEPART"); order.setVehiclePlate("CA-100");
|
||||
DispatchPdaTask task = new DispatchPdaTask();
|
||||
task.setId(2L); task.setStatus("COMPLETED");
|
||||
DispatchItem item = new DispatchItem(); item.setPackageCount(6);
|
||||
DispatchPdaPick pick = new DispatchPdaPick(); pick.setPackages(6L);
|
||||
DispatchPdaRequests.CompletePicking request = new DispatchPdaRequests.CompletePicking("complete-1", "normal");
|
||||
when(orders.findByDispatchNo("100", "PC1")).thenReturn(order);
|
||||
when(orders.lockById("100", 1L)).thenReturn(order);
|
||||
when(operations.findKey("100", "complete-1")).thenReturn(null);
|
||||
when(tasks.lockByDispatch("100", 1L)).thenReturn(task);
|
||||
when(items.findByDispatchOrder("100", 1L)).thenReturn(List.of(item));
|
||||
when(picks.listTask("100", 2L)).thenReturn(List.of(pick));
|
||||
when(picks.listTask("100", 11L)).thenReturn(List.of(pick));
|
||||
|
||||
DispatchPdaResponses.PickingCompleted result = service.completePicking("100", "PC1", request);
|
||||
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
|
||||
() -> service.completePicking("WH-1", "DSP-1", new DispatchPdaRequests.CompletePicking("done-1")));
|
||||
|
||||
assertEquals("READY_TO_DEPART", result.status());
|
||||
assertEquals(6, result.pickedPackages());
|
||||
assertEquals(true, result.replayed());
|
||||
assertEquals(409, exception.getStatusCode().value());
|
||||
assertEquals("PICKING", order.getStatus());
|
||||
verify(orders, never()).updateById((DispatchOrder) any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void automaticallyCompletedTaskPersistsFirstCompleteKeyThenReplaysIt() throws Exception {
|
||||
DispatchOrder order = order("100", "DSP-1", "WH-1", "READY_TO_DEPART");
|
||||
order.setVehiclePlate("CA-100");
|
||||
DispatchPdaTask task = task(11L, "100", "WH-1", "COMPLETED");
|
||||
DispatchItem item = item(21L, "100", 6);
|
||||
DispatchPdaPick pick = new DispatchPdaPick(); pick.setPackages(6L);
|
||||
DispatchPdaRequests.CompletePicking request = new DispatchPdaRequests.CompletePicking("done-1");
|
||||
when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order);
|
||||
when(orders.lockById("100", 1L)).thenReturn(order);
|
||||
when(tasks.lockByDispatch("100", 1L)).thenReturn(task);
|
||||
when(items.findByDispatchOrder("100", 1L)).thenReturn(List.of(item));
|
||||
when(picks.listTask("100", 11L)).thenReturn(List.of(pick));
|
||||
|
||||
DispatchPdaResponses.PickingCompleted first = service.completePicking("WH-1", "DSP-1", request);
|
||||
assertFalse(first.replayed());
|
||||
verify(operations).insert((DispatchPdaOperation) any());
|
||||
|
||||
DispatchPdaOperation replay = operation("COMPLETE_PICKING", request, first);
|
||||
when(operations.findKey("100", "done-1")).thenReturn(replay);
|
||||
DispatchPdaResponses.PickingCompleted second = service.completePicking("WH-1", "DSP-1", request);
|
||||
assertTrue(second.replayed());
|
||||
}
|
||||
|
||||
@Test
|
||||
void departRequiresFullyCompletedPickingAndPendingOutbound() {
|
||||
DispatchOrder order = order("100", "DSP-1", "WH-1", "PARTIAL_READY_TO_DEPART");
|
||||
order.setVehiclePlate("CA-100");
|
||||
OutboundOrder outbound = new OutboundOrder(); outbound.setStatus("WAIT_OUTBOUND");
|
||||
when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order);
|
||||
when(orders.lockById("100", 1L)).thenReturn(order);
|
||||
when(outboundOrders.lockByDispatch("100", 1L)).thenReturn(outbound);
|
||||
|
||||
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
|
||||
() -> service.depart("WH-1", "DSP-1", "7", new DispatchPdaRequests.Depart("depart-1", null)));
|
||||
|
||||
assertEquals(409, exception.getStatusCode().value());
|
||||
verify(outboundOrders, never()).updateById((OutboundOrder) any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void exactDepartRetryReturnsPersistedResultAfterStatusChanged() throws Exception {
|
||||
DispatchOrder order = order("100", "DSP-1", "WH-1", "DEPARTED");
|
||||
DispatchPdaRequests.Depart request = new DispatchPdaRequests.Depart("depart-1", null);
|
||||
DispatchPdaResponses.Departed persisted = new DispatchPdaResponses.Departed("DSP-1", "DEPARTED", 12, false);
|
||||
when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order);
|
||||
when(orders.lockById("100", 1L)).thenReturn(order);
|
||||
when(operations.findKey("100", "depart-1")).thenReturn(operation("DEPART", request, persisted));
|
||||
|
||||
DispatchPdaResponses.Departed result = service.depart("WH-1", "DSP-1", "7", request);
|
||||
|
||||
assertEquals("DEPARTED", result.status());
|
||||
assertTrue(result.replayed());
|
||||
}
|
||||
|
||||
private DispatchPdaOperation operation(String type, Object request, Object result) throws Exception {
|
||||
DispatchPdaOperation value = new DispatchPdaOperation();
|
||||
value.setOperationType(type); value.setStatus("COMPLETED");
|
||||
value.setRequestHash(HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
||||
.digest(objectMapper.writeValueAsString(request).getBytes(StandardCharsets.UTF_8))));
|
||||
value.setResultJson(objectMapper.writeValueAsString(result));
|
||||
return value;
|
||||
}
|
||||
|
||||
private static DispatchOrder order(String customerId, String dispatchNo, String warehouseId, String status) {
|
||||
DispatchOrder value = new DispatchOrder();
|
||||
value.setId(1L); value.setCustomerId(customerId); value.setDispatchNo(dispatchNo);
|
||||
value.setOverseasWarehouseId(warehouseId); value.setStatus(status);
|
||||
return value;
|
||||
}
|
||||
|
||||
private static DispatchPdaTask task(Long id, String customerId, String warehouseId, String status) {
|
||||
DispatchPdaTask value = new DispatchPdaTask();
|
||||
value.setId(id); value.setCustomerId(customerId); value.setDispatchOrderId(1L);
|
||||
value.setTaskNo("DPDA-1"); value.setWarehouseId(warehouseId); value.setStatus(status);
|
||||
return value;
|
||||
}
|
||||
|
||||
private static DispatchItem item(Long id, String customerId, int packages) {
|
||||
DispatchItem value = new DispatchItem();
|
||||
value.setId(id); value.setCustomerId(customerId); value.setDispatchOrderId(1L);
|
||||
value.setOverseasWarehouseId("WH-1"); value.setWarehousePalletId(41L); value.setPackageCount(packages);
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
+54
-8
@@ -3,6 +3,7 @@ package com.baiyue.lastleg.dispatch.service;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baiyue.lastleg.dispatch.dto.DispatchRequests;
|
||||
import com.baiyue.lastleg.dispatch.dto.DispatchResponse;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchOrder;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchPalletLock;
|
||||
@@ -13,6 +14,8 @@ import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchPalletLockMapper;
|
||||
import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
|
||||
import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaTask;
|
||||
import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaTaskMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
@@ -45,6 +48,7 @@ class DispatchServiceTest {
|
||||
@Mock DispatchCandidateMapper candidates;
|
||||
@Mock DispatchDraftMapper drafts;
|
||||
@Mock DispatchDraftContainerMapper draftContainers;
|
||||
@Mock DispatchPdaTaskMapper pdaTasks;
|
||||
private DispatchService service;
|
||||
|
||||
@BeforeEach
|
||||
@@ -56,6 +60,7 @@ class DispatchServiceTest {
|
||||
ReflectionTestUtils.setField(service, "candidates", candidates);
|
||||
ReflectionTestUtils.setField(service, "drafts", drafts);
|
||||
ReflectionTestUtils.setField(service, "draftContainers", draftContainers);
|
||||
ReflectionTestUtils.setField(service, "pdaTasks", pdaTasks);
|
||||
ReflectionTestUtils.setField(service, "orderNoGenerator", new com.baiyue.lastleg.service.LastlegOrderNoGenerator());
|
||||
lenient().when(items.findByDispatchOrder(any(), any())).thenReturn(List.of());
|
||||
}
|
||||
@@ -73,6 +78,11 @@ class DispatchServiceTest {
|
||||
assertEquals("ONT8", result.route());
|
||||
ArgumentCaptor<DispatchOrder> dispatch = ArgumentCaptor.forClass(DispatchOrder.class);
|
||||
verify(orders).insert(dispatch.capture());
|
||||
ArgumentCaptor<DispatchPdaTask> pdaTask = ArgumentCaptor.forClass(DispatchPdaTask.class);
|
||||
verify(pdaTasks).insert(pdaTask.capture());
|
||||
assertEquals(dispatch.getValue().getId(), pdaTask.getValue().getDispatchOrderId());
|
||||
assertEquals("WH-1", pdaTask.getValue().getWarehouseId());
|
||||
assertEquals("PDA_PENDING", pdaTask.getValue().getStatus());
|
||||
assertEquals(1, dispatch.getValue().getPalletCount());
|
||||
verify(locks).insert(any(DispatchPalletLock.class));
|
||||
verify(items).insert(any(DispatchItem.class));
|
||||
@@ -204,20 +214,35 @@ class DispatchServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void editCannotBypassPdaManagedDispatchStatus() {
|
||||
void platformEditResolvesCustomerFromGlobalDispatchNumberAndPreservesStatus() {
|
||||
DispatchOrder dispatch = new DispatchOrder();
|
||||
dispatch.setId(1L); dispatch.setCustomerId("100"); dispatch.setDispatchNo("PC1");
|
||||
dispatch.setStatus(DispatchService.PENDING_DEPARTURE);
|
||||
when(orders.findByDispatchNo("100", "PC1")).thenReturn(dispatch);
|
||||
when(orders.lockById("100", 1L)).thenReturn(dispatch);
|
||||
when(orders.lockGlobalByDispatchNo("PC1")).thenReturn(dispatch);
|
||||
DispatchRequests.Update request = new DispatchRequests.Update("ABC-1", "Driver", null, null, null,
|
||||
null, null, null, LocalDateTime.now());
|
||||
|
||||
DispatchResponse result = service.update("PC1", request);
|
||||
|
||||
assertEquals(DispatchService.PENDING_DEPARTURE, dispatch.getStatus());
|
||||
assertEquals("ABC-1", result.vehiclePlate());
|
||||
verify(orders).updateById(dispatch);
|
||||
verify(items).findByDispatchOrder("100", 1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void platformEditRejectsAnExecutedDispatchWithoutWriting() {
|
||||
DispatchOrder dispatch = new DispatchOrder();
|
||||
dispatch.setId(1L); dispatch.setCustomerId("100"); dispatch.setDispatchNo("PC1");
|
||||
dispatch.setStatus("DEPARTED");
|
||||
when(orders.lockGlobalByDispatchNo("PC1")).thenReturn(dispatch);
|
||||
DispatchRequests.Update request = new DispatchRequests.Update(null, null, null, null, null,
|
||||
LocalDateTime.now(), null, null, null, null, "COMPLETED");
|
||||
null, null, null, null);
|
||||
|
||||
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
|
||||
() -> service.update("100", "PC1", request));
|
||||
() -> service.update("PC1", request));
|
||||
|
||||
assertEquals(400, exception.getStatusCode().value());
|
||||
assertEquals(DispatchService.PENDING_DEPARTURE, dispatch.getStatus());
|
||||
assertEquals(409, exception.getStatusCode().value());
|
||||
verify(orders, never()).updateById(any(DispatchOrder.class));
|
||||
}
|
||||
|
||||
@@ -255,13 +280,34 @@ class DispatchServiceTest {
|
||||
void platformDetailUsesCustomerFromMatchedDispatch() {
|
||||
DispatchOrder dispatch = new DispatchOrder();
|
||||
dispatch.setId(1L); dispatch.setCustomerId("200"); dispatch.setDispatchNo("PC1");
|
||||
when(orders.findAcrossCustomersByDispatchNo("PC1")).thenReturn(List.of(dispatch));
|
||||
when(orders.findGlobalByDispatchNo("PC1")).thenReturn(dispatch);
|
||||
|
||||
service.platformDetail("PC1");
|
||||
|
||||
verify(items).findByDispatchOrder("200", 1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void globalCancelIsIdempotentAndReleasesOnlyTheResolvedCustomerLocks() {
|
||||
DispatchOrder dispatch = new DispatchOrder();
|
||||
dispatch.setId(1L); dispatch.setCustomerId("200"); dispatch.setDispatchNo("PC1");
|
||||
dispatch.setStatus(DispatchService.PENDING_DEPARTURE);
|
||||
DispatchPalletLock lock = new DispatchPalletLock();
|
||||
lock.setStatus("ACTIVE"); lock.setActiveKey("PALLET:1");
|
||||
when(orders.lockGlobalByDispatchNo("PC1")).thenReturn(dispatch);
|
||||
when(locks.selectList(any())).thenReturn(List.of(lock));
|
||||
|
||||
service.cancel("PC1");
|
||||
|
||||
assertEquals(DispatchService.CANCELLED, dispatch.getStatus());
|
||||
assertEquals("RELEASED", lock.getStatus());
|
||||
assertEquals(null, lock.getActiveKey());
|
||||
verify(locks).updateById(lock);
|
||||
|
||||
service.cancel("PC1");
|
||||
verify(locks, org.mockito.Mockito.times(1)).selectList(any());
|
||||
}
|
||||
|
||||
private DispatchPalletCandidate candidate(long planPalletId, Long warehousePalletId, String destination) {
|
||||
return candidate(planPalletId, warehousePalletId, destination, 12);
|
||||
}
|
||||
|
||||
+19
-5
@@ -4,6 +4,8 @@ import com.baiyue.lastleg.palletops.dto.PalletOperationRequests;
|
||||
import com.baiyue.lastleg.pda.move.dto.PdaInventoryMoveRequest;
|
||||
import com.baiyue.lastleg.pda.stocktake.dto.PdaStocktakeRequests;
|
||||
import com.baiyue.lastleg.relabel.pda.dto.PdaRelabelRequests;
|
||||
import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaRequests;
|
||||
import com.baiyue.lastleg.delivery.dto.DeliveryRequests;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
@@ -32,6 +34,11 @@ class PdaOperationsOpenApiContractTest {
|
||||
}
|
||||
}
|
||||
assertNoServerOwnedFields(PdaInventoryMoveRequest.class);
|
||||
assertNoServerOwnedFields(DispatchPdaRequests.Pick.class);
|
||||
assertNoServerOwnedFields(DispatchPdaRequests.CompletePicking.class);
|
||||
assertNoServerOwnedFields(DispatchPdaRequests.Depart.class);
|
||||
assertNoServerOwnedFields(DeliveryRequests.PdaPick.class);
|
||||
assertNoServerOwnedFields(DeliveryRequests.PdaComplete.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -56,16 +63,23 @@ class PdaOperationsOpenApiContractTest {
|
||||
"/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/exceptions:",
|
||||
"/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/photos/files:",
|
||||
"/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/complete:",
|
||||
"/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/review:");
|
||||
"/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/review:",
|
||||
"/api/pda/v1/last-leg/dispatches:",
|
||||
"/api/pda/v1/last-leg/dispatches/{dispatchNo}:",
|
||||
"/api/pda/v1/last-leg/dispatches/{dispatchNo}/items/{itemId}/pick:",
|
||||
"/api/pda/v1/last-leg/dispatches/{dispatchNo}/complete:",
|
||||
"/api/pda/v1/last-leg/dispatches/{dispatchNo}/depart:",
|
||||
"/api/pda/v1/last-leg/outbound-tasks:",
|
||||
"/api/pda/v1/last-leg/outbound-tasks/{taskNo}:",
|
||||
"/api/pda/v1/last-leg/outbound-tasks/{taskNo}/pick:",
|
||||
"/api/pda/v1/last-leg/outbound-tasks/{taskNo}/complete:");
|
||||
for (String path : paths) assertTrue(yaml.contains(path), path);
|
||||
assertEquals(19, paths.size());
|
||||
assertEquals(28, paths.size());
|
||||
assertTrue(yaml.contains("enum: [LOCATION, PALLET]"));
|
||||
assertTrue(yaml.contains("enum: [APPROVE, REWORK]"));
|
||||
assertTrue(yaml.contains("enum: [DAMAGED, MISSING_LABEL, LABEL_MISMATCH, UNREADABLE, OTHER]"));
|
||||
for (String field : SERVER_OWNED) assertFalse(yaml.contains(field + ":"), field);
|
||||
for (String excluded : List.of("/outbound", "/dispatch", "/delivery")) {
|
||||
assertFalse(yaml.contains(excluded), excluded);
|
||||
}
|
||||
assertFalse(yaml.contains("/api/pda/v1/last-leg/delivery"));
|
||||
}
|
||||
|
||||
private static void assertNoServerOwnedFields(Class<?> type) {
|
||||
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
package com.baiyue.lastleg.pda;
|
||||
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.delivery.controller.OutboundPdaController;
|
||||
import com.baiyue.lastleg.delivery.dto.DeliveryRequests;
|
||||
import com.baiyue.lastleg.delivery.dto.DeliveryResponses;
|
||||
import com.baiyue.lastleg.delivery.service.DeliveryTransferService;
|
||||
import com.baiyue.lastleg.dispatch.pda.controller.DispatchPdaController;
|
||||
import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaRequests;
|
||||
import com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Arrays;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
class PdaOutboundDispatchContractTest {
|
||||
private static final String SECURITY =
|
||||
"hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')";
|
||||
|
||||
@Test
|
||||
void controllersRequirePdaRoleAndNeverAcceptCustomerOrWarehouseScope() {
|
||||
assertEquals(SECURITY, DispatchPdaController.class.getAnnotation(PreAuthorize.class).value());
|
||||
assertEquals(SECURITY, OutboundPdaController.class.getAnnotation(PreAuthorize.class).value());
|
||||
for (Class<?> controller : Set.of(DispatchPdaController.class, OutboundPdaController.class)) {
|
||||
for (Method method : controller.getDeclaredMethods()) {
|
||||
assertTrue(Arrays.stream(method.getParameters()).noneMatch(parameter -> {
|
||||
RequestParam query = parameter.getAnnotation(RequestParam.class);
|
||||
return query != null && Set.of("customerId", "warehouseId")
|
||||
.contains(query.name().isBlank() ? query.value() : query.name());
|
||||
}));
|
||||
}
|
||||
}
|
||||
assertTrue(Arrays.stream(DispatchPdaRequests.Pick.class.getRecordComponents())
|
||||
.noneMatch(component -> "warehouseId".equals(component.getName()) || "customerId".equals(component.getName())));
|
||||
assertTrue(Arrays.stream(DeliveryRequests.PdaPick.class.getRecordComponents())
|
||||
.noneMatch(component -> "warehouseId".equals(component.getName()) || "customerId".equals(component.getName())));
|
||||
}
|
||||
|
||||
@Test
|
||||
void controllersPassOnlyTokenBoundWarehouseToServices() {
|
||||
DispatchPdaWorkflowService dispatchService = mock(DispatchPdaWorkflowService.class);
|
||||
DeliveryTransferService outboundService = mock(DeliveryTransferService.class);
|
||||
DispatchPdaController dispatch = new DispatchPdaController();
|
||||
OutboundPdaController outbound = new OutboundPdaController();
|
||||
ReflectionTestUtils.setField(dispatch, "service", dispatchService);
|
||||
ReflectionTestUtils.setField(outbound, "service", outboundService);
|
||||
AuthenticatedUser user = pda(Set.of("lastleg:pda:operate", "ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR"), 900L);
|
||||
|
||||
dispatch.list(user, null);
|
||||
outbound.list(user, null);
|
||||
|
||||
verify(dispatchService).list("900", null);
|
||||
verify(outboundService).pdaTasks("900", null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingRolePermissionOrWarehouseBindingIsRejected() {
|
||||
DispatchPdaController controller = new DispatchPdaController();
|
||||
assertThrows(ResponseStatusException.class,
|
||||
() -> controller.list(pda(Set.of("lastleg:pda:operate"), 900L), null));
|
||||
assertThrows(ResponseStatusException.class,
|
||||
() -> controller.list(pda(Set.of("ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR"), 900L), null));
|
||||
assertThrows(ResponseStatusException.class,
|
||||
() -> controller.list(pda(Set.of("lastleg:pda:operate", "ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR"), null), null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void pdaOpenApiContainsOnlyFrozenDispatchAndOutboundWorkflow() throws Exception {
|
||||
String yaml = Files.readString(Path.of("src/main/resources/openapi/overseas-pda-operations-v1.yaml"),
|
||||
StandardCharsets.UTF_8);
|
||||
assertTrue(yaml.contains("/api/pda/v1/last-leg/dispatches:"));
|
||||
assertTrue(yaml.contains("/api/pda/v1/last-leg/dispatches/{dispatchNo}/depart:"));
|
||||
assertTrue(yaml.contains("/api/pda/v1/last-leg/outbound-tasks/{taskNo}/complete:"));
|
||||
assertFalse(yaml.contains("name: customerId"));
|
||||
assertFalse(yaml.contains("name: warehouseId"));
|
||||
assertFalse(yaml.contains("/api/pda/v1/last-leg/delivery"));
|
||||
assertTrue(yaml.contains("inventoryPositionId:"));
|
||||
assertTrue(yaml.contains("cartonMark:"));
|
||||
assertTrue(yaml.contains("availablePackages:"));
|
||||
assertTrue(yaml.contains("pickedPackages:"));
|
||||
assertEquals(Set.of("inventoryPositionId", "palletNo", "cartonMark", "skuCode", "inboundCode",
|
||||
"availablePackages", "pickedPackages"),
|
||||
Arrays.stream(DeliveryResponses.PdaPickPosition.class.getRecordComponents())
|
||||
.map(component -> component.getName()).collect(java.util.stream.Collectors.toSet()));
|
||||
assertEquals(Set.of("idempotencyKey"),
|
||||
Arrays.stream(DeliveryRequests.PdaComplete.class.getRecordComponents())
|
||||
.map(component -> component.getName()).collect(java.util.stream.Collectors.toSet()));
|
||||
}
|
||||
|
||||
private static AuthenticatedUser pda(Set<String> permissions, Long warehouseId) {
|
||||
return new AuthenticatedUser(7L, 0L, 0L, 8L, "picker", "PLATFORM", "PDA", permissions,
|
||||
false, null, null, false, warehouseId);
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -46,7 +46,9 @@ class OverseasProcurementOpenApiContractTest {
|
||||
assertTrue(yaml.contains("platform:overseas-business:looseboard:create"));
|
||||
assertTrue(yaml.contains("platform:overseas-business:looseboard:edit"));
|
||||
assertTrue(yaml.contains("platform:overseas-business:looseboard:status"));
|
||||
assertFalse(yaml.contains("/api/platform/v1/last-leg/outbound"));
|
||||
assertFalse(yaml.contains("/api/platform/v1/last-leg/delivery"));
|
||||
String businessContract = yaml.substring(yaml.indexOf("/api/platform/v1/last-leg/overseas-procurements:"),
|
||||
yaml.indexOf("/api/platform/v1/last-leg/relabel-applications:"));
|
||||
assertFalse(businessContract.contains("/api/platform/v1/last-leg/outbound"));
|
||||
assertFalse(businessContract.contains("/api/platform/v1/last-leg/delivery"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"schemaVersion": 1,
|
||||
"description": "Development-stage Lastleg slice dependency baseline. Existing edges may be removed; new edges require architecture review.",
|
||||
"allowedSliceEdges": [
|
||||
"dispatch-delivery->dropship",
|
||||
"dispatch-delivery->inbound-pda",
|
||||
"dispatch-delivery->inventory",
|
||||
"dispatch-delivery->shared-technical",
|
||||
@@ -24,6 +25,7 @@
|
||||
"relabel-transfer-discard->shared-technical",
|
||||
"relabel-transfer-discard->tailleg-handoff",
|
||||
"security-projection->shared-technical",
|
||||
"tailleg-handoff->dispatch-delivery",
|
||||
"tailleg-handoff->dropship",
|
||||
"tailleg-handoff->inbound-pda",
|
||||
"tailleg-handoff->inventory",
|
||||
|
||||
Reference in New Issue
Block a user