Merge remote-tracking branch 'origin/dev' into dev
This commit is contained in:
@@ -26,10 +26,11 @@
|
||||
| 履约分配、入库、测量、审核、FBA 计费 | `firstleg/` | `firstleg/AGENTS.md` |
|
||||
| Firstleg 内部模块化与依赖基线 | `firstleg/` | `firstleg/docs/firstleg-internal-modularization-baseline.md`、`firstleg/scripts/analyze-firstleg-dependencies.mjs`;价格簿查询和客户统计按端口/适配器切片,不改变服务与数据库边界 |
|
||||
| Mapper 新增、移动、Bean 缺失与扫描遗漏 | 目标持久化服务 | 所有 Mapper 显式 `@Mapper`,启动类按服务根包注解过滤扫描;先运行 `node scripts/mapper-scan-contract.mjs`,再按目标模块执行 `test-compile/test`,不得等部署启动发现 |
|
||||
| 脚本管理、全量验证、测试造数与诊断 | 仓库根 `scripts/`;服务专属脚本留在 `<service>/scripts/` | 先读 `scripts/AGENTS.md` 和 `scripts/README.md`;Admin HTTP 内部模块继续读 `scripts/admin-test-env-modules/README.md`。新增入口必须登记用途、目标环境、数据副作用和报告位置,并通过 `scripts/script-documentation-contract.test.mjs` |
|
||||
| 分柜、柜次、智能排柜、预装/装柜清单、PDA | `firstleg/` | `firstleg/AGENTS.md`,再按 `split/`、`loading/`、`outbound/` 定位;预装/正式装柜详情阶段由 `/loading-plans/{planId}/lifecycle` 按稳定 planId 解析,不从 404 或文案推断 |
|
||||
| 合柜报关 / 同柜不可拆组、跨客户合柜、整组分配/移除 | `firstleg/` | `loading/coload/controller/PlatformColoadGroupController.java`、`service/ColoadGroupService.java`、`persistence/ColoadGroupMapper.java`、`src/main/resources/openapi/coload-groups-v1.yaml`;按订单建组并冻结全部可用入仓片段,仅要求同国内仓、同渠道 |
|
||||
| 智能装柜入仓码候选与箱数分配 | `firstleg/` | `controller/PlatformSmartLoadingController.java` 的 `/inbound-candidates`、`/plans/{planId}/inbound-assignments`,业务编排 `service/SmartLoadingService.java` |
|
||||
| 订舱、船期、拖车、报关 | `firstleg/` | `oceanbooking/`、`portschedule/`、`outbound/dispatch/`、`customs/` |
|
||||
| 订舱资料/打印、船期、订舱拥有的拖车、报关 | `firstleg/` | `oceanbooking/`、`portschedule/`、`TruckAppointmentService.java`、`outbound/dispatch/`、`customs/`;拖车不带客户归属,必须绑定 `RELEASED` 订舱及其真实柜,订舱文件联查 `file/AGENTS.md` |
|
||||
| 客户账期、费用池、账单、收款、成本、核销 | `finance/` | `finance/AGENTS.md`;费用来源和柜次成本快照再联查 `firstleg/AGENTS.md`,凭证再联查 `file/AGENTS.md` |
|
||||
| 入库/柜次应付、直接已付、付款流水与凭证 | `firstleg/`、`finance/`、`file/` | 业务费用与付款快照见 `firstleg/AGENTS.md` 的 `FeePaymentSnapshotValidator`;`/api/platform/finance/v1/vouchers` 上传凭证,File 按 `FINANCE_VOUCHER` 和客户范围校验 |
|
||||
| 仓库、往来单位、快递、托盘、库位 | `warehouse/` | `warehouse/AGENTS.md` |
|
||||
@@ -45,6 +46,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 候选进入可收货 |
|
||||
@@ -94,12 +98,15 @@
|
||||
|
||||
## 全量 HTTP 验收脚本路由
|
||||
|
||||
- 脚本管理的唯一总索引是 `scripts/README.md`,最近执行规则是 `scripts/AGENTS.md`。仓库级脚本、Admin 内部模块、服务局部合同和部署脚本的目录归属以这两个文件为准,不再通过无范围搜索猜测入口。
|
||||
- 全量验收唯一指向测试环境 HTTP:平台端执行 `scripts/admin-test-env-acceptance.mjs`,客户端执行 `scripts/client-test-env-acceptance.mjs`;二期专项场景才单独执行 `scripts/client-phase2-test-env-acceptance.mjs`,不替代客户端全量入口。
|
||||
- 固定客户待审核订单使用 `scripts/create-pending-review-orders.mjs`;固定“IT测试”与“义乌仓”、随机真实线路并推进至 `WAIT_SPLIT` 使用 `scripts/it-test-wait-split-bulk.mjs`。二者都是造数入口,不属于全量验证;会写目标环境且不自动清理成功数据。
|
||||
- Admin 总入口默认按模块列表覆盖平台全链路;需要定位单个业务时使用 `--modules <name>`,模块名称和静态合同先查 `scripts/admin-test-env-modules/README.md`。
|
||||
- 这些脚本会创建、更新或最终停用测试数据,必须在当前对话明确授权测试环境验收后执行,并保存 JSON 报告;未授权不得请求 Gateway。
|
||||
- 后端 Maven、前端 `pnpm test/typecheck`、PDA `node --test` 和数据库 Dry-run 都是本地质量检查,不属于全量 HTTP 验收。
|
||||
- Java 模块、OpenAPI 覆盖、JaCoCo 与 SpotBugs 聚合摘要使用 `node scripts/java-quality-summary.mjs`;默认仅报告存量缺口,是否阻断由显式 `--require-*` 参数决定,输出位于 `target/` 时不得作为业务事实或提交物。
|
||||
- HTTP 全量通过后,才进入 Admin/Client 真实页面调用、浏览器样式和 PDA 页面/设备验收;自动化 HTTP 通过不代表视觉验收完成。
|
||||
- 修改脚本目录、入口或说明后,至少运行 `node --test scripts/script-documentation-contract.test.mjs`;修改 `AGENTS.md` 后继续运行 `./scripts/agents-index-contract.ps1`。两者均为离线只读合同,不能代替真实 HTTP 验收。
|
||||
|
||||
## 阶段文档入口
|
||||
|
||||
@@ -107,6 +114,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` 中已确认的范围、对象、状态和接口约束
|
||||
|
||||
全量验收任务再读取对应阶段进度表、验收报告和证据矩阵;普通代码定位只读取当前链路涉及的范围、契约、模块指引和代码。
|
||||
|
||||
+10
@@ -19,6 +19,11 @@ public record FirstLegLoadingPlanCompletedV2(
|
||||
containerNo = ContainerNumbers.requireCanonical(containerNo);
|
||||
orders = orders == null ? List.of() : List.copyOf(orders);
|
||||
if (orders.isEmpty()) throw new IllegalArgumentException("orders are required");
|
||||
Order owner = orders.get(0);
|
||||
if (orders.stream().anyMatch(order -> !owner.customerId().equals(order.customerId())
|
||||
|| !owner.customerName().equals(order.customerName()))) {
|
||||
throw new IllegalArgumentException("orders must share one immutable customerId/customerName snapshot");
|
||||
}
|
||||
}
|
||||
|
||||
public FirstLegLoadingPlanCompletedV2(String loadingPlanId, String planNo, String containerBookingId,
|
||||
@@ -47,6 +52,11 @@ public record FirstLegLoadingPlanCompletedV2(
|
||||
if (customerName == null || customerName.isBlank()) {
|
||||
throw new IllegalArgumentException("customerName is required");
|
||||
}
|
||||
customerId = customerId.trim();
|
||||
customerName = customerName.trim();
|
||||
if (customerId.length() > 128 || customerName.length() > 128) {
|
||||
throw new IllegalArgumentException("customerId/customerName snapshot is too long");
|
||||
}
|
||||
orderType = orderType == null || orderType.isBlank() ? "FIRST_LEG" : orderType;
|
||||
pallets = pallets == null ? List.of() : pallets;
|
||||
}
|
||||
|
||||
+16
@@ -118,6 +118,22 @@ class FirstLegLoadingPlanCompletedV2Test {
|
||||
.isInstanceOf(IllegalArgumentException.class).hasMessageContaining("customerName");
|
||||
}
|
||||
|
||||
@Test
|
||||
void freezesTrimmedOwnerSnapshotAndRejectsMixedOwners() {
|
||||
var first = new FirstLegLoadingPlanCompletedV2.Order(
|
||||
"2", "2", "3", "ORD", "3", "RK", null, null, null,
|
||||
1, 1, 0, null, null, null, List.of(), " 11 ", " 客户甲 ", "FIRST_LEG", List.of());
|
||||
assertThat(first.customerId()).isEqualTo("11");
|
||||
assertThat(first.customerName()).isEqualTo("客户甲");
|
||||
|
||||
var anotherOwner = new FirstLegLoadingPlanCompletedV2.Order(
|
||||
"3", "3", "4", "ORD-2", "4", "RK-2", null, null, null,
|
||||
1, 1, 0, null, null, null, List.of(), "12", "客户乙", "FIRST_LEG", List.of());
|
||||
assertThatThrownBy(() -> new FirstLegLoadingPlanCompletedV2(
|
||||
"4", "P", "5", "ZCSU7881970", "CAB", "6", "WH", List.of(first, anotherOwner)))
|
||||
.isInstanceOf(IllegalArgumentException.class).hasMessageContaining("immutable customerId/customerName");
|
||||
}
|
||||
|
||||
@Test
|
||||
void openApiRequiresImmutableCustomerNameNextToCustomerId() throws Exception {
|
||||
String yaml;
|
||||
|
||||
+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。
|
||||
|
||||
+384
-93
@@ -4872,7 +4872,7 @@ CREATE TABLE IF NOT EXISTS fl_document_sequence (
|
||||
CREATE TABLE IF NOT EXISTS fl_container_booking (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID(雪花ID)',
|
||||
booking_no VARCHAR(40) NOT NULL COMMENT '平台唯一货柜预订单号',
|
||||
container_no VARCHAR(64) NULL COMMENT '国内唯一权威真实集装箱号;ISO 6346标准格式;仅订舱关联首次写入、预装纠错命令可修改',
|
||||
container_no VARCHAR(64) NULL COMMENT '国内唯一权威真实集装箱号;ISO 6346标准格式;订舱关联可选写入、提柜后可原子补录、预装纠错命令可修改',
|
||||
cabinet_no VARCHAR(64) NOT NULL COMMENT '平台唯一内部柜号',
|
||||
source_type VARCHAR(24) NOT NULL DEFAULT 'SIMULATION' COMMENT '记录来源:SIMULATION内部模拟柜、OCEAN_BOOKING订舱真实柜',
|
||||
record_status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '记录状态:ACTIVE可操作、MERGED已合并历史',
|
||||
@@ -9458,12 +9458,13 @@ CREATE TABLE IF NOT EXISTS fl_truck_appointment (
|
||||
store_name VARCHAR(128) NULL COMMENT '到仓装柜地点名称快照', store_contact VARCHAR(64) NULL COMMENT '到仓地点联系人快照', store_phone VARCHAR(32) NULL COMMENT '到仓地点联系电话快照', store_address VARCHAR(255) NULL COMMENT '到仓装柜地点地址快照',
|
||||
store_city VARCHAR(64) NULL COMMENT '到仓地点城市快照', store_zipcode VARCHAR(16) NULL COMMENT '到仓地点邮编快照', package_count INT UNSIGNED NULL COMMENT '预约货物包装件数快照', gross_weight_kg DECIMAL(18,3) NULL COMMENT '预约货物毛重,单位千克',
|
||||
volume_cbm DECIMAL(18,4) NULL COMMENT '预约货物体积,单位立方米', pallet_count INT UNSIGNED NULL COMMENT '预约托盘数量', po_no VARCHAR(255) NULL COMMENT '关联采购单号', planned_loading_at DATETIME(3) NULL COMMENT '预约到仓装柜时间',
|
||||
actual_arrived_warehouse_at DATETIME(3) NULL COMMENT '车辆实际到达收货仓时间', actual_loading_at DATETIME(3) NULL COMMENT '实际开始装柜时间', actual_departed_at DATETIME(3) NULL COMMENT '车辆实际离仓时间;DEPARTED后供LOADED门禁使用', transit_minutes INT UNSIGNED NULL COMMENT '从到仓到离仓的运输作业耗时,单位分钟', payable_amount DECIMAL(18,2) NULL COMMENT '应付拖车服务商金额;币种由currency指定', receivable_amount DECIMAL(18,2) NULL COMMENT '拖车业务应收金额;币种由currency指定',
|
||||
currency VARCHAR(8) NOT NULL DEFAULT 'RMB' COMMENT '金额币种;首期默认RMB', status VARCHAR(32) NOT NULL DEFAULT 'DRAFT' COMMENT '拖车预约状态:DRAFT草稿、PENDING_DISPATCH待派车、DISPATCHED已派车、PICKUP_IN_PROGRESS提货中、ARRIVED_WAREHOUSE已到仓、LOADING装柜中、DEPARTED已离仓、COMPLETED已完成、RESCHEDULED已改约、EXCEPTION异常、CANCELLED已取消', active_container_booking_id BIGINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN status NOT IN ('COMPLETED','CANCELLED','EXCEPTION') THEN container_booking_id ELSE NULL END) STORED COMMENT '非终态预约的柜次唯一键', revision INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '拖车预约业务修订号;每次状态或改约递增',
|
||||
actual_arrived_warehouse_at DATETIME(3) NULL COMMENT '车辆实际到达收货仓时间', actual_loading_at DATETIME(3) NULL COMMENT '实际开始装柜时间', actual_departed_at DATETIME(3) NULL COMMENT '车辆实际离仓时间;DEPARTED后供LOADED门禁使用', transit_minutes INT UNSIGNED NULL COMMENT '从到仓到离仓的运输作业耗时,单位分钟', payable_amount DECIMAL(18,2) NULL COMMENT '应付拖车服务商金额;币种由currency指定',
|
||||
currency VARCHAR(8) NOT NULL DEFAULT 'RMB' COMMENT '应付金额币种;仅RMB或USD', status VARCHAR(32) NOT NULL DEFAULT 'DRAFT' COMMENT '拖车预约状态:DRAFT草稿、PENDING_DISPATCH待派车、DISPATCHED已派车、PICKUP_IN_PROGRESS提货中、ARRIVED_WAREHOUSE已到仓、LOADING装柜中、DEPARTED已离仓、COMPLETED已完成、RESCHEDULED已改约、EXCEPTION异常、CANCELLED已取消', active_container_booking_id BIGINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN status NOT IN ('COMPLETED','CANCELLED','EXCEPTION') THEN container_booking_id ELSE NULL END) STORED COMMENT '非终态预约的柜次唯一键', revision INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '拖车预约业务修订号;每次状态或改约递增',
|
||||
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', remark VARCHAR(1000) 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_truck_appointment_no(appointment_no), UNIQUE KEY uk_truck_active_container(active_container_booking_id),
|
||||
KEY idx_truck_appointment_status(status,updated_at), KEY idx_truck_appointment_container(container_booking_id),
|
||||
CONSTRAINT chk_truck_appointment_status CHECK(status IN ('DRAFT','PENDING_DISPATCH','DISPATCHED','PICKUP_IN_PROGRESS','ARRIVED_WAREHOUSE','LOADING','DEPARTED','COMPLETED','RESCHEDULED','EXCEPTION','CANCELLED'))
|
||||
CONSTRAINT chk_truck_appointment_status CHECK(status IN ('DRAFT','PENDING_DISPATCH','DISPATCHED','PICKUP_IN_PROGRESS','ARRIVED_WAREHOUSE','LOADING','DEPARTED','COMPLETED','RESCHEDULED','EXCEPTION','CANCELLED')),
|
||||
CONSTRAINT chk_truck_appointment_currency CHECK(currency IN ('RMB','USD'))
|
||||
) ENGINE=InnoDB COMMENT='三期拖车预约派车聚合';
|
||||
CREATE TABLE IF NOT EXISTS fl_truck_appointment_history (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '拖车预约历史ID', appointment_id BIGINT UNSIGNED NOT NULL COMMENT '所属拖车预约ID', revision INT UNSIGNED NOT NULL COMMENT '该预约业务修订号', action VARCHAR(32) NOT NULL COMMENT '触发状态或改约的业务动作',
|
||||
@@ -9912,24 +9913,181 @@ WHERE role.tenant_id=0 AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')
|
||||
|
||||
USE baiyue_firstleg;
|
||||
|
||||
-- Latest container customs permissions; old case/review/receipt actions are removed.
|
||||
-- 来源:firstleg/src/main/resources/db/phase3-container-customs-schema.sql
|
||||
SET NAMES utf8mb4;
|
||||
USE baiyue_firstleg;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_customs_container (
|
||||
id BIGINT UNSIGNED NOT NULL,
|
||||
loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '已完成装柜计划ID;报关柜级聚合唯一键',
|
||||
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '物理货柜ID',
|
||||
status VARCHAR(40) NOT NULL COMMENT 'WAITING_CUSTOMER_DOCUMENTS、READY_TO_START、DECLARING、COMPLETED',
|
||||
started_by BIGINT UNSIGNED NULL,
|
||||
started_by_name VARCHAR(64) NULL,
|
||||
started_at DATETIME(3) NULL,
|
||||
completed_by BIGINT UNSIGNED NULL,
|
||||
completed_by_name VARCHAR(64) NULL,
|
||||
completed_at DATETIME(3) NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
version INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY(id),
|
||||
UNIQUE KEY uk_customs_container_plan(loading_plan_id),
|
||||
UNIQUE KEY uk_customs_container_booking(container_booking_id),
|
||||
KEY idx_customs_container_status(status,updated_at),
|
||||
CONSTRAINT chk_customs_container_status CHECK(status IN
|
||||
('WAITING_CUSTOMER_DOCUMENTS','READY_TO_START','DECLARING','COMPLETED'))
|
||||
) ENGINE=InnoDB COMMENT='装柜完成后的整柜出口报关聚合';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_customs_customer_document (
|
||||
id BIGINT UNSIGNED NOT NULL,
|
||||
customs_container_id BIGINT UNSIGNED NOT NULL,
|
||||
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离键;只允许关联同客户退税票',
|
||||
file_id BIGINT UNSIGNED NOT NULL,
|
||||
file_name VARCHAR(255) NOT NULL,
|
||||
file_hash CHAR(64) NOT NULL,
|
||||
content_type VARCHAR(128) NULL,
|
||||
size_bytes BIGINT UNSIGNED NOT NULL,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
|
||||
uploaded_by BIGINT UNSIGNED NOT NULL,
|
||||
uploaded_at DATETIME(3) NOT NULL,
|
||||
frozen_version INT UNSIGNED NULL,
|
||||
frozen_at DATETIME(3) NULL,
|
||||
deleted_by BIGINT UNSIGNED NULL,
|
||||
deleted_at DATETIME(3) NULL,
|
||||
version INT UNSIGNED NOT NULL DEFAULT 1,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY(id),
|
||||
KEY idx_customs_customer_document_container(customs_container_id,status,tenant_id),
|
||||
CONSTRAINT fk_customs_customer_document_container FOREIGN KEY(customs_container_id) REFERENCES fl_customs_container(id),
|
||||
CONSTRAINT chk_customs_customer_document_status CHECK(status IN('ACTIVE','DELETED')),
|
||||
CONSTRAINT chk_customs_customer_document_freeze CHECK((frozen_at IS NULL AND frozen_version IS NULL) OR
|
||||
(frozen_at IS NOT NULL AND frozen_version IS NOT NULL))
|
||||
) ENGINE=InnoDB COMMENT='客户整份退税报送资料逻辑根';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_customs_customer_document_inbound (
|
||||
document_id BIGINT UNSIGNED NOT NULL,
|
||||
inbound_id BIGINT UNSIGNED NOT NULL,
|
||||
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||
order_id BIGINT UNSIGNED NOT NULL,
|
||||
inbound_code VARCHAR(40) NOT NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY(document_id,inbound_id),
|
||||
KEY idx_customs_document_inbound(inbound_id,tenant_id),
|
||||
CONSTRAINT fk_customs_document_inbound_document FOREIGN KEY(document_id) REFERENCES fl_customs_customer_document(id)
|
||||
) ENGINE=InnoDB COMMENT='客户报送资料与当前柜退税入仓码多对多关系';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_customs_customer_document_version (
|
||||
id BIGINT UNSIGNED NOT NULL,
|
||||
document_id BIGINT UNSIGNED NOT NULL,
|
||||
document_version INT UNSIGNED NOT NULL,
|
||||
file_id BIGINT UNSIGNED NOT NULL,
|
||||
file_name VARCHAR(255) NOT NULL,
|
||||
file_hash CHAR(64) NOT NULL,
|
||||
content_type VARCHAR(128) NULL,
|
||||
size_bytes BIGINT UNSIGNED NOT NULL,
|
||||
scope_snapshot_json JSON NOT NULL,
|
||||
action VARCHAR(24) NOT NULL COMMENT 'UPLOAD、REPLACE、SCOPE_CHANGE、DELETE',
|
||||
operator_id BIGINT UNSIGNED NOT NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY(id),
|
||||
UNIQUE KEY uk_customs_customer_document_version(document_id,document_version),
|
||||
CONSTRAINT fk_customs_customer_document_version FOREIGN KEY(document_id) REFERENCES fl_customs_customer_document(id),
|
||||
CONSTRAINT chk_customs_customer_document_version_action CHECK(action IN('UPLOAD','REPLACE','SCOPE_CHANGE','DELETE'))
|
||||
) ENGINE=InnoDB COMMENT='客户报送资料文件与覆盖范围不可变版本';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_customs_frozen_document_inbound (
|
||||
document_id BIGINT UNSIGNED NOT NULL,
|
||||
inbound_id BIGINT UNSIGNED NOT NULL,
|
||||
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||
order_id BIGINT UNSIGNED NOT NULL,
|
||||
inbound_code VARCHAR(40) NOT NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY(document_id,inbound_id),
|
||||
CONSTRAINT fk_customs_frozen_document FOREIGN KEY(document_id) REFERENCES fl_customs_customer_document(id)
|
||||
) ENGINE=InnoDB COMMENT='开始报关时冻结的客户资料覆盖范围';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_customs_completion_document (
|
||||
id BIGINT UNSIGNED NOT NULL,
|
||||
source_document_id BIGINT UNSIGNED NOT NULL COMMENT '一份冻结客户资料严格对应一份完成文件逻辑根',
|
||||
file_id BIGINT UNSIGNED NOT NULL,
|
||||
file_name VARCHAR(255) NOT NULL,
|
||||
file_hash CHAR(64) NOT NULL,
|
||||
content_type VARCHAR(128) NULL,
|
||||
size_bytes BIGINT UNSIGNED NOT NULL,
|
||||
uploaded_by BIGINT UNSIGNED NOT NULL,
|
||||
uploaded_by_name VARCHAR(64) NULL,
|
||||
uploaded_at DATETIME(3) NOT NULL,
|
||||
version INT UNSIGNED NOT NULL DEFAULT 1,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY(id),
|
||||
UNIQUE KEY uk_customs_completion_source(source_document_id),
|
||||
CONSTRAINT fk_customs_completion_source FOREIGN KEY(source_document_id) REFERENCES fl_customs_customer_document(id)
|
||||
) ENGINE=InnoDB COMMENT='报关完成文件当前版本';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_customs_completion_document_version (
|
||||
id BIGINT UNSIGNED NOT NULL,
|
||||
completion_document_id BIGINT UNSIGNED NOT NULL,
|
||||
document_version INT UNSIGNED NOT NULL,
|
||||
file_id BIGINT UNSIGNED NOT NULL,
|
||||
file_name VARCHAR(255) NOT NULL,
|
||||
file_hash CHAR(64) NOT NULL,
|
||||
content_type VARCHAR(128) NULL,
|
||||
size_bytes BIGINT UNSIGNED NOT NULL,
|
||||
operator_id BIGINT UNSIGNED NOT NULL,
|
||||
operator_name VARCHAR(64) NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY(id),
|
||||
UNIQUE KEY uk_customs_completion_version(completion_document_id,document_version),
|
||||
CONSTRAINT fk_customs_completion_version FOREIGN KEY(completion_document_id) REFERENCES fl_customs_completion_document(id)
|
||||
) ENGINE=InnoDB COMMENT='报关完成文件不可变覆盖版本';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_customs_container_history (
|
||||
id BIGINT UNSIGNED NOT NULL,
|
||||
customs_container_id BIGINT UNSIGNED NOT NULL,
|
||||
action VARCHAR(40) NOT NULL,
|
||||
from_status VARCHAR(40) NULL,
|
||||
to_status VARCHAR(40) NULL,
|
||||
remark VARCHAR(500) NULL,
|
||||
operator_id BIGINT UNSIGNED NOT NULL,
|
||||
operator_name VARCHAR(64) NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY(id),
|
||||
KEY idx_customs_container_history(customs_container_id,created_at,id),
|
||||
CONSTRAINT fk_customs_container_history FOREIGN KEY(customs_container_id) REFERENCES fl_customs_container(id)
|
||||
) ENGINE=InnoDB COMMENT='整柜报关状态与系统触发历史';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_customs_command (
|
||||
id BIGINT UNSIGNED NOT NULL,
|
||||
actor_id BIGINT UNSIGNED NOT NULL,
|
||||
action VARCHAR(80) NOT NULL,
|
||||
idempotency_key VARCHAR(128) NOT NULL,
|
||||
request_hash CHAR(64) NOT NULL,
|
||||
status VARCHAR(16) NOT NULL,
|
||||
result_id BIGINT UNSIGNED NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
completed_at DATETIME(3) NULL,
|
||||
PRIMARY KEY(id),
|
||||
UNIQUE KEY uk_customs_command(actor_id,action,idempotency_key),
|
||||
CONSTRAINT chk_customs_command_status CHECK(status IN('PROCESSING','SUCCESS'))
|
||||
) ENGINE=InnoDB COMMENT='整柜报关写命令幂等事实';
|
||||
|
||||
USE baiyue_iam;
|
||||
DELETE rp FROM iam_role_permission rp WHERE rp.permission_id IN (5102171,5102173,5102174,5102175);
|
||||
DELETE FROM iam_permission WHERE id IN (5102171,5102173,5102174,5102175)
|
||||
AND permission_code IN ('platform:firstleg:customs:view','platform:firstleg:customs:review','platform:firstleg:customs:submit','platform:firstleg:customs:receipt');
|
||||
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
|
||||
(5102170,0,'PLATFORM','MENU',5102000,'platform:firstleg:customs:list','整柜报关','/first-leg/customs','GET',100,'ACTIVE',1,0),
|
||||
(5102172,0,'PLATFORM','BUTTON',5102170,'platform:firstleg:customs:manage','维护整柜报关',NULL,'POST',20,'ACTIVE',1,0)
|
||||
ON DUPLICATE KEY UPDATE tenant_id=0,app_code='PLATFORM',permission_type=VALUES(permission_type),
|
||||
permission_code=VALUES(permission_code),permission_name=VALUES(permission_name),parent_id=VALUES(parent_id),
|
||||
route_path=VALUES(route_path),http_method=VALUES(http_method),sort_no=VALUES(sort_no),status='ACTIVE',built_in=1,deleted=0;
|
||||
DELETE rp FROM iam_role_permission rp WHERE rp.permission_id IN(5102171,5102173,5102174,5102175);
|
||||
DELETE FROM iam_permission WHERE id IN(5102171,5102173,5102174,5102175)
|
||||
AND permission_code IN('platform:firstleg:customs:view','platform:firstleg:customs:review','platform:firstleg:customs:submit','platform:firstleg:customs:receipt');
|
||||
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
|
||||
(5102170,0,'PLATFORM','MENU',5102000,'platform:firstleg:customs:list','整柜报关','/first-leg/customs','GET',100,'ACTIVE',1,0),
|
||||
(5102172,0,'PLATFORM','BUTTON',5102170,'platform:firstleg:customs:manage','维护整柜报关',NULL,'POST',20,'ACTIVE',1,0)
|
||||
ON DUPLICATE KEY UPDATE permission_code=VALUES(permission_code),permission_name=VALUES(permission_name),
|
||||
parent_id=VALUES(parent_id),route_path=VALUES(route_path),http_method=VALUES(http_method),status='ACTIVE',deleted=0;
|
||||
INSERT IGNORE INTO iam_role_permission(role_id,permission_id,tenant_id)
|
||||
SELECT role.id,permission.id,0 FROM iam_role role JOIN iam_permission permission
|
||||
ON permission.tenant_id=0 AND permission.app_code='PLATFORM'
|
||||
AND permission.permission_code IN ('platform:firstleg:customs:list','platform:firstleg:customs:manage')
|
||||
WHERE role.tenant_id=0 AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR');
|
||||
SELECT r.id,p.id,0 FROM iam_role r JOIN iam_permission p ON p.tenant_id=0 AND p.app_code='PLATFORM'
|
||||
AND p.permission_code IN('platform:firstleg:customs:list','platform:firstleg:customs:manage')
|
||||
WHERE r.tenant_id=0 AND r.role_code IN('PLATFORM_ADMIN','ORDER_OPERATOR');
|
||||
USE baiyue_firstleg;
|
||||
/* Phase 3: link inbound preload plans to one or more container loading plans. */
|
||||
CREATE TABLE IF NOT EXISTS fl_preload_loading_plan_link (
|
||||
@@ -13973,7 +14131,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'carrier_id
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'carrier_name', '拖车服务商名称快照');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'container_booking_id', '关联柜次逻辑ID;拖车完成供装柜门禁校验');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'created_at', '拖车预约创建时间');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'currency', '金额币种;首期默认RMB');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'currency', '应付金额币种;仅RMB或USD');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'driver_id_card', '司机身份证号;平台内部敏感信息');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'driver_name', '司机姓名');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'driver_phone', '司机联系电话');
|
||||
@@ -13989,7 +14147,6 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'pallet_cou
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'payable_amount', '应付拖车服务商金额;币种由currency指定');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'planned_loading_at', '预约到仓装柜时间');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'po_no', '关联采购单号');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'receivable_amount', '拖车业务应收金额;币种由currency指定');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'remark', '平台内部备注或异常说明');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'revision', '拖车预约业务修订号;每次状态或改约递增');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'status', '拖车预约状态:DRAFT草稿、PENDING_DISPATCH待派车、DISPATCHED已派车、PICKUP_IN_PROGRESS提货中、ARRIVED_WAREHOUSE已到仓、LOADING装柜中、DEPARTED已离仓、COMPLETED已完成、RESCHEDULED已改约、EXCEPTION异常、CANCELLED已取消');
|
||||
@@ -16340,6 +16497,211 @@ 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
|
||||
DECLARE v_warehouse_operator_permission_count INT DEFAULT 0;
|
||||
DECLARE v_warehouse_tally_permission_count INT DEFAULT 0;
|
||||
|
||||
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;
|
||||
SELECT COALESCE(SUM(role_code='WAREHOUSE_OPERATOR'),0),
|
||||
COALESCE(SUM(role_code='WAREHOUSE_TALLY'),0)
|
||||
INTO v_warehouse_operator_permission_count, v_warehouse_tally_permission_count
|
||||
FROM tmp_phase5_delivery_role_permission;
|
||||
IF v_warehouse_operator_permission_count <> 7
|
||||
OR v_warehouse_tally_permission_count <> 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;
|
||||
@@ -16578,77 +16940,6 @@ BEGIN
|
||||
END$$
|
||||
DELIMITER ;
|
||||
|
||||
-- 最新整柜报关基线:装柜完成后收集退税资料,按柜冻结、回传并自动收敛离仓。
|
||||
USE baiyue_firstleg;
|
||||
CREATE TABLE IF NOT EXISTS fl_customs_container(
|
||||
id BIGINT UNSIGNED NOT NULL,loading_plan_id BIGINT UNSIGNED NOT NULL,container_booking_id BIGINT UNSIGNED NOT NULL,
|
||||
status VARCHAR(40) NOT NULL,started_by BIGINT UNSIGNED NULL,started_by_name VARCHAR(64) NULL,started_at DATETIME(3) NULL,
|
||||
completed_by BIGINT UNSIGNED NULL,completed_by_name VARCHAR(64) NULL,completed_at DATETIME(3) NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
version INT UNSIGNED NOT NULL DEFAULT 0,PRIMARY KEY(id),UNIQUE KEY uk_customs_container_plan(loading_plan_id),
|
||||
UNIQUE KEY uk_customs_container_booking(container_booking_id),KEY idx_customs_container_status(status,updated_at),
|
||||
CONSTRAINT chk_customs_container_status CHECK(status IN('WAITING_CUSTOMER_DOCUMENTS','READY_TO_START','DECLARING','COMPLETED'))
|
||||
)ENGINE=InnoDB COMMENT='装柜完成后的整柜出口报关聚合';
|
||||
CREATE TABLE IF NOT EXISTS fl_customs_customer_document(
|
||||
id BIGINT UNSIGNED NOT NULL,customs_container_id BIGINT UNSIGNED NOT NULL,tenant_id BIGINT UNSIGNED NOT NULL,
|
||||
file_id BIGINT UNSIGNED NOT NULL,file_name VARCHAR(255) NOT NULL,file_hash CHAR(64) NOT NULL,content_type VARCHAR(128) NULL,
|
||||
size_bytes BIGINT UNSIGNED NOT NULL,status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',uploaded_by BIGINT UNSIGNED NOT NULL,
|
||||
uploaded_at DATETIME(3) NOT NULL,frozen_version INT UNSIGNED NULL,frozen_at DATETIME(3) NULL,deleted_by BIGINT UNSIGNED NULL,
|
||||
deleted_at DATETIME(3) NULL,version INT UNSIGNED NOT NULL DEFAULT 1,created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),PRIMARY KEY(id),
|
||||
KEY idx_customs_customer_document_container(customs_container_id,status,tenant_id),
|
||||
CONSTRAINT fk_customs_customer_document_container FOREIGN KEY(customs_container_id) REFERENCES fl_customs_container(id),
|
||||
CONSTRAINT chk_customs_customer_document_status CHECK(status IN('ACTIVE','DELETED')),
|
||||
CONSTRAINT chk_customs_customer_document_freeze CHECK((frozen_at IS NULL AND frozen_version IS NULL)OR(frozen_at IS NOT NULL AND frozen_version IS NOT NULL))
|
||||
)ENGINE=InnoDB COMMENT='客户整份退税报送资料逻辑根';
|
||||
CREATE TABLE IF NOT EXISTS fl_customs_customer_document_inbound(
|
||||
document_id BIGINT UNSIGNED NOT NULL,inbound_id BIGINT UNSIGNED NOT NULL,tenant_id BIGINT UNSIGNED NOT NULL,
|
||||
order_id BIGINT UNSIGNED NOT NULL,inbound_code VARCHAR(40) NOT NULL,created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY(document_id,inbound_id),KEY idx_customs_document_inbound(inbound_id,tenant_id),
|
||||
CONSTRAINT fk_customs_document_inbound_document FOREIGN KEY(document_id) REFERENCES fl_customs_customer_document(id)
|
||||
)ENGINE=InnoDB COMMENT='客户报送资料与当前柜退税入仓码多对多关系';
|
||||
CREATE TABLE IF NOT EXISTS fl_customs_customer_document_version(
|
||||
id BIGINT UNSIGNED NOT NULL,document_id BIGINT UNSIGNED NOT NULL,document_version INT UNSIGNED NOT NULL,file_id BIGINT UNSIGNED NOT NULL,
|
||||
file_name VARCHAR(255) NOT NULL,file_hash CHAR(64) NOT NULL,content_type VARCHAR(128) NULL,size_bytes BIGINT UNSIGNED NOT NULL,
|
||||
scope_snapshot_json JSON NOT NULL,action VARCHAR(24) NOT NULL,operator_id BIGINT UNSIGNED NOT NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),PRIMARY KEY(id),UNIQUE KEY uk_customs_customer_document_version(document_id,document_version),
|
||||
CONSTRAINT fk_customs_customer_document_version FOREIGN KEY(document_id) REFERENCES fl_customs_customer_document(id),
|
||||
CONSTRAINT chk_customs_customer_document_version_action CHECK(action IN('UPLOAD','REPLACE','SCOPE_CHANGE','DELETE'))
|
||||
)ENGINE=InnoDB COMMENT='客户报送资料文件与覆盖范围不可变版本';
|
||||
CREATE TABLE IF NOT EXISTS fl_customs_frozen_document_inbound(
|
||||
document_id BIGINT UNSIGNED NOT NULL,inbound_id BIGINT UNSIGNED NOT NULL,tenant_id BIGINT UNSIGNED NOT NULL,
|
||||
order_id BIGINT UNSIGNED NOT NULL,inbound_code VARCHAR(40) NOT NULL,created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY(document_id,inbound_id),CONSTRAINT fk_customs_frozen_document FOREIGN KEY(document_id) REFERENCES fl_customs_customer_document(id)
|
||||
)ENGINE=InnoDB COMMENT='开始报关时冻结的客户资料覆盖范围';
|
||||
CREATE TABLE IF NOT EXISTS fl_customs_completion_document(
|
||||
id BIGINT UNSIGNED NOT NULL,source_document_id BIGINT UNSIGNED NOT NULL,file_id BIGINT UNSIGNED NOT NULL,
|
||||
file_name VARCHAR(255) NOT NULL,file_hash CHAR(64) NOT NULL,content_type VARCHAR(128) NULL,size_bytes BIGINT UNSIGNED NOT NULL,
|
||||
uploaded_by BIGINT UNSIGNED NOT NULL,uploaded_by_name VARCHAR(64) NULL,uploaded_at DATETIME(3) NOT NULL,version INT UNSIGNED NOT NULL DEFAULT 1,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY(id),UNIQUE KEY uk_customs_completion_source(source_document_id),
|
||||
CONSTRAINT fk_customs_completion_source FOREIGN KEY(source_document_id) REFERENCES fl_customs_customer_document(id)
|
||||
)ENGINE=InnoDB COMMENT='报关完成文件当前版本';
|
||||
CREATE TABLE IF NOT EXISTS fl_customs_completion_document_version(
|
||||
id BIGINT UNSIGNED NOT NULL,completion_document_id BIGINT UNSIGNED NOT NULL,document_version INT UNSIGNED NOT NULL,
|
||||
file_id BIGINT UNSIGNED NOT NULL,file_name VARCHAR(255) NOT NULL,file_hash CHAR(64) NOT NULL,content_type VARCHAR(128) NULL,
|
||||
size_bytes BIGINT UNSIGNED NOT NULL,operator_id BIGINT UNSIGNED NOT NULL,operator_name VARCHAR(64) NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),PRIMARY KEY(id),
|
||||
UNIQUE KEY uk_customs_completion_version(completion_document_id,document_version),
|
||||
CONSTRAINT fk_customs_completion_version FOREIGN KEY(completion_document_id) REFERENCES fl_customs_completion_document(id)
|
||||
)ENGINE=InnoDB COMMENT='报关完成文件不可变覆盖版本';
|
||||
CREATE TABLE IF NOT EXISTS fl_customs_container_history(
|
||||
id BIGINT UNSIGNED NOT NULL,customs_container_id BIGINT UNSIGNED NOT NULL,action VARCHAR(40) NOT NULL,
|
||||
from_status VARCHAR(40) NULL,to_status VARCHAR(40) NULL,remark VARCHAR(500) NULL,operator_id BIGINT UNSIGNED NOT NULL,
|
||||
operator_name VARCHAR(64) NULL,created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),PRIMARY KEY(id),
|
||||
KEY idx_customs_container_history(customs_container_id,created_at,id),
|
||||
CONSTRAINT fk_customs_container_history FOREIGN KEY(customs_container_id) REFERENCES fl_customs_container(id)
|
||||
)ENGINE=InnoDB COMMENT='整柜报关状态与系统触发历史';
|
||||
CREATE TABLE IF NOT EXISTS fl_customs_command(
|
||||
id BIGINT UNSIGNED NOT NULL,actor_id BIGINT UNSIGNED NOT NULL,action VARCHAR(80) NOT NULL,idempotency_key VARCHAR(128) NOT NULL,
|
||||
request_hash CHAR(64) NOT NULL,status VARCHAR(16) NOT NULL,result_id BIGINT UNSIGNED NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),completed_at DATETIME(3) NULL,PRIMARY KEY(id),
|
||||
UNIQUE KEY uk_customs_command(actor_id,action,idempotency_key),CONSTRAINT chk_customs_command_status CHECK(status IN('PROCESSING','SUCCESS'))
|
||||
)ENGINE=InnoDB COMMENT='整柜报关写命令幂等事实';
|
||||
-- 同柜且不可拆组:跨客户但同国内仓、渠道和目的仓港,整组锁定/解锁/消费。
|
||||
USE baiyue_firstleg;
|
||||
CREATE TABLE IF NOT EXISTS fl_coload_group (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
| 财务付款凭证、海外卸柜照片 | 通用业务文件入口 `InternalBusinessFileController.java`、`InternalBusinessFileService.java`;业务类型分别为 `FINANCE_VOUCHER`、`OVERSEAS_UNLOAD_PHOTO`,必须按客户与业务 ID 精确校验 |
|
||||
| 报关源文件、完成文件与买单明细导出 | `src/main/resources/openapi/customs-file-and-export-v1.yaml`;文件类别 `LOADING_LIST_CUSTOMS_SOURCE`、`LOADING_LIST_CUSTOMS_RESULT` 均为客户作用域且最多 50MB,异步导出类型 `LOADING_LIST_BUY_DECLARATION_ITEMS` 由 Firstleg Provider 按固定十三列供数 |
|
||||
| 订舱文件 | 通用内部业务文件入口,契约 `src/main/resources/openapi/ocean-booking-file-v1.yaml`;类型固定 `OCEAN_BOOKING_DOCUMENT`、`tenantId=订舱customerId`、`businessId=oceanBookingId`,任意格式且最多 50MiB,不得绑定拖车;`BOOKING_CONFIRMATION/BILL_OF_LADING` 文档类型及有效状态由 Firstleg 保存 |
|
||||
| 拖车预约附件 | 通用内部业务文件入口;固定 `tenantId=0`、`businessType=TRUCK_APPOINTMENT_ATTACHMENT`、`businessId=appointmentId`,只允许派车单、司机/车辆资料和其他平台附件;提单文件归订舱 `OCEAN_BOOKING_DOCUMENT`,不得复制为拖车附件 |
|
||||
| 存储与实体 | `service/LocalQualificationStorage.java`、`MeasurementImageStorage.java`、`entity/FileObjectEntity.java` |
|
||||
| 下游 OCR | `client/WeightOcrClient.java` |
|
||||
| SQL/配置 | `src/main/resources/db/file.sql`、`application.yml` |
|
||||
|
||||
@@ -59,14 +59,15 @@ public class FinanceExportProviderService {
|
||||
RECEIVABLE_STATISTICS);
|
||||
private static final Set<String> SETTLEMENT_STATUSES = Set.of("ISSUED", "PARTIALLY_PAID", "PAID", "VOIDED");
|
||||
private static final Set<String> LEDGER_TYPES = Set.of("FEE_SOURCE_EDIT", "FEE_SOURCE_SUBMIT", "FEE_SOURCE_REVIEW_APPROVE", "FEE_SOURCE_REVIEW_REJECT", "FEE_SOURCE_ADJUST", "FEE_SOURCE_VOID", "RECONCILIATION_CREATED",
|
||||
"SETTLEMENT_ISSUED", "RECEIPT", "PAYMENT", "ADJUSTMENT", "VOID");
|
||||
"SETTLEMENT_ISSUED", "RECEIPT", "PAYMENT", "SUPPLIER_DIRECT_PAID",
|
||||
"SUPPLIER_ON_ACCOUNT_PAYMENT", "ADJUSTMENT", "VOID");
|
||||
|
||||
private static final List<ExportColumn> ACCOUNT_COLUMNS = List.of(
|
||||
column("customerCode", "客户编码", ExportColumnType.STRING, 18), column("customerName", "客户", ExportColumnType.STRING, 28),
|
||||
column("contactName", "联系人", ExportColumnType.STRING, 16), column("contactPhone", "联系电话", ExportColumnType.STRING, 18),
|
||||
column("outstandingReceivable", "待收(CNY)", ExportColumnType.DECIMAL, 16), column("pendingBilling", "待开账(CNY)", ExportColumnType.DECIMAL, 16),
|
||||
column("outstandingReceivable", "待收客户款(CNY)", ExportColumnType.DECIMAL, 18), column("pendingBilling", "待开账(CNY)", ExportColumnType.DECIMAL, 16),
|
||||
column("receivedInPeriod", "本期已收(CNY)", ExportColumnType.DECIMAL, 16), column("totalCost", "总成本(CNY)", ExportColumnType.DECIMAL, 16),
|
||||
column("paidCost", "已付成本(CNY)", ExportColumnType.DECIMAL, 16), column("outstandingPayable", "待付(CNY)", ExportColumnType.DECIMAL, 16),
|
||||
column("paidCost", "已线下支付供应商款(CNY)", ExportColumnType.DECIMAL, 24), column("outstandingPayable", "待付供应商款(CNY)", ExportColumnType.DECIMAL, 18),
|
||||
column("status", "客户状态", ExportColumnType.STRING, 14), column("lastChangedAt", "最近变动", ExportColumnType.DATETIME, 21));
|
||||
private static final List<ExportColumn> LEDGER_COLUMNS = List.of(
|
||||
column("ledgerId", "流水号", ExportColumnType.STRING, 22), column("occurredAt", "发生时间", ExportColumnType.DATETIME, 21),
|
||||
|
||||
+18
@@ -112,6 +112,24 @@ class FinanceExportProviderServiceTest {
|
||||
verify(operationService).listDerivedCustomerAccountLedger(10001L, 1, 200, null, null, null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void accountExportUsesOperatingFundNamesAndAcceptsSupplierPaymentLedgerFilters() {
|
||||
when(accountService.list(1, 100, null, null, 10001L))
|
||||
.thenReturn(PageEnvelope.success(List.of(), 1, 100, 0));
|
||||
var account = service.exportBatch(FinanceExportProviderService.CUSTOMER_ACCOUNT_LIST,
|
||||
request(Map.of("customerId", "10001"), null, null));
|
||||
assertThat(account.columns()).extracting(column -> column.title()).contains(
|
||||
"待收客户款(CNY)", "待付供应商款(CNY)", "已线下支付供应商款(CNY)");
|
||||
|
||||
for (String businessType : List.of("SUPPLIER_DIRECT_PAID", "SUPPLIER_ON_ACCOUNT_PAYMENT")) {
|
||||
when(operationService.listDerivedCustomerAccountLedger(10001L, 1, 100, businessType, null, null))
|
||||
.thenReturn(PageEnvelope.success(List.of(), 1, 100, 0));
|
||||
service.exportBatch(FinanceExportProviderService.CUSTOMER_ACCOUNT_LEDGER,
|
||||
request(Map.of("customerId", "10001", "businessType", businessType), null, null));
|
||||
verify(operationService).listDerivedCustomerAccountLedger(10001L, 1, 100, businessType, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void exportsTheSameMonthlyCnyCustomerStatisticsWithoutExcludedPrototypeFields() {
|
||||
when(receivableStatisticsService.exportRows(YearMonth.of(2026, 7), 10001L)).thenReturn(List.of(
|
||||
|
||||
+4
-3
@@ -37,11 +37,11 @@
|
||||
| 装柜完成跨区事件 | `service/LoadingCompletedOutboxService.java`、`LoadingCompletedOutboxRelay.java`、`repository/LoadingCompletedOutboxRepository.java`、`mapper/LoadingCartonManifestMapper.java`、`client/EventBridgeClient.java`;从 `fl_inbound_order.destination_type` 冻结 V2 `orders[].destinationType=FBA|OVERSEAS`,OVERSEAS 按最终实装与稳定箱码冻结,FBA 可仅冻结箱数;真实生产验收 `src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java`;事件模型见 `../common-event-contract/AGENTS.md` |
|
||||
| 海外卸柜完成事件回写 | 内部入口 `controller/InternalOverseasContainerEventController.java`、消费者 `service/OverseasContainerEventConsumer.java`、Inbox `entity/ContainerEventInboxEntity.java` 与 `mapper/ContainerEventInboxMapper.java`;事件模型见 `../common-event-contract/AGENTS.md` |
|
||||
| 海外暂扣状态回写 | 同一内部事件入口、消费者 `service/OverseasInboundOrderDetainedConsumer.java`;按来源入仓单 ID 更新 `fl_inbound_order.hold_flag/current_hold_reason`,复用跨区 Inbox 并写 `fl_inbound_hold_record` |
|
||||
| 三期海运订舱、关联候选与出仓前船期资料 | `oceanbooking/controller/PlatformOceanBookingController.java`、`oceanbooking/service/OceanBookingService.java`、`oceanbooking/mapper/OceanBookingMapper.java`、`portschedule/controller/PlatformPortScheduleController.java`;关联候选入口 `/api/platform/v1/ocean-bookings/link-candidates`,订舱契约见 `src/main/resources/openapi/ocean-bookings-v1.yaml`,表结构见 `src/main/resources/db/phase3-ocean-booking-schema.sql` |
|
||||
| 三期海运订舱、资料/打印、关联候选与出仓前船期资料 | `oceanbooking/controller/PlatformOceanBookingController.java`、`oceanbooking/service/OceanBookingService.java`、`OceanBookingDocumentService.java`、`OceanBookingPrintService.java`、`oceanbooking/mapper/OceanBookingMapper.java`、`portschedule/controller/PlatformPortScheduleController.java`;文件经 `client/OceanBookingFileClient.java` 进入 File,关联候选 `/api/platform/v1/ocean-bookings/link-candidates`,契约 `src/main/resources/openapi/ocean-bookings-v1.yaml`,表结构见 `src/main/resources/db/phase3-ocean-booking-schema.sql` |
|
||||
| 四期国内出仓后船节点定时查询 | `outbound/dispatch/service/OutboundDispatchService.java` 在确认出仓事务内幂等创建现有 `CarrierScheduleQuery`;编排与租约 `portschedule/service/CarrierScheduleQueryScheduler.java`,任务/十节点事实/异常事实/不可变 attempt `CarrierScheduleQueryService.java`,平台查询与人工补录入口 `PlatformCarrierScheduleQueryController.java` 的 `/{queryId}/manual-nodes`;停止只由实际 `EMPTY_RETURNED` 节点触发,不提供无节点停止入口;外部船司/轨迹调用经 `integration-service/`;标准见 `../../docs/四期/船节点标准化契约.md` |
|
||||
| 船节点跨区交接事件 | `portschedule/service/CarrierScheduleNodeOutboxService.java`;实际节点落库后发布 `FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED` V1,复用 `LoadingCompletedOutboxRepository`/Relay 投 Event Bridge;海外消费见 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java` |
|
||||
| Firstleg 业务单据编号 | `service/FirstlegDocumentNumberGenerator.java`;当前支持 `OB`、`TA`、`CB`、`CAB`、`LP` 前缀,按北京时间每日独立编号;具体业务归属以各调用服务为准 |
|
||||
| 拖车与整柜出仓调度 | `controller/PlatformTruckAppointmentController.java`、`outbound/dispatch/controller/PlatformOutboundDispatchController.java`;按真实 `containerBookingId` 查询聚合和 `gate`、确认离仓,契约 `src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml` |
|
||||
| 订舱拥有的拖车与整柜出仓调度 | `controller/PlatformTruckAppointmentController.java`、`service/TruckAppointmentService.java`、`repository/TruckAppointmentRepository.java`、`outbound/dispatch/controller/PlatformOutboundDispatchController.java`;拖车契约 `src/main/resources/openapi/truck-appointments-v1.yaml`,出仓契约 `phase3-outbound-dispatch-v1.yaml`。预约无客户字段,必须绑定 `RELEASED` 订舱及其已分配真实 ISO 柜,同柜最多一条有效预约;提单号和仓库取订舱/柜次权威事实,附件固定平台作用域 |
|
||||
| 整柜出口报关 | `customs/container/controller/PlatformContainerCustomsController.java`、`customs/container/controller/MerchantContainerCustomsController.java`、`customs/container/service/ContainerCustomsService.java`、`customs/container/mapper/ContainerCustomsMapper.java`;装柜完成后按柜收集退税资料,多对多覆盖入仓码,开始报关冻结,完成文件逐资料一对一回传,全买单柜由平台确认并通过导出中心取明细;契约 `src/main/resources/openapi/customs-v1.yaml`,基线 `src/main/resources/db/phase3-container-customs-schema.sql`;消费者为 Admin `../../apps/admin-web/src/modules/container-loading/components/ContainerCustomsPanel.vue` 和 Client `../../apps/client-web/src/modules/first-leg/components/CustomsDocumentsDialog.vue` |
|
||||
| 买单订单明细导出 Provider | `customs/container/controller/LoadingListBuyDeclarationExportController.java`、`service/LoadingListBuyDeclarationExportService.java`;只为 File 导出中心提供 `LOADING_LIST_BUY_DECLARATION_ITEMS` 的已完成装柜实际买单行,不在 Firstleg 生成文件 |
|
||||
| 跨模型桥接 | `loading/bridge/controller/PlatformLoadingModelBridgeController.java` |
|
||||
@@ -64,9 +64,10 @@
|
||||
- 订舱柜型需求至少一项、最多 20 项,数量必须为正整数,容量和限重必须匹配平台柜型规格。起运仓必须是启用的国内中转仓;物流渠道必须来自该仓当前 ACTIVE 价格版本,服务端保存渠道名称快照,渠道失效时失败关闭。
|
||||
- 订舱号统一使用 `OB-yyMMdd-####`,按北京时间创建日每日独立递增且单日最多 9999;不得以实体 ID 拼接生成。放舱不要求真实柜号,也不得为满足放舱校验伪造柜号。
|
||||
- 订舱列表 `containerCount` 固定为该订舱全部柜型需求 `quantity` 之和,与真实柜关联/解除无关;关联候选只返回 `RELEASED`、剩余数量大于 0 且 `warehouseId + channelCode + containerType` 精确匹配的需求,由服务端集合查询 ACTIVE 分配量。前端不得扇出详情计算或用已关联柜数覆盖该字段,最终关联命令仍须重验目标货柜可修改性。
|
||||
- 拖车预约是平台级订舱履约资源,不是客户资源:请求、实体、查询和附件不得恢复 `customerId/customerName`。创建或编辑必须选择 `RELEASED` 订舱及其已分配、有效且带真实 ISO 6346 柜号的柜次,订舱还须有有效提单号和至少一份 ACTIVE `BOOKING_CONFIRMATION` 或 `BILL_OF_LADING`;同一真实柜通过查询锁与唯一约束最多保留一条非取消预约。拖车附件使用 `tenantId=0 + TRUCK_APPOINTMENT_ATTACHMENT + appointmentId`,提单文件继续归订舱,不复制到拖车附件。
|
||||
- 四期船节点任务从确认柜子离开国内仓库后启用,必须绑定真实订舱和柜次;十个标准节点、预计/实际时间、异常事实、供应商缺失节点和还柜停止规则统一以 `../../docs/四期/船节点标准化契约.md` 为准。`CarrierScheduleQuery` 的正常业务查询槽位固定为 `Asia/Shanghai` 每日 09:00、12:30、16:00;每分钟任务只扫描已到期 `next_allowed_at`,不是每分钟请求船司。成功、无变化、需人工或永久失败进入下一固定槽位;仅技术性 `RETRYABLE_FAILURE` 在当前槽内按15/30分钟退避,含首次最多3次且不得跨下一槽,实际 `EMPTY_RETURNED` 后停止。错过的正常槽位直接顺延,不在服务恢复时补跑;单轮按受控批次持续领取并受最大任务数保护。租约、调度尝试和内部停止入口不得暴露为平台页面 API,外部失败不得推进业务节点,不得在三期另建查询任务。
|
||||
- 国内出口报关在装柜完成后启用,不再阻塞 `LOADED`。整柜 `fl_customs_container.status=COMPLETED` 才通过离仓报关门禁;完成后以系统身份、确定性幂等键立即尝试确认国内离仓,其他门禁未齐时由受控定时补偿重试,禁止部分入仓成功或重复创建离仓/船司查询事实。
|
||||
- 国内出仓确认以真实 `containerBookingId` 聚合全部入仓绑定并锁定校验;装柜证据按物理货柜只计算一次,拖车状态和独立船期只作参考。全部门禁在同一事务通过后推进所有技术行、记录一条可见历史并幂等创建 `CarrierScheduleQuery/DEPARTED_WAREHOUSE`,不允许单入仓局部成功。
|
||||
- 国内出仓确认以真实 `containerBookingId` 聚合全部入仓绑定并锁定校验;装柜证据按物理货柜只计算一次,PDA `taskId` 仅用于来源审计和任务授权,File 所有权仍固定 `tenantId=0 + containerBookingId`;拖车状态和独立船期只作参考。全部门禁在同一事务通过后推进所有技术行、记录一条可见历史并幂等创建 `CarrierScheduleQuery/DEPARTED_WAREHOUSE`,不允许单入仓局部成功。
|
||||
- 手工船节点的预计时间只追加事实,不推进 `latestStandardNode`;携带 `actualAt` 时标准节点序号必须严格大于最新实际节点,重复、相同或回退节点返回冲突。已审核实际时间修正只能更新同一事实的时间/证据,不得制造第二条同节点实际事实或绕过单调推进。
|
||||
- 海外卸柜完成事件只接受 `OVERSEAS -> CN` 的 `OVERSEAS_CONTAINER_UNLOADED` V1,并通过内部服务令牌进入。消费者必须校验信封、Payload 哈希、来源货柜和时间顺序,以 event ID + Payload 哈希幂等,使用事件聚合版本阻止乱序覆盖;预计报空时间固定按卸柜照片上传时间加两天计算,实际报空时间可为空。
|
||||
- Firstleg 只拥有业务费用及履约事实;Finance 拥有费用入池、账期、账单、收款、成本分摊结果和财务状态。修改费用来源版本、发布条件、柜次归属或体积快照时同步检查 `../finance/src/main/java/com/baiyue/finance/client/FirstlegFeeSourceClient.java`、同目录 `FirstlegCostSnapshotClient.java` 及双方契约测试,不在 Firstleg 复制财务状态机。
|
||||
|
||||
@@ -85,9 +85,9 @@ public final class ContainerDtos {
|
||||
@NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal capacityCbm,
|
||||
@NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal maxWeightKg) {}
|
||||
|
||||
@Schema(name = "ContainerOceanBookingLinkRequest", description = "将现有模拟柜原地关联到已发布订舱箱型需求并补录真实柜号")
|
||||
@Schema(name = "ContainerOceanBookingLinkRequest", description = "将现有模拟柜原地关联到已发布订舱箱型需求;真实柜号可在提柜后补录")
|
||||
public record OceanBookingLinkRequest(@NotNull @Positive Long oceanBookingId,
|
||||
@NotNull @Positive Long requirementId, @NotBlank @Size(max = 64) String containerNo,
|
||||
@NotNull @Positive Long requirementId, @Size(max = 64) String containerNo,
|
||||
@NotNull @PositiveOrZero Integer expectedVersion) {}
|
||||
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ package com.baiyue.firstleg.dto;
|
||||
import jakarta.validation.constraints.*; import java.math.BigDecimal; import java.time.LocalDateTime;
|
||||
public final class TruckAppointmentDtos { private TruckAppointmentDtos(){}
|
||||
public record Query(@Size(max=64) String keyword, com.baiyue.firstleg.domain.TruckAppointmentStatus status, @Positive Long containerBookingId, @Min(1) long page, @Min(1) @Max(200) long size){ public Query{if(page==0)page=1;if(size==0)size=20;} }
|
||||
public record SaveRequest(@Positive Long outboundOrderId,@NotNull @Positive Long oceanBookingId,@NotNull @Positive Long containerBookingId,@Positive Long loadingPlanId,@Positive Long warehouseId,@NotNull @Positive Long carrierId,@Size(max=128) String carrierName,@Size(max=128) String billOfLadingNo,@Size(max=64) String fleetContact,@Size(max=32) String fleetPhone,@Size(max=64) String driverName,@Size(max=32) String driverPhone,@Size(max=32) String driverIdCard,@Size(max=32) String vehicleNo,@Size(max=128) String storeName,@Size(max=64) String storeContact,@Size(max=32) String storePhone,@Size(max=255) String storeAddress,@Size(max=64) String storeCity,@Size(max=16) String storeZipcode,@PositiveOrZero Integer packageCount,@DecimalMin("0") BigDecimal grossWeightKg,@DecimalMin("0") BigDecimal volumeCbm,@PositiveOrZero Integer palletCount,@Size(max=255) String poNo,@NotNull LocalDateTime plannedLoadingAt,@PositiveOrZero Integer transitMinutes,@DecimalMin("0") BigDecimal payableAmount,@DecimalMin("0") BigDecimal receivableAmount,@Size(max=8) String currency,@Size(max=1000) String remark,@NotNull @PositiveOrZero Integer expectedVersion){}
|
||||
public record SaveRequest(@Positive Long outboundOrderId,@NotNull @Positive Long oceanBookingId,@NotNull @Positive Long containerBookingId,@Positive Long loadingPlanId,@Positive Long warehouseId,@NotNull @Positive Long carrierId,@Size(max=128) String carrierName,@Size(max=128) String billOfLadingNo,@Size(max=64) String fleetContact,@Size(max=32) String fleetPhone,@Size(max=64) String driverName,@Size(max=32) String driverPhone,@Size(max=32) String driverIdCard,@Size(max=32) String vehicleNo,@Size(max=128) String storeName,@Size(max=64) String storeContact,@Size(max=32) String storePhone,@Size(max=255) String storeAddress,@Size(max=64) String storeCity,@Size(max=16) String storeZipcode,@PositiveOrZero Integer packageCount,@DecimalMin("0") BigDecimal grossWeightKg,@DecimalMin("0") BigDecimal volumeCbm,@PositiveOrZero Integer palletCount,@Size(max=255) String poNo,@NotNull LocalDateTime plannedLoadingAt,@PositiveOrZero Integer transitMinutes,@DecimalMin("0") BigDecimal payableAmount,@Pattern(regexp="RMB|USD") String currency,@Size(max=1000) String remark,@NotNull @PositiveOrZero Integer expectedVersion){}
|
||||
public record ActionRequest(@NotNull @PositiveOrZero Integer expectedVersion,@Size(max=500) String reason){}
|
||||
public record AttachmentRequest(@NotNull @Positive Long fileId,@NotBlank @Pattern(regexp="DISPATCH_ORDER|DRIVER_DOCUMENT|VEHICLE_DOCUMENT|OTHER") String attachmentType,@Size(max=255) String fileName,@Size(max=128) String fileHash){ }
|
||||
}
|
||||
|
||||
@@ -45,7 +45,6 @@ public class TruckAppointmentEntity {
|
||||
private LocalDateTime actualDepartedAt;
|
||||
private Integer transitMinutes;
|
||||
private BigDecimal payableAmount;
|
||||
private BigDecimal receivableAmount;
|
||||
private String currency;
|
||||
private TruckAppointmentStatus status;
|
||||
private Integer revision;
|
||||
|
||||
@@ -13,6 +13,20 @@ import org.apache.ibatis.annotations.Update;
|
||||
public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntity> {
|
||||
@Update("UPDATE fl_container_booking SET container_no=#{containerNo},ocean_booking_id=#{oceanBookingId},ocean_booking_requirement_id=#{requirementId},record_status='ACTIVE',bill_of_lading_no=#{billOfLadingNo},vessel_name=#{vesselName},voyage_no=#{voyageNo},shipping_company_id=#{shippingCompanyId},shipping_company_name=#{shippingCompanyName},departure_port_code=#{departurePortCode},departure_port_name=#{departurePortName},destination_port_code=#{destinationPortCode},destination_port_name=#{destinationPortName},channel_code=#{channelCode},channel_name=#{channelName},customer_id=#{customerId},customer_name=#{customerName},estimated_sailing_at=#{estimatedSailingAt},estimated_port_arrival_at=#{estimatedPortArrivalAt},updated_by=#{operatorId},updated_by_name=#{operatorName},updated_at=NOW(),version=version+1 WHERE id=#{id} AND version=#{version} AND source_type='SIMULATION' AND record_status='ACTIVE' AND ocean_booking_id IS NULL")
|
||||
int linkReleasedBooking(@Param("id") long id, @Param("oceanBookingId") long oceanBookingId, @Param("requirementId") long requirementId, @Param("containerNo") String containerNo, @Param("billOfLadingNo") String billOfLadingNo, @Param("vesselName") String vesselName, @Param("voyageNo") String voyageNo, @Param("shippingCompanyId") Long shippingCompanyId, @Param("shippingCompanyName") String shippingCompanyName, @Param("departurePortCode") String departurePortCode, @Param("departurePortName") String departurePortName, @Param("destinationPortCode") String destinationPortCode, @Param("destinationPortName") String destinationPortName, @Param("channelCode") String channelCode, @Param("channelName") String channelName, @Param("customerId") Long customerId, @Param("customerName") String customerName, @Param("estimatedSailingAt") java.time.LocalDateTime estimatedSailingAt, @Param("estimatedPortArrivalAt") java.time.LocalDateTime estimatedPortArrivalAt, @Param("version") int version, @Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
|
||||
@Update("""
|
||||
UPDATE fl_container_booking SET container_no=#{containerNo},updated_by=#{operatorId},
|
||||
updated_by_name=#{operatorName},updated_at=CURRENT_TIMESTAMP(3),version=version+1
|
||||
WHERE id=#{id} AND version=#{version} AND source_type='SIMULATION' AND record_status='ACTIVE'
|
||||
AND container_no IS NULL AND ocean_booking_id IS NOT NULL AND ocean_booking_requirement_id IS NOT NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM fl_loading_plan p WHERE p.container_booking_id=fl_container_booking.id
|
||||
AND p.plan_status NOT IN ('EDITING'))
|
||||
AND NOT EXISTS (SELECT 1 FROM fl_loading_pick_task t WHERE t.container_booking_id=fl_container_booking.id)
|
||||
AND NOT EXISTS (SELECT 1 FROM fl_loading_cabinet_evidence e WHERE e.container_booking_id=fl_container_booking.id)
|
||||
AND NOT EXISTS (SELECT 1 FROM fl_container_document d WHERE d.container_booking_id=fl_container_booking.id)
|
||||
""")
|
||||
int assignRealContainerNo(@Param("id") long id, @Param("containerNo") String containerNo,
|
||||
@Param("version") int version, @Param("operatorId") long operatorId,
|
||||
@Param("operatorName") String operatorName);
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT b.*, p.id AS plan_id, p.plan_no AS plan_no,
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
package com.baiyue.firstleg.mapper;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.firstleg.dto.TruckAppointmentDtos.Query; import com.baiyue.firstleg.entity.TruckAppointmentEntity; import org.apache.ibatis.annotations.*;
|
||||
@Mapper public interface TruckAppointmentMapper extends BaseMapper<TruckAppointmentEntity>{
|
||||
@Select("SELECT * FROM fl_truck_appointment WHERE container_booking_id=#{containerBookingId} AND status NOT IN ('COMPLETED','CANCELLED','EXCEPTION') ORDER BY created_at,id LIMIT 1 FOR UPDATE") TruckAppointmentEntity firstActiveForUpdate(@Param("containerBookingId") long containerBookingId);
|
||||
@Select("SELECT * FROM fl_truck_appointment WHERE container_booking_id=#{containerBookingId} AND status NOT IN ('COMPLETED','CANCELLED','EXCEPTION') ORDER BY id FOR UPDATE") java.util.List<TruckAppointmentEntity> activeForUpdate(@Param("containerBookingId") long containerBookingId);
|
||||
@Select("<script>SELECT a.*,o.booking_no ocean_booking_no,COALESCE(o.warehouse_name,c.warehouse_name) warehouse_name,o.shipping_company_id,o.shipping_company_name,o.booking_agent_id,o.booking_agent_name,c.cabinet_no,c.container_no,(SELECT COUNT(*) FROM fl_truck_appointment_attachment x WHERE x.appointment_id=a.id) attachment_count FROM fl_truck_appointment a LEFT JOIN fl_ocean_booking o ON o.id=a.ocean_booking_id LEFT JOIN fl_container_booking c ON c.id=a.container_booking_id AND c.record_status='ACTIVE' WHERE (#{q.keyword} IS NULL OR a.appointment_no LIKE CONCAT('%',#{q.keyword},'%') OR a.vehicle_no LIKE CONCAT('%',#{q.keyword},'%') OR a.carrier_name LIKE CONCAT('%',#{q.keyword},'%')) AND (#{q.status} IS NULL OR a.status=#{q.status}) AND (#{q.containerBookingId} IS NULL OR a.container_booking_id=#{q.containerBookingId}) ORDER BY a.updated_at DESC,a.id DESC</script>") Page<TruckAppointmentEntity> page(Page<TruckAppointmentEntity> p,@Param("q") Query q);
|
||||
@Update("UPDATE fl_truck_appointment SET status=#{toStatus}, actual_arrived_warehouse_at=CASE WHEN #{toStatus}='ARRIVED_WAREHOUSE' THEN CURRENT_TIMESTAMP(3) ELSE actual_arrived_warehouse_at END, actual_loading_at=CASE WHEN #{toStatus}='LOADING' THEN CURRENT_TIMESTAMP(3) ELSE actual_loading_at END, actual_departed_at=CASE WHEN #{toStatus}='DEPARTED' THEN CURRENT_TIMESTAMP(3) ELSE actual_departed_at END, revision=revision+1, version=version+1, updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND status=#{fromStatus} AND version=#{version}") int transition(@Param("id") long id,@Param("fromStatus") String from,@Param("toStatus") String to,@Param("version") int version);
|
||||
@Update("UPDATE fl_truck_appointment SET status='DISPATCHED',bill_of_lading_no=#{billOfLadingNo},revision=revision+1,version=version+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND status=#{fromStatus} AND version=#{version}") int dispatch(@Param("id") long id,@Param("fromStatus") String fromStatus,@Param("billOfLadingNo") String billOfLadingNo,@Param("version") int version);
|
||||
@Update("UPDATE fl_truck_appointment SET status='COMPLETED',actual_loading_at=COALESCE(actual_loading_at,CURRENT_TIMESTAMP(3)),revision=revision+1,version=version+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND status=#{fromStatus} AND version=#{version}") int completeLoadingFromPda(@Param("id") long id,@Param("fromStatus") String fromStatus,@Param("version") int version);
|
||||
@Update("UPDATE fl_truck_appointment SET planned_loading_at=#{at}, revision=revision+1, version=version+1, status='RESCHEDULED', remark=#{reason}, updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND version=#{version}") int reschedule(@Param("id") long id,@Param("at") java.time.LocalDateTime at,@Param("reason") String reason,@Param("version") int version);
|
||||
@Update("UPDATE fl_truck_appointment SET outbound_order_id=#{e.outboundOrderId},ocean_booking_id=#{e.oceanBookingId},container_booking_id=#{e.containerBookingId},loading_plan_id=#{e.loadingPlanId},warehouse_id=#{e.warehouseId},carrier_id=#{e.carrierId},carrier_name=#{e.carrierName},bill_of_lading_no=#{e.billOfLadingNo},fleet_contact=#{e.fleetContact},fleet_phone=#{e.fleetPhone},driver_name=#{e.driverName},driver_phone=#{e.driverPhone},driver_id_card=#{e.driverIdCard},vehicle_no=#{e.vehicleNo},store_name=#{e.storeName},store_contact=#{e.storeContact},store_phone=#{e.storePhone},store_address=#{e.storeAddress},store_city=#{e.storeCity},store_zipcode=#{e.storeZipcode},package_count=#{e.packageCount},gross_weight_kg=#{e.grossWeightKg},volume_cbm=#{e.volumeCbm},pallet_count=#{e.palletCount},po_no=#{e.poNo},planned_loading_at=#{e.plannedLoadingAt},transit_minutes=#{e.transitMinutes},payable_amount=#{e.payableAmount},receivable_amount=#{e.receivableAmount},currency=#{e.currency},remark=#{e.remark},version=version+1,revision=revision+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{e.id} AND version=#{version}") int updateEdit(@Param("e") TruckAppointmentEntity e,@Param("version") int version);
|
||||
@Update("UPDATE fl_truck_appointment SET outbound_order_id=#{e.outboundOrderId},ocean_booking_id=#{e.oceanBookingId},container_booking_id=#{e.containerBookingId},loading_plan_id=#{e.loadingPlanId},warehouse_id=#{e.warehouseId},carrier_id=#{e.carrierId},carrier_name=#{e.carrierName},bill_of_lading_no=#{e.billOfLadingNo},fleet_contact=#{e.fleetContact},fleet_phone=#{e.fleetPhone},driver_name=#{e.driverName},driver_phone=#{e.driverPhone},driver_id_card=#{e.driverIdCard},vehicle_no=#{e.vehicleNo},store_name=#{e.storeName},store_contact=#{e.storeContact},store_phone=#{e.storePhone},store_address=#{e.storeAddress},store_city=#{e.storeCity},store_zipcode=#{e.storeZipcode},package_count=#{e.packageCount},gross_weight_kg=#{e.grossWeightKg},volume_cbm=#{e.volumeCbm},pallet_count=#{e.palletCount},po_no=#{e.poNo},planned_loading_at=#{e.plannedLoadingAt},transit_minutes=#{e.transitMinutes},payable_amount=#{e.payableAmount},currency=#{e.currency},remark=#{e.remark},version=version+1,revision=revision+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{e.id} AND version=#{version}") int updateEdit(@Param("e") TruckAppointmentEntity e,@Param("version") int version);
|
||||
}
|
||||
|
||||
@@ -38,6 +38,10 @@ public class ContainerRepository {
|
||||
int version, long operatorId, String operatorName) {
|
||||
return mapper.linkReleasedBooking(id, oceanBookingId, requirementId, containerNo, booking.getBillOfLadingNo(), booking.getVesselName(), booking.getVoyageNo(), booking.getShippingCompanyId(), booking.getShippingCompanyName(), booking.getDeparturePortCode(), booking.getDeparturePortName(), booking.getDestinationPortCode(), booking.getDestinationPortName(), booking.getChannelCode(), booking.getChannelName(), booking.getCustomerId(), booking.getCustomerName(), booking.getEstimatedSailingAt(), booking.getEstimatedArrivalAt(), version, operatorId, operatorName);
|
||||
}
|
||||
public int assignRealContainerNo(long id, String containerNo, int version,
|
||||
long operatorId, String operatorName) {
|
||||
return mapper.assignRealContainerNo(id, containerNo, version, operatorId, operatorName);
|
||||
}
|
||||
public int updateDraft(ContainerBookingEntity value, int version, long operatorId, String operatorName) {
|
||||
return mapper.updateDraft(value, version, operatorId, operatorName);
|
||||
}
|
||||
|
||||
+1
-1
@@ -4,5 +4,5 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba
|
||||
public PageResult<TruckAppointmentEntity> page(Query q){return PageResult.from(mapper.page(new Page<>(q.page(),q.size()),q));} public TruckAppointmentEntity find(long id){return mapper.selectById(id);} public void insert(TruckAppointmentEntity e){mapper.insert(e);} public int transition(long id,String from,String to,int v){return mapper.transition(id,from,to,v);} public int dispatch(long id,String from,String billOfLadingNo,int v){return mapper.dispatch(id,from,billOfLadingNo,v);} public int reschedule(long id,java.time.LocalDateTime at,String reason,int v){return mapper.reschedule(id,at,reason,v);} public void history(TruckAppointmentHistoryEntity e){history.insert(e);} public void operation(TruckAppointmentOperationEntity e){operations.insert(e);} public void attachment(TruckAppointmentAttachmentEntity e){attachments.insert(e);}
|
||||
public boolean claimOperation(long operationId,long appointmentId,String action,String key,String hash){return operations.claim(operationId,appointmentId,action,key,hash)==1;} public TruckAppointmentOperationEntity operation(String key){return operations.find(key);} public int completeOperation(long appointmentId,String action,String key,String response){return operations.complete(appointmentId,action,key,response);}
|
||||
public int updateEdit(TruckAppointmentEntity e,int v){return mapper.updateEdit(e,v);} public java.util.List<TruckAppointmentHistoryEntity> histories(long id){return history.selectList(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<TruckAppointmentHistoryEntity>().eq(TruckAppointmentHistoryEntity::getAppointmentId,id).orderByAsc(TruckAppointmentHistoryEntity::getCreatedAt));} public java.util.List<TruckAppointmentAttachmentEntity> attachments(long id){return attachments.selectList(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<TruckAppointmentAttachmentEntity>().eq(TruckAppointmentAttachmentEntity::getAppointmentId,id).orderByAsc(TruckAppointmentAttachmentEntity::getUploadedAt));} public int deleteAttachment(long appointmentId,long attachmentId){return attachments.delete(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<TruckAppointmentAttachmentEntity>().eq(TruckAppointmentAttachmentEntity::getAppointmentId,appointmentId).eq(TruckAppointmentAttachmentEntity::getId,attachmentId));}
|
||||
public java.util.List<TruckAppointmentEntity> activeForUpdate(long containerBookingId){return mapper.activeForUpdate(containerBookingId);} public int completeLoadingFromPda(long id,String fromStatus,int version){return mapper.completeLoadingFromPda(id,fromStatus,version);}
|
||||
public TruckAppointmentEntity firstActiveForUpdate(long containerBookingId){return mapper.firstActiveForUpdate(containerBookingId);} public java.util.List<TruckAppointmentEntity> activeForUpdate(long containerBookingId){return mapper.activeForUpdate(containerBookingId);} public int completeLoadingFromPda(long id,String fromStatus,int version){return mapper.completeLoadingFromPda(id,fromStatus,version);}
|
||||
}
|
||||
|
||||
@@ -76,8 +76,9 @@ public class ContainerService {
|
||||
if (requirement == null) throw new BizException(ResultCode.NOT_FOUND, "订舱箱型需求不存在");
|
||||
long allocated = oceanBookings.activeLinkedCount(request.oceanBookingId(), request.requirementId());
|
||||
if (allocated >= requirement.getQuantity()) throw conflict("该订舱箱型需求已达到数量上限");
|
||||
String containerNo = Iso6346ContainerNumber.require(request.containerNo());
|
||||
if (repository.containerNoExists(containerNo, id)) throw conflict("集装箱号已存在");
|
||||
String requestedContainerNo = trim(request.containerNo());
|
||||
String containerNo = requestedContainerNo == null ? null : Iso6346ContainerNumber.require(requestedContainerNo);
|
||||
if (containerNo != null && repository.containerNoExists(containerNo, id)) throw conflict("集装箱号已存在");
|
||||
if (!java.util.Objects.equals(container.getWarehouseId(), booking.getWarehouseId())
|
||||
|| !java.util.Objects.equals(container.getChannelCode(), booking.getChannelCode())
|
||||
|| !java.util.Objects.equals(container.getContainerType(), requirement.getContainerType())) {
|
||||
@@ -95,6 +96,32 @@ public class ContainerService {
|
||||
return required(id);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ContainerBookingEntity assignRealContainerNo(long id, String requestedContainerNo, int expectedVersion,
|
||||
long operatorId, String operatorName) {
|
||||
ContainerBookingEntity container = required(id);
|
||||
requireVersion(container, expectedVersion);
|
||||
if (container.getOceanBookingId() == null || container.getOceanBookingRequirementId() == null) {
|
||||
throw conflict("模拟柜尚未关联已发布订舱需求");
|
||||
}
|
||||
if (trim(container.getContainerNo()) != null) throw conflict("真实集装箱号已补录");
|
||||
SmartLoadingStatus value = status(container);
|
||||
if ((value != SmartLoadingStatus.WAIT_LOADING && value != SmartLoadingStatus.EDITING)
|
||||
|| repository.hasPickTask(id) || repository.hasEvidence(id) || repository.hasDocuments(id)) {
|
||||
throw conflict("已进入PDA、证据、单证或不可逆装柜阶段,不能补录真实柜号");
|
||||
}
|
||||
String containerNo = Iso6346ContainerNumber.require(requestedContainerNo);
|
||||
if (repository.containerNoExists(containerNo, id)) throw conflict("集装箱号已存在");
|
||||
try {
|
||||
if (repository.assignRealContainerNo(id, containerNo, expectedVersion, operatorId, operatorName) != 1) {
|
||||
throw conflict("真实柜号已被补录或货柜版本发生变化,请刷新后重试");
|
||||
}
|
||||
} catch (DuplicateKeyException exception) {
|
||||
throw conflict("集装箱号已存在");
|
||||
}
|
||||
return required(id);
|
||||
}
|
||||
|
||||
private void requireFirstLinkMutable(ContainerBookingEntity container) {
|
||||
SmartLoadingStatus value = status(container);
|
||||
if ((value != SmartLoadingStatus.WAIT_LOADING && value != SmartLoadingStatus.EDITING)
|
||||
|
||||
@@ -123,11 +123,13 @@ public class InboundMeasurementService {
|
||||
long tenantId, long inboundId, long operatorId,
|
||||
InboundMeasurementRequests.SubmitRequest request) {
|
||||
InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
|
||||
if (!Set.of("WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED")
|
||||
.contains(inbound.getProcessStatus())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "测量已提交或审核完成,不能重复提交或修改冻结结果");
|
||||
}
|
||||
InboundMeasurementEntity measurement = requireMeasurement(tenantId, inboundId, request.measurementId());
|
||||
requireVersion(request.version(), measurement.getVersion());
|
||||
boolean alreadyAdvanced = !Set.of("WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED")
|
||||
.contains(inbound.getProcessStatus());
|
||||
if (!alreadyAdvanced && !Set.of(MeasurementBatchService.STATUS_DRAFT, MeasurementBatchService.STATUS_RECEIVED)
|
||||
if (!Set.of(MeasurementBatchService.STATUS_DRAFT, MeasurementBatchService.STATUS_RECEIVED)
|
||||
.contains(measurement.getStatus())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "当前测量批次不能重复提交审核");
|
||||
}
|
||||
@@ -135,19 +137,15 @@ public class InboundMeasurementService {
|
||||
List<InboundWeightResultEntity> weights = repository.listWeights(tenantId, inboundId);
|
||||
ConfirmedWeightTotals confirmedTotals = confirmedWeightTotals(weights);
|
||||
|
||||
if (!alreadyAdvanced) {
|
||||
requireConfirmedPackageTotal(inbound, items);
|
||||
measurement.setStatus(STATUS_SUBMITTED);
|
||||
if (!repository.updateMeasurement(measurement)) {
|
||||
throw concurrentModification();
|
||||
}
|
||||
requireConfirmedPackageTotal(inbound, items);
|
||||
measurement.setStatus(STATUS_SUBMITTED);
|
||||
if (!repository.updateMeasurement(measurement)) {
|
||||
throw concurrentModification();
|
||||
}
|
||||
|
||||
if (!alreadyAdvanced) {
|
||||
inbound.setProcessStatus("MEASURE_PENDING");
|
||||
inbound.setMeasureReviewStatus("PENDING");
|
||||
inbound.setMeasureCompletedAt(LocalDateTime.now());
|
||||
}
|
||||
inbound.setProcessStatus("MEASURE_PENDING");
|
||||
inbound.setMeasureReviewStatus("PENDING");
|
||||
inbound.setMeasureCompletedAt(LocalDateTime.now());
|
||||
if (measurement.getTotalPackageCount() != null) {
|
||||
inbound.setTotalPackageCount(measurement.getTotalPackageCount());
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.support.TransactionSynchronization;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.slf4j.Logger;
|
||||
@@ -86,6 +87,8 @@ public class LoadingPickService {
|
||||
@Resource
|
||||
private LoadingCompletionCommandRepository completionCommands;
|
||||
@Resource
|
||||
private TransactionTemplate completionTransaction;
|
||||
@Resource
|
||||
private SmartLoadingRepository smartLoadingRepository;
|
||||
@Resource
|
||||
private OceanBookingRepository oceanBookingRepository;
|
||||
@@ -565,6 +568,14 @@ public class LoadingPickService {
|
||||
"INBOUND_COMPLETED");
|
||||
}
|
||||
}
|
||||
completionTransaction.executeWithoutResult(ignored -> finalizeCompletion(command, worker, task, taskGroups));
|
||||
completeTruckAfterCommit(task.getContainerBookingId(), taskId, command.getOperatorId(), command.getOperatorName());
|
||||
}
|
||||
|
||||
/** Final Firstleg convergence only; all remote owner settlement calls have completed before this short transaction. */
|
||||
private void finalizeCompletion(LoadingCompletionCommandEntity command, String worker,
|
||||
LoadingPickTaskEntity task, List<GroupSnapshot> taskGroups) {
|
||||
long taskId = task.getId();
|
||||
if (!repository.allPlanItemsPicked(task.getLoadingPlanId())) {
|
||||
throw new CompletionProcessingException("PLAN_GATE", conflict("柜次仍有未完成PDA拣货的箱数"));
|
||||
}
|
||||
@@ -594,7 +605,6 @@ public class LoadingPickService {
|
||||
if (!completionCommands.complete(command.getId(), worker)) {
|
||||
throw new CompletionProcessingException("COMMAND_FINALIZE", conflict("装柜完成命令租约已变化"));
|
||||
}
|
||||
completeTruckAfterCommit(task.getContainerBookingId(), taskId, command.getOperatorId(), command.getOperatorName());
|
||||
}
|
||||
|
||||
private void requireColoadConservation(LoadingPickTaskEntity task,
|
||||
|
||||
@@ -74,8 +74,14 @@ public class MeasurementReviewService {
|
||||
}
|
||||
if (approved) {
|
||||
var items = repository.listMeasurementItems(tenantId, measurement.getId());
|
||||
if (items.isEmpty() || items.stream().anyMatch(item -> item.getPackageCount() == null
|
||||
|| item.getPackageCount() <= 0 || item.getTotalVolumeCbm() == null
|
||||
|| item.getTotalVolumeCbm().signum() <= 0 || item.getVolumeWeightKg() == null
|
||||
|| item.getVolumeWeightKg().signum() <= 0)) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED,
|
||||
"测量明细箱数、体积和体积重必须完整且大于零,才能审核通过");
|
||||
}
|
||||
long confirmed = items.stream().map(com.baiyue.firstleg.entity.InboundMeasurementItemEntity::getPackageCount)
|
||||
.filter(java.util.Objects::nonNull)
|
||||
.mapToLong(Integer::longValue)
|
||||
.sum();
|
||||
if (order.getTotalPackageCount() == null || confirmed != order.getTotalPackageCount()) {
|
||||
@@ -99,13 +105,23 @@ public class MeasurementReviewService {
|
||||
.map(com.baiyue.firstleg.entity.InboundWeightResultEntity::getNetWeightKg)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
BigDecimal volume = items.stream().map(com.baiyue.firstleg.entity.InboundMeasurementItemEntity::getTotalVolumeCbm)
|
||||
.filter(java.util.Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
BigDecimal volumeWeight = items.stream()
|
||||
.map(com.baiyue.firstleg.entity.InboundMeasurementItemEntity::getVolumeWeightKg)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
if (order.getActualGrossWeightKg() == null || order.getActualNetWeightKg() == null
|
||||
|| order.getActualGrossWeightKg().compareTo(grossWeight) != 0
|
||||
|| order.getActualNetWeightKg().compareTo(netWeight) != 0
|
||||
|| volume.signum() <= 0) {
|
||||
|| order.getActualVolumeCbm() == null || order.getActualVolumeCbm().compareTo(volume) != 0
|
||||
|| order.getVolumeWeightKg() == null || order.getVolumeWeightKg().compareTo(volumeWeight) != 0
|
||||
|| measurement.getTotalPackageCount() == null
|
||||
|| measurement.getTotalPackageCount() != confirmed
|
||||
|| measurement.getTotalVolumeCbm() == null
|
||||
|| measurement.getTotalVolumeCbm().compareTo(volume) != 0
|
||||
|| measurement.getVolumeWeightKg() == null
|
||||
|| measurement.getVolumeWeightKg().compareTo(volumeWeight) != 0) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED,
|
||||
"审核通过前称重毛重、货物净重汇总和体积必须完整且一致");
|
||||
"审核通过前箱数、称重、体积和体积重必须在明细、测量批次与入库汇总间完整且一致");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ import org.springframework.web.multipart.MultipartFile;
|
||||
@Service
|
||||
public class TruckAppointmentService {
|
||||
private static final Set<String> ATTACHMENT_TYPES = Set.of("DISPATCH_ORDER", "DRIVER_DOCUMENT", "VEHICLE_DOCUMENT", "OTHER");
|
||||
private static final Set<String> CURRENCIES = Set.of("RMB", "USD");
|
||||
@Resource TruckAppointmentRepository repo;
|
||||
@Resource OceanBookingRepository oceanBookings;
|
||||
@Resource OceanBookingDocumentService oceanBookingDocuments;
|
||||
@@ -51,7 +52,7 @@ public class TruckAppointmentService {
|
||||
@Transactional public TruckAppointmentAttachmentEntity upload(long id,String type,MultipartFile file,long uid){String attachmentType=attachmentType(type);required(id);try{var response=files.upload(internalToken,uid,0L,"TRUCK_APPOINTMENT_ATTACHMENT",Long.toString(id),file);if(response==null||!response.successful()||response.data()==null)throw bad("附件上传失败");var m=response.data();TruckAppointmentAttachmentEntity a=new TruckAppointmentAttachmentEntity();a.setId(IdWorker.getId());a.setAppointmentId(id);a.setFileId(m.fileId());a.setAttachmentType(attachmentType);a.setFileName(m.fileName());a.setFileHash(m.sha256());a.setVisibility("PLATFORM");a.setUploadedBy(uid);a.setUploadedAt(LocalDateTime.now());repo.attachment(a);return a;}catch(FeignException ex){throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"文件服务暂不可用");}}
|
||||
@Transactional public TruckAppointmentAttachmentEntity attach(long id,AttachmentRequest r,long uid){String attachmentType=attachmentType(r.attachmentType());required(id);TruckAppointmentFileClient.Metadata metadata;try{var response=files.metadata(internalToken,0L,"TRUCK_APPOINTMENT_ATTACHMENT",Long.toString(id),r.fileId());if(response==null||!response.successful()||response.data()==null)throw bad("附件不存在或归属不匹配");metadata=response.data();}catch(FeignException ex){throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"文件服务暂不可用");} TruckAppointmentAttachmentEntity a=new TruckAppointmentAttachmentEntity();a.setId(IdWorker.getId());a.setAppointmentId(id);a.setFileId(metadata.fileId());a.setAttachmentType(attachmentType);a.setFileName(metadata.fileName());a.setFileHash(metadata.sha256());a.setVisibility("PLATFORM");a.setUploadedBy(uid);a.setUploadedAt(LocalDateTime.now());repo.attachment(a);return a;}
|
||||
@Transactional public void deleteAttachment(long id,long attachmentId){required(id);if(repo.deleteAttachment(id,attachmentId)!=1)throw new BizException(ResultCode.NOT_FOUND,"附件引用不存在");}
|
||||
@Transactional public TruckAppointmentEntity create(long uid,String name,SaveRequest r){if(r.expectedVersion()==null||r.expectedVersion()!=0)throw bad("新增 expectedVersion 必须为0");validateRequired(r);AssociationContext links=validateAssociations(r);if(!repo.activeForUpdate(r.containerBookingId()).isEmpty())throw conflict("当前真实柜已有有效拖车预约");var carrier=suppliers.trucking(r.carrierId(),r.carrierName());TruckAppointmentEntity e=new TruckAppointmentEntity();e.setId(IdWorker.getId());e.setAppointmentNo(documentNumbers.next("TA"));e.setStatus(TruckAppointmentStatus.DRAFT);e.setRevision(0);e.setVersion(0);e.setCreatedAt(LocalDateTime.now());e.setUpdatedAt(e.getCreatedAt());copy(e,r,links,carrier.name());try{repo.insert(e);}catch(DuplicateKeyException exception){if(exception.getMessage()!=null&&exception.getMessage().contains("uk_truck_active_container"))throw conflict("当前真实柜已有有效拖车预约");throw exception;}record(e,null,e.getStatus().name(),"CREATE",null,uid,name);return enrich(e);}
|
||||
@Transactional public TruckAppointmentEntity create(long uid,String name,SaveRequest r){if(r.expectedVersion()==null||r.expectedVersion()!=0)throw bad("新增 expectedVersion 必须为0");validateRequired(r);TruckAppointmentEntity active=repo.firstActiveForUpdate(r.containerBookingId());if(active!=null)throw existingAppointmentConflict(active);AssociationContext links=validateAssociations(r);var carrier=suppliers.trucking(r.carrierId(),r.carrierName());TruckAppointmentEntity e=new TruckAppointmentEntity();e.setId(IdWorker.getId());e.setAppointmentNo(documentNumbers.next("TA"));e.setStatus(TruckAppointmentStatus.DRAFT);e.setRevision(0);e.setVersion(0);e.setCreatedAt(LocalDateTime.now());e.setUpdatedAt(e.getCreatedAt());copy(e,r,links,carrier.name());try{repo.insert(e);}catch(DuplicateKeyException exception){if(exception.getMessage()!=null&&exception.getMessage().contains("uk_truck_active_container"))throw existingAppointmentConflict(repo.firstActiveForUpdate(r.containerBookingId()));throw exception;}record(e,null,e.getStatus().name(),"CREATE",null,uid,name);return enrich(e);}
|
||||
@Transactional public TruckAppointmentEntity edit(long id,long uid,String name,SaveRequest r){validateRequired(r);TruckAppointmentEntity e=required(id);checkVersion(e,r.expectedVersion());if(!(e.getStatus()==TruckAppointmentStatus.DRAFT||e.getStatus()==TruckAppointmentStatus.PENDING_DISPATCH||e.getStatus()==TruckAppointmentStatus.RESCHEDULED))throw conflict("当前状态不可编辑");AssociationContext links=validateAssociations(r);var carrier=suppliers.trucking(r.carrierId(),r.carrierName());copy(e,r,links,carrier.name());if(repo.updateEdit(e,r.expectedVersion())!=1)throw conflict("版本已变化,请刷新后重试");record(e,e.getStatus().name(),e.getStatus().name(),"EDIT",null,uid,name);return required(id);}
|
||||
@Transactional public TruckAppointmentEntity action(long id,String action,ActionRequest r,String idem,String clientHash,long uid,String name){String requestHash=hash(action,id,r);verifyClientHash(clientHash,requestHash);TruckAppointmentEntity replay=claimOrReplay(id,action,idem,requestHash);if(replay!=null)return replay;TruckAppointmentEntity e=required(id); checkVersion(e,r.expectedVersion()); if("RESCHEDULE".equals(action))throw conflict("改约请使用专用接口"); TruckAppointmentStatus from=e.getStatus(),to=next(from,action); String dispatchBill="DISPATCH".equals(action)?requireDispatchReady(e):null; int changed="DISPATCH".equals(action)?repo.dispatch(id,from.name(),dispatchBill,e.getVersion()):repo.transition(id,from.name(),to.name(),e.getVersion()); if(changed!=1)throw conflict("状态或版本已变化,请刷新后重试"); TruckAppointmentEntity after=required(id);record(after,from.name(),to.name(),action,r.reason(),uid,name);return completeOperation(id,action,idem,after);}
|
||||
@Transactional public TruckAppointmentEntity reschedule(long id,LocalDateTime at,ActionRequest r,String idem,String clientHash,long uid,String name){String action="RESCHEDULE";String requestHash=hash(action,id,new ReschedulePayload(at,r));verifyClientHash(clientHash,requestHash);TruckAppointmentEntity replay=claimOrReplay(id,action,idem,requestHash);if(replay!=null)return replay;TruckAppointmentEntity e=required(id);checkVersion(e,r.expectedVersion()); if(e.getStatus()==TruckAppointmentStatus.COMPLETED||e.getStatus()==TruckAppointmentStatus.CANCELLED)throw conflict("当前状态不可改约"); String from=e.getStatus().name(); if(repo.reschedule(id,at,r.reason(),e.getVersion())!=1)throw conflict("状态或版本已变化,请刷新后重试");TruckAppointmentEntity after=required(id);record(after,from,"RESCHEDULED",action,r.reason(),uid,name);return completeOperation(id,action,idem,after);}
|
||||
@@ -79,7 +80,7 @@ public class TruckAppointmentService {
|
||||
private TruckAppointmentStatus next(TruckAppointmentStatus s,String a){return switch(a){case"SUBMIT"->s==TruckAppointmentStatus.DRAFT?TruckAppointmentStatus.PENDING_DISPATCH:badStatus();case"DISPATCH"->s==TruckAppointmentStatus.PENDING_DISPATCH||s==TruckAppointmentStatus.RESCHEDULED?TruckAppointmentStatus.DISPATCHED:badStatus();case"PICKUP"->s==TruckAppointmentStatus.DISPATCHED?TruckAppointmentStatus.PICKUP_IN_PROGRESS:badStatus();case"ARRIVE"->s==TruckAppointmentStatus.PICKUP_IN_PROGRESS?TruckAppointmentStatus.ARRIVED_WAREHOUSE:badStatus();case"LOAD"->s==TruckAppointmentStatus.ARRIVED_WAREHOUSE?TruckAppointmentStatus.LOADING:badStatus();case"DEPART"->s==TruckAppointmentStatus.LOADING?TruckAppointmentStatus.DEPARTED:badStatus();case"COMPLETE"->s==TruckAppointmentStatus.DEPARTED?TruckAppointmentStatus.COMPLETED:badStatus();case"EXCEPTION"->s!=TruckAppointmentStatus.COMPLETED&&s!=TruckAppointmentStatus.CANCELLED?TruckAppointmentStatus.EXCEPTION:badStatus();case"CANCEL"->s!=TruckAppointmentStatus.COMPLETED?TruckAppointmentStatus.CANCELLED:badStatus();default->badStatus();};}
|
||||
private TruckAppointmentStatus badStatus(){throw conflict("不允许的状态动作");} private TruckAppointmentEntity required(long id){TruckAppointmentEntity e=repo.find(id);if(e==null)throw new BizException(ResultCode.NOT_FOUND,"拖车预约不存在");return e;} private void checkVersion(TruckAppointmentEntity e,int v){if(e.getVersion()!=v)throw conflict("版本已变化,请刷新后重试");} private static BizException conflict(String m){return new BizException(ResultCode.CONFLICT,m);} private static BizException bad(String m){return new BizException(ResultCode.BAD_REQUEST,m);}
|
||||
private TruckAppointmentEntity enrich(TruckAppointmentEntity value){if(value.getOceanBookingId()!=null){OceanBookingEntity booking=oceanBookings.find(value.getOceanBookingId());if(booking!=null){value.setOceanBookingNo(booking.getBookingNo());value.setWarehouseName(booking.getWarehouseName());value.setShippingCompanyId(booking.getShippingCompanyId());value.setShippingCompanyName(booking.getShippingCompanyName());value.setBookingAgentId(booking.getBookingAgentId());value.setBookingAgentName(booking.getBookingAgentName());}}if(value.getContainerBookingId()!=null){ContainerBookingEntity container=containers.find(value.getContainerBookingId());if(container!=null){value.setCabinetNo(container.getCabinetNo());value.setContainerNo(container.getContainerNo());if(!StringUtils.hasText(value.getWarehouseName()))value.setWarehouseName(container.getWarehouseName());}}value.setAttachmentCount((long)repo.attachments(value.getId()).size());return value;}
|
||||
private void copy(TruckAppointmentEntity e,SaveRequest r,AssociationContext links,String carrierName){e.setOutboundOrderId(r.outboundOrderId());e.setOceanBookingId(r.oceanBookingId());e.setContainerBookingId(r.containerBookingId());e.setLoadingPlanId(r.loadingPlanId());e.setWarehouseId(links.warehouseId());e.setCarrierId(r.carrierId());e.setCarrierName(carrierName);e.setBillOfLadingNo(links.billOfLadingNo());e.setFleetContact(r.fleetContact());e.setFleetPhone(r.fleetPhone());e.setDriverName(r.driverName());e.setDriverPhone(r.driverPhone());e.setDriverIdCard(r.driverIdCard());e.setVehicleNo(r.vehicleNo());e.setStoreName(r.storeName());e.setStoreContact(r.storeContact());e.setStorePhone(r.storePhone());e.setStoreAddress(r.storeAddress());e.setStoreCity(r.storeCity());e.setStoreZipcode(r.storeZipcode());e.setPackageCount(r.packageCount());e.setGrossWeightKg(r.grossWeightKg());e.setVolumeCbm(r.volumeCbm());e.setPalletCount(r.palletCount());e.setPoNo(r.poNo());e.setPlannedLoadingAt(r.plannedLoadingAt());e.setTransitMinutes(r.transitMinutes());e.setPayableAmount(r.payableAmount());e.setReceivableAmount(r.receivableAmount());e.setCurrency(r.currency()==null?"RMB":r.currency());e.setRemark(r.remark());}
|
||||
private void copy(TruckAppointmentEntity e,SaveRequest r,AssociationContext links,String carrierName){e.setOutboundOrderId(r.outboundOrderId());e.setOceanBookingId(r.oceanBookingId());e.setContainerBookingId(r.containerBookingId());e.setLoadingPlanId(r.loadingPlanId());e.setWarehouseId(links.warehouseId());e.setCarrierId(r.carrierId());e.setCarrierName(carrierName);e.setBillOfLadingNo(links.billOfLadingNo());e.setFleetContact(r.fleetContact());e.setFleetPhone(r.fleetPhone());e.setDriverName(r.driverName());e.setDriverPhone(r.driverPhone());e.setDriverIdCard(r.driverIdCard());e.setVehicleNo(r.vehicleNo());e.setStoreName(r.storeName());e.setStoreContact(r.storeContact());e.setStorePhone(r.storePhone());e.setStoreAddress(r.storeAddress());e.setStoreCity(r.storeCity());e.setStoreZipcode(r.storeZipcode());e.setPackageCount(r.packageCount());e.setGrossWeightKg(r.grossWeightKg());e.setVolumeCbm(r.volumeCbm());e.setPalletCount(r.palletCount());e.setPoNo(r.poNo());e.setPlannedLoadingAt(r.plannedLoadingAt());e.setTransitMinutes(r.transitMinutes());e.setPayableAmount(r.payableAmount());e.setCurrency(currency(r.currency()));e.setRemark(r.remark());}
|
||||
private TruckAppointmentEntity claimOrReplay(long id,String action,String key,String hash){validateKey(key);String normalized=key.trim();if(repo.claimOperation(IdWorker.getId(),id,action,normalized,hash))return null;TruckAppointmentOperationEntity existing=repo.operation(normalized);if(existing==null||!Objects.equals(existing.getAppointmentId(),id)||!action.equals(existing.getAction())||!hash.equals(existing.getRequestHash()))throw conflict("Idempotency-Key已用于不同请求");if(!StringUtils.hasText(existing.getResponseSnapshot()))throw conflict("相同请求正在处理中,请稍后重试");try{return objectMapper.readValue(existing.getResponseSnapshot(),TruckAppointmentEntity.class);}catch(JsonProcessingException ex){throw new IllegalStateException("无法读取拖车预约幂等结果",ex);}}
|
||||
private TruckAppointmentEntity completeOperation(long id,String action,String key,TruckAppointmentEntity result){try{String normalized=key.trim();if(repo.completeOperation(id,action,normalized,objectMapper.writeValueAsString(result))!=1)throw conflict("拖车预约幂等请求完成状态冲突");return result;}catch(JsonProcessingException ex){throw new IllegalStateException("无法保存拖车预约幂等结果",ex);}}
|
||||
private String hash(String action,long id,Object request){try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest((action+'|'+id+'|'+objectMapper.writeValueAsString(request)).getBytes(StandardCharsets.UTF_8)));}catch(Exception ex){throw new IllegalStateException("无法计算拖车预约请求摘要",ex);}}
|
||||
@@ -102,6 +103,8 @@ public class TruckAppointmentService {
|
||||
}
|
||||
private static String releasedBookingBill(OceanBookingEntity booking,String requested){String authoritative=trim(booking.getBillOfLadingNo());if(!StringUtils.hasText(authoritative))throw conflict("关联已放舱订舱缺少提单号");if(StringUtils.hasText(requested)&&!authoritative.equals(requested))throw bad("billOfLadingNo与关联订舱不一致");return authoritative;}
|
||||
private static String attachmentType(String value){String normalized=trim(value);if(!ATTACHMENT_TYPES.contains(normalized))throw bad("不支持的拖车附件类型");return normalized;}
|
||||
private static String currency(String value){if(!StringUtils.hasText(value))return "RMB";String normalized=value.trim();if(!CURRENCIES.contains(normalized))throw bad("currency仅支持RMB或USD");return normalized;}
|
||||
private static BizException existingAppointmentConflict(TruckAppointmentEntity existing){String appointmentNo=existing==null?null:trim(existing.getAppointmentNo());return conflict(StringUtils.hasText(appointmentNo)?"该柜已存在拖车预约 "+appointmentNo+",请继续编辑":"该柜已存在拖车预约,请刷新后继续编辑");}
|
||||
private static String trim(String value){return StringUtils.hasText(value)?value.trim():null;}
|
||||
private record AssociationContext(OceanBookingEntity booking,ContainerBookingEntity container,Long warehouseId,String billOfLadingNo){}
|
||||
private void record(TruckAppointmentEntity e,String from,String to,String action,String reason,long uid,String name){TruckAppointmentHistoryEntity h=new TruckAppointmentHistoryEntity();h.setId(IdWorker.getId());h.setAppointmentId(e.getId());h.setRevision(e.getRevision());h.setAction(action);h.setFromStatus(from);h.setToStatus(to);h.setReason(reason);h.setOperatorId(uid);h.setOperatorName(name);h.setCreatedAt(LocalDateTime.now());repo.history(h);}
|
||||
|
||||
@@ -36,7 +36,7 @@ CREATE TABLE IF NOT EXISTS fl_document_sequence (
|
||||
CREATE TABLE IF NOT EXISTS fl_container_booking (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID(雪花ID)',
|
||||
booking_no VARCHAR(40) NOT NULL COMMENT '平台唯一货柜预订单号',
|
||||
container_no VARCHAR(64) NULL COMMENT '国内唯一权威真实集装箱号;ISO 6346标准格式;仅订舱关联首次写入、预装纠错命令可修改',
|
||||
container_no VARCHAR(64) NULL COMMENT '国内唯一权威真实集装箱号;ISO 6346标准格式;订舱关联可选写入、提柜后可原子补录、预装纠错命令可修改',
|
||||
cabinet_no VARCHAR(64) NOT NULL COMMENT '平台唯一内部柜号',
|
||||
source_type VARCHAR(24) NOT NULL DEFAULT 'SIMULATION' COMMENT '记录来源:SIMULATION内部模拟柜、OCEAN_BOOKING订舱真实柜',
|
||||
record_status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '记录状态:ACTIVE可操作、MERGED已合并历史',
|
||||
|
||||
@@ -33,8 +33,7 @@ CREATE TABLE IF NOT EXISTS fl_truck_appointment (
|
||||
actual_departed_at DATETIME(3) NULL COMMENT '车辆实际离仓时间;DEPARTED后供LOADED门禁使用',
|
||||
transit_minutes INT UNSIGNED NULL COMMENT '从到仓到离仓的运输作业耗时,单位分钟',
|
||||
payable_amount DECIMAL(18,2) NULL COMMENT '应付拖车服务商金额;币种由currency指定',
|
||||
receivable_amount DECIMAL(18,2) NULL COMMENT '拖车业务应收金额;币种由currency指定',
|
||||
currency VARCHAR(8) NOT NULL DEFAULT 'RMB' COMMENT '金额币种;首期默认RMB',
|
||||
currency VARCHAR(8) NOT NULL DEFAULT 'RMB' COMMENT '应付金额币种;仅RMB或USD',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'DRAFT' COMMENT '拖车预约状态:DRAFT草稿、PENDING_DISPATCH待派车、DISPATCHED已派车、PICKUP_IN_PROGRESS提货中、ARRIVED_WAREHOUSE已到仓、LOADING装柜中、DEPARTED已离仓、COMPLETED已完成、RESCHEDULED已改约、EXCEPTION异常、CANCELLED已取消',
|
||||
active_container_booking_id BIGINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN status NOT IN ('COMPLETED','CANCELLED','EXCEPTION') THEN container_booking_id ELSE NULL END) STORED COMMENT '非终态预约的柜次唯一键',
|
||||
revision INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '拖车预约业务修订号;每次状态或改约递增',
|
||||
@@ -44,7 +43,8 @@ CREATE TABLE IF NOT EXISTS fl_truck_appointment (
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '拖车预约最后更新时间',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_truck_appointment_no(appointment_no), UNIQUE KEY uk_truck_active_container(active_container_booking_id),
|
||||
KEY idx_truck_appointment_status(status,updated_at), KEY idx_truck_appointment_container(container_booking_id),
|
||||
CONSTRAINT chk_truck_appointment_status CHECK(status IN ('DRAFT','PENDING_DISPATCH','DISPATCHED','PICKUP_IN_PROGRESS','ARRIVED_WAREHOUSE','LOADING','DEPARTED','COMPLETED','RESCHEDULED','EXCEPTION','CANCELLED'))
|
||||
CONSTRAINT chk_truck_appointment_status CHECK(status IN ('DRAFT','PENDING_DISPATCH','DISPATCHED','PICKUP_IN_PROGRESS','ARRIVED_WAREHOUSE','LOADING','DEPARTED','COMPLETED','RESCHEDULED','EXCEPTION','CANCELLED')),
|
||||
CONSTRAINT chk_truck_appointment_currency CHECK(currency IN ('RMB','USD'))
|
||||
) ENGINE=InnoDB COMMENT='三期拖车预约派车聚合';
|
||||
CREATE TABLE IF NOT EXISTS fl_truck_appointment_history (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '拖车预约历史ID', appointment_id BIGINT UNSIGNED NOT NULL COMMENT '所属拖车预约ID', revision INT UNSIGNED NOT NULL COMMENT '该预约业务修订号', action VARCHAR(32) NOT NULL COMMENT '触发状态或改约的业务动作',
|
||||
|
||||
@@ -294,8 +294,8 @@ paths:
|
||||
'403': {description: 无新增按钮权限}
|
||||
/api/platform/v1/containers/{id}/ocean-booking-link:
|
||||
post:
|
||||
summary: 将内部模拟柜关联到已发布订舱需求并补录真实柜号
|
||||
description: 需要 platform:firstleg:container:edit;请求指定 RELEASED oceanBookingId、未满配 requirementId 和港口提柜后取得的唯一 containerNo,服务端规范化并校验ISO 6346结构、设备类别和校验位。首次关联允许 WAIT_LOADING 或已有预排货的 EDITING 模拟柜,保留原 containerBookingId/cabinetNo/loadingPlanId/items,原子写入订舱、运输和范围快照并创建 allocation;不会创建或消费另一条真实柜记录。存在跨仓、跨渠道、柜型规格不一致、超量、重复柜号、PDA、有效装柜证据、业务单证或 LOADING/COMPLETED 等事实时拒绝。
|
||||
summary: 将内部模拟柜关联到已发布订舱需求
|
||||
description: 需要 platform:firstleg:container:edit;请求指定 RELEASED oceanBookingId 和未满配 requirementId,containerNo 可空。首次关联允许 WAIT_LOADING 或已有预排货的 EDITING 模拟柜,保留原 containerBookingId/cabinetNo/loadingPlanId/items,原子写入订舱、运输和范围快照并创建 allocation;提柜后由拖车链调用柜次服务原子补录真实柜号。若关联请求已提供 containerNo,服务端仍规范化并校验 ISO 6346 结构、设备类别、校验位和全局唯一性。存在跨仓、跨渠道、柜型规格不一致、超量、重复柜号、PDA、有效装柜证据、业务单证或 LOADING/COMPLETED 等事实时拒绝。
|
||||
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}]
|
||||
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ContainerOceanBookingLinkRequest'}}}}
|
||||
responses: {'200': {description: 关联后的同一模拟柜 containerBookingId/cabinetNo;列表仍只有这一条 ACTIVE 操作身份}}
|
||||
@@ -1328,10 +1328,10 @@ components:
|
||||
type: array
|
||||
maxItems: 100
|
||||
items: {$ref: '#/components/schemas/WeightPalletSnapshot'}
|
||||
page: {type: integer, format: int64, minimum: 1}
|
||||
size: {type: integer, format: int64, minimum: 1, maximum: 100}
|
||||
total: {type: integer, format: int64, minimum: 0}
|
||||
pages: {type: integer, format: int64, minimum: 0}
|
||||
page: {oneOf: [{type: integer, format: int64, minimum: 1}, {type: string, pattern: '^[1-9][0-9]*$'}]}
|
||||
size: {oneOf: [{type: integer, format: int64, minimum: 1, maximum: 100}, {type: string, pattern: '^(?:[1-9]|[1-9][0-9]|100)$'}]}
|
||||
total: {oneOf: [{type: integer, format: int64, minimum: 0}, {type: string, pattern: '^(?:0|[1-9][0-9]*)$'}]}
|
||||
pages: {oneOf: [{type: integer, format: int64, minimum: 0}, {type: string, pattern: '^(?:0|[1-9][0-9]*)$'}]}
|
||||
CreateWeightResultRequest:
|
||||
type: object
|
||||
required: [fileId, palletId]
|
||||
@@ -1957,11 +1957,11 @@ components:
|
||||
maxWeightKg: {type: number, minimum: 0, exclusiveMinimum: true, description: 必须等于柜型固定限重}
|
||||
ContainerOceanBookingLinkRequest:
|
||||
type: object
|
||||
required: [oceanBookingId, requirementId, containerNo, expectedVersion]
|
||||
required: [oceanBookingId, requirementId, expectedVersion]
|
||||
properties:
|
||||
oceanBookingId: {type: integer, format: int64, minimum: 1, description: RELEASED订舱ID}
|
||||
requirementId: {type: integer, format: int64, minimum: 1, description: 未满配的订舱柜型需求ID}
|
||||
containerNo: {type: string, maxLength: 64, description: 港口提柜后取得的全局唯一真实集装箱号;服务端移除格式空白、转大写并校验ISO 6346结构、设备类别和校验位}
|
||||
containerNo: {type: string, nullable: true, maxLength: 64, description: 可选;港口提柜后可由拖车链另行原子补录。非空时服务端移除格式空白、转大写并校验ISO 6346结构、设备类别、校验位和全局唯一性}
|
||||
expectedVersion: {type: integer, minimum: 0}
|
||||
ContainerFeeAdjustRequest:
|
||||
type: object
|
||||
|
||||
@@ -21,6 +21,7 @@ paths:
|
||||
records: {type: array, items: {$ref: '#/components/schemas/DetailStableReferences'}}
|
||||
post:
|
||||
summary: Create a truck appointment for a released booking and its allocated real container
|
||||
description: If the real container already has a non-terminal appointment, the server returns A0409 with the existing appointment number and instructs the operator to continue editing. This duplicate guard runs before booking-document validation; the database unique key provides the same response for concurrent creates.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -28,6 +29,7 @@ paths:
|
||||
schema: {$ref: '#/components/schemas/SaveRequest'}
|
||||
responses:
|
||||
'200': {description: 'Created appointment', content: {application/json: {schema: {$ref: '#/components/schemas/DetailEnvelope'}}}}
|
||||
'409': {description: 'A0409 - this container already has a non-terminal appointment; message includes its appointment number and asks the operator to continue editing'}
|
||||
/api/platform/v1/truck-appointments/{id}:
|
||||
parameters: [{$ref: '#/components/parameters/AppointmentId'}]
|
||||
get:
|
||||
@@ -187,6 +189,8 @@ components:
|
||||
carrierName: {type: string, maxLength: 128, nullable: true, description: Optional consistency hint only; the persisted snapshot comes from Warehouse supplier master data.}
|
||||
billOfLadingNo: {type: string, maxLength: 128, nullable: true, description: Optional consistency hint; the persisted snapshot is always frozen from the selected RELEASED booking.}
|
||||
plannedLoadingAt: {type: string, format: date-time}
|
||||
payableAmount: {type: number, format: double, minimum: 0, nullable: true, description: Amount payable to the trucking supplier.}
|
||||
currency: {type: string, enum: [RMB, USD], default: RMB, description: Payable amount currency.}
|
||||
expectedVersion: {type: integer, minimum: 0, description: Must be 0 on create; current version on edit.}
|
||||
DetailStableReferences:
|
||||
type: object
|
||||
@@ -209,4 +213,6 @@ components:
|
||||
carrierId: {type: integer, format: int64}
|
||||
carrierName: {type: string}
|
||||
billOfLadingNo: {type: string, maxLength: 128, nullable: true, description: Frozen bill of lading snapshot used by dispatch readiness.}
|
||||
payableAmount: {type: number, format: double, minimum: 0, nullable: true}
|
||||
currency: {type: string, enum: [RMB, USD]}
|
||||
attachmentCount: {type: integer, format: int64, minimum: 0, description: Number of attachment references owned by this appointment}
|
||||
|
||||
+6
-3
@@ -62,8 +62,9 @@ class ContainerControllerContractTest {
|
||||
.contains("首次关联允许 WAIT_LOADING 或已有预排货的 EDITING 模拟柜")
|
||||
.contains("RELEASED oceanBookingId")
|
||||
.contains("未满配 requirementId")
|
||||
.contains("唯一 containerNo")
|
||||
.contains("校验ISO 6346结构、设备类别和校验位")
|
||||
.contains("containerNo 可空")
|
||||
.contains("提柜后由拖车链调用柜次服务原子补录真实柜号")
|
||||
.contains("校验 ISO 6346 结构、设备类别、校验位和全局唯一性")
|
||||
.doesNotContain("/api/platform/v1/containers/{id}/transport:")
|
||||
.doesNotContain("ContainerOceanBookingUnlinkRequest")
|
||||
.doesNotContain("ContainerTransportUpdateRequest")
|
||||
@@ -81,7 +82,9 @@ class ContainerControllerContractTest {
|
||||
assertThat(openapi)
|
||||
.contains("/api/platform/v1/containers/{id}/clearance/document/{fileId}/download:")
|
||||
.contains("Content-Disposition 与 Cache-Control no-store")
|
||||
.contains("required: [oceanBookingId, requirementId, containerNo, expectedVersion]")
|
||||
.contains("required: [oceanBookingId, requirementId, expectedVersion]")
|
||||
.contains("containerNo: {type: string, nullable: true")
|
||||
.doesNotContain("required: [oceanBookingId, requirementId, containerNo, expectedVersion]")
|
||||
.contains("required: [id, bookingNo, cabinetNo, sourceType, recordStatus")
|
||||
.contains("sourceType: {type: string, enum: [SIMULATION, OCEAN_BOOKING]")
|
||||
.contains("recordStatus: {type: string, enum: [ACTIVE, MERGED]")
|
||||
|
||||
+3
-1
@@ -19,7 +19,7 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class TruckAppointmentControllerContractTest {
|
||||
@Test void exposesDedicatedEndpointAndPermissions() {String p=PlatformTruckAppointmentController.class.getAnnotation(org.springframework.web.bind.annotation.RequestMapping.class).value()[0];assertThat(p).isEqualTo("/api/platform/v1/truck-appointments"); for(String n:new String[]{"page","detail","history","attachments","attach","upload","deleteAttachment","create","edit","action","reschedule"}) assertThat(java.util.Arrays.stream(PlatformTruckAppointmentController.class.getDeclaredMethods()).anyMatch(m->m.getName().equals(n))).isTrue();}
|
||||
@Test void schemaHasPlatformAggregateWithoutCustomerColumns() throws Exception {String s=new ClassPathResource("db/phase3-truck-appointment-schema.sql").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("fl_truck_appointment","fl_truck_appointment_history","fl_truck_appointment_attachment","fl_truck_appointment_operation","bill_of_lading_no VARCHAR(128)","chk_truck_attachment_type","DISPATCH_ORDER","PENDING_DISPATCH","PICKUP_IN_PROGRESS","ARRIVED_WAREHOUSE","RESCHEDULED","actual_arrived_warehouse_at","actual_loading_at","actual_departed_at","CALL phase3_truck_add_column","visibility VARCHAR(24) NOT NULL DEFAULT 'PLATFORM'","active_container_booking_id BIGINT UNSIGNED GENERATED ALWAYS AS","UNIQUE KEY uk_truck_active_container(active_container_booking_id)").doesNotContain("customer_id", "customer_name", "idx_truck_appointment_customer");}
|
||||
@Test void schemaHasPlatformAggregateWithoutCustomerOrReceivableColumns() throws Exception {String s=new ClassPathResource("db/phase3-truck-appointment-schema.sql").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("fl_truck_appointment","fl_truck_appointment_history","fl_truck_appointment_attachment","fl_truck_appointment_operation","bill_of_lading_no VARCHAR(128)","chk_truck_attachment_type","DISPATCH_ORDER","PENDING_DISPATCH","PICKUP_IN_PROGRESS","ARRIVED_WAREHOUSE","RESCHEDULED","actual_arrived_warehouse_at","actual_loading_at","actual_departed_at","CALL phase3_truck_add_column","visibility VARCHAR(24) NOT NULL DEFAULT 'PLATFORM'","active_container_booking_id BIGINT UNSIGNED GENERATED ALWAYS AS","UNIQUE KEY uk_truck_active_container(active_container_booking_id)","chk_truck_appointment_currency CHECK(currency IN ('RMB','USD'))").doesNotContain("customer_id", "customer_name", "idx_truck_appointment_customer", "receivable_amount");}
|
||||
@Test void existingAppointmentTableReceivesAllLateColumns() throws Exception {
|
||||
String moduleSql=new ClassPathResource("db/phase3-truck-appointment-schema.sql").getContentAsString(StandardCharsets.UTF_8);
|
||||
Path direct=Path.of("deploy","sql","头程数据库统一增量-MySQL8.sql");
|
||||
@@ -30,8 +30,10 @@ class TruckAppointmentControllerContractTest {
|
||||
}
|
||||
}
|
||||
@Test void openApiDocumentsLifecycleIdempotencyAndActualTimes() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("/api/platform/v1/truck-appointments/{id}/{action}:","/api/platform/v1/truck-appointments/{id}/reschedule:","name: Idempotency-Key","required: true","actual event timestamps");}
|
||||
@Test void openApiDocumentsActionableDuplicateCreateConflict() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("A0409","existing appointment number","continue editing","before booking-document validation","database unique key");}
|
||||
@Test void openApiRequiresReleasedBookingAndAllocatedRealContainerWithoutCustomerScope() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("required: [oceanBookingId, containerBookingId, carrierId, plannedLoadingAt, expectedVersion]","RELEASED ocean booking","ACTIVE real-numbered container","OCEAN_BOOKING_DOCUMENT","valid unique ISO 6346 container number","schema: {$ref: '#/components/schemas/DetailEnvelope'}","required: [code, message, data, timestamp]","allOf: [{$ref: '#/components/schemas/DetailStableReferences'}]","nullable: true").doesNotContain("customerId", "customerName", "Filter by customer");}
|
||||
@Test void openApiKeepsOnlyNonBookingTruckAttachments() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("AttachmentRequest:","required: [fileId, attachmentType]","required: [attachmentType, file]","TRUCK_APPOINTMENT_ATTACHMENT","Deletes the appointment reference only","DetailStableReferences:","oceanBookingNo:","shippingCompanyName:","bookingAgentName:","cabinetNo:","billOfLadingNo:").doesNotContain("enum: [BILL_OF_LADING, DISPATCH_ORDER");}
|
||||
@Test void openApiExposesOnlyPayableWithFixedCurrencies() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("payableAmount:","enum: [RMB, USD]","default: RMB").doesNotContain("receivableAmount");}
|
||||
@Test void methodsHavePlatformPermissionsAndAuthenticatedScope() {
|
||||
Map<String,String> expected=Map.ofEntries(
|
||||
Map.entry("page","platform:firstleg:truck-appointment:list"), Map.entry("detail","platform:firstleg:truck-appointment:list"),
|
||||
|
||||
+51
@@ -3,6 +3,7 @@ package com.baiyue.firstleg.customs.container;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.customs.container.dto.ContainerCustomsDtos.ActionRequest;
|
||||
import com.baiyue.firstleg.customs.container.dto.ContainerCustomsDtos.ScopeRequest;
|
||||
import com.baiyue.firstleg.customs.container.mapper.ContainerCustomsMapper;
|
||||
import com.baiyue.firstleg.customs.container.service.ContainerCustomsService;
|
||||
import com.baiyue.firstleg.customs.client.CustomsFileClient;
|
||||
@@ -108,6 +109,35 @@ class ContainerCustomsServiceTest {
|
||||
verify(mapper,never()).freezeScopes(anyLong());
|
||||
}
|
||||
|
||||
@Test void declaringContainerFreezesUploadReplacementScopeAndDeletionWithoutDrift(){
|
||||
var inbound=new ContainerCustomsMapper.InboundRow(9L,"LP9",8L,"COMPLETED","CAB","C",11L,21L,"O","I",7L,"客户","TAX_REFUND");
|
||||
var declaring=container(ContainerCustomsService.DECLARING,3);
|
||||
var source=document(1L);
|
||||
var file=new MockMultipartFile("file","source.bin","application/octet-stream",new byte[]{1});
|
||||
when(mapper.planInbounds(9L)).thenReturn(List.of(inbound));
|
||||
when(mapper.container(9L)).thenReturn(declaring);
|
||||
when(mapper.document(1L)).thenReturn(source);
|
||||
when(mapper.lockContainerById(2L)).thenReturn(declaring);
|
||||
|
||||
assertThatThrownBy(()->service.uploadSource(7L,9L,5L,"upload-after-start",List.of(11L),file))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("资料已锁定");
|
||||
assertThatThrownBy(()->service.replaceSource(7L,1L,5L,"replace-after-start",new ScopeRequest(List.of(11L),1),file))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("资料已锁定");
|
||||
assertThatThrownBy(()->service.updateScope(7L,1L,5L,"scope-after-start",new ScopeRequest(List.of(11L),1)))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("资料已锁定");
|
||||
assertThatThrownBy(()->service.deleteSource(7L,1L,5L,"delete-after-start",1))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("资料已锁定");
|
||||
|
||||
verifyNoInteractions(files);
|
||||
verify(mapper,never()).insertDocument(anyLong(),anyLong(),anyLong(),anyLong(),anyString(),anyString(),anyString(),anyLong(),anyLong());
|
||||
verify(mapper,never()).replaceDocument(anyLong(),anyLong(),anyInt(),anyLong(),anyString(),anyString(),anyString(),anyLong(),anyLong());
|
||||
verify(mapper,never()).touchScope(anyLong(),anyLong(),anyInt());
|
||||
verify(mapper,never()).deleteDocument(anyLong(),anyLong(),anyInt(),anyLong());
|
||||
verify(mapper,never()).insertDocumentVersion(anyLong(),anyLong(),anyInt(),anyLong(),anyString(),anyString(),anyString(),anyLong(),anyString(),anyString(),anyLong());
|
||||
assertThat(source.version()).isEqualTo(1);
|
||||
assertThat(source.status()).isEqualTo("ACTIVE");
|
||||
}
|
||||
|
||||
@Test void concurrentStartWithSameKeyReplaysOneAggregateAndDifferentPayloadConflicts() throws Exception {
|
||||
var row=new ContainerCustomsMapper.InboundRow(9L,"LP9",8L,"COMPLETED","CAB","C",11L,21L,"O","I",7L,"客户","TAX_REFUND");
|
||||
var ready=container(ContainerCustomsService.READY,2);
|
||||
@@ -271,6 +301,27 @@ class ContainerCustomsServiceTest {
|
||||
verify(mapper,never()).complete(anyLong(),anyInt(),anyLong(),anyString());
|
||||
}
|
||||
|
||||
@Test void allBuyContainerCompletesOnceAndPublishesAutoDispatchTrigger(){
|
||||
var declaring=container(ContainerCustomsService.DECLARING,3);
|
||||
var completed=container(ContainerCustomsService.COMPLETED,4);
|
||||
var buy=new ContainerCustomsMapper.InboundRow(9L,"LP9",8L,"COMPLETED","CAB","C",11L,21L,"O","I",7L,"客户","BUY_DECLARATION");
|
||||
when(mapper.claimCommand(anyLong(),eq(5L),eq("COMPLETE_ALL_BUY_CUSTOMS_9"),eq("all-buy-key"),anyString())).thenReturn(1);
|
||||
when(mapper.lockContainer(9L)).thenReturn(declaring);
|
||||
when(mapper.planInbounds(9L)).thenReturn(List.of(buy));
|
||||
when(mapper.complete(2L,3,5L,"admin")).thenReturn(1);
|
||||
when(mapper.completeCommand(5L,"COMPLETE_ALL_BUY_CUSTOMS_9","all-buy-key",2L)).thenReturn(1);
|
||||
when(mapper.container(9L)).thenReturn(completed);
|
||||
when(mapper.documents(2L,null)).thenReturn(List.of());
|
||||
|
||||
var result=service.completeAllBuy(9L,5L,"admin","all-buy-key",new ActionRequest(3,true,"代理已完成"));
|
||||
|
||||
assertThat(result.status()).isEqualTo(ContainerCustomsService.COMPLETED);
|
||||
verify(mapper,times(1)).complete(2L,3,5L,"admin");
|
||||
verify(mapper,times(1)).insertHistory(anyLong(),eq(2L),eq("COMPLETE_CUSTOMS"),
|
||||
eq(ContainerCustomsService.DECLARING),eq(ContainerCustomsService.COMPLETED),eq("代理已完成"),eq(5L),eq("admin"));
|
||||
verify(events,times(1)).publishEvent(new ContainerCustomsService.CustomsCompletedEvent(8L));
|
||||
}
|
||||
|
||||
private static ContainerCustomsMapper.DocumentRow document(long id){return new ContainerCustomsMapper.DocumentRow(id,2L,7L,10L,"source.bin","a".repeat(64),"application/octet-stream",1L,"ACTIVE",5L,java.time.LocalDateTime.now(),1,java.time.LocalDateTime.now(),1);}
|
||||
private static ContainerCustomsMapper.ContainerRow container(String status,int version){return new ContainerCustomsMapper.ContainerRow(2L,9L,8L,status,null,null,null,null,null,null,null,null,version);}
|
||||
private void assertMerchantOrderNotFound(long tenantId,long orderId){
|
||||
|
||||
+74
@@ -14,8 +14,10 @@ import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
@SpringJUnitConfig(LoadingDeliveryService.class)
|
||||
class LoadingDeliveryServiceTest {
|
||||
@@ -45,4 +47,76 @@ class LoadingDeliveryServiceTest {
|
||||
|
||||
verify(cabinetEvidence).requireComplete(99L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void pdaFinalizeUsesSourceVersionIncrementedByFullSettlement() throws Exception {
|
||||
Snapshot refreshed = new Snapshot(20L, 10L, "LOADING", 8);
|
||||
when(repository.completionTarget(10L, 20L)).thenReturn(null);
|
||||
when(repository.findInboundForUpdate(10L, 20L)).thenReturn(refreshed);
|
||||
when(repository.claimOperation(eq(10L), eq(20L), eq(9L), eq("COMPLETE_LOADING"),
|
||||
eq("pda-full"), anyString())).thenReturn(true);
|
||||
when(repository.findInbound(10L, 20L)).thenReturn(refreshed);
|
||||
when(loadingPicks.inboundProgress(10L, 20L)).thenReturn(resolvedProgress());
|
||||
when(cabinetEvidence.requireContainerBookingId(10L, 20L)).thenReturn(99L);
|
||||
when(repository.completeLoading(10L, 20L, 9L, 8)).thenReturn(1);
|
||||
when(objectMapper.writeValueAsString(org.mockito.ArgumentMatchers.any())).thenReturn("result");
|
||||
|
||||
service.completeAfterPdaSettlement(10L, 20L, 9L, "operator", "pda-full", "done");
|
||||
|
||||
verify(repository).completeLoading(10L, 20L, 9L, 8);
|
||||
}
|
||||
|
||||
@Test
|
||||
void pdaFinalizeLocksAndUsesLoadedSliceVersionAfterPartialSettlement() throws Exception {
|
||||
var target = new com.baiyue.firstleg.loading.mapper.LoadingInboundMapper.CompletionTarget(
|
||||
20L, 21L, 22L, "LOADING", 6, "LOADING", 9);
|
||||
Snapshot source = new Snapshot(20L, 10L, "LOADING", 6);
|
||||
Snapshot loadedSlice = new Snapshot(21L, 10L, "LOADING", 9);
|
||||
when(repository.completionTarget(10L, 20L)).thenReturn(target);
|
||||
when(repository.findInboundForUpdate(10L, 21L)).thenReturn(loadedSlice);
|
||||
when(repository.claimOperation(eq(10L), eq(20L), eq(9L), eq("COMPLETE_LOADING"),
|
||||
eq("pda-partial"), anyString())).thenReturn(true);
|
||||
when(repository.findInbound(10L, 20L)).thenReturn(source);
|
||||
when(loadingPicks.inboundProgress(10L, 20L)).thenReturn(resolvedProgress());
|
||||
when(cabinetEvidence.requireContainerBookingId(10L, 20L)).thenReturn(99L);
|
||||
when(repository.completeLoadedChild(10L, 20L, 9L, 9)).thenReturn(1);
|
||||
when(objectMapper.writeValueAsString(org.mockito.ArgumentMatchers.any())).thenReturn("result");
|
||||
|
||||
service.completeAfterPdaSettlement(10L, 20L, 9L, "operator", "pda-partial", "done");
|
||||
|
||||
verify(repository).findInboundForUpdate(10L, 21L);
|
||||
verify(repository).completeLoadedChild(10L, 20L, 9L, 9);
|
||||
}
|
||||
|
||||
@Test
|
||||
void pdaFinalizeKeepsOptimisticConflictForRealConcurrentChange() {
|
||||
Snapshot refreshed = new Snapshot(20L, 10L, "LOADING", 8);
|
||||
when(repository.completionTarget(10L, 20L)).thenReturn(null);
|
||||
when(repository.findInboundForUpdate(10L, 20L)).thenReturn(refreshed);
|
||||
when(repository.claimOperation(eq(10L), eq(20L), eq(9L), eq("COMPLETE_LOADING"),
|
||||
eq("pda-conflict"), anyString())).thenReturn(true);
|
||||
when(repository.findInbound(10L, 20L)).thenReturn(refreshed);
|
||||
when(loadingPicks.inboundProgress(10L, 20L)).thenReturn(resolvedProgress());
|
||||
when(cabinetEvidence.requireContainerBookingId(10L, 20L)).thenReturn(99L);
|
||||
when(repository.completeLoading(10L, 20L, 9L, 8)).thenReturn(0);
|
||||
try {
|
||||
when(objectMapper.writeValueAsString(org.mockito.ArgumentMatchers.any())).thenReturn("request");
|
||||
} catch (com.fasterxml.jackson.core.JsonProcessingException exception) {
|
||||
throw new AssertionError(exception);
|
||||
}
|
||||
|
||||
assertThatThrownBy(() -> service.completeAfterPdaSettlement(10L, 20L, 9L, "operator",
|
||||
"pda-conflict", "done")).isInstanceOf(com.baiyue.common.core.exception.BizException.class);
|
||||
|
||||
verify(repository).completeLoading(10L, 20L, 9L, 8);
|
||||
verify(histories, never()).insert(org.mockito.ArgumentMatchers
|
||||
.any(com.baiyue.firstleg.entity.InboundStatusHistoryEntity.class));
|
||||
}
|
||||
|
||||
private static InboundProgress resolvedProgress() {
|
||||
InboundProgress progress = new InboundProgress();
|
||||
progress.setExpectedCount(2); progress.setPickedCount(2); progress.setTaskCount(1);
|
||||
progress.setCompletedTaskCount(0); progress.setUnresolvedCount(0);
|
||||
return progress;
|
||||
}
|
||||
}
|
||||
|
||||
+52
@@ -67,6 +67,37 @@ class OceanBookingDocumentServiceTest {
|
||||
verify(repository, times(1)).insert(any(OceanBookingDocumentEntity.class));
|
||||
}
|
||||
|
||||
@Test void draftAcceptsBookingConfirmationAndBillOfLadingAsTwoVersionedFiles() {
|
||||
MockMultipartFile confirmation = new MockMultipartFile("file", "confirmation.pdf",
|
||||
"application/pdf", "confirmation".getBytes());
|
||||
MockMultipartFile bill = new MockMultipartFile("file", "bill.pdf",
|
||||
"application/pdf", "bill".getBytes());
|
||||
when(repository.claimOperation(anyLong(), eq(7L), eq("UPLOAD_DOCUMENT"), anyString(), anyString()))
|
||||
.thenReturn(true);
|
||||
when(repository.findForUpdate(10L)).thenReturn(draft(0), draft(1));
|
||||
when(files.upload("token", 7L, 9L, "OCEAN_BOOKING_DOCUMENT", "10", confirmation))
|
||||
.thenReturn(ApiResponse.success(metadata(91L)));
|
||||
when(files.upload("token", 7L, 9L, "OCEAN_BOOKING_DOCUMENT", "10", bill))
|
||||
.thenReturn(ApiResponse.success(metadata(92L)));
|
||||
when(repository.touchDraft(eq(10L), anyInt(), eq(7L), eq("admin"))).thenReturn(1);
|
||||
when(repository.completeOperation(eq(10L), eq(7L), eq("UPLOAD_DOCUMENT"), anyString(), anyString()))
|
||||
.thenReturn(1);
|
||||
|
||||
var first = service.upload(10L, 0, "BOOKING_CONFIRMATION", "confirmation-key",
|
||||
confirmation, 7L, "admin");
|
||||
var second = service.upload(10L, 1, "BILL_OF_LADING", "bill-key", bill, 7L, "admin");
|
||||
|
||||
assertThat(first.bookingVersion()).isEqualTo(1);
|
||||
assertThat(second.bookingVersion()).isEqualTo(2);
|
||||
ArgumentCaptor<OceanBookingDocumentEntity> documents =
|
||||
ArgumentCaptor.forClass(OceanBookingDocumentEntity.class);
|
||||
verify(repository, times(2)).insert(documents.capture());
|
||||
assertThat(documents.getAllValues()).extracting(OceanBookingDocumentEntity::getDocumentType)
|
||||
.containsExactly("BOOKING_CONFIRMATION", "BILL_OF_LADING");
|
||||
assertThat(documents.getAllValues()).extracting(OceanBookingDocumentEntity::getFileId)
|
||||
.containsExactly(91L, 92L);
|
||||
}
|
||||
|
||||
@Test void attachRejectsCrossScopeFileBeforePersisting() {
|
||||
when(repository.claimOperation(any(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
|
||||
when(repository.findForUpdate(10L)).thenReturn(draft(1));
|
||||
@@ -121,6 +152,27 @@ class OceanBookingDocumentServiceTest {
|
||||
verify(files).metadata("token", 9L, "OCEAN_BOOKING_DOCUMENT", "10", 91L);
|
||||
}
|
||||
|
||||
@Test void releaseGateRejectsWhenBothActiveFileReferencesNoLongerExist() {
|
||||
OceanBookingEntity booking = draft(3);
|
||||
OceanBookingDocumentEntity confirmation = activeDocument(20L, 91L, "BOOKING_CONFIRMATION");
|
||||
OceanBookingDocumentEntity bill = activeDocument(21L, 92L, "BILL_OF_LADING");
|
||||
when(repository.activeDocuments(10L)).thenReturn(List.of(confirmation, bill));
|
||||
|
||||
assertThatThrownBy(() -> service.requireActiveValidDocument(booking))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("有效订舱确认或提单文件");
|
||||
|
||||
verify(files).metadata("token", 9L, "OCEAN_BOOKING_DOCUMENT", "10", 91L);
|
||||
verify(files).metadata("token", 9L, "OCEAN_BOOKING_DOCUMENT", "10", 92L);
|
||||
verify(repository, never()).touchDraft(anyLong(), anyInt(), anyLong(), anyString());
|
||||
}
|
||||
|
||||
private static OceanBookingDocumentEntity activeDocument(long id, long fileId, String type) {
|
||||
OceanBookingDocumentEntity document = new OceanBookingDocumentEntity();
|
||||
document.setId(id); document.setOceanBookingId(10L); document.setFileId(fileId);
|
||||
document.setDocumentType(type); document.setValid(true);
|
||||
return document;
|
||||
}
|
||||
|
||||
private static OceanBookingEntity draft(int version) {
|
||||
OceanBookingEntity booking = new OceanBookingEntity();
|
||||
booking.setId(10L); booking.setCustomerId(9L); booking.setStatus("DRAFT"); booking.setVersion(version);
|
||||
|
||||
+11
-2
@@ -75,14 +75,17 @@ class ContainerBookingMapperContractTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("货柜草稿更新不得形成真实柜号或内部柜次号写入口")
|
||||
void onlyOceanBookingLinkWritesInitialContainerNumber() throws Exception {
|
||||
@DisplayName("真实柜号只允许订舱关联或提柜后原子补录")
|
||||
void onlyBookingLinkOrPickupAssignmentWritesContainerNumber() throws Exception {
|
||||
String draftSql = String.join("\n", ContainerBookingMapper.class
|
||||
.getDeclaredMethod("updateDraft", ContainerBookingEntity.class, int.class,
|
||||
long.class, String.class).getAnnotation(Update.class).value());
|
||||
String linkSql = String.join("\n", Arrays.stream(ContainerBookingMapper.class.getDeclaredMethods())
|
||||
.filter(method -> method.getName().equals("linkReleasedBooking"))
|
||||
.findFirst().orElseThrow().getAnnotation(Update.class).value());
|
||||
String pickupSql = String.join("\n", ContainerBookingMapper.class
|
||||
.getDeclaredMethod("assignRealContainerNo", long.class, String.class, int.class,
|
||||
long.class, String.class).getAnnotation(Update.class).value());
|
||||
assertThat(draftSql)
|
||||
.contains("capacity_cbm")
|
||||
.contains("p.plan_status!='EDITING'")
|
||||
@@ -92,6 +95,11 @@ class ContainerBookingMapperContractTest {
|
||||
.doesNotContain(">");
|
||||
assertThat(linkSql).contains("container_no=#{containerNo}", "source_type='SIMULATION'",
|
||||
"ocean_booking_id IS NULL");
|
||||
assertThat(pickupSql)
|
||||
.contains("container_no=#{containerNo}", "container_no IS NULL", "ocean_booking_id IS NOT NULL",
|
||||
"ocean_booking_requirement_id IS NOT NULL", "version=#{version}", "fl_loading_pick_task",
|
||||
"fl_loading_cabinet_evidence", "fl_container_document")
|
||||
.doesNotContain("ocean_booking_id=#{");
|
||||
assertThat(Arrays.stream(ContainerBookingMapper.class.getDeclaredMethods()).map(java.lang.reflect.Method::getName))
|
||||
.doesNotContain("updateTransport", "unlinkReleasedBooking");
|
||||
}
|
||||
@@ -105,6 +113,7 @@ class ContainerBookingMapperContractTest {
|
||||
}) {
|
||||
assertThat(source)
|
||||
.contains("国内唯一权威真实集装箱号")
|
||||
.contains("提柜后可原子补录")
|
||||
.contains("UNIQUE KEY uk_container_booking_container_no (container_no)")
|
||||
.contains("CONSTRAINT chk_container_booking_iso6346")
|
||||
.contains("container_no REGEXP '^[A-Z]{3}[UJZ][0-9]{7}$'");
|
||||
|
||||
+31
-2
@@ -78,7 +78,7 @@ class Phase3FinalUnifiedSqlContractTest {
|
||||
|
||||
for (int id = 5102160; id <= 5102164; id++) assertThat(sql).contains(Integer.toString(id));
|
||||
for (int id : java.util.List.of(5102170,5102172)) assertThat(sql).contains(Integer.toString(id));
|
||||
assertThat(sql).contains("DELETE FROM iam_permission WHERE id IN (5102171,5102173,5102174,5102175)")
|
||||
assertThat(sql).contains("DELETE FROM iam_permission WHERE id IN(5102171,5102173,5102174,5102175)")
|
||||
.doesNotContain("(5102171,0,'PLATFORM','BUTTON'","(5102173,0,'PLATFORM','BUTTON'",
|
||||
"(5102174,0,'PLATFORM','BUTTON'","(5102175,0,'PLATFORM','BUTTON'");
|
||||
for (int id = 5102180; id <= 5102183; id++) assertThat(sql).contains(Integer.toString(id));
|
||||
@@ -111,6 +111,35 @@ class Phase3FinalUnifiedSqlContractTest {
|
||||
"iam/src/main/resources/db/phase3-warehouse-roles.sql");
|
||||
}
|
||||
|
||||
@Test
|
||||
void customsBaselineIsUniqueSynchronisedAndRunsBeforeFinalSchemaAssertion() throws Exception {
|
||||
String unified = normalise(Files.readString(resolveUnified()));
|
||||
String marker = "-- 来源:firstleg/src/main/resources/db/phase3-container-customs-schema.sql";
|
||||
String finalAssertion = "CREATE PROCEDURE assert_phase3_final_schema()";
|
||||
List<String> tables = List.of(
|
||||
"fl_customs_container",
|
||||
"fl_customs_customer_document",
|
||||
"fl_customs_customer_document_inbound",
|
||||
"fl_customs_customer_document_version",
|
||||
"fl_customs_frozen_document_inbound",
|
||||
"fl_customs_completion_document",
|
||||
"fl_customs_completion_document_version",
|
||||
"fl_customs_container_history",
|
||||
"fl_customs_command"
|
||||
);
|
||||
|
||||
assertBlock(unified, marker, "/* Phase 3: link inbound preload plans",
|
||||
"firstleg/src/main/resources/db/phase3-container-customs-schema.sql");
|
||||
assertThat(count(unified, marker)).isEqualTo(1);
|
||||
assertThat(unified.indexOf(marker)).isLessThan(unified.indexOf(finalAssertion));
|
||||
for (String table : tables) {
|
||||
String create = "CREATE TABLE IF NOT EXISTS " + table + " (";
|
||||
assertThat(count(unified, create)).as(table).isEqualTo(1);
|
||||
assertThat(unified.indexOf(create)).as(table + " before final assertion")
|
||||
.isLessThan(unified.indexOf(finalAssertion));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void financeFeeSourceMigrationsAreContiguousAndSynchronised() throws Exception {
|
||||
String unified = normalise(Files.readString(resolveUnified()));
|
||||
@@ -155,7 +184,7 @@ class Phase3FinalUnifiedSqlContractTest {
|
||||
.contains("存在缺失中文注释的业务字段")
|
||||
.doesNotContain("CREATE PROCEDURE upgrade_column_comment");
|
||||
assertThat(count(sql, "CALL queue_table_comment(")).isEqualTo(144);
|
||||
assertThat(count(sql, "CALL queue_column_comment(")).isGreaterThanOrEqualTo(2379);
|
||||
assertThat(count(sql, "CALL queue_column_comment(")).isGreaterThanOrEqualTo(2378);
|
||||
assertThat(sql)
|
||||
.contains("CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'supplier_id'")
|
||||
.contains("CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'supplier_name'")
|
||||
|
||||
+4
@@ -23,6 +23,9 @@ class TruckAppointmentMapperContractTest {
|
||||
String active = TruckAppointmentMapper.class.getMethod("activeForUpdate", long.class).getAnnotation(Select.class).value()[0];
|
||||
assertThat(active).contains("container_booking_id=#{containerBookingId}", "FOR UPDATE")
|
||||
.contains("'COMPLETED','CANCELLED','EXCEPTION'");
|
||||
String firstActive = TruckAppointmentMapper.class.getMethod("firstActiveForUpdate", long.class).getAnnotation(Select.class).value()[0];
|
||||
assertThat(firstActive).contains("container_booking_id=#{containerBookingId}", "ORDER BY created_at,id", "LIMIT 1 FOR UPDATE")
|
||||
.contains("'COMPLETED','CANCELLED','EXCEPTION'");
|
||||
String pdaComplete = TruckAppointmentMapper.class.getMethod("completeLoadingFromPda", long.class, String.class, int.class).getAnnotation(Update.class).value()[0];
|
||||
assertThat(pdaComplete).contains("status='COMPLETED'", "actual_loading_at=COALESCE", "status=#{fromStatus}",
|
||||
"version=#{version}", "revision=revision+1", "version=version+1");
|
||||
@@ -34,5 +37,6 @@ class TruckAppointmentMapperContractTest {
|
||||
"planned_loading_at=#{e.plannedLoadingAt}", "bill_of_lading_no=#{e.billOfLadingNo}", "WHERE id=#{e.id} AND version=#{version}",
|
||||
"version=version+1", "revision=revision+1");
|
||||
assertThat(edit).doesNotContain("customer_id", "customer_name", "customerId", "customerName");
|
||||
assertThat(edit).doesNotContain("receivable_amount", "receivableAmount");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,6 +212,58 @@ class ContainerServiceTest {
|
||||
verify(oceanBookings).insert(any(OceanBookingContainerAllocationEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("模拟柜允许不带真实柜号原子关联已发布订舱需求")
|
||||
void linksReleasedBookingWithoutRealContainerNumber() {
|
||||
stubLinkContext();
|
||||
ContainerBookingEntity container = repository.find(11L);
|
||||
when(repository.find(11L)).thenReturn(container, container);
|
||||
when(repository.linkReleasedBooking(eq(11L), eq(21L), eq(31L), isNull(),
|
||||
any(OceanBookingEntity.class), eq(2), eq(9L), eq("admin"))).thenReturn(1);
|
||||
|
||||
ContainerBookingEntity result = service.linkOceanBooking(11L, 9L, "admin",
|
||||
new OceanBookingLinkRequest(21L, 31L, null, 2));
|
||||
|
||||
assertThat(result.getContainerNo()).isNull();
|
||||
assertThat(result.getCabinetNo()).isEqualTo("柜-001");
|
||||
verify(repository, never()).containerNoExists(anyString(), anyLong());
|
||||
verify(repository).linkReleasedBooking(eq(11L), eq(21L), eq(31L), isNull(),
|
||||
any(OceanBookingEntity.class), eq(2), eq(9L), eq("admin"));
|
||||
verify(oceanBookings).insert(any(OceanBookingContainerAllocationEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("提柜后按柜次版本原子补录真实柜号")
|
||||
void assignsRealContainerNumberAfterPickup() {
|
||||
ContainerBookingEntity linked = booking("EDITING", 3);
|
||||
linked.setContainerNo(null); linked.setOceanBookingId(21L); linked.setOceanBookingRequirementId(31L);
|
||||
ContainerBookingEntity assigned = booking("EDITING", 4);
|
||||
assigned.setContainerNo("MSCU6639870"); assigned.setOceanBookingId(21L); assigned.setOceanBookingRequirementId(31L);
|
||||
when(repository.find(11L)).thenReturn(linked, assigned);
|
||||
when(repository.assignRealContainerNo(11L, "MSCU6639870", 3, 9L, "admin")).thenReturn(1);
|
||||
|
||||
ContainerBookingEntity result = service.assignRealContainerNo(
|
||||
11L, " ms cu 663987 0 ", 3, 9L, "admin");
|
||||
|
||||
assertThat(result.getContainerNo()).isEqualTo("MSCU6639870");
|
||||
verify(repository).assignRealContainerNo(11L, "MSCU6639870", 3, 9L, "admin");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("提柜补录真实柜号仍拒绝全局重复号")
|
||||
void rejectsDuplicateRealContainerNumberAfterPickup() {
|
||||
ContainerBookingEntity linked = booking("EDITING", 3);
|
||||
linked.setContainerNo(null); linked.setOceanBookingId(21L); linked.setOceanBookingRequirementId(31L);
|
||||
when(repository.find(11L)).thenReturn(linked);
|
||||
when(repository.containerNoExists("MSCU6639870", 11L)).thenReturn(true);
|
||||
|
||||
assertThatThrownBy(() -> service.assignRealContainerNo(
|
||||
11L, "MSCU6639870", 3, 9L, "admin"))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("已存在");
|
||||
|
||||
verify(repository, never()).assignRealContainerNo(anyLong(), anyString(), anyInt(), anyLong(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("首次关联拒绝错误ISO 6346校验位")
|
||||
void rejectsInvalidContainerNumberCheckDigit() {
|
||||
|
||||
+70
-7
@@ -234,6 +234,38 @@ class InboundMeasurementServiceTest {
|
||||
assertThat(result.confidence()).isEqualByComparingTo("0.50972");
|
||||
}
|
||||
|
||||
@Test
|
||||
void ocrFailureCreatesManualWeightThatCanBeAdjustedAndConfirmed() {
|
||||
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
|
||||
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
|
||||
|
||||
InboundMeasurementResponses.WeightResult created = service.createWeightResult(
|
||||
1L, 10L, 9L,
|
||||
new InboundMeasurementRequests.CreateWeightResultRequest(
|
||||
120L, 501L, "FILE_WEIGHT_OCR", null, null, null, null));
|
||||
|
||||
assertThat(created.recognitionStatus()).isEqualTo("NEEDS_MANUAL");
|
||||
ArgumentCaptor<InboundWeightResultEntity> saved =
|
||||
ArgumentCaptor.forClass(InboundWeightResultEntity.class);
|
||||
verify(repository).insertWeight(saved.capture());
|
||||
InboundWeightResultEntity weight = saved.getValue();
|
||||
weight.setId(20L);
|
||||
when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight);
|
||||
when(repository.updateWeight(weight)).thenReturn(true);
|
||||
when(repository.listWeights(1L, 10L)).thenReturn(List.of(weight));
|
||||
when(repository.updateInboundWeightSummary(inbound)).thenReturn(true);
|
||||
|
||||
InboundMeasurementResponses.WeightResult confirmed = service.confirmWeightResult(
|
||||
1L, 10L, 20L, 9L, "操作员",
|
||||
new InboundMeasurementRequests.ConfirmWeightResultRequest(
|
||||
0, new BigDecimal("30.000"), "KG", "OCR失败后人工确认"));
|
||||
|
||||
assertThat(confirmed.recognitionStatus()).isEqualTo("CONFIRMED");
|
||||
assertThat(confirmed.grossWeightKg()).isEqualByComparingTo("30.000");
|
||||
assertThat(confirmed.netWeightKg()).isEqualByComparingTo("11.500");
|
||||
verify(repository, org.mockito.Mockito.times(2)).insertRevision(any(InboundWeightRevisionEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void weightPalletOptionsUsesInboundWarehouseWithoutCustomerScope() {
|
||||
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
|
||||
@@ -254,6 +286,30 @@ class InboundMeasurementServiceTest {
|
||||
verify(warehousePallets).palletSnapshots("internal-token", 100L, 1L, 20L, "TP-5");
|
||||
}
|
||||
|
||||
@Test
|
||||
void weightPalletOptionsFailsClosedOnWarehouseNon2xxResponse() {
|
||||
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
|
||||
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
|
||||
when(warehousePallets.palletSnapshots("internal-token", 100L, 1L, 20L, "TP-5"))
|
||||
.thenThrow(mock(feign.FeignException.class));
|
||||
|
||||
assertThatThrownBy(() -> service.weightPalletOptions(1L, 10L, 1L, 20L, "TP-5"))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("仓库服务暂不可用");
|
||||
}
|
||||
|
||||
@Test
|
||||
void weightPalletOptionsFailsClosedOnWarehouseNetworkError() {
|
||||
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
|
||||
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
|
||||
when(warehousePallets.palletSnapshots("internal-token", 100L, 1L, 20L, null))
|
||||
.thenThrow(mock(feign.RetryableException.class));
|
||||
|
||||
assertThatThrownBy(() -> service.weightPalletOptions(1L, 10L, 1L, 20L, null))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("仓库服务暂不可用");
|
||||
}
|
||||
|
||||
@Test
|
||||
void creatingWeightResultRejectsPalletOutsideTheInboundWarehouse() {
|
||||
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
|
||||
@@ -775,9 +831,13 @@ class InboundMeasurementServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void editingAndResubmittingInboundMeasurementDoesNotRollBackMainStatus() {
|
||||
void approvedMeasurementCannotBeResubmittedOrMutateFrozenTotals() {
|
||||
InboundOrderEntity inbound = inbound("WAIT_SPLIT");
|
||||
inbound.setMeasureReviewStatus("APPROVED");
|
||||
inbound.setActualGrossWeightKg(new BigDecimal("40.000"));
|
||||
inbound.setActualNetWeightKg(new BigDecimal("30.000"));
|
||||
inbound.setActualVolumeCbm(new BigDecimal("2.0000"));
|
||||
inbound.setVolumeWeightKg(new BigDecimal("334.000"));
|
||||
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
|
||||
measurement.setId(30L);
|
||||
measurement.setStatus("APPROVED");
|
||||
@@ -791,14 +851,17 @@ class InboundMeasurementServiceTest {
|
||||
when(repository.listWeights(1L, 10L)).thenReturn(List.of());
|
||||
when(repository.updateInbound(inbound)).thenReturn(true);
|
||||
|
||||
InboundMeasurementResponses.MeasurementSubmitted result = service.submit(1L, 10L, 9L,
|
||||
new InboundMeasurementRequests.SubmitRequest(30L, 2));
|
||||
assertThatThrownBy(() -> service.submit(1L, 10L, 9L,
|
||||
new InboundMeasurementRequests.SubmitRequest(30L, 2)))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("审核完成");
|
||||
|
||||
assertThat(result.processStatus()).isEqualTo("WAIT_SPLIT");
|
||||
assertThat(result.measureReviewStatus()).isEqualTo("APPROVED");
|
||||
assertThat(measurement.getStatus()).isEqualTo("APPROVED");
|
||||
assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("40.000");
|
||||
assertThat(inbound.getActualNetWeightKg()).isEqualByComparingTo("30.000");
|
||||
assertThat(inbound.getActualVolumeCbm()).isEqualByComparingTo("2.0000");
|
||||
assertThat(inbound.getVolumeWeightKg()).isEqualByComparingTo("334.000");
|
||||
verify(repository, never()).updateMeasurement(any());
|
||||
verify(repository).updateInbound(inbound);
|
||||
verify(repository, never()).updateInbound(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -44,10 +44,13 @@ import org.mockito.InOrder;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
||||
import org.springframework.transaction.TransactionStatus;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
@@ -74,6 +77,8 @@ class LoadingPickServiceTest {
|
||||
@MockBean
|
||||
private LoadingCompletionCommandRepository completionCommands;
|
||||
@MockBean
|
||||
private TransactionTemplate completionTransaction;
|
||||
@MockBean
|
||||
private SmartLoadingRepository smartLoadingRepository;
|
||||
@MockBean
|
||||
private OceanBookingRepository oceanBookingRepository;
|
||||
@@ -100,6 +105,12 @@ class LoadingPickServiceTest {
|
||||
|
||||
@BeforeEach
|
||||
void activeOperationalContainer() {
|
||||
doAnswer(invocation -> {
|
||||
@SuppressWarnings("unchecked")
|
||||
Consumer<TransactionStatus> action = invocation.getArgument(0);
|
||||
action.accept(mock(TransactionStatus.class));
|
||||
return null;
|
||||
}).when(completionTransaction).executeWithoutResult(any());
|
||||
ContainerBookingEntity booking = new ContainerBookingEntity();
|
||||
booking.setId(99L);
|
||||
booking.setContainerNo("CSLU1234567");
|
||||
|
||||
+49
-4
@@ -92,6 +92,7 @@ class MeasurementReviewServiceTest {
|
||||
InboundMeasurementItemEntity item = new InboundMeasurementItemEntity();
|
||||
item.setPackageCount(5);
|
||||
item.setTotalVolumeCbm(new BigDecimal("0.7480"));
|
||||
item.setVolumeWeightKg(new BigDecimal("124.916"));
|
||||
when(fixture.repository.listMeasurementItems(10001L, 20L)).thenReturn(List.of(item));
|
||||
when(fixture.repository.transitionReview(10001L, 10L, 9L, 3, true, 20L)).thenReturn(1);
|
||||
when(fixture.repository.transitionMeasurement(10001L, 10L, 20L, "APPROVED")).thenReturn(1);
|
||||
@@ -119,6 +120,40 @@ class MeasurementReviewServiceTest {
|
||||
verify(fixture.repository, never()).transitionReview(anyLong(), anyLong(), anyLong(), anyInt(), anyBoolean(), anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("多SKU测量行的箱数、重量、体积和体积重守恒后才能进入WAIT_SPLIT")
|
||||
void shouldApproveMultipleCargoLinesOnlyWhenAllFrozenTotalsMatch() {
|
||||
Fixture fixture = fixture(false);
|
||||
InboundMeasurementItemEntity first = measurementItem(2, "0.3000", "50.100");
|
||||
InboundMeasurementItemEntity second = measurementItem(3, "0.4480", "74.816");
|
||||
when(fixture.repository.listMeasurementItems(10001L, 20L)).thenReturn(List.of(first, second));
|
||||
when(fixture.repository.transitionReview(10001L, 10L, 9L, 3, true, 20L)).thenReturn(1);
|
||||
when(fixture.repository.transitionMeasurement(10001L, 10L, 20L, "APPROVED")).thenReturn(1);
|
||||
|
||||
fixture.service.approve(10001L, 9L, "审核员", 10L, "approve-multi-sku",
|
||||
new MeasurementReviewRequest(20L, 3, null));
|
||||
|
||||
ArgumentCaptor<InboundStatusHistoryEntity> history =
|
||||
ArgumentCaptor.forClass(InboundStatusHistoryEntity.class);
|
||||
verify(fixture.repository).insertHistory(history.capture());
|
||||
assertThat(history.getValue().getToStatus()).isEqualTo("WAIT_SPLIT");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("体积或体积重任一层汇总不一致时审核零写入")
|
||||
void shouldRejectApprovalWhenVolumeSnapshotDoesNotConserve() {
|
||||
Fixture fixture = fixture(false);
|
||||
fixture.order.setActualVolumeCbm(new BigDecimal("0.7479"));
|
||||
|
||||
assertThatThrownBy(() -> fixture.service.approve(10001L, 9L, "审核员", 10L,
|
||||
"approve-volume-mismatch", new MeasurementReviewRequest(20L, 3, null)))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("体积和体积重");
|
||||
|
||||
verify(fixture.repository, never()).transitionReview(anyLong(), anyLong(), anyLong(), anyInt(), anyBoolean(), anyLong());
|
||||
verify(fixture.repository, never()).transitionMeasurement(anyLong(), anyLong(), anyLong(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("驳回原因必填且状态推进为测量驳回")
|
||||
void shouldRequireRejectReason() {
|
||||
@@ -218,13 +253,15 @@ class MeasurementReviewServiceTest {
|
||||
order.setTotalPackageCount(5);
|
||||
order.setActualGrossWeightKg(new BigDecimal("1787.500"));
|
||||
order.setActualNetWeightKg(new BigDecimal("1747.500"));
|
||||
order.setActualVolumeCbm(new BigDecimal("0.7480"));
|
||||
order.setVolumeWeightKg(new BigDecimal("124.916"));
|
||||
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
|
||||
measurement.setId(20L);
|
||||
measurement.setStatus("SUBMITTED");
|
||||
InboundMeasurementItemEntity item = new InboundMeasurementItemEntity();
|
||||
item.setPackageCount(5);
|
||||
item.setTotalVolumeCbm(new BigDecimal("0.7480"));
|
||||
item.setVolumeWeightKg(new BigDecimal("124.916"));
|
||||
measurement.setTotalPackageCount(5);
|
||||
measurement.setTotalVolumeCbm(new BigDecimal("0.7480"));
|
||||
measurement.setVolumeWeightKg(new BigDecimal("124.916"));
|
||||
InboundMeasurementItemEntity item = measurementItem(5, "0.7480", "124.916");
|
||||
when(repository.findOwned(10001L, 10L)).thenReturn(order);
|
||||
when(repository.findMeasurement(10001L, 10L, 20L)).thenReturn(measurement);
|
||||
when(repository.listMeasurementItems(10001L, 20L)).thenReturn(List.of(item));
|
||||
@@ -237,6 +274,14 @@ class MeasurementReviewServiceTest {
|
||||
return new Fixture(service, repository, idempotency, order, measurement);
|
||||
}
|
||||
|
||||
private InboundMeasurementItemEntity measurementItem(int packages, String volume, String volumeWeight) {
|
||||
InboundMeasurementItemEntity item = new InboundMeasurementItemEntity();
|
||||
item.setPackageCount(packages);
|
||||
item.setTotalVolumeCbm(new BigDecimal(volume));
|
||||
item.setVolumeWeightKg(new BigDecimal(volumeWeight));
|
||||
return item;
|
||||
}
|
||||
|
||||
private InboundWeightResultEntity confirmedWeight(String grossWeightKg, String netWeightKg) {
|
||||
InboundWeightResultEntity weight = new InboundWeightResultEntity();
|
||||
weight.setRecognitionStatus("CONFIRMED");
|
||||
|
||||
+39
-3
@@ -125,24 +125,35 @@ class TruckAppointmentServiceTest {
|
||||
|
||||
@Test void createRejectsAnotherActiveAppointmentForTheSameRealContainerWithoutWriting() {
|
||||
SaveRequest request = validRequest();
|
||||
when(repository.activeForUpdate(88L)).thenReturn(List.of(entity(TruckAppointmentStatus.DRAFT, 0)));
|
||||
TruckAppointmentEntity existing = entity(TruckAppointmentStatus.DRAFT, 0);
|
||||
existing.setAppointmentNo("TA-260901-0003");
|
||||
when(repository.firstActiveForUpdate(88L)).thenReturn(existing);
|
||||
|
||||
assertThatThrownBy(() -> service.create(7L, "admin", request))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("已有有效拖车预约");
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("该柜已存在拖车预约 TA-260901-0003,请继续编辑");
|
||||
|
||||
verifyNoInteractions(oceanBookingDocuments);
|
||||
verify(repository, never()).insert(any());
|
||||
verify(repository, never()).history(any());
|
||||
assertThat(existing.getStatus()).isEqualTo(TruckAppointmentStatus.DRAFT);
|
||||
assertThat(existing.getVersion()).isZero();
|
||||
}
|
||||
|
||||
@Test void concurrentCreateUniqueCollisionBecomesBusinessConflictWithoutHistory() {
|
||||
SaveRequest request = validRequest();
|
||||
TruckAppointmentEntity concurrent = entity(TruckAppointmentStatus.DRAFT, 0);
|
||||
concurrent.setAppointmentNo("TA-260901-0003");
|
||||
when(repository.firstActiveForUpdate(88L)).thenReturn(null, concurrent);
|
||||
doThrow(new DuplicateKeyException("Duplicate entry for key 'uk_truck_active_container'"))
|
||||
.when(repository).insert(any());
|
||||
|
||||
assertThatThrownBy(() -> service.create(7L, "admin", request))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("已有有效拖车预约");
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("该柜已存在拖车预约 TA-260901-0003,请继续编辑");
|
||||
|
||||
verify(repository, times(2)).firstActiveForUpdate(88L);
|
||||
verify(repository, never()).history(any());
|
||||
assertThat(concurrent.getStatus()).isEqualTo(TruckAppointmentStatus.DRAFT);
|
||||
assertThat(concurrent.getVersion()).isZero();
|
||||
}
|
||||
|
||||
@Test void createPreservesMaximumChineseRemarkAndBoundaryValues() {
|
||||
@@ -164,6 +175,31 @@ class TruckAppointmentServiceTest {
|
||||
assertThat(inserted.getValue().getPalletCount()).isZero();
|
||||
assertThat(inserted.getValue().getGrossWeightKg()).isZero();
|
||||
assertThat(inserted.getValue().getVolumeCbm()).isZero();
|
||||
assertThat(inserted.getValue().getCurrency()).isEqualTo("RMB");
|
||||
}
|
||||
|
||||
@Test void createAcceptsUsd() {
|
||||
SaveRequest request = validRequest();
|
||||
when(request.currency()).thenReturn("USD");
|
||||
when(request.payableAmount()).thenReturn(new java.math.BigDecimal("123.45"));
|
||||
|
||||
service.create(7L, "admin", request);
|
||||
|
||||
ArgumentCaptor<TruckAppointmentEntity> inserted = ArgumentCaptor.forClass(TruckAppointmentEntity.class);
|
||||
verify(repository).insert(inserted.capture());
|
||||
assertThat(inserted.getValue().getCurrency()).isEqualTo("USD");
|
||||
assertThat(inserted.getValue().getPayableAmount()).isEqualByComparingTo("123.45");
|
||||
}
|
||||
|
||||
@Test void createRejectsUnsupportedCurrencyWithoutWriting() {
|
||||
SaveRequest request = validRequest();
|
||||
when(request.currency()).thenReturn("CNY");
|
||||
|
||||
assertThatThrownBy(() -> service.create(7L, "admin", request))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("RMB或USD");
|
||||
|
||||
verify(repository, never()).insert(any());
|
||||
verify(repository, never()).history(any());
|
||||
}
|
||||
|
||||
@Test void platformOwnedAttachmentUsesZeroTenantWithoutCustomerSnapshot() {
|
||||
|
||||
+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,202 @@
|
||||
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
|
||||
DECLARE v_warehouse_operator_permission_count INT DEFAULT 0;
|
||||
DECLARE v_warehouse_tally_permission_count INT DEFAULT 0;
|
||||
|
||||
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;
|
||||
SELECT COALESCE(SUM(role_code='WAREHOUSE_OPERATOR'),0),
|
||||
COALESCE(SUM(role_code='WAREHOUSE_TALLY'),0)
|
||||
INTO v_warehouse_operator_permission_count, v_warehouse_tally_permission_count
|
||||
FROM tmp_phase5_delivery_role_permission;
|
||||
IF v_warehouse_operator_permission_count <> 7
|
||||
OR v_warehouse_tally_permission_count <> 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")));
|
||||
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
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 validatesTheWarehouseRoleMatrixWithOneTemporaryTableReadPerStatement() throws Exception {
|
||||
String sql = sql();
|
||||
|
||||
assertThat(sql).contains(
|
||||
"DECLARE v_warehouse_operator_permission_count INT DEFAULT 0",
|
||||
"DECLARE v_warehouse_tally_permission_count INT DEFAULT 0",
|
||||
"SELECT COALESCE(SUM(role_code='WAREHOUSE_OPERATOR'),0)",
|
||||
"COALESCE(SUM(role_code='WAREHOUSE_TALLY'),0)",
|
||||
"INTO v_warehouse_operator_permission_count, v_warehouse_tally_permission_count",
|
||||
"IF v_warehouse_operator_permission_count <> 7",
|
||||
"OR v_warehouse_tally_permission_count <> 2 THEN");
|
||||
assertThat(sql).doesNotContain(
|
||||
"IF (SELECT COUNT(*) FROM tmp_phase5_delivery_role_permission",
|
||||
"OR (SELECT COUNT(*) FROM tmp_phase5_delivery_role_permission");
|
||||
}
|
||||
|
||||
@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";
|
||||
|
||||
+36
-8
@@ -58,6 +58,7 @@ public class LoadingCompletedConsumer {
|
||||
String containerNo = ContainerNumbers.requireCanonical(payload.containerNo());
|
||||
String sourceContainerBookingId = sourceContainerBookingId(envelope, payload);
|
||||
if(payload.orders()==null||payload.orders().isEmpty()) throw new IllegalArgumentException("事件没有入仓单");
|
||||
OwnerSnapshot owner = requireOwnerSnapshot(payload.orders(), envelope.tenantId());
|
||||
if (envelope.eventVersion() == 2) validateCartonSkuMappings(payload);
|
||||
freezeContainerIdentity(envelope, sourceContainerBookingId, containerNo);
|
||||
LastlegEventInbox existing=inboxMapper.selectById(envelope.eventId());
|
||||
@@ -87,7 +88,8 @@ public class LoadingCompletedConsumer {
|
||||
for (int index = 0; index < payload.orders().size(); index++) {
|
||||
var source = payload.orders().get(index);
|
||||
JsonNode rawSource = rawOrders.isArray() && index < rawOrders.size() ? rawOrders.get(index) : objectMapper.createObjectNode();
|
||||
createOrder(envelope, payload, source, orderNo, rawSource, containerNo, sourceContainerBookingId);
|
||||
createOrder(envelope, payload, source, orderNo, rawSource, containerNo,
|
||||
sourceContainerBookingId, owner);
|
||||
}
|
||||
inbox.setStatus("PROCESSED"); inbox.setProcessedAt(LocalDateTime.now()); inboxMapper.updateById(inbox);
|
||||
return payload.orders().size();
|
||||
@@ -136,6 +138,33 @@ public class LoadingCompletedConsumer {
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
private OwnerSnapshot requireOwnerSnapshot(List<FirstLegLoadingPlanCompletedV1.Order> orders,
|
||||
String trustedCustomerId) {
|
||||
String customerId = requiredOwnerValue(orders.get(0).customerId(), "customerId");
|
||||
String customerName = requiredOwnerValue(orders.get(0).customerName(), "customerName");
|
||||
if (!trustedCustomerId.equals(customerId)) {
|
||||
throw new IllegalArgumentException("Order customerId does not match the trusted event customer");
|
||||
}
|
||||
for (FirstLegLoadingPlanCompletedV1.Order order : orders) {
|
||||
if (!customerId.equals(requiredOwnerValue(order.customerId(), "customerId"))
|
||||
|| !customerName.equals(requiredOwnerValue(order.customerName(), "customerName"))) {
|
||||
throw new IllegalArgumentException("同一装柜完成事件的货主快照不一致");
|
||||
}
|
||||
}
|
||||
return new OwnerSnapshot(customerId, customerName);
|
||||
}
|
||||
|
||||
private String requiredOwnerValue(String value, String field) {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalArgumentException("尾程事件缺少不可变货主快照 " + field);
|
||||
}
|
||||
String normalized = value.trim();
|
||||
if (normalized.length() > 128) {
|
||||
throw new IllegalArgumentException("尾程事件货主快照过长 " + field);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private void validateCartonSkuMappings(FirstLegLoadingPlanCompletedV1 payload) {
|
||||
for (FirstLegLoadingPlanCompletedV1.Order order : payload.orders()) {
|
||||
Set<String> orderCartonMarks = new HashSet<>();
|
||||
@@ -186,7 +215,7 @@ public class LoadingCompletedConsumer {
|
||||
|
||||
private void createOrder(EventEnvelope event,FirstLegLoadingPlanCompletedV1 payload,
|
||||
FirstLegLoadingPlanCompletedV1.Order source,String orderNo, JsonNode rawSource,
|
||||
String containerNo, String sourceContainerBookingId) {
|
||||
String containerNo, String sourceContainerBookingId, OwnerSnapshot owner) {
|
||||
String orderHash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(rawSource));
|
||||
LastlegOrder existing=orderMapper.findSource(event.tenantId(),event.sourceRegion().name(),source.sourceInboundId());
|
||||
if(existing!=null) {
|
||||
@@ -196,11 +225,8 @@ public class LoadingCompletedConsumer {
|
||||
LastlegOrder order=new LastlegOrder();
|
||||
String orderType = source.orderType();
|
||||
String destinationType = destinationType(payload, source);
|
||||
String customerId = source.customerId() == null || source.customerId().isBlank()
|
||||
? event.tenantId() : source.customerId();
|
||||
if (!event.tenantId().equals(customerId)) {
|
||||
throw new IllegalArgumentException("Order customerId does not match the trusted event customer");
|
||||
}
|
||||
String customerId = owner.customerId();
|
||||
String customerName = owner.customerName();
|
||||
List<DropshipRequests.Pallet> dropshipPallets = "ONE_CLICK_DROPSHIP".equals(orderType)
|
||||
? pallets(source, rawSource) : List.of();
|
||||
if ("ONE_CLICK_DROPSHIP".equals(orderType) && dropshipPallets.isEmpty()) {
|
||||
@@ -211,7 +237,7 @@ public class LoadingCompletedConsumer {
|
||||
order.setInboundCode(source.inboundCode()); order.setSourceRegion(event.sourceRegion().name());
|
||||
order.setSourceInboundId(source.sourceInboundId()); order.setRootInboundId(source.rootInboundId());
|
||||
order.setSourceOrderId(source.sourceOrderId()); order.setSourceOrderNo(source.sourceOrderNo()); order.setRootOrderId(source.rootOrderId());
|
||||
order.setCustomerName(source.customerName());
|
||||
order.setCustomerName(customerName);
|
||||
order.setSourceWarehouseId(payload.warehouseId()); order.setSourceWarehouseName(payload.warehouseName());
|
||||
order.setOverseasWarehouseId(null); order.setOverseasWarehouseName(null);
|
||||
order.setFbaWarehouseCode(source.destinationWarehouseCode()); order.setContainerNo(containerNo);
|
||||
@@ -425,4 +451,6 @@ public class LoadingCompletedConsumer {
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private record OwnerSnapshot(String customerId, String customerName) { }
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
+12
@@ -49,6 +49,18 @@ class LastlegOrderNoGeneratorTest {
|
||||
assertTrue(insert.value()[0].contains("LAST_INSERT_ID(last_sequence+1)"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void crossDayAllocationUsesTheNewShanghaiBusinessDateSequence() {
|
||||
LocalDate firstDay = LocalDate.of(2026, 8, 27);
|
||||
LocalDate nextDay = firstDay.plusDays(1);
|
||||
when(sequenceMapper.currentValue()).thenReturn(42L, 1L);
|
||||
|
||||
assertEquals("LMO-260827-0042", generator.next(firstDay));
|
||||
assertEquals("LMO-260828-0001", generator.next(nextDay));
|
||||
verify(sequenceMapper).nextValue(firstDay);
|
||||
verify(sequenceMapper).nextValue(nextDay);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsDailySequenceBeyondFourDigits() {
|
||||
when(sequenceMapper.currentValue()).thenReturn(10_000L);
|
||||
|
||||
+4
-2
@@ -5,7 +5,9 @@ class LoadingCompletedConsumerTest {
|
||||
private final Map<String,String> frozenContainerNos=new HashMap<>();
|
||||
@Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegInboundOrderMapper inboundOrders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock InboundPlanCartonMapper cartons; @Mock DropshipOrderService dropshipOrders; @Mock InventoryCommandService inventory; @Mock com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper shippingGates; @Mock LastlegOrderNoDailySequenceMapper sequences; ObjectMapper json=new ObjectMapper().findAndRegisterModules(); LoadingCompletedConsumer service;
|
||||
@BeforeEach void setup(){ service=new LoadingCompletedConsumer(); ReflectionTestUtils.setField(service,"inboxMapper",inbox); ReflectionTestUtils.setField(service,"orderMapper",orders); ReflectionTestUtils.setField(service,"inboundOrderMapper",inboundOrders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"cartonMapper",cartons); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"inventory",inventory); ReflectionTestUtils.setField(service,"shippingGates",shippingGates); ReflectionTestUtils.setField(service,"objectMapper",json); LastlegOrderNoGenerator generator=new LastlegOrderNoGenerator(); ReflectionTestUtils.setField(generator,"sequenceMapper",sequences); ReflectionTestUtils.setField(service,"orderNoGenerator",generator); lenient().when(sequences.currentValue()).thenReturn(1L); lenient().doAnswer(i->{ LastlegOrder o=i.getArgument(0); o.setId(Math.abs(o.getSourceInboundId().hashCode())+1L); return 1; }).when(orders).insert(any(LastlegOrder.class)); lenient().when(orders.lockContainerNumbers(anyString())).thenReturn(List.of()); lenient().when(shippingGates.initialize(anyString(),anyString(),anyString(),anyString(),anyLong(),anyLong())).thenAnswer(i->{ String key=i.getArgument(0); String value=i.getArgument(3); return frozenContainerNos.putIfAbsent(key,value)==null?1:0; }); lenient().when(shippingGates.lock(anyString())).thenAnswer(i->{ String key=i.getArgument(0); String value=frozenContainerNos.get(key); if(value==null)return null; var gate=new com.baiyue.lastleg.shipping.entity.ContainerShippingGate(); gate.setContainerBookingId(key); gate.setContainerNo(value); return gate; }); }
|
||||
@Test void createsPendingOrdersAndInTransitSnapshotsWithoutInboundPlans(){ EventEnvelope event=event(); assertEquals(2,service.consume(event)); assertThrows(NoSuchFieldException.class,()->LoadingCompletedConsumer.class.getDeclaredField("inboundPlans")); verify(orders,times(2)).insert(any(LastlegOrder.class)); ArgumentCaptor<InventoryCommandRequests.InTransit> inTransit=ArgumentCaptor.forClass(InventoryCommandRequests.InTransit.class); verify(inventory,times(2)).registerInTransit(eq("100"),eq("SYSTEM"),inTransit.capture()); assertEquals(Set.of("RK1","RK2"),inTransit.getAllValues().stream().map(InventoryCommandRequests.InTransit::inboundOrderNo).collect(java.util.stream.Collectors.toSet())); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor<LastlegOrder> c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getInboundOrderNo()==null)); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU6639870".equals(o.getContainerNo()))); assertTrue(c.getAllValues().stream().allMatch(o->"FBA".equals(o.getDestinationType()))); assertTrue(c.getAllValues().stream().allMatch(o->"Customer 100".equals(o.getCustomerName()))); assertTrue(c.getAllValues().stream().allMatch(o->"ow-1".equals(o.getSourceWarehouseId())&&o.getOverseasWarehouseId()==null)); ArgumentCaptor<LastlegInboundOrder> snapshots=ArgumentCaptor.forClass(LastlegInboundOrder.class); verify(inboundOrders,times(2)).insert(snapshots.capture()); assertTrue(snapshots.getAllValues().stream().allMatch(o->"100".equals(o.getCustomerId())&&"FBA".equals(o.getDestinationType()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(c.getAllValues().get(0).getOrderNo().matches("FL-WH-[0-9]{8}-[0-9]{6}")); }
|
||||
@Test void createsPendingOrdersAndInTransitSnapshotsWithoutInboundPlans(){ EventEnvelope event=event(); assertEquals(2,service.consume(event)); assertThrows(NoSuchFieldException.class,()->LoadingCompletedConsumer.class.getDeclaredField("inboundPlans")); verify(orders,times(2)).insert(any(LastlegOrder.class)); ArgumentCaptor<InventoryCommandRequests.InTransit> inTransit=ArgumentCaptor.forClass(InventoryCommandRequests.InTransit.class); verify(inventory,times(2)).registerInTransit(eq("100"),eq("SYSTEM"),inTransit.capture()); assertEquals(Set.of("RK1","RK2"),inTransit.getAllValues().stream().map(InventoryCommandRequests.InTransit::inboundOrderNo).collect(java.util.stream.Collectors.toSet())); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor<LastlegOrder> c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getInboundOrderNo()==null)); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU6639870".equals(o.getContainerNo()))); assertTrue(c.getAllValues().stream().allMatch(o->"FBA".equals(o.getDestinationType()))); assertTrue(c.getAllValues().stream().allMatch(o->"Customer 100".equals(o.getCustomerName()))); assertTrue(c.getAllValues().stream().allMatch(o->"ow-1".equals(o.getSourceWarehouseId())&&o.getOverseasWarehouseId()==null)); ArgumentCaptor<LastlegInboundOrder> snapshots=ArgumentCaptor.forClass(LastlegInboundOrder.class); verify(inboundOrders,times(2)).insert(snapshots.capture()); assertTrue(snapshots.getAllValues().stream().allMatch(o->"100".equals(o.getCustomerId())&&"FBA".equals(o.getDestinationType()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(c.getAllValues().get(0).getOrderNo().matches("FL-WH-[0-9]{8}-[0-9]{6}")); }
|
||||
@Test void oneContainerWith15InboundRowsSharesOneStableContainerOrderNumber(){ EventEnvelope source=event(); com.fasterxml.jackson.databind.node.ObjectNode node=source.payload().deepCopy(); com.fasterxml.jackson.databind.node.ArrayNode rows=json.createArrayNode(); for(int index=1;index<=15;index++){ com.fasterxml.jackson.databind.node.ObjectNode row=node.path("orders").get(0).deepCopy(); row.put("sourceInboundId","in-"+index).put("rootInboundId","root-"+index).put("sourceOrderId","order-"+index).put("sourceOrderNo","FL-"+index).put("rootOrderId","root-order-"+index).put("inboundCode","RK-"+index); rows.add(row); } node.set("orders",rows); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope fifteen=new EventEnvelope("123e4567-e89b-12d3-a456-426614174015",source.eventType(),source.eventVersion(),source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); assertEquals(15,service.consume(fifteen)); ArgumentCaptor<LastlegOrder> captured=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(15)).insert(captured.capture()); assertEquals(1,captured.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(captured.getAllValues().get(0).getOrderNo().matches("^FL-WH-\\d{8}-\\d{6}$")); verify(sequences,never()).nextValue(any(java.time.LocalDate.class)); }
|
||||
@Test void rejectsMissingOwnerSnapshotBeforeAnyLastlegWrite(){ EventEnvelope source=event(); com.fasterxml.jackson.databind.node.ObjectNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node.path("orders").get(0)).put("customerName"," "); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope missing=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(missing)); assertTrue(error.getMessage().contains("尾程事件负载无效")||error.getMessage().contains("货主快照")); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); }
|
||||
@Test void freezesFormalPlannedWarehouseArrivalFromLoadingEvent(){ EventEnvelope source=event(); JsonNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node).put("estimatedWarehouseArrivalAt","2026-10-03T09:00"); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope changed=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); service.consume(changed); ArgumentCaptor<LastlegOrder> captor=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(captor.capture()); assertTrue(captor.getAllValues().stream().allMatch(order->java.time.LocalDateTime.of(2026,10,3,9,0).equals(order.getEstimatedWarehouseAt()))); }
|
||||
@Test void loadingCreatesAwaitingPickupReadyGateWithoutPollutingShippingVersion(){ service.consume(event()); verify(shippingGates).initialize("book-1","100","","MSCU6639870",0L,1L); }
|
||||
@Test void rejectsUnnormalizedActualContainerNumberInsteadOfUsingCabinetNumber(){ EventEnvelope source=event(); JsonNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node).put("containerNo"," mscu6639870 ").put("cabinetNo","FALLBACK-CABINET"); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope changed=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); assertThrows(IllegalArgumentException.class,()->service.consume(changed)); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); }
|
||||
@@ -13,7 +15,7 @@ class LoadingCompletedConsumerTest {
|
||||
@Test void rejectsDifferentContainerNumberForSameSourceBookingBeforeAnyBusinessWrite(){ EventEnvelope source=event(); frozenContainerNos.put("book-1","CSQU3054383"); IllegalStateException error=assertThrows(IllegalStateException.class,()->service.consume(source)); assertTrue(error.getMessage().contains("sourceContainerBookingId")); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); }
|
||||
@Test void rejectsExistingOrderContainerDriftForSameSourceBooking(){ when(orders.lockContainerNumbers("book-1")).thenReturn(List.of("MSCU6639870","CSQU3054383")); assertThrows(IllegalStateException.class,()->service.consume(event())); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); }
|
||||
@Test void acceptsSeparateCustomerEventsForTheSameFrozenContainerIdentity(){ EventEnvelope first=event(); assertEquals(2,service.consume(first)); com.fasterxml.jackson.databind.node.ObjectNode node=first.payload().deepCopy(); int index=0; for(JsonNode order:node.path("orders")){ ((com.fasterxml.jackson.databind.node.ObjectNode)order).put("customerId","200").put("customerName","Customer 200").put("sourceInboundId","customer-200-in-"+(++index)); } String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope second=new EventEnvelope("123e4567-e89b-12d3-a456-426614174200",first.eventType(),first.eventVersion(),first.occurredAt(),first.sourceRegion(),first.targetRegion(),"200",first.aggregateType(),first.aggregateId(),first.aggregateVersion(),first.handoverVersion(),first.traceId(),first.correlationId(),first.causationId(),node,hash); assertEquals(2,service.consume(second)); verify(shippingGates,times(2)).initialize(eq("book-1"),anyString(),eq(""),eq("MSCU6639870"),eq(0L),eq(1L)); ArgumentCaptor<LastlegOrder> captured=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(4)).insert(captured.capture()); assertEquals(Set.of("100","200"),captured.getAllValues().stream().map(LastlegOrder::getCustomerId).collect(java.util.stream.Collectors.toSet())); assertTrue(captured.getAllValues().stream().allMatch(order->"MSCU6639870".equals(order.getContainerNo()))); }
|
||||
@Test void repeatedEventReturnsExistingEffect(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash(event.payloadHash()); when(inbox.selectById(event.eventId())).thenReturn(row); when(orders.countContainer("100","MSCU6639870")).thenReturn(2L); assertEquals(2,service.consume(event)); verify(orders).countContainer("100","MSCU6639870"); verify(orders,never()).insert(any(LastlegOrder.class)); verify(details,never()).insert(any(LastlegOrderDetail.class)); }
|
||||
@Test void replayPreservesExistingLmoNumberAndAddsNothing(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash(event.payloadHash()); when(inbox.selectById(event.eventId())).thenReturn(row); when(orders.countContainer("100","MSCU6639870")).thenReturn(2L); assertEquals(2,service.consume(event)); verify(orders).countContainer("100","MSCU6639870"); verify(sequences,never()).nextValue(any(java.time.LocalDate.class)); verify(orders,never()).insert(any(LastlegOrder.class)); verify(details,never()).insert(any(LastlegOrderDetail.class)); verify(histories,never()).insert(any(LastlegOrderStatusHistory.class)); }
|
||||
@Test void sameEventWithDifferentHashIsRejected(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash("sha256:"+"0".repeat(64)); when(inbox.selectById(event.eventId())).thenReturn(row); assertThrows(IllegalArgumentException.class,()->service.consume(event)); }
|
||||
@Test void tamperedPayloadHashIsRejectedBeforeInboxMutation(){ EventEnvelope source=event(); EventEnvelope tampered=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),source.payload(),"sha256:"+"0".repeat(64)); IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(tampered)); assertTrue(error.getMessage().contains("payloadHash")); verify(inbox,never()).insert(any(LastlegEventInbox.class)); }
|
||||
@Test void concurrentDuplicateKeyWithDifferentHashIsRejected(){ EventEnvelope event=event(); LastlegEventInbox raced=new LastlegEventInbox(); raced.setPayloadHash("sha256:"+"0".repeat(64)); when(inbox.selectById(event.eventId())).thenReturn(null,raced); doThrow(new org.springframework.dao.DuplicateKeyException("race")).when(inbox).insert(any(LastlegEventInbox.class)); IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(event)); assertTrue(error.getMessage().contains("摘要冲突")); verify(orders,never()).countContainer(anyString(),anyString()); }
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# 后端脚本管理指引
|
||||
|
||||
## 适用范围与读取顺序
|
||||
|
||||
- 本文件适用于后端根 `scripts/` 及其子目录;服务自己的 `<service>/scripts/` 继续受对应服务 `AGENTS.md` 约束。
|
||||
- 先用本文件判断脚本类别,再到 `README.md` 查询仓库级入口的用途、参数边界和副作用;Admin HTTP 模块继续读 `admin-test-env-modules/README.md`。
|
||||
- 不扫描 `target/`、`reports/`、`.logs/`、`node_modules/`、`.codex-*`、`output/` 或 `outputs/` 寻找业务脚本和验收事实。
|
||||
|
||||
## 脚本分类与入口
|
||||
|
||||
| 类别 | 正式入口 | 管理边界 |
|
||||
|---|---|---|
|
||||
| Admin 全量 HTTP 验证 | `admin-test-env-acceptance.mjs` | 默认模块覆盖平台全链路;定向验证使用 `--modules`,模块注册和依赖以总入口及 `admin-test-env-modules/README.md` 为准 |
|
||||
| Client 全量 HTTP 验证 | `client-test-env-acceptance.mjs` | 客户工作台完整链路;`client-phase2-test-env-acceptance.mjs` 只是二期专项,不替代全量入口 |
|
||||
| 待审核订单造数 | `create-pending-review-orders.mjs` | 使用已配置客户创建并提交订单,停在待审核;不审核、不推进后续履约 |
|
||||
| 批量待分柜造数 | `it-test-wait-split-bulk.mjs` | 固定“IT测试”和“义乌仓”,随机客户可选真实线路并尽力推进到 `WAIT_SPLIT`;失败记录后继续,不自动删除成功数据;只复用 `TP-001`/`YW-TP-002`,不新增托盘或库位,普通位不足落 `A0` |
|
||||
| Admin HTTP 内部模块 | `admin-test-env-modules/*.mjs` | 由 Admin 总入口动态加载,除文档明确的独立编排器外不得当作独立全量入口;`*contract.test.mjs` 只做离线合同 |
|
||||
| 定向 HTTP/诊断 | 以 `README.md` 的“仓库级入口清单”为准 | 必须明确是只读诊断还是会补证据、创建资源或推进状态,不能用诊断成功冒充全量通过 |
|
||||
| 本地质量与静态合同 | `backend-full-regression.ps1`、`frontend-full-regression.ps1`、`mapper-scan-contract.mjs`、`script-documentation-contract.test.mjs` 等 | 默认不访问测试环境;数据库非 dry-run、Nacos 发布等有写入能力的入口必须按 README 单独识别 |
|
||||
| 部署与基础设施 | `../deploy/scripts/`、`../deploy/rocketmq/` | 靠近部署组件管理,不移动到根脚本目录;部署、初始化数据库或发布配置必须获得当前轮明确授权 |
|
||||
| 服务局部合同 | `../firstleg/scripts/`、`../lastleg-service/scripts/`、`../warehouse/scripts/` | 只服务所属模块,入口与命令登记在服务 `AGENTS.md` 或局部 README;不得散落到 `src/test/scripts/` |
|
||||
|
||||
## 新增与修改规则
|
||||
|
||||
- 跨服务编排、全仓质量检查、测试环境验收和通用诊断放在根 `scripts/`;只理解单一服务源码或数据的脚本放在 `<service>/scripts/`。
|
||||
- 可直接运行的脚本必须在 `README.md` 登记:用途、目标环境、是否写数据、凭据来源、报告位置、失败/重跑语义和清理边界。内部模块必须登记到 `admin-test-env-modules/README.md`,并按需同步总入口模块注册及依赖展开。
|
||||
- 文件命名使用稳定业务语义:离线 Node 合同以 `.test.mjs` 结尾;环境写入入口显式包含 `test-env` 或在 README 中标记目标环境;fixture/verify 必须区分准备写入和只读核验。
|
||||
- 不在源码、AGENTS、README 或报告中写入密码、Token、私钥、真实客户数据和固定环境密钥;环境地址与账号从入口已支持的参数或本机环境配置读取。
|
||||
- 开发与测试验收配置分别只维护在 `$env:USERPROFILE\.codex\chongchong-global-dev-acceptance.env`、`$env:USERPROFILE\.codex\chongchong-global-test-acceptance.env`。脚本不得自动猜测环境或读取另一份文件;运行者必须通过位于脚本路径之前的 `node --env-file="<明确文件>"` 选择环境。
|
||||
- 不直接写业务数据库伪造 HTTP 验收成功,不吞掉 `deploymentBlocker`、业务错误码、`requestId` 或 `traceId`。造数脚本的部分成功数据默认保留,清理、重置或删除必须另行授权。
|
||||
- 不默认并发运行共享环境造数。只有用户明确要求并确认序号、报告目录和服务容量边界时,才允许拆分 worker;每个 worker 必须使用不重叠序号和独立报告目录。
|
||||
- 修改公共参数、上下文或模块签名时,定向检查总入口、生产模块、fixture、合同测试、README 和调用命令,禁止保留旧参数兼容分支。
|
||||
|
||||
## 最低验证
|
||||
|
||||
- 修改单个 Node 脚本先运行 `node --check <script>`;PowerShell 使用解析检查或该脚本声明的 `-DryRun`,Shell/Groovy 使用现有离线合同,不为验证触发部署。
|
||||
- 修改 Admin 模块时运行直接对应的 `*contract.test.mjs`;修改造数链时同时运行 `admin-test-env-modules/it-test-wait-split-bulk-contract.test.mjs` 等受影响合同。
|
||||
- 新增、移动或删除脚本后运行 `node --test scripts/script-documentation-contract.test.mjs`,确认目录已登记且所有直接入口都有说明。
|
||||
- 修改任一 `AGENTS.md` 后从后端根运行 `./scripts/agents-index-contract.ps1`;真实 HTTP、数据库、Nacos、Jenkins 和部署验证仍须当前轮明确授权。
|
||||
+40
-3
@@ -1,7 +1,44 @@
|
||||
# 全量 HTTP 验收脚本索引
|
||||
# 后端脚本管理与全量 HTTP 验收索引
|
||||
|
||||
全量验收只认测试环境 HTTP 脚本。它们通过 Gateway 调用已部署服务,按业务模块执行真实登录、权限、状态、幂等、跨服务和数据链路检查;脚本通过后,剩余工作是 Admin/Client 的真实接口调用与浏览器样式验收,以及 PDA 页面/设备验收。
|
||||
|
||||
进入本目录维护或运行脚本前先读 `scripts/AGENTS.md`;本文件是脚本入口、用途和副作用的唯一总索引,具体模块说明继续下沉到相邻 README。
|
||||
|
||||
## 验收环境变量文件
|
||||
|
||||
开发与测试环境的账号、密码和 Gateway 只维护在用户目录的两份外部文件中,不写入仓库:
|
||||
|
||||
| 环境 | 文件 |
|
||||
|---|---|
|
||||
| 开发环境 | `$env:USERPROFILE\.codex\chongchong-global-dev-acceptance.env` |
|
||||
| 测试环境 | `$env:USERPROFILE\.codex\chongchong-global-test-acceptance.env` |
|
||||
|
||||
两份文件统一提供以下变量:
|
||||
|
||||
```text
|
||||
ADMIN_TEST_GATEWAY
|
||||
ADMIN_OVERSEAS_GATEWAY
|
||||
ADMIN_TEST_USERNAME
|
||||
ADMIN_TEST_PASSWORD
|
||||
MERCHANT_TEST_USERNAME
|
||||
MERCHANT_TEST_PASSWORD
|
||||
```
|
||||
|
||||
脚本只读取当前 Node 进程的环境变量,不会自行查找或加载上述文件。运行时必须把 `--env-file` 放在脚本路径之前,明确选择环境:
|
||||
|
||||
```powershell
|
||||
# 开发环境
|
||||
node --env-file="$env:USERPROFILE\.codex\chongchong-global-dev-acceptance.env" scripts/admin-test-env-acceptance.mjs
|
||||
|
||||
# 测试环境
|
||||
node --env-file="$env:USERPROFILE\.codex\chongchong-global-test-acceptance.env" scripts/admin-test-env-acceptance.mjs
|
||||
|
||||
# 测试环境批量待分柜造数
|
||||
node --env-file="$env:USERPROFILE\.codex\chongchong-global-test-acceptance.env" scripts/it-test-wait-split-bulk.mjs
|
||||
```
|
||||
|
||||
Client 全量、Client 二期、待审核订单和批量待分柜均复用这套变量,不再维护各自的 Gateway、管理员或客户账号变量。外部文件必须保留在仓库之外,不得复制到项目目录、报告或提交记录。
|
||||
|
||||
## 脚本目录归属
|
||||
|
||||
脚本按作用域放置,不要求全部集中到根 `scripts/`:
|
||||
@@ -64,7 +101,7 @@
|
||||
|---|---|---|---|
|
||||
| 平台后台全模块 HTTP 验收 | `scripts/admin-test-env-acceptance.mjs` | 会创建、更新或最终停用测试数据 | `--modules` 默认列表中的平台客户、仓储、订单、入库、分柜、装柜、船期、拖车、报关、财务、通知、统计等模块 |
|
||||
| 客户工作台完整 HTTP 链路验收 | `scripts/client-test-env-acceptance.mjs` | 会创建测试企业、订单、入库、权限和站内消息数据 | 注册/审核、客户隔离、订单、入库计费、文件、财务、客户侧权限,以及我的消息未读/已读/全部已读链路 |
|
||||
| 固定客户批量创建待审核订单 | `scripts/create-pending-review-orders.mjs` | 只使用固定客户账号创建指定数量订单 | 登录 `17512095155`,选择真实可计价仓库/渠道,创建并提交订单,停在待审核,不创建客户、不审核订单 |
|
||||
| 固定客户批量创建待审核订单 | `scripts/create-pending-review-orders.mjs` | 只使用 `MERCHANT_TEST_USERNAME/PASSWORD` 配置的客户账号创建指定数量订单 | 使用 `ADMIN_TEST_GATEWAY`,选择真实可计价仓库/渠道,创建并提交订单,停在待审核,不创建客户、不审核订单 |
|
||||
| Client 二期专项 HTTP 验收 | `scripts/client-phase2-test-env-acceptance.mjs` | 会写入测试环境业务数据 | 二期客户入库、测量、费用和权限专项场景;不是替代 Client 全量入口 |
|
||||
| 人工分柜至 LOADED 分段验收 | `scripts/admin-test-env-acceptance.mjs` 的 `manual-*-fixture/verify` 可选模块;步骤和参数见 `scripts/admin-test-env-modules/README.md` | fixture 通过正式 HTTP 准备本轮数据,verify 只读核验;不进入默认全量 | `WAIT_SPLIT → 人工分柜 → 推送预装 → 订舱关联/计划确认 → PDA 领取与原子 batch → 五类证据/完成 → LOADED` |
|
||||
|
||||
@@ -74,7 +111,7 @@
|
||||
|
||||
## 固定客户待审核订单
|
||||
|
||||
需要为人工审核准备固定数量的订单时,使用 `create-pending-review-orders.mjs`。可直接在文件顶部 `CONFIG` 中填写 `GATEWAY`、`USERNAME`、`PASSWORD` 和 `COUNT`;固定账号为 `17512095155`。脚本不会创建客户,也不会调用平台审核、入库或后续履约接口。数量支持 `1-100`,每张订单创建后立即提交并回查客户端状态为 `SUBMITTED`,报告统一记录为 `PENDING_REVIEW`。
|
||||
需要为人工审核准备固定数量的订单时,使用 `create-pending-review-orders.mjs`。Gateway 统一读取 `ADMIN_TEST_GATEWAY`,客户账号统一读取 `MERCHANT_TEST_USERNAME/PASSWORD`;数量可通过 `--count` 或 `PENDING_REVIEW_ORDER_COUNT` 设置,默认 50。脚本不会创建客户,也不会调用平台审核、入库或后续履约接口。数量支持 `1-100`,每张订单创建后立即提交并回查客户端状态为 `SUBMITTED`,报告统一记录为 `PENDING_REVIEW`。
|
||||
|
||||
```powershell
|
||||
node scripts/create-pending-review-orders.mjs
|
||||
|
||||
@@ -13,6 +13,33 @@ const OPTIONAL_MODULES = ['manual-order-splitting-fixture', 'manual-order-splitt
|
||||
'manual-loading-complete-fixture', 'manual-loading-complete-verify',
|
||||
'coload-groups']
|
||||
const AVAILABLE_MODULES = new Set([...CONFIGURED_MODULES, ...OPTIONAL_MODULES])
|
||||
const A0_LOCATION_PAGE_SIZE = 200
|
||||
const A0_MAX_LOCATION_MUTATIONS = 8
|
||||
|
||||
export function planA0LocationIsolation(locations, requiredSlots = 1,
|
||||
maxMutations = A0_MAX_LOCATION_MUTATIONS) {
|
||||
const slots = Math.max(1, Number.isInteger(Number(requiredSlots)) ? Number(requiredSlots) : 1)
|
||||
const limit = Math.max(0, Number.isInteger(Number(maxMutations)) ? Number(maxMutations) : 0)
|
||||
const unique = new Map()
|
||||
for (const location of Array.isArray(locations) ? locations : []) {
|
||||
const locationId = location?.id == null ? '' : String(location.id)
|
||||
if (!locationId || location?.sharedOverflow === true || unique.has(locationId)) continue
|
||||
unique.set(locationId, location)
|
||||
}
|
||||
const normal = [...unique.values()].sort((left, right) =>
|
||||
String(left?.locationCode || '').localeCompare(String(right?.locationCode || ''))
|
||||
|| String(left.id).localeCompare(String(right.id)))
|
||||
const remainingNormal = Math.max(0, slots - 1)
|
||||
const mutationsRequired = Math.max(0, normal.length - remainingNormal)
|
||||
return {
|
||||
enabled: mutationsRequired <= limit,
|
||||
requiredSlots: slots,
|
||||
normalCount: normal.length,
|
||||
expectedRemainingNormal: Math.min(normal.length, remainingNormal),
|
||||
mutationsRequired,
|
||||
targets: mutationsRequired <= limit ? normal.slice(0, mutationsRequired) : [],
|
||||
}
|
||||
}
|
||||
|
||||
const MODULE_DEPENDENCIES = new Map([
|
||||
['suppliers', ['merchant-qualification-fixture']],
|
||||
@@ -1236,15 +1263,17 @@ export class AcceptanceRun {
|
||||
this.assert('phase3-loading-fixture', 'A0 隔离使用本轮订单收货仓', null,
|
||||
Boolean(warehouseId), `warehouseId=${warehouseId || 'missing'}`,
|
||||
{ scenario: 'phase3-loading-a0-fixture', deploymentBlocker: true })
|
||||
const disabledLocations = requireSharedOverflow
|
||||
? await this.disableAvailableLocationsForA0(warehouseId) : []
|
||||
this.context.phase3LoadingRequireSharedOverflow = requireSharedOverflow
|
||||
const requiredPalletCount = Math.max(1, Number(this.context?.waitSplitVariant?.palletCount || 1))
|
||||
const isolation = requireSharedOverflow
|
||||
? await this.disableAvailableLocationsForA0(warehouseId, requiredPalletCount)
|
||||
: { forced: false, disabled: [] }
|
||||
this.context.phase3LoadingRequireSharedOverflow = isolation.forced === true
|
||||
try {
|
||||
const measurement = await import(new URL('./admin-test-env-modules/inbound-measurement.mjs', import.meta.url))
|
||||
await this.executeModule('inbound-measurement-phase3-loading-fixture', () => measurement.run(this))
|
||||
} finally {
|
||||
if (requireSharedOverflow) {
|
||||
await this.restoreAvailableLocationsAfterA0(warehouseId, disabledLocations)
|
||||
await this.restoreAvailableLocationsAfterA0(warehouseId, isolation)
|
||||
}
|
||||
}
|
||||
const fixture = this.createdResources.inboundMeasurement || this.createdResources.orders
|
||||
@@ -1430,24 +1459,60 @@ export class AcceptanceRun {
|
||||
{ scenario: 'phase3-phase4-producer-fixture', deploymentBlocker: true })
|
||||
}
|
||||
|
||||
async disableAvailableLocationsForA0(warehouseId) {
|
||||
async listAvailableLocationsForA0(warehouseId, module, options) {
|
||||
const locations = []
|
||||
const seen = new Set()
|
||||
let expectedTotal = null
|
||||
for (let pageNumber = 1; pageNumber <= 100; pageNumber += 1) {
|
||||
const response = await this.request('GET',
|
||||
`/api/platform/v1/locations?tenantId=0&page=${pageNumber}&size=${A0_LOCATION_PAGE_SIZE}&warehouseId=${encodeURIComponent(warehouseId)}&status=AVAILABLE`)
|
||||
const page = this.apiSuccess(module, `查询 A0 隔离前可用库位第 ${pageNumber} 页`, response,
|
||||
`page=${pageNumber}`, options)
|
||||
if (!page) return null
|
||||
const pageTotal = Number(page.total)
|
||||
const records = Array.isArray(page.records) ? page.records : []
|
||||
if (!Number.isInteger(pageTotal) || pageTotal < 0
|
||||
|| (expectedTotal !== null && pageTotal !== expectedTotal)) {
|
||||
this.assert(module, 'A0 隔离库位分页总数稳定', response, false,
|
||||
`page=${pageNumber}, firstTotal=${expectedTotal ?? 'missing'}, currentTotal=${page.total ?? 'missing'}`, options)
|
||||
return null
|
||||
}
|
||||
expectedTotal ??= pageTotal
|
||||
for (const location of records) {
|
||||
const locationId = location?.id == null ? '' : String(location.id)
|
||||
if (locationId && !seen.has(locationId)) {
|
||||
seen.add(locationId)
|
||||
locations.push(location)
|
||||
}
|
||||
}
|
||||
if (locations.length >= expectedTotal) break
|
||||
if (records.length === 0) break
|
||||
}
|
||||
const complete = locations.length === expectedTotal
|
||||
this.assert(module, 'A0 隔离库位分页完整且无重复', null, complete,
|
||||
`total=${expectedTotal ?? 'missing'}, uniqueRecords=${locations.length}`, options)
|
||||
return complete ? locations : null
|
||||
}
|
||||
|
||||
async disableAvailableLocationsForA0(warehouseId, requiredSlots = 1) {
|
||||
const module = 'phase3-loading-fixture'
|
||||
const options = { scenario: 'phase3-loading-a0-fixture', deploymentBlocker: true }
|
||||
if (!warehouseId) {
|
||||
this.assert(module, 'A0 隔离收货仓 ID 完整', null, false, 'warehouseId=missing', options)
|
||||
return []
|
||||
return { forced: false, disabled: [] }
|
||||
}
|
||||
const locations = await this.listAvailableLocationsForA0(warehouseId, module, options)
|
||||
if (!locations) return { forced: false, disabled: [] }
|
||||
const isolation = planA0LocationIsolation(locations, requiredSlots)
|
||||
if (!isolation.enabled) {
|
||||
this.addStep(module, 'A0 大仓保护:不批量停用普通库位', null, true,
|
||||
`normal=${isolation.normalCount}, requiredSlots=${isolation.requiredSlots}, mutationsRequired=${isolation.mutationsRequired}, safetyLimit=${A0_MAX_LOCATION_MUTATIONS}`,
|
||||
{ scenario: options.scenario })
|
||||
return { forced: false, disabled: [], isolation }
|
||||
}
|
||||
const pageResponse = await this.request('GET',
|
||||
`/api/platform/v1/locations?tenantId=0&page=1&size=200&warehouseId=${encodeURIComponent(warehouseId)}&status=AVAILABLE`)
|
||||
const page = this.apiSuccess(module, '查询 A0 隔离前全部可用普通库位', pageResponse, '', options)
|
||||
const records = Array.isArray(page?.records) ? page.records : []
|
||||
const locations = records.filter((item) => item.sharedOverflow !== true)
|
||||
this.assert(module, 'A0 隔离库位分页完整', pageResponse,
|
||||
Number(page?.total) === records.length && records.length < 200,
|
||||
`total=${page?.total ?? 'missing'}, records=${records.length}, normal=${locations.length}`, options)
|
||||
const disabled = []
|
||||
const skippedOccupied = []
|
||||
for (const location of locations) {
|
||||
for (const location of isolation.targets) {
|
||||
const response = await this.request('PATCH',
|
||||
`/api/platform/v1/locations/${encodeURIComponent(location.id)}/status?tenantId=0&warehouseId=${encodeURIComponent(warehouseId)}`,
|
||||
{ status: 'DISABLED', version: Number(location.version) })
|
||||
@@ -1462,16 +1527,19 @@ export class AcceptanceRun {
|
||||
}
|
||||
const availableResponse = await this.request('GET',
|
||||
`/api/platform/v1/locations/available?tenantId=0&warehouseId=${encodeURIComponent(warehouseId)}&limit=200`)
|
||||
const available = this.apiSuccess(module, '停用后回查收货仓无可用普通库位', availableResponse, '', options)
|
||||
this.assert(module, '测量前 A0 溢出条件成立', availableResponse,
|
||||
Array.isArray(available) && available.length === 0,
|
||||
`disabled=${disabled.length}, skippedOccupied=${skippedOccupied.map((item) => `${item.code}:${item.id}`).join('|') || 'none'}, available=${Array.isArray(available) ? available.length : 'missing'}`, options)
|
||||
return disabled
|
||||
const available = this.apiSuccess(module, '停用后回查收货仓剩余普通库位', availableResponse, '', options)
|
||||
const remainingNormal = Array.isArray(available)
|
||||
? available.filter((item) => item?.sharedOverflow !== true) : null
|
||||
const forced = Array.isArray(remainingNormal) && remainingNormal.length < isolation.requiredSlots
|
||||
this.assert(module, '测量前 A0 溢出条件成立', availableResponse, forced,
|
||||
`requiredSlots=${isolation.requiredSlots}, disabled=${disabled.length}, skippedOccupied=${skippedOccupied.map((item) => `${item.code}:${item.id}`).join('|') || 'none'}, remainingNormal=${Array.isArray(remainingNormal) ? remainingNormal.length : 'missing'}`, options)
|
||||
return { forced, disabled, isolation }
|
||||
}
|
||||
|
||||
async restoreAvailableLocationsAfterA0(warehouseId, disabledLocations) {
|
||||
async restoreAvailableLocationsAfterA0(warehouseId, result) {
|
||||
const module = 'phase3-loading-fixture'
|
||||
const options = { scenario: 'phase3-loading-a0-fixture', deploymentBlocker: true }
|
||||
const disabledLocations = Array.isArray(result?.disabled) ? result.disabled : []
|
||||
for (const location of disabledLocations) {
|
||||
const response = await this.request('PATCH',
|
||||
`/api/platform/v1/locations/${encodeURIComponent(location.id)}/status?tenantId=0&warehouseId=${encodeURIComponent(warehouseId)}`,
|
||||
@@ -1481,7 +1549,10 @@ export class AcceptanceRun {
|
||||
`locationId=${location.id}, restoreVersion=${location.version}`, options)
|
||||
}
|
||||
this.createdResources.phase3LoadingA0Isolation = {
|
||||
warehouseId, restored: disabledLocations.map((item) => item.id), marker: this.marker,
|
||||
warehouseId, forced: result?.forced === true,
|
||||
normalCount: result?.isolation?.normalCount ?? null,
|
||||
mutationsRequired: result?.isolation?.mutationsRequired ?? 0,
|
||||
restored: disabledLocations.map((item) => item.id), marker: this.marker,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ export async function run(context) {
|
||||
|---|---|---|
|
||||
| 商户资质与下单身份 | `merchant-qualification-fixture.mjs` | `merchant-qualification-fixture-contract.test.mjs` |
|
||||
| 公共主数据与往来单位 | `master-data.mjs` | `master-data-partner-contract.test.mjs` |
|
||||
| 订单创建、可计价主数据与审核异步收敛 | `orders.mjs` | `orders-pricing-fixture-contract.test.mjs`、`orders-approval-convergence-contract.test.mjs` |
|
||||
| 订单创建、可计价主数据、幂等零漂移与审核异步收敛 | `orders.mjs` | `orders-pricing-fixture-contract.test.mjs`、`orders-lifecycle-idempotency-contract.test.mjs`、`orders-approval-convergence-contract.test.mjs` |
|
||||
| 入库测量、首次打印冻结箱码与墨家扫码 | `inbound-measurement.mjs` | `inbound-measurement-box-code-contract.test.mjs` |
|
||||
| 入仓自动头程费用、派生详情、人工发布与 MANUAL 其他费用版本 | `inbound-fees.mjs`(前置:`merchant-qualification-fixture,orders,inbound-measurement`) | `inbound-fees-contract.test.mjs` |
|
||||
| 三期模拟柜关联、柜次概览、渠道隔离、装柜模型桥接、PDA、动态增量、四类货柜资料、报关与证据/出仓门禁 | `phase3-container-loading.mjs`、`ocean-booking-fixture.mjs`、`phase3-container-overview.mjs`、`phase3-loading.mjs`、`phase3-loading-model-bridge.mjs`、`phase3-pda-convergence.mjs`、`phase3-evidence-gate.mjs`、`phase3-loading-finalize.mjs`、`phase3-container-documents.mjs`、`phase3-customs.mjs`、`phase3-outbound-dispatch.mjs` | `container-simulation-association-contract.test.mjs`、`container-specification-fixture-contract.test.mjs`、`ocean-booking-channel-contract.test.mjs`、`phase3-container-regression-contract.test.mjs`、`phase3-loading-model-bridge-contract.test.mjs`、`phase3-loading-dynamic-final-contract.test.mjs`、`phase3-container-documents-contract.test.mjs`、`phase3-phase4-departure-pickup-chain-contract.test.mjs`、`phase3-producer-chain-contract.test.mjs`、`manual-state-loading-pda-contract.test.mjs` |
|
||||
|
||||
@@ -37,6 +37,7 @@ test('验收只使用本轮 marker fixture,解析真实 XLSX 并覆盖门禁
|
||||
assert.match(source, /无 Finance export 权限拒绝创建/)
|
||||
assert.match(source, /Provider 失败信息可见/)
|
||||
assert.match(source, /9223372036854775806/)
|
||||
assert.match(source, /find\(\(item\) => item\?\.direction === 'RECEIVABLE'\)/)
|
||||
assert.doesNotMatch(source, /exportType:\s*['"]CUSTOMER_LIST/)
|
||||
assert.doesNotMatch(source, /FINANCE_FEE_SOURCE_LIST:[^\n]*参与结算/)
|
||||
})
|
||||
|
||||
@@ -13,7 +13,7 @@ export const REQUIRED_PERMISSIONS = Object.freeze([
|
||||
])
|
||||
|
||||
export const EXPORT_SPECS = Object.freeze({
|
||||
FINANCE_CUSTOMER_ACCOUNT_LIST: ['客户编码', '客户', '联系人', '联系电话', '待收(CNY)', '待开账(CNY)', '本期已收(CNY)', '总成本(CNY)', '已付成本(CNY)', '待付(CNY)', '客户状态', '最近变动'],
|
||||
FINANCE_CUSTOMER_ACCOUNT_LIST: ['客户编码', '客户', '联系人', '联系电话', '待收客户款(CNY)', '待开账(CNY)', '本期已收(CNY)', '总成本(CNY)', '已线下支付供应商款(CNY)', '待付供应商款(CNY)', '客户状态', '最近变动'],
|
||||
FINANCE_CUSTOMER_ACCOUNT_LEDGER: ['流水号', '发生时间', '业务类型', '关联单据', '应收变动(CNY)', '应付变动(CNY)', '变动后未收(CNY)', '操作人', '备注'],
|
||||
FINANCE_FEE_SOURCE_LIST: ['客户', '收付方向', '来源类型', '来源单据ID', '来源版本', '入仓号', '柜次号', '业务发生日', '费用项目', '计费量', '单价(CNY)', '金额(CNY)', '审核状态', '来源状态', '已生成对账单'],
|
||||
FINANCE_RECONCILIATION_STATEMENT_LIST: ['对账单号', '客户', '结算起始日', '结算结束日', '账期(天)', '货币', '总应收金额(CNY)', '已收金额(CNY)', '未收金额(CNY)', '是否结清', '审核状态', '备注', '创建人', '创建时间', '更新人', '更新时间'],
|
||||
@@ -233,7 +233,10 @@ export async function run(context) {
|
||||
const ar = await official(context, `/api/platform/finance/v1/settlements/${encodeURIComponent(arId)}`, '读取应收结算导出事实')
|
||||
const ap = await official(context, `/api/platform/finance/v1/settlements/${encodeURIComponent(apId)}`, '读取应付结算导出事实')
|
||||
const fee = await official(context, `/api/platform/finance/v1/fee-pool?customerId=${encodeURIComponent(customerId)}&direction=ALL`, '读取费用来源导出事实')
|
||||
const feeRow = values(fee.data)[0]
|
||||
// PAYABLE rows are removed from the payable pool once an AP settlement owns
|
||||
// them. Use a receivable row so the export filter still has an authoritative
|
||||
// post-settlement match instead of asserting against an intentionally consumed row.
|
||||
const feeRow = values(fee.data).find((item) => item?.direction === 'RECEIVABLE') || values(fee.data)[0]
|
||||
const statementNo = statement.data?.statementNo
|
||||
const arSummary = ar.data?.summary || ar.data
|
||||
const apSummary = ap.data?.summary || ap.data
|
||||
|
||||
@@ -16,6 +16,7 @@ test('Finance AP consumes only published fin_fee_source rows and never substitut
|
||||
})
|
||||
const financePermissions = [
|
||||
'platform:finance:customer-account:list', 'platform:finance:customer-arap:list',
|
||||
'platform:finance:fee-source:edit',
|
||||
'platform:finance:fee-source:review', 'platform:finance:customer-statement:list',
|
||||
'platform:finance:reconciliation:create', 'platform:finance:reconciliation:review',
|
||||
'platform:finance:reconciliation:issue', 'platform:finance:reconciliation:adjust',
|
||||
@@ -61,6 +62,7 @@ function fakeContext(withFixture = true, permissions = financePermissions, denyF
|
||||
const calls = []
|
||||
const steps = []
|
||||
const coverageGaps = []
|
||||
const submittedSourceIds = new Set()
|
||||
const reviewedSourceIds = new Set()
|
||||
|
||||
function responseForStatement(statement) { return structuredClone(statement) }
|
||||
@@ -124,14 +126,24 @@ function fakeContext(withFixture = true, permissions = financePermissions, denyF
|
||||
const feeSourceDetailMatch = requestPath.match(/\/fee-sources\/(\d+)$/)
|
||||
if (method === 'GET' && feeSourceDetailMatch) {
|
||||
const localId = feeSourceDetailMatch[1]
|
||||
return ok({ feeSourceId: localId, reviewStatus: reviewedSourceIds.has(localId) ? 'APPROVED' : 'PENDING',
|
||||
sourceStatus: 'AVAILABLE', statementGenerated: false, operationVersion: reviewedSourceIds.has(localId) ? 1 : 0 })
|
||||
return ok({ feeSourceId: localId, reviewStatus: reviewedSourceIds.has(localId) ? 'APPROVED'
|
||||
: submittedSourceIds.has(localId) ? 'PENDING' : 'DRAFT',
|
||||
sourceStatus: 'AVAILABLE', statementGenerated: false,
|
||||
operationVersion: reviewedSourceIds.has(localId) ? 2 : submittedSourceIds.has(localId) ? 1 : 0 })
|
||||
}
|
||||
const feeSourceSubmitMatch = requestPath.match(/\/fee-sources\/(\d+)\/submit$/)
|
||||
if (method === 'POST' && feeSourceSubmitMatch) {
|
||||
submittedSourceIds.add(feeSourceSubmitMatch[1])
|
||||
const submitted = { feeSourceId: feeSourceSubmitMatch[1], reviewStatus: 'PENDING',
|
||||
sourceStatus: 'AVAILABLE', statementGenerated: false, operationVersion: 1 }
|
||||
if (idem) idempotency.set(idem, submitted)
|
||||
return ok(submitted)
|
||||
}
|
||||
const feeSourceReviewMatch = requestPath.match(/\/fee-sources\/(\d+)\/review$/)
|
||||
if (method === 'POST' && feeSourceReviewMatch) {
|
||||
reviewedSourceIds.add(feeSourceReviewMatch[1])
|
||||
const reviewed = { feeSourceId: feeSourceReviewMatch[1], reviewStatus: 'APPROVED',
|
||||
sourceStatus: 'AVAILABLE', statementGenerated: false, operationVersion: 1 }
|
||||
sourceStatus: 'AVAILABLE', statementGenerated: false, operationVersion: 2 }
|
||||
if (idem) idempotency.set(idem, reviewed)
|
||||
return ok(reviewed)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ const LEGACY_STATEMENT_VIEW_PERMISSION = 'platform:finance:bill:view'
|
||||
const REQUIRED_PERMISSIONS = [
|
||||
'platform:finance:customer-account:list',
|
||||
'platform:finance:customer-arap:list',
|
||||
'platform:finance:fee-source:edit',
|
||||
'platform:finance:fee-source:review',
|
||||
STATEMENT_LIST_PERMISSION,
|
||||
'platform:finance:reconciliation:create',
|
||||
@@ -172,6 +173,16 @@ async function approveFixtureSources(context, fixture) {
|
||||
let detail = context.apiSuccess(MODULE, '回读本轮 Finance 费用来源审核状态', detailResponse,
|
||||
`feeSourceId=${source.feeSourceId}`, OPTIONS)
|
||||
if (!detail) return false
|
||||
if (detail.reviewStatus === 'DRAFT' || detail.reviewStatus === 'REJECTED') {
|
||||
const submitResponse = await write(context, 'POST', `${BASE}/fee-sources/${encodeURIComponent(source.feeSourceId)}/submit`, {
|
||||
reason: context.marker, expectedVersion: Number(detail.operationVersion),
|
||||
}, `fee-source-submit-${source.feeSourceId}`)
|
||||
detail = context.apiSuccess(MODULE, '提交本轮 Finance 费用来源审核', submitResponse,
|
||||
`feeSourceId=${source.feeSourceId}`, OPTIONS)
|
||||
context.assert(MODULE, '费用来源提交后进入待审核', submitResponse,
|
||||
detail?.reviewStatus === 'PENDING' && detail?.sourceStatus === 'AVAILABLE',
|
||||
`feeSourceId=${source.feeSourceId}, reviewStatus=${detail?.reviewStatus ?? 'missing'}, sourceStatus=${detail?.sourceStatus ?? 'missing'}`, OPTIONS)
|
||||
}
|
||||
if (detail.reviewStatus === 'PENDING') {
|
||||
const reviewResponse = await write(context, 'POST', `${BASE}/fee-sources/${encodeURIComponent(source.feeSourceId)}/review`, {
|
||||
action: 'APPROVE', reason: context.marker, expectedVersion: Number(detail.operationVersion),
|
||||
|
||||
@@ -122,6 +122,8 @@ export function truckAppointmentFieldGaps(record) {
|
||||
status: isNonBlank,
|
||||
carrierId: isPositiveId,
|
||||
carrierName: isNonBlank,
|
||||
payableAmount: isNonNegativeNumber,
|
||||
currency: (value) => value === 'RMB' || value === 'USD',
|
||||
attachmentCount: isNonNegativeNumber,
|
||||
})
|
||||
}
|
||||
|
||||
+6
-10
@@ -6,7 +6,6 @@ import { fileURLToPath } from 'node:url'
|
||||
|
||||
import {
|
||||
containerFieldGaps,
|
||||
customsCaseFieldGaps,
|
||||
loadingListFieldGaps,
|
||||
oceanBookingFieldGaps,
|
||||
outboundAggregateFieldGaps,
|
||||
@@ -33,14 +32,13 @@ test('原型锁定货柜、预装、装柜、订舱与拖车关键列表字段',
|
||||
}
|
||||
})
|
||||
|
||||
test('八类 Admin 列表均以左固定操作列开头', () => {
|
||||
test('现行 Admin 履约列表均以左固定操作列开头', () => {
|
||||
const sources = [
|
||||
['container-loading/pages/ContainersView.vue', 1],
|
||||
['loading-lists/components/LoadingListsView.vue', 2],
|
||||
['ocean-bookings/pages/OceanBookingsView.vue', 1],
|
||||
['port-schedules/pages/PortSchedulesView.vue', 1],
|
||||
['trucking/pages/TruckAppointmentsView.vue', 1],
|
||||
['customs/pages/CustomsCasesView.vue', 1],
|
||||
['outbound-dispatch/pages/OutboundDispatchesView.vue', 1],
|
||||
]
|
||||
for (const [relative, count] of sources) {
|
||||
@@ -56,8 +54,7 @@ test('Admin 当前列表逐字段列与验收矩阵一致', () => {
|
||||
'loading-lists/components/LoadingListsView.vue': ['preloadNo', 'customers', 'skuCount', 'loadingListNo', 'totalPackageCount', 'totalGrossWeightKg', 'totalNetWeightKg', 'totalVolumeCbm', 'totalQuantity', 'createdByName'],
|
||||
'ocean-bookings/pages/OceanBookingsView.vue': ['bookingNo', 'customerName', 'billOfLadingNo', 'carrierName', 'bookingAgent', 'vesselName', 'pol', 'pod', 'etd', 'eta', 'updatedAt'],
|
||||
'port-schedules/pages/PortSchedulesView.vue': ['schedule.scheduleNo', 'schedule.sourceName', 'schedule.vesselName', 'schedule.departurePortName', 'schedule.destinationPortName', 'schedule.etdAt', 'schedule.etaAt', 'schedule.observedAt', 'expired'],
|
||||
'trucking/pages/TruckAppointmentsView.vue': ['appointmentNo', 'containerNo', 'company', 'driver', 'vehicleNo', 'store', 'payableAmount', 'receivableAmount', 'plannedLoadingAt', 'attachmentCount'],
|
||||
'customs/pages/CustomsCasesView.vue': ['customsNo', 'orderLink', 'inboundLink', 'checklistCode', 'checklistVersionNo', 'status', 'updatedAt'],
|
||||
'trucking/pages/TruckAppointmentsView.vue': ['appointmentNo', 'containerNo', 'company', 'driver', 'vehicleNo', 'store', 'payableAmount', 'currency', 'plannedLoadingAt', 'attachmentCount'],
|
||||
'outbound-dispatch/pages/OutboundDispatchesView.vue': ['inboundCode', 'customerName', 'containerNo', 'oceanBookingStatus', 'truckAppointmentStatus', 'pickTaskStatus', 'dispatchStatus', 'gate'],
|
||||
}
|
||||
for (const [relative, fields] of Object.entries(expectations)) {
|
||||
@@ -73,7 +70,8 @@ test('正式 DTO/OpenAPI 暴露矩阵使用的稳定字段', () => {
|
||||
['firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java', ['record Detail', 'record RealContainer', 'bookingAgentName', 'estimatedSailingAt', 'estimatedArrivalAt']],
|
||||
['firstleg/src/main/java/com/baiyue/firstleg/portschedule/dto/PortScheduleDtos.java', ['record ScheduleView', 'sourceName', 'etdAt', 'etaAt', 'expiresAt']],
|
||||
['firstleg/src/main/java/com/baiyue/firstleg/entity/TruckAppointmentEntity.java', ['appointmentNo', 'carrierName', 'plannedLoadingAt', 'containerNo']],
|
||||
['firstleg/src/main/java/com/baiyue/firstleg/customs/entity/CustomsCaseEntity.java', ['customsNo', 'checklistCode', 'checklistVersionNo', 'updatedAt']],
|
||||
['firstleg/src/main/java/com/baiyue/firstleg/customs/container/dto/ContainerCustomsDtos.java', ['record ContainerView', 'aggregateId', 'loadingPlanId', 'containerBookingId', 'planNo', 'cabinetNo', 'merchantStatus', 'coveredTaxRefundCount', 'documents']],
|
||||
['firstleg/src/main/resources/openapi/customs-v1.yaml', ['ContainerView:', 'WAITING_CUSTOMER_DOCUMENTS', 'READY_TO_START', 'DECLARING', 'COMPLETED']],
|
||||
['firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/dto/OutboundDispatchDtos.java', ['record Aggregate', 'customerName', 'pickTaskStatus', 'dispatchStatus', 'GateResult']],
|
||||
]
|
||||
for (const [relative, fields] of sources) {
|
||||
@@ -89,14 +87,13 @@ test('字段校验器接受正式完整投影并精确暴露缺字段', () => {
|
||||
[loadingListFieldGaps, { planId: ids.plan, containerBookingId: ids.container, loadingListNo: 'LL-1', containerNo: 'MSCU1234567', cabinetNo: 'CAB-1', warehouseName: '宁波仓', status: 'COMPLETED', totalPackageCount: 1, totalGrossWeightKg: 10, totalNetWeightKg: 9, totalVolumeCbm: 1, totalQuantity: 2, createdAt: at }, undefined, 'containerNo'],
|
||||
[oceanBookingFieldGaps, { id: ids.id, bookingNo: 'BK-1', status: 'RELEASED', vesselName: 'VESSEL', voyageNo: 'V1', estimatedSailingAt: at, estimatedArrivalAt: at, customerId: ids.id, customerName: '测试客户', shippingCompanyId: null, shippingCompanyName: null, bookingAgentId: ids.id, bookingAgentName: '代理' }, undefined, 'voyageNo'],
|
||||
[portScheduleFieldGaps, { portSchedule: { schedule: { id: ids.id, scheduleNo: 'PS-1', sourceName: '船公司', vesselName: 'VESSEL', voyageNo: 'V1', departurePortCode: 'CNNGB', destinationPortCode: 'USLAX', etdAt: at, etaAt: at, observedAt: at, updatedAt: at, expiresAt: at }, expired: false } }, undefined, 'sourceName'],
|
||||
[truckAppointmentFieldGaps, { id: ids.id, appointmentNo: 'TA-1', oceanBookingId: ids.id, containerBookingId: ids.container, loadingPlanId: ids.plan, warehouseId: ids.id, warehouseName: '宁波仓', carrierId: ids.id, carrierName: '拖车商', plannedLoadingAt: at, status: 'DRAFT', attachmentCount: 0 }, undefined, 'appointmentNo'],
|
||||
[customsCaseFieldGaps, { customsCase: { id: ids.id, customsNo: 'CUS-1', orderId: ids.order, inboundId: ids.inbound, containerBookingId: ids.container, checklistVersionId: ids.id, checklistCode: 'CHECK', checklistVersionNo: 'V1', status: 'DOCUMENTS_PENDING', updatedAt: at } }, undefined, 'checklistCode'],
|
||||
[truckAppointmentFieldGaps, { id: ids.id, appointmentNo: 'TA-1', oceanBookingId: ids.id, containerBookingId: ids.container, loadingPlanId: ids.plan, warehouseId: ids.id, warehouseName: '宁波仓', carrierId: ids.id, carrierName: '拖车商', payableAmount: 0, currency: 'RMB', plannedLoadingAt: at, status: 'DRAFT', attachmentCount: 0 }, undefined, 'appointmentNo'],
|
||||
[outboundAggregateFieldGaps, { containerBookingId: ids.container, containerNo: 'MSCU1234567', loadingPlanId: ids.plan, loadingPlanStatus: 'COMPLETED', oceanBookingId: ids.id, oceanBookingStatus: 'RELEASED', dispatchStatus: 'READY_FOR_DISPATCH', version: 1, inbounds: [{ orderId: ids.order, inboundId: ids.inbound, inboundCode: 'IN-1', tenantId: ids.id, inboundStatus: 'LOADED', pickTaskId: ids.task, pickTaskStatus: 'COMPLETED' }], gate: { inbounds: [{ inboundId: ids.inbound, inboundCode: 'IN-1', gate: { items: [] } }] } }, undefined, 'containerNo'],
|
||||
]
|
||||
for (const [validator, sample, options, missing] of samples) {
|
||||
assert.deepEqual(validator(sample, options), [])
|
||||
const invalid = structuredClone(sample)
|
||||
const target = invalid.portSchedule?.schedule ?? invalid.customsCase ?? invalid
|
||||
const target = invalid.portSchedule?.schedule ?? invalid
|
||||
target[missing] = ''
|
||||
assert.ok(validator(invalid, options).includes(missing))
|
||||
}
|
||||
@@ -108,7 +105,6 @@ test('字段断言接入正式生产模块和默认全量入口', () => {
|
||||
'ocean-bookings.mjs': ['oceanBookingFieldGaps'],
|
||||
'phase3-port-schedules.mjs': ['portScheduleFieldGaps'],
|
||||
'truck-appointments.mjs': ['truckAppointmentFieldGaps'],
|
||||
'phase3-customs.mjs': ['customsCaseFieldGaps'],
|
||||
'phase3-outbound-dispatch.mjs': ['outboundAggregateFieldGaps'],
|
||||
}
|
||||
for (const [file, tokens] of Object.entries(consumers)) {
|
||||
|
||||
@@ -3,6 +3,11 @@ import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
const source = readFileSync(new URL('inbound-measurement.mjs', import.meta.url), 'utf8')
|
||||
const read = (relativeUrl) => readFileSync(new URL(relativeUrl, import.meta.url), 'utf8')
|
||||
const labelTests = read('../../firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelServiceTest.java')
|
||||
const measurementTests = read('../../firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java')
|
||||
const cartonTests = read('../../firstleg/src/test/java/com/baiyue/firstleg/service/CartonMeasurementServiceTest.java')
|
||||
const reviewTests = read('../../firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java')
|
||||
|
||||
test('measurement acceptance scans the stable box code frozen by first print', () => {
|
||||
assert.match(source, /const boxCode = `\$\{inboundCode\}#\$\{originalPackageCount\}-1`/)
|
||||
@@ -32,3 +37,37 @@ test('measurement fixture derives pallet resources from confirmed image count',
|
||||
test('volume ceiling removes binary floating point noise before scaling', () => {
|
||||
assert.match(source, /Number\.EPSILON \* Math\.max\(1, Math\.abs\(value\)\) \* 4/)
|
||||
})
|
||||
|
||||
test('demo package freezes repeated label printing and Mojia dimension replacement', () => {
|
||||
assert.match(labelTests, /repeatedAndPartialPrintsReadFrozenMappingWithoutReallocation/)
|
||||
assert.match(labelTests, /changedCargoKeepsTheFirstFrozenBoxCodes/)
|
||||
assert.match(cartonTests, /repeatedScanReplacesDeviceValuesButPreservesManualCountAndOldImageIsDeleted/)
|
||||
assert.match(source, /增量 fixture 形成动态真实独立货物测量明细/)
|
||||
})
|
||||
|
||||
test('demo package covers OCR failure, manual confirmation, aggregation and stale versions', () => {
|
||||
assert.match(source, /OCR无有效重量时转人工确认/)
|
||||
assert.match(source, /称重旧版本确认被拒绝且不覆盖已确认结果/)
|
||||
assert.match(source, /图片张数决定托数且净重汇总已落地/)
|
||||
assert.match(measurementTests, /ocrFailureCreatesManualWeightThatCanBeAdjustedAndConfirmed/)
|
||||
assert.match(measurementTests, /confirmingFifthPalletSeparatelyAggregatesGrossAndCargoNetWeight/)
|
||||
assert.match(measurementTests, /confirmingWithStaleVersionDoesNotTreatRequestAsIdempotent/)
|
||||
})
|
||||
|
||||
test('demo package keeps pallet pages bounded and historical snapshots read-only', () => {
|
||||
assert.match(source, /weight-pallet-options[^\n]+page=1&size=20&keyword=/)
|
||||
assert.match(source, /records\.length <= 20/)
|
||||
assert.match(measurementTests, /detailUsesStoredPalletSnapshotAfterCandidateBecomesUnavailable/)
|
||||
})
|
||||
|
||||
test('approval freezes measurement and requires quantity weight and volume conservation', () => {
|
||||
assert.match(source, /审核进入WAIT_SPLIT后箱码尺寸与图片快照不可再覆盖/)
|
||||
assert.match(measurementTests, /approvedMeasurementCannotBeResubmittedOrMutateFrozenTotals/)
|
||||
assert.match(reviewTests, /shouldApproveMultipleCargoLinesOnlyWhenAllFrozenTotalsMatch/)
|
||||
assert.match(reviewTests, /shouldRejectApprovalWhenVolumeSnapshotDoesNotConserve/)
|
||||
assert.match(source, /actualPackageCount[^\n]+originalPackageCount/)
|
||||
assert.match(source, /actualGrossWeightKg[^\n]+weightEvidence\.grossWeightKg/)
|
||||
assert.match(source, /actualNetWeightKg[^\n]+weightEvidence\.netWeightKg/)
|
||||
assert.match(source, /actualVolumeCbm[^\n]+totalVolume/)
|
||||
assert.match(source, /volumeWeightKg[^\n]+volumeWeight/)
|
||||
})
|
||||
|
||||
@@ -84,7 +84,7 @@ async function waitForCompletedMeasurement(context, measurementPath, attempts =
|
||||
return { response, measurement, attempts }
|
||||
}
|
||||
|
||||
async function provisionWarehouseResources(context, { inboundId, warehouseId, palletCount }) {
|
||||
async function provisionWarehouseResources(context, { inboundId, tenantId, warehouseId, palletCount }) {
|
||||
if (!warehouseId || !Number.isInteger(palletCount) || palletCount < 1) return null
|
||||
|
||||
const resources = []
|
||||
@@ -92,6 +92,20 @@ async function provisionWarehouseResources(context, { inboundId, warehouseId, pa
|
||||
if (!candidates) return null
|
||||
const palletIds = randomlyReusePalletIds(candidates, palletCount,
|
||||
context.context?.waitSplitVariant?.randomValue)
|
||||
const candidateKeyword = candidates.find((item) => item.id === palletIds[0])?.code || ''
|
||||
const candidateResponse = await context.request('GET',
|
||||
`/api/platform/v1/first-leg/inbounds/${inboundId}/weight-pallet-options?${query(tenantId)}&page=1&size=20&keyword=${encodeURIComponent(candidateKeyword)}`)
|
||||
const candidatePage = context.apiSuccess(MODULE, '按关键词分页查询当前入库仓称重托盘候选', candidateResponse,
|
||||
`warehouseId=${warehouseId}, keyword=${candidateKeyword}`, OPTIONS)
|
||||
const pageControlled = Array.isArray(candidatePage?.records) && candidatePage.records.length <= 20
|
||||
&& Number(candidatePage?.page) === 1 && Number(candidatePage?.size) === 20
|
||||
&& Number(candidatePage?.total) >= candidatePage.records.length
|
||||
&& candidatePage.records.some((item) => asId(item?.palletId) === palletIds[0])
|
||||
context.assert(MODULE, '托盘候选保持服务端关键词分页且不返回无界数组', candidateResponse,
|
||||
pageControlled,
|
||||
`records=${candidatePage?.records?.length ?? 'missing'}, page=${candidatePage?.page ?? 'missing'}, size=${candidatePage?.size ?? 'missing'}, total=${candidatePage?.total ?? 'missing'}`,
|
||||
OPTIONS)
|
||||
if (!pageControlled) return null
|
||||
for (let index = 1; index <= palletCount; index += 1) {
|
||||
const palletId = palletIds[index - 1]
|
||||
context.assert(MODULE, `随机复用原有称重托盘 ${index}/${palletCount}`, null,
|
||||
@@ -134,6 +148,10 @@ async function createConfirmedWeightEvidence(context, {
|
||||
const created = context.apiSuccess(MODULE, `登记称重图片与托盘快照 ${index + 1}/${palletIds.length}`,
|
||||
createResponse, `fileId=${fileId}, palletId=${palletId}`, OPTIONS)
|
||||
if (!created?.id) return null
|
||||
context.assert(MODULE, `OCR无有效重量时转人工确认 ${index + 1}/${palletIds.length}`, createResponse,
|
||||
created.recognitionStatus === 'NEEDS_MANUAL' && created.ocrWeightValue == null,
|
||||
`recognitionStatus=${created.recognitionStatus || 'missing'}, ocrWeightValue=${created.ocrWeightValue ?? 'null'}`,
|
||||
OPTIONS)
|
||||
|
||||
const grossWeight = Number(grossWeightPerPalletKg) + index * 5
|
||||
const confirmResponse = await context.request('POST',
|
||||
@@ -148,6 +166,16 @@ async function createConfirmedWeightEvidence(context, {
|
||||
if (confirmed?.recognitionStatus !== 'CONFIRMED' || !Number.isFinite(netWeightKg) || netWeightKg <= 0) {
|
||||
return null
|
||||
}
|
||||
const staleConfirmResponse = await context.request('POST',
|
||||
`/api/platform/v1/first-leg/inbounds/${inboundId}/weight-results/${created.id}/confirm?${query(tenantId)}`, {
|
||||
version: Number(created.version), adjustedWeightValue: grossWeight + 1,
|
||||
adjustedWeightUnit: 'KG', adjustmentReason: `${context.marker} 旧版本冲突验证`,
|
||||
})
|
||||
context.assert(MODULE, `称重旧版本确认被拒绝且不覆盖已确认结果 ${index + 1}/${palletIds.length}`,
|
||||
staleConfirmResponse,
|
||||
staleConfirmResponse.http === 200 && String(staleConfirmResponse.body?.code) === 'A0409',
|
||||
`HTTP=${staleConfirmResponse.http}, code=${staleConfirmResponse.body?.code ?? 'missing'}, requestId=${staleConfirmResponse.requestId || 'missing'}`,
|
||||
OPTIONS)
|
||||
confirmedResults.push({ id: asId(created.id), palletId, netWeightKg, grossWeightKg })
|
||||
}
|
||||
|
||||
@@ -161,7 +189,9 @@ async function createConfirmedWeightEvidence(context, {
|
||||
const netWeightKg = confirmedResults.reduce((sum, result) => sum + result.netWeightKg, 0)
|
||||
const persisted = confirmedRows.length === palletIds.length
|
||||
&& confirmedResults.every((expected) => confirmedRows.some((row) =>
|
||||
asId(row?.id) === expected.id && asId(row?.palletId) === expected.palletId))
|
||||
asId(row?.id) === expected.id && asId(row?.palletId) === expected.palletId
|
||||
&& String(row?.palletCode || '').trim() && String(row?.palletType || '').trim()
|
||||
&& Number.isFinite(Number(row?.palletTareWeightKg))))
|
||||
&& closeTo(workspace?.actualGrossWeightKg, grossWeightKg, 0.001)
|
||||
&& closeTo(workspace?.actualNetWeightKg, netWeightKg, 0.001)
|
||||
context.assert(MODULE, '图片张数决定托数且净重汇总已落地', workspaceResponse, persisted,
|
||||
@@ -533,7 +563,7 @@ export async function run(context) {
|
||||
|
||||
const palletCount = Math.max(1, Number(context.context?.waitSplitVariant?.palletCount || 1))
|
||||
const warehouseResourcesReady = await provisionWarehouseResources(context, {
|
||||
inboundId, warehouseId: asId(summary?.warehouseId), palletCount,
|
||||
inboundId, tenantId, warehouseId: asId(summary?.warehouseId), palletCount,
|
||||
})
|
||||
context.assert(MODULE, '测量提交前按称重图片数准备真实库位', draftResponse,
|
||||
Boolean(warehouseResourcesReady),
|
||||
@@ -606,6 +636,15 @@ export async function run(context) {
|
||||
&& closeTo(finalMeasurement?.volumeWeightKg, volumeWeight),
|
||||
`processStatus=${finalMeasurement?.processStatus ?? 'missing'}, measurementStatus=${finalMeasurement?.measurementStatus ?? 'missing'}, packages=${finalMeasurement?.actualPackageCount ?? 'missing'}, pallets=${weightEvidence.count}, grossWeight=${finalMeasurement?.actualGrossWeightKg ?? 'missing'}, netWeight=${finalMeasurement?.actualNetWeightKg ?? 'missing'}`, OPTIONS)
|
||||
|
||||
const approvedScanResponse = await multipartRequest(context, scanPath, {
|
||||
boxCode, ...replacement, machineNo: 'MJ-ADMIN-E2E-AFTER-APPROVAL',
|
||||
measuredAt: new Date().toISOString().slice(0, 19),
|
||||
})
|
||||
context.assert(MODULE, '审核进入WAIT_SPLIT后箱码尺寸与图片快照不可再覆盖', approvedScanResponse,
|
||||
approvedScanResponse.http === 200 && String(approvedScanResponse.body?.code) === 'A0409',
|
||||
`HTTP=${approvedScanResponse.http}, code=${approvedScanResponse.body?.code ?? 'missing'}, requestId=${approvedScanResponse.requestId || 'missing'}`,
|
||||
OPTIONS)
|
||||
|
||||
context.createdResources.inboundMeasurement = {
|
||||
marker: context.marker, tenantId, inboundId, measurementId: asId(draft.measurementId),
|
||||
boxCode, finalStatus: finalMeasurement?.measurementStatus || null,
|
||||
|
||||
@@ -8,7 +8,7 @@ const measurement = readFileSync(new URL('./inbound-measurement.mjs', import.met
|
||||
const runner = readFileSync(new URL('../it-test-wait-split-bulk.mjs', import.meta.url), 'utf8')
|
||||
const warehouseLocationResources = readFileSync(new URL('../../warehouse/src/main/java/com/baiyue/warehouse/service/LocationResourceService.java', import.meta.url), 'utf8')
|
||||
|
||||
test('单票随机箱数严格分配到各 SKU 并限制为1到1000', () => {
|
||||
test('Admin 通用单票支持到1000箱且八千票专项入口限制为300箱', () => {
|
||||
assert.match(acceptance, /--wait-split-package-count <n>/)
|
||||
assert.match(acceptance, /wait-split-package-count必须是1-1000的整数或0/)
|
||||
assert.match(acceptance, /packageCounts\.push\(remaining\)/)
|
||||
@@ -16,6 +16,7 @@ test('单票随机箱数严格分配到各 SKU 并限制为1到1000', () => {
|
||||
assert.match(orders, /packageCounts\[index\] \|\| packageCountPerSku/)
|
||||
assert.match(measurement, /detailPackageCounts\.reduce\(\(sum, count\) => sum \+ count, 0\)/)
|
||||
assert.match(measurement, /detailPackageCounts\.slice\(0, detailIndex\)/)
|
||||
assert.match(runner, /boxMax > 300/)
|
||||
})
|
||||
|
||||
test('随机线路只固定义乌仓并从客户真实可计价候选选择', () => {
|
||||
|
||||
@@ -42,7 +42,11 @@ test('backend never appends a generic STATUS_UPDATE and push replay is read-only
|
||||
assert.doesNotMatch(serviceSources, /(?:history|appendHistory)\([^;]{0,500}"STATUS_UPDATE"/s)
|
||||
assert.match(smartLoading, /if \(key\.equals\(plan\.getPushIdempotencyKey\(\)\)\)[\s\S]{0,300}return view\(plan, booking\)/)
|
||||
assert.match(smartLoading, /history\(planId, null, "PUSH_PRELOAD"/)
|
||||
assert.match(smartLoadingTest, /exactPushReplayIsReadOnly[\s\S]{0,1000}never\(\)\.insert/)
|
||||
const replayStart = smartLoadingTest.indexOf('void exactPushReplayIsReadOnly()')
|
||||
const replayEnd = smartLoadingTest.indexOf('@Test', replayStart + 10)
|
||||
const replayBlock = smartLoadingTest.slice(replayStart, replayEnd)
|
||||
assert.notEqual(replayStart, -1)
|
||||
assert.match(replayBlock, /never\(\)\)\.insert/)
|
||||
assert.match(smartLoadingTest, /getActionType\(\)\)\.isEqualTo\("PUSH_PRELOAD"\)/)
|
||||
})
|
||||
|
||||
@@ -50,7 +54,7 @@ test('operationId is projected end-to-end for identity-based frontend deduplicat
|
||||
assert.match(loadingDtos, /record History\([\s\S]{0,180}String operationId/)
|
||||
assert.match(loadingRepository, /new History\([\s\S]{0,180}getOperationId\(\)/)
|
||||
assert.match(openapi, /OperationHistory:[\s\S]{0,500}operationId:/)
|
||||
assert.match(adminApi, /interface LoadingHistory[\s\S]{0,260}operationId:\s*string/)
|
||||
assert.match(adminApi, /interface LoadingHistory[\s\S]*?operationId:\s*string/)
|
||||
assert.match(adminApi, /normalizeHistory[\s\S]{0,260}operationId:/)
|
||||
})
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ function truckPayload(context, oceanBooking, carrier, index) {
|
||||
storeAddress: '深圳市南山区人工验收地址', storeCity: '深圳市', storeZipcode: '518000',
|
||||
packageCount: 10, grossWeightKg: 100, volumeCbm: 2.5, palletCount: 1, poNo: `PO-${value}`,
|
||||
plannedLoadingAt: '2026-09-16T08:00:00', transitMinutes: 120, payableAmount: 100,
|
||||
receivableAmount: 150, currency: 'RMB', remark: `${context.marker} 人工列表状态 ${index}`, expectedVersion: 0 }
|
||||
currency: 'RMB', remark: `${context.marker} 人工列表状态 ${index}`, expectedVersion: 0 }
|
||||
}
|
||||
|
||||
async function createTruck(context, oceanBooking, index) {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
import { orderPersistenceFacts, requestedOrderDestinationType } from './orders.mjs'
|
||||
|
||||
const source = readFileSync(new URL('orders.mjs', import.meta.url), 'utf8')
|
||||
|
||||
test('order demo payload covers multi-box multi-SKU images and both destination families', () => {
|
||||
for (const boxType of ['SMALL', 'MEDIUM', 'LARGE', 'EXTRA_LARGE']) {
|
||||
assert.match(source, new RegExp(`boxType: '${boxType}'`))
|
||||
}
|
||||
assert.match(source, /cargoes: selectedBoxes\.map/)
|
||||
assert.match(source, /productImageFileId/)
|
||||
assert.equal(requestedOrderDestinationType({ context: { orderDestinationType: 'FBA' } }), 'FBA')
|
||||
assert.equal(requestedOrderDestinationType({ context: { orderDestinationType: 'OVERSEAS' } }), 'OVERSEAS')
|
||||
})
|
||||
|
||||
test('create and submit lifecycle covers replay payload conflict and zero drift', () => {
|
||||
assert.match(source, /Client 创建订单幂等重放/)
|
||||
assert.match(source, /Client 创建订单同键异载荷返回A0409/)
|
||||
assert.match(source, /创建同键异载荷失败后订单全事实零漂移/)
|
||||
assert.match(source, /Client 提交订单同键同载荷精确重放/)
|
||||
assert.match(source, /已提交订单使用新键重复提交返回A0409/)
|
||||
assert.match(source, /重复提交失败后订单状态货物与历史零漂移/)
|
||||
assert.match(source, /orderPersistenceFacts\(draftAfterConflict\) === orderPersistenceFacts\(draft\)/)
|
||||
assert.match(source, /orderPersistenceFacts\(submittedAfterFailure\) === orderPersistenceFacts\(submitted\)/)
|
||||
})
|
||||
|
||||
test('persistence fact comparison includes order cargo parties notes and history', () => {
|
||||
const detail = {
|
||||
order: { id: '1', status: 'DRAFT' }, cargoes: [{ skuNo: 'SKU-1' }],
|
||||
parties: [{ role: 'SENDER' }], internalNotes: [{ content: 'note' }],
|
||||
statusHistory: [{ toStatus: 'DRAFT' }],
|
||||
}
|
||||
const baseline = orderPersistenceFacts(detail)
|
||||
assert.equal(orderPersistenceFacts(structuredClone(detail)), baseline)
|
||||
assert.notEqual(orderPersistenceFacts({ ...detail, order: { ...detail.order, status: 'SUBMITTED' } }), baseline)
|
||||
assert.notEqual(orderPersistenceFacts({ ...detail, cargoes: [{ skuNo: 'SKU-2' }] }), baseline)
|
||||
assert.notEqual(orderPersistenceFacts({ ...detail, statusHistory: [...detail.statusHistory, { toStatus: 'SUBMITTED' }] }), baseline)
|
||||
})
|
||||
|
||||
test('approval convergence is a hard WAIT_MEASURE prerequisite without coverage downgrade', () => {
|
||||
assert.match(source, /审核后入库单与履约任务异步收敛/)
|
||||
assert.match(source, /MEASURABLE_STATUSES/)
|
||||
assert.match(source, /throw new Error\('订单审核后的入库单与履约任务未在30秒内收敛/)
|
||||
assert.doesNotMatch(source, /审核后入库单与履约任务异步收敛[\s\S]{0,300}addCoverageGap/)
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user