fix(dropship): generate pallet numbers atomically and extend full regressions
This commit is contained in:
@@ -25,9 +25,11 @@
|
||||
| 订单、货物、快照、状态、幂等、Outbox | `order/` | `order/AGENTS.md`;统一列表由 `UnifiedOrderQueryService` 组合交易与履约投影,仍固定显示 `PENDING_REVIEW` 并提供审核动作:Order 为 `SUBMITTED` 时必须优先返回 `PENDING_REVIEW/APPROVE_ORDER`,不得被 Firstleg 准备中状态覆盖 |
|
||||
| 客户头程订单中文批量导入 | `order/` | `MerchantBatchImportService.java`、`MerchantWarehouseCatalogClient.java`、`MerchantPricingCatalogClient.java`、`order-v1.yaml`;模板和导入都按当前客户读取动态地址、全部有效仓库与渠道,最多 50 行,只创建草稿,支持 `PARTIAL/ATOMIC` |
|
||||
| 平台修改客户原始申报、履约渠道变更与跨服务同步 | `order/`、`firstleg/` | Order `OrderDeclarationUpdateService.java`、`OrderInboundOutboxPublisher.java`、`order-v1.yaml`;Firstleg `OrderDeclarationSyncService.java`、`OrderDeclarationSyncMapper.java`、`firstleg-v1.yaml`。只更新申报快照;渠道变化先失败关闭校验履约可逆性与重新计价,成功后由版本化 `ORDER_DECLARATION_UPDATED_V1` 幂等同步,不覆盖实测、箱码或已确认费用 |
|
||||
| 平台订单批量审核预览 | `order/` | `PlatformFirstLegOrderController.java` 的 `/batch-review-preview`、`BatchOrderReviewPreviewService.java`、`BatchOrderReviewPreviewResponse.java`、`order-v1.yaml`;一次请求固定用订单与货物两次批量查询返回同序快照,不允许前端逐票请求详情,逐项资格以 `canApprove/blockReason` 为准 |
|
||||
| 履约分配、入库、测量、箱规算法、审核、FBA 计费 | `firstleg/` | `firstleg/AGENTS.md`;客户四档箱规与 `ADD/MULTIPLY` 默认算法从 `CustomerDimensionConfigService` 进入,单箱测量和最终计价分别由 `CustomerDimensionWeightingService`、`FbaPricingService` 保存并消费服务端快照 |
|
||||
| 入库单扣货、解除、凭证与履约门禁 | `firstleg/`、`file/` | Firstleg `InboundHoldService`、`InboundHoldGuard`、`InboundHoldEvidenceService`;平台接口 `/api/platform/inbounds/{inboundId}/hold`、`/release`、`/evidence`。扣货不改变 Order 主状态,命令使用入库聚合版本和幂等键;凭证只按客户、入库单及 `INBOUND_HOLD_EVIDENCE` 精确归属,所有装柜、PDA 拣货和国内出仓写入口统一复用门禁并失败关闭 |
|
||||
| 称重确认、整票库位块与 PDA 资源结算 | `firstleg/`、`warehouse/` | Firstleg `MeasurementLocationOccupationService.java`、`InboundMeasurementService.java`、`MeasurementSubmissionService.java`、`WarehousePalletLocationClient.java`;Warehouse `LocationBlockService.java` 与 `location-block-v1.yaml`。称重确认尽力建块、提交时再次补齐,库位指引失败不阻断测量主流程;部分拣货整段转移给余量子单,整票拣完才整块释放,A0 共享资源按 `reservationId` 区分 |
|
||||
| 客户专属价格簿、柜级阶梯运费与受控调价 | `firstleg/` | `PlatformPriceBookService.java`、`ActivePriceBookEstimator.java`、`FbaPricingService.java`、`pricing/ContainerTierAmountAllocator.java`、`InboundFeeDetailService.java`、`fba-pricing-v1.yaml`。`customerId=0` 为公共报价,正数为客户专属版本;计价优先专属 ACTIVE、缺失才回退公共 ACTIVE。同柜区域重量阶梯按实际成员计费量聚合命中并守恒分摊,装柜变化只重算未锁定客户费用;人工计费量调整限基准上下 10%,私人仓尾程派送费只绑定 `PRIVATE` 入库单;一件代发 `WAIT_PALLETIZE` 允许在预装确认前试算,写费用时必须保留托盘化状态 |
|
||||
| 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` |
|
||||
@@ -52,11 +54,11 @@
|
||||
| 海外 API 路由、海外 Token、Audience/JWKS | `gateway-overseas/` | `gateway-overseas/AGENTS.md` |
|
||||
| 海外 IAM 状态与权限版本投影 | `lastleg-service/security/` | `lastleg-service/AGENTS.md`;内部入口 `/internal/lastleg/security/v1/events`、`/validate`;消费三类 IAM 安全事件并为海外 Gateway 提供 fail-closed 校验,投影表归 `baiyue_lastleg` |
|
||||
| 尾程订单、海外入仓计划、换标、库存、采购、本土卡车 | `lastleg-service/` | `lastleg-service/AGENTS.md`,尾程订单控制从 `LastlegOrderController.java`、`LastlegOrderCommandService.java` 进入,再按 `inboundplan/`、`relabel/`、`inventory/`、`procurement/`、`trucking/` 定位 |
|
||||
| 尾程订单控制与柜级入仓计划转换 | `lastleg-service/` | `orders/container-summaries` 按 `sourceContainerBookingId` 跨客户聚合,分页前排除全部入仓码均已生成有效独立计划的柜,详情返回全柜数据;`inbound-plans/convert-container` 锁定同柜订单,每票生成独立计划,整批原子且幂等。单票完整入库计划进入 `InboundPlanService.java`,暂扣/解除和固定八行人工节点进入 `LastlegOrderCommandService.java`;命令按订单版本、客户范围和幂等键执行,暂扣中的订单不得进入排车或出库 |
|
||||
| 尾程订单控制、柜级预览与入仓计划转换 | `lastleg-service/`、`warehouse-overseas-service/` | `orders/container-summaries` 按 `sourceContainerBookingId` 跨客户聚合并分页返回全部稳定来源柜,已完成计划转换的柜仍用于接柜仓查询与修改,生成资格由 `allPlansReady/pendingPlanCount` 表达,详情返回全柜数据。`inbound-plans/container-preview` 由 `InboundPlanService.java` 返回服务端分组、托数、阻断项及快照版本/哈希;`ContainerInboundPlanConversionCoordinator.java` 校验同一快照并以唯一 Saga 先向 `WarehousePalletBatchService.java` 批量预留真实托盘,再本地原子生成剩余独立计划并确认资源,失败只补偿未确认资源,同柜重放不得新建第二批。暂扣/解除和固定八行人工节点进入 `LastlegOrderCommandService.java`;命令按订单版本、客户范围和幂等键执行,暂扣中的订单不得进入排车或出库 |
|
||||
| 海外入仓分页、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 自身客户范围和状态为准 |
|
||||
| 海外异常退回、库存恢复与客户回访 | `lastleg-service/`、`file/` | `exceptionorder/`、`inventory/command/`、`customervisit/`;异常按版本和幂等键执行 `PENDING → PROCESSING → COMPLETED`,完成证据固定归 `OVERSEAS_EXCEPTION_EVIDENCE`。派送退回明细只能来自原出库拣货事实,退回海外仓时校验真实目标托盘/库位并以 `RETURN_STOCK_IN` 恢复库存后,原子完成异常单与派送 `RETURNED`;回访仍以 Lastleg 客户范围和派送状态为准 |
|
||||
| 一件代发 SKU/箱唛与暂扣跨区协同 | `order/`、`firstleg/`、`common-event-contract/`、`lastleg-service/` | Order `InternalOrderCargoController` 提供强制 SKU 与每箱数量;Firstleg `InboundCartonLabelService` 冻结箱唛 SKU,`DropshipPalletizationService` 完成清点、组托和封托,智能排柜只按 `SEALED` 整托分配,并消费 `OverseasInboundOrderDetainedChangedV1`;Lastleg `dropship/` 建立库存并发布暂扣变更 |
|
||||
| 客户海外仓贴标、转运、废弃与退货换标 | `lastleg-service/` | `relabel/`、`relabel/pda/`、`transfer/`、`discard/` 的 Merchant/PDA Controller 和 Service;换标下单按“托盘中的入仓单库存”选择,合托后的同一托盘允许包含多张入仓单但冻结明细必须保持原入仓单归属。每个我方箱唛只形成一条箱任务,支持箱唛、SKU 和组合换标,SKU 子项不重复冻结;PDA 扫箱唛、照片复核完成后释放原库存,再沿既有排车出库。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` 为准 |
|
||||
|
||||
@@ -230,12 +230,15 @@ SELECT
|
||||
## 海外排车、出库与派送权限
|
||||
|
||||
- 国内固定入口已在海外仓业务 `lastleg-permissions.sql` 权限段之后完整内联 `iam/src/main/resources/db/phase5-delivery-permissions.sql`;测试和部署只执行固定入口,不再单独执行模块脚本。
|
||||
- 平台端固定使用排车列表/编辑/取消/转派送、出库列表/创建/取消、派送列表/退回/状态、异常订单列表/创建/处理和客户回访列表/编辑十五项权限;列表权限包含详情及只读辅助接口,写操作保持独立授权。
|
||||
- 平台端固定使用排车列表/编辑/取消/转派送、出库列表/创建/取消、派送列表/退回/状态/资料管理、异常订单列表/创建/处理和客户回访列表/编辑十六项权限;列表权限包含详情及只读辅助接口,写操作保持独立授权。
|
||||
- 派送七类资料上传、替换、解绑和保存统一使用 `platform:overseas-warehouse:delivery:documents`,读取和下载仍使用 `platform:overseas-warehouse:delivery:list`;默认授予 `PLATFORM_ADMIN`、`PLATFORM_CUSTOM_ADMIN`、`WAREHOUSE_OPERATOR`。
|
||||
- 客户端固定使用 `merchant:warehouse:delivery:list` 和 `merchant:warehouse:delivery:create`;客户默认管理员获得两项权限,其他客户角色不自动扩权。
|
||||
- `WAREHOUSE_OPERATOR` 获得十五项后台作业权限,`WAREHOUSE_TALLY` 只获得排车、出库、派送、异常订单和客户回访五项列表只读;国内、海外 PDA 岗位不得获得上述后台权限。
|
||||
- `WAREHOUSE_OPERATOR` 获得十六项后台作业权限,`WAREHOUSE_TALLY` 只获得排车、出库、派送、异常订单和客户回访五项列表只读;国内、海外 PDA 岗位不得获得上述后台权限。
|
||||
|
||||
## 海外排车与出库结构升级
|
||||
|
||||
- 派送三类资料当前基线包含 `ll_delivery_order.bol_no/documents_json` 与 `ll_delivery_document_revision`;海外固定入口和 Lastleg 模块基线同步维护这些定义。仅保存装车照片、BOL文件(含签名)、签收文件(POD签收/快递签收)三个类别,摘要按类别计数。资料历史不可变,文件对象由 File 按真实客户与派送单绑定;不按 BOL 文本复用文件,不生成旧类别迁移或清理脚本。
|
||||
|
||||
- 海外仓出库与派送数据库部署仍只执行 `海外区域统一增量-MySQL8.sql`;空库先执行 Lastleg 与 Warehouse Overseas 当前基线,再完整执行固定入口并确认最末 `migration_status = PASS`。
|
||||
- 该海外固定入口会在任何海外仓出库与派送 `ALTER` 与数据门禁前幂等创建排车 PDA、出库、派送、一件代发、异常订单和客户回访的完整当前结构,因此既有海外仓业务库不依赖模块基线或历史 `scripts/db/incremental.sql`。国内固定入口只保留 IAM 海外仓出库与派送权限目录,不创建或修改 `baiyue_lastleg` 业务表。
|
||||
- 固定入口只补目标字段并做 fail-fast 校验,不回填、改号、清理或从其他表推断开发旧行。`ll_dispatch_pda_task.warehouse_id` 存在 `NULL`、空值或 `0`,或任一待收敛全局业务号/任务号、完成幂等键发生冲突时,脚本立即 `SIGNAL`;应重新造数,重置或清理仍需用户另行授权。
|
||||
|
||||
@@ -5088,6 +5088,7 @@ CREATE TABLE IF NOT EXISTS fl_container_booking (
|
||||
CONSTRAINT chk_container_booking_record_status CHECK (record_status IN ('ACTIVE','MERGED')),
|
||||
CONSTRAINT chk_container_booking_capacity CHECK (capacity_cbm IS NULL OR capacity_cbm > 0),
|
||||
CONSTRAINT chk_container_booking_weight CHECK (max_weight_kg IS NULL OR max_weight_kg > 0),
|
||||
CONSTRAINT chk_container_booking_pickup_code CHECK (pickup_code IS NULL OR pickup_code REGEXP '^[0-9]{1,64}$'),
|
||||
CONSTRAINT chk_container_booking_iso6346 CHECK
|
||||
(container_no IS NULL OR container_no REGEXP '^[A-Z]{3}[UJZ][0-9]{7}$'),
|
||||
CONSTRAINT chk_container_booking_pickup_code CHECK
|
||||
@@ -5311,7 +5312,7 @@ CREATE TABLE IF NOT EXISTS fl_loading_plan_item (
|
||||
CREATE TABLE IF NOT EXISTS fl_container_document (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '货柜文件关联ID(雪花ID)',
|
||||
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID',
|
||||
document_type VARCHAR(32) NOT NULL COMMENT '文件类型:DESTINATION_CLEARANCE目的港清关、DELIVERY_ORDER提货单、PICKUP_ORDER取货单',
|
||||
document_type VARCHAR(32) NOT NULL COMMENT '文件类型:DESTINATION_CLEARANCE目的港清关、DELIVERY_ORDER提货单',
|
||||
file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务文件ID,不建立跨库外键',
|
||||
file_name VARCHAR(255) NOT NULL COMMENT '文件名称快照',
|
||||
file_hash VARCHAR(64) NULL COMMENT 'File服务SHA-256快照',
|
||||
@@ -5331,8 +5332,8 @@ CREATE TABLE IF NOT EXISTS fl_container_document (
|
||||
CONSTRAINT fk_container_document_booking FOREIGN KEY (container_booking_id)
|
||||
REFERENCES fl_container_booking (id),
|
||||
CONSTRAINT chk_container_document_type
|
||||
CHECK (document_type IN ('DESTINATION_CLEARANCE', 'DELIVERY_ORDER', 'PICKUP_ORDER'))
|
||||
) ENGINE=InnoDB COMMENT='平台货柜清关、DO与PU文件关联';
|
||||
CHECK (document_type IN ('DESTINATION_CLEARANCE', 'DELIVERY_ORDER'))
|
||||
) ENGINE=InnoDB COMMENT='平台货柜清关与DO文件关联';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_loading_plan_history (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '装柜操作历史ID(雪花ID)',
|
||||
@@ -6096,7 +6097,7 @@ BEGIN
|
||||
SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan
|
||||
WHERE container_booking_id = NEW.container_booking_id;
|
||||
IF current_plan_status = 'COMPLETED'
|
||||
AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'DELIVERY_ORDER', 'PICKUP_ORDER') THEN
|
||||
AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'DELIVERY_ORDER') THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可新增';
|
||||
END IF;
|
||||
END$$
|
||||
@@ -6112,7 +6113,7 @@ BEGIN
|
||||
SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan
|
||||
WHERE container_booking_id = OLD.container_booking_id;
|
||||
IF current_plan_status = 'COMPLETED'
|
||||
AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'DELIVERY_ORDER', 'PICKUP_ORDER') THEN
|
||||
AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'DELIVERY_ORDER') THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可修改';
|
||||
END IF;
|
||||
END$$
|
||||
@@ -12659,7 +12660,7 @@ CALL queue_table_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', '
|
||||
CALL queue_table_comment('baiyue_firstleg', 'fl_carrier_schedule_exception_fact', '船节点异常发生与解除不可变事实');
|
||||
CALL queue_table_comment('baiyue_firstleg', 'fl_consumed_event', '头程消息消费幂等表:记录已处理事件并按事件ID去重,防止RocketMQ重复投递造成重复建单;不是消息发布表');
|
||||
CALL queue_table_comment('baiyue_firstleg', 'fl_container_booking', '平台货柜预订资料;预装状态唯一来源为装柜计划');
|
||||
CALL queue_table_comment('baiyue_firstleg', 'fl_container_document', '平台货柜清关、DO与PU文件关联');
|
||||
CALL queue_table_comment('baiyue_firstleg', 'fl_container_document', '平台货柜清关与DO文件关联');
|
||||
CALL queue_table_comment('baiyue_firstleg', 'fl_container_fee_detail', '柜次级人工应收应付费用来源;审核通过且参与结算后同步Finance');
|
||||
CALL queue_table_comment('baiyue_firstleg', 'fl_customer_charge_rule', '客户头程计费规则版本');
|
||||
CALL queue_table_comment('baiyue_firstleg', 'fl_customer_dimension_config', '客户固定四箱规尺寸配置');
|
||||
@@ -13379,7 +13380,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_container_booking', 'warehouse_
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_container_booking', 'warehouse_name', '装柜仓库名称快照');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_container_document', 'container_booking_id', '货柜预订ID');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_container_document', 'created_at', '创建时间');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_container_document', 'document_type', '文件类型:DESTINATION_CLEARANCE目的港清关、DELIVERY_ORDER提货单、PICKUP_ORDER取货单');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_container_document', 'document_type', '文件类型:DESTINATION_CLEARANCE目的港清关、DELIVERY_ORDER提货单');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_container_document', 'file_id', 'File服务文件ID,不建立跨库外键');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_container_document', 'file_name', '文件名称快照');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_container_document', 'file_url', '文件访问地址快照');
|
||||
@@ -16242,7 +16243,7 @@ BEGIN
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
-- 货柜目的港清关与DO/PU采用轻量文件事实;出口报关由customs-v1整柜聚合唯一承载。
|
||||
-- 货柜目的港清关与DO采用轻量文件事实;PU为货柜主表数字码;出口报关由customs-v1整柜聚合唯一承载。
|
||||
USE baiyue_firstleg;
|
||||
DROP PROCEDURE IF EXISTS phase3_simple_customs_add_column;
|
||||
DELIMITER $$
|
||||
@@ -16262,6 +16263,32 @@ CALL phase3_simple_customs_add_column('completed_by_name', "VARCHAR(64) NULL COM
|
||||
CALL phase3_simple_customs_add_column('completed_at', "DATETIME(3) NULL COMMENT '一键完成时间' AFTER completed_by_name");
|
||||
DROP PROCEDURE phase3_simple_customs_add_column;
|
||||
|
||||
DROP PROCEDURE IF EXISTS phase3_pickup_code_add_column;
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE phase3_pickup_code_add_column(IN p_name VARCHAR(64), IN p_definition TEXT)
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema='baiyue_firstleg' AND table_name='fl_container_booking'
|
||||
AND column_name=p_name) THEN
|
||||
SET @ddl=CONCAT('ALTER TABLE fl_container_booking ADD COLUMN ',p_name,' ',p_definition);
|
||||
PREPARE statement FROM @ddl; EXECUTE statement; DEALLOCATE PREPARE statement;
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
CALL phase3_pickup_code_add_column('pickup_code', "VARCHAR(64) NULL COMMENT 'PU数字码;按字符串保存以保留前导零' AFTER actual_warehouse_arrival_at");
|
||||
CALL phase3_pickup_code_add_column('pickup_code_updated_by', "BIGINT UNSIGNED NULL COMMENT 'PU码最后填写人ID' AFTER pickup_code");
|
||||
CALL phase3_pickup_code_add_column('pickup_code_updated_by_name', "VARCHAR(64) NULL COMMENT 'PU码最后填写人名称快照' AFTER pickup_code_updated_by");
|
||||
CALL phase3_pickup_code_add_column('pickup_code_updated_at', "DATETIME(3) NULL COMMENT 'PU码最后填写时间' AFTER pickup_code_updated_by_name");
|
||||
DROP PROCEDURE phase3_pickup_code_add_column;
|
||||
|
||||
SET @has_pickup_code_check=(SELECT COUNT(*) FROM information_schema.table_constraints
|
||||
WHERE table_schema='baiyue_firstleg' AND table_name='fl_container_booking'
|
||||
AND constraint_name='chk_container_booking_pickup_code' AND constraint_type='CHECK');
|
||||
SET @ddl=IF(@has_pickup_code_check=0,
|
||||
"ALTER TABLE fl_container_booking ADD CONSTRAINT chk_container_booking_pickup_code CHECK (pickup_code IS NULL OR pickup_code REGEXP '^[0-9]{1,64}$')",
|
||||
'SELECT 1');
|
||||
PREPARE statement FROM @ddl; EXECUTE statement; DEALLOCATE PREPARE statement;
|
||||
|
||||
SET @has_simple_customs_check = (SELECT COUNT(*) FROM information_schema.table_constraints
|
||||
WHERE table_schema='baiyue_firstleg' AND table_name='fl_container_document'
|
||||
AND constraint_name='chk_container_document_type' AND constraint_type='CHECK');
|
||||
@@ -16269,7 +16296,7 @@ SET @ddl = IF(@has_simple_customs_check=0, 'SELECT 1',
|
||||
'ALTER TABLE fl_container_document DROP CHECK chk_container_document_type');
|
||||
PREPARE statement FROM @ddl; EXECUTE statement; DEALLOCATE PREPARE statement;
|
||||
ALTER TABLE fl_container_document ADD CONSTRAINT chk_container_document_type
|
||||
CHECK (document_type IN ('DESTINATION_CLEARANCE','DELIVERY_ORDER','PICKUP_ORDER'));
|
||||
CHECK (document_type IN ('DESTINATION_CLEARANCE','DELIVERY_ORDER'));
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_container_document_before_insert;
|
||||
DELIMITER $$
|
||||
@@ -16277,7 +16304,7 @@ CREATE TRIGGER trg_container_document_before_insert BEFORE INSERT ON fl_containe
|
||||
BEGIN
|
||||
DECLARE current_plan_status VARCHAR(24);
|
||||
SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id=NEW.container_booking_id;
|
||||
IF current_plan_status='COMPLETED' AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE','DELIVERY_ORDER','PICKUP_ORDER') THEN
|
||||
IF current_plan_status='COMPLETED' AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE','DELIVERY_ORDER') THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='装柜完成后货柜物流文件不可新增';
|
||||
END IF;
|
||||
END$$
|
||||
@@ -16288,7 +16315,7 @@ CREATE TRIGGER trg_container_document_before_update BEFORE UPDATE ON fl_containe
|
||||
BEGIN
|
||||
DECLARE current_plan_status VARCHAR(24);
|
||||
SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id=OLD.container_booking_id;
|
||||
IF current_plan_status='COMPLETED' AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE','DELIVERY_ORDER','PICKUP_ORDER') THEN
|
||||
IF current_plan_status='COMPLETED' AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE','DELIVERY_ORDER') THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='装柜完成后货柜物流文件不可修改';
|
||||
END IF;
|
||||
END$$
|
||||
@@ -17037,6 +17064,7 @@ BEGIN
|
||||
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 = 5107094 AND permission_code <> 'platform:overseas-warehouse:delivery:documents')
|
||||
OR (id = 5107093 AND permission_code <> 'platform:overseas-warehouse:outbound:cancel')
|
||||
OR (id = 5107070 AND permission_code <> 'platform:overseas-warehouse:exception-order:list')
|
||||
OR (id = 5107071 AND permission_code <> 'platform:overseas-warehouse:exception-order:create')
|
||||
@@ -17056,6 +17084,7 @@ BEGIN
|
||||
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:delivery:documents' AND id <> 5107094)
|
||||
OR (permission_code = 'platform:overseas-warehouse:outbound:cancel' AND id <> 5107093)
|
||||
OR (permission_code = 'platform:overseas-warehouse:exception-order:list' AND id <> 5107070)
|
||||
OR (permission_code = 'platform:overseas-warehouse:exception-order:create' AND id <> 5107071)
|
||||
@@ -17092,6 +17121,7 @@ VALUES
|
||||
(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),
|
||||
(5107094,0,'PLATFORM','BUTTON',5107092,'platform:overseas-warehouse:delivery:documents','管理派送资料',NULL,'POST',769,'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','海外仓派送单','/orders/delivery','GET',60,'ACTIVE',1,0),
|
||||
(5306001,0,'MERCHANT','BUTTON',5306000,'merchant:warehouse:delivery:create','创建海外仓派送单',NULL,'POST',10,'ACTIVE',1,0)
|
||||
@@ -17123,6 +17153,7 @@ SELECT 0, role.id, permission.id, CURRENT_TIMESTAMP(3)
|
||||
'platform:overseas-warehouse:exception-order:process',
|
||||
'platform:overseas-warehouse:delivery:return',
|
||||
'platform:overseas-warehouse:delivery:status',
|
||||
'platform:overseas-warehouse:delivery:documents',
|
||||
'platform:overseas-warehouse:customer-visit:list',
|
||||
'platform:overseas-warehouse:customer-visit:edit')
|
||||
AND permission.status='ACTIVE' AND permission.deleted=0;
|
||||
@@ -17148,6 +17179,7 @@ INSERT INTO tmp_phase5_delivery_role_permission (role_code, permission_code) VAL
|
||||
('WAREHOUSE_OPERATOR','platform:overseas-warehouse:exception-order:process'),
|
||||
('WAREHOUSE_OPERATOR','platform:overseas-warehouse:delivery:return'),
|
||||
('WAREHOUSE_OPERATOR','platform:overseas-warehouse:delivery:status'),
|
||||
('WAREHOUSE_OPERATOR','platform:overseas-warehouse:delivery:documents'),
|
||||
('WAREHOUSE_OPERATOR','platform:overseas-warehouse:customer-visit:list'),
|
||||
('WAREHOUSE_OPERATOR','platform:overseas-warehouse:customer-visit:edit'),
|
||||
('WAREHOUSE_TALLY','platform:overseas-warehouse:dispatch:list'),
|
||||
@@ -17177,6 +17209,7 @@ WHERE role.tenant_id=0 AND role.app_code='PLATFORM'
|
||||
'platform:overseas-warehouse:exception-order:process',
|
||||
'platform:overseas-warehouse:delivery:return',
|
||||
'platform:overseas-warehouse:delivery:status',
|
||||
'platform:overseas-warehouse:delivery:documents',
|
||||
'platform:overseas-warehouse:customer-visit:list',
|
||||
'platform:overseas-warehouse:customer-visit:edit')
|
||||
AND expected.permission_code IS NULL;
|
||||
@@ -17225,10 +17258,11 @@ BEGIN
|
||||
'platform:overseas-warehouse:exception-order:process',
|
||||
'platform:overseas-warehouse:delivery:return',
|
||||
'platform:overseas-warehouse:delivery:status',
|
||||
'platform:overseas-warehouse:delivery:documents',
|
||||
'platform:overseas-warehouse:customer-visit:list',
|
||||
'platform:overseas-warehouse:customer-visit:edit',
|
||||
'merchant:warehouse:delivery:list',
|
||||
'merchant:warehouse:delivery:create')) <> 17 THEN
|
||||
'merchant:warehouse:delivery:create')) <> 18 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:权限目录不完整';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM iam_role_permission grant_row
|
||||
@@ -17249,8 +17283,9 @@ BEGIN
|
||||
'platform:overseas-warehouse:exception-order:process',
|
||||
'platform:overseas-warehouse:delivery:return',
|
||||
'platform:overseas-warehouse:delivery:status',
|
||||
'platform:overseas-warehouse:delivery:documents',
|
||||
'platform:overseas-warehouse:customer-visit:list',
|
||||
'platform:overseas-warehouse:customer-visit:edit')) <> 15 THEN
|
||||
'platform:overseas-warehouse:customer-visit:edit')) <> 16 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:平台管理员授权不完整';
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM iam_role role
|
||||
@@ -17274,15 +17309,16 @@ BEGIN
|
||||
'platform:overseas-warehouse:exception-order:process',
|
||||
'platform:overseas-warehouse:delivery:return',
|
||||
'platform:overseas-warehouse:delivery:status',
|
||||
'platform:overseas-warehouse:delivery:documents',
|
||||
'platform:overseas-warehouse:customer-visit:list',
|
||||
'platform:overseas-warehouse:customer-visit:edit')) <> 15) THEN
|
||||
'platform:overseas-warehouse:customer-visit:edit')) <> 16) 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 <> 15
|
||||
IF v_warehouse_operator_permission_count <> 16
|
||||
OR v_warehouse_tally_permission_count <> 5 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:仓库岗位最小权限矩阵异常';
|
||||
END IF;
|
||||
@@ -17304,6 +17340,7 @@ BEGIN
|
||||
'platform:overseas-warehouse:exception-order:process',
|
||||
'platform:overseas-warehouse:delivery:return',
|
||||
'platform:overseas-warehouse:delivery:status',
|
||||
'platform:overseas-warehouse:delivery:documents',
|
||||
'platform:overseas-warehouse:customer-visit:list',
|
||||
'platform:overseas-warehouse:customer-visit:edit')) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:PDA岗位错误获得后台权限';
|
||||
|
||||
@@ -1181,7 +1181,10 @@ CREATE TABLE IF NOT EXISTS ll_delivery_order (
|
||||
dispatch_order_id BIGINT NOT NULL COMMENT '关联派车单ID', dispatch_no VARCHAR(64) NOT NULL COMMENT '关联派车单号',
|
||||
overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓ID', overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照', customer_name VARCHAR(255) NULL COMMENT '客户名称快照',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'WAIT_DELIVERY' COMMENT '派送状态:WAIT_DELIVERY待派送及后续正式状态',
|
||||
waybill_no VARCHAR(128) NULL COMMENT '承运运单号', delivery_method VARCHAR(32) NULL COMMENT '派送方式', carrier_name VARCHAR(255) NULL COMMENT '派送承运商',
|
||||
waybill_no VARCHAR(128) NULL COMMENT '承运运单号',
|
||||
bol_no VARCHAR(128) NULL COMMENT '派送资料BOL号,不作为文件归属标识',
|
||||
documents_json JSON NULL COMMENT '派送三类资料当前绑定及权威文件元数据快照',
|
||||
delivery_method VARCHAR(32) NULL COMMENT '派送方式', carrier_name VARCHAR(255) NULL COMMENT '派送承运商',
|
||||
pickup_method VARCHAR(64) NULL COMMENT '提货方式', loading_type VARCHAR(64) NULL COMMENT '装车类型', vehicle_type VARCHAR(64) NULL COMMENT '车辆类型',
|
||||
vehicle_size VARCHAR(64) NULL COMMENT '车辆大小', vehicle_plate VARCHAR(64) NULL COMMENT '车牌号', driver_name VARCHAR(128) NULL COMMENT '司机或车队名称',
|
||||
driver_phone VARCHAR(64) NULL COMMENT '车队联系电话', appointment_account VARCHAR(128) NULL COMMENT '预约账号', seller_name VARCHAR(128) NULL COMMENT '卖家姓名',
|
||||
@@ -1202,6 +1205,21 @@ CREATE TABLE IF NOT EXISTS ll_delivery_fee (
|
||||
amount DECIMAL(18,2) NOT NULL COMMENT '费用金额', remark VARCHAR(500) NULL COMMENT '费用备注', created_by VARCHAR(128) NULL COMMENT '创建人ID',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), KEY idx_ll_delivery_fee_order(customer_id,delivery_order_id,id)
|
||||
) ENGINE=InnoDB COMMENT='派送单费用明细';
|
||||
CREATE TABLE IF NOT EXISTS ll_delivery_document_revision (
|
||||
id BIGINT NOT NULL COMMENT '派送资料历史ID',
|
||||
customer_id VARCHAR(128) NOT NULL COMMENT '派送所属客户隔离键',
|
||||
delivery_order_id BIGINT NOT NULL COMMENT '派送单ID',
|
||||
version BIGINT NOT NULL COMMENT '保存成功时派送单版本',
|
||||
idempotency_key VARCHAR(128) NOT NULL COMMENT '资料保存幂等键',
|
||||
request_hash VARCHAR(64) NOT NULL COMMENT '完整保存请求哈希',
|
||||
snapshot_json JSON NOT NULL COMMENT '不可变三类资料及BOL号结果快照',
|
||||
operator_id VARCHAR(128) NOT NULL COMMENT '保存操作人ID',
|
||||
created_at DATETIME(3) NOT NULL COMMENT '资料保存时间',
|
||||
PRIMARY KEY(id),
|
||||
UNIQUE KEY uk_delivery_document_command(customer_id,delivery_order_id,idempotency_key),
|
||||
UNIQUE KEY uk_delivery_document_version(customer_id,delivery_order_id,version)
|
||||
) ENGINE=InnoDB COMMENT='派送资料版本历史与幂等保存记录';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_delivery_operation (
|
||||
id BIGINT NOT NULL COMMENT '派送操作主键', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', delivery_order_id BIGINT NOT NULL COMMENT '派送单ID',
|
||||
operation_node VARCHAR(64) NOT NULL COMMENT '操作节点', operator_id VARCHAR(128) NULL COMMENT '操作人ID', operator_name VARCHAR(128) NULL COMMENT '操作人名称',
|
||||
|
||||
+2
-1
@@ -19,9 +19,10 @@
|
||||
| 海外异常订单证据 | 通用内部业务文件入口;固定 `businessType=OVERSEAS_EXCEPTION_EVIDENCE`、`tenantId=异常订单所属客户ID`、`businessId=异常订单ID`,File 只校验文件复合归属,异常状态、完成门禁和证据引用归 Lastleg |
|
||||
| 入库扣货凭证 | 通用内部业务文件入口;固定 `businessType=INBOUND_HOLD_EVIDENCE`、`tenantId=客户ID`、`businessId=入库单ID`,客户作用域单文件最多 10MiB;可见性与历史引用由 Firstleg 校验 |
|
||||
| 报关源文件、完成文件与买单明细导出 | `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 保存 |
|
||||
| 订舱文件 | 通用内部业务文件入口,契约 `src/main/resources/openapi/ocean-booking-file-v1.yaml`;类型固定 `OCEAN_BOOKING_DOCUMENT`、`tenantId=0`(平台专属,客户可选快照不改变文件归属,拒绝客户作用域)、`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` |
|
||||
| 派送单三类资料 | `src/main/resources/openapi/delivery-files-v1.yaml`;依次为装车照片 `DELIVERY_LOADING_PHOTO`、BOL文件(含签名)`DELIVERY_BOL`、签收文件(POD签收/快递签收)`DELIVERY_RECEIPT`,按客户、派送内部ID、类别隔离,任意格式单文件50MiB;Lastleg持有分类绑定、保存、替换删除和历史可见性,按非空类别计数x/3,不强制必传或识别签名;File保留不可变对象与真实createdAt |
|
||||
| 下游 OCR | `client/WeightOcrClient.java` |
|
||||
| SQL/配置 | `src/main/resources/db/file.sql`、`application.yml` |
|
||||
| 测试/验证 | `src/test/java/com/baiyue/file/`;`mvn -pl file -am test` |
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# 派送资料 File 交付及打印 BOL 核查
|
||||
|
||||
范围:测试工作区当前 test 分支;File/Print。未执行部署、真实 HTTP 或数据库操作。
|
||||
|
||||
## 能力矩阵
|
||||
|
||||
| 能力 | 状态与证据 |
|
||||
|---|---|
|
||||
| 三类上传 | 按序 LOADING_PHOTO→DELIVERY_LOADING_PHOTO(装车照片)、BOL→DELIVERY_BOL(BOL文件(含签名))、RECEIPT→DELIVERY_RECEIPT(签收文件(POD签收/快递签收));任意格式,非空且每文件≤52428800字节 |
|
||||
| 所有权与元数据 | 复用 InternalBusinessFileService:tenantId=真实客户>0、businessId=deliveryOrderId内部Long十进制字符串、businessType和fileId精确过滤;同时只读ACTIVE且未删除对象 |
|
||||
| 上传时间 | Metadata增加真实持久化createdAt,LocalDateTime无偏移;已有其他通用业务文件OpenAPI同步字段 |
|
||||
| 下载 | 内部服务令牌必需,复合归属查询成功后读取真实对象;attachment、UTF-8文件名、no-store |
|
||||
| 替换与删除 | 新上传产生独立对象并保留旧内容;当前/历史引用、原子保存、版本和可见性归Lastleg。File未新增物理删除入口 |
|
||||
| 类别计数 | Lastleg每类0至20文件,totalCategoryCount=3,计数按已绑定非空类别x/3,不按文件数计数、不要求全部必传;无新增完成门禁、签名识别或电子签名;File不复制绑定或状态 |
|
||||
| 数据库 | 现有business_type VARCHAR(32)、business_id VARCHAR(64)、created_at足够,无结构变更或SQL执行 |
|
||||
|
||||
## 打印 BOL 现状
|
||||
|
||||
判定:Print通用渲染能力已实现,派送BOL业务打印链路未实现/待业务契约确认,未作环境验收。
|
||||
|
||||
- `print/src/main/java/com/baiyue/print/controller/InternalPdfController.java` 提供内部 inbound-marks、freight-a4-documents 两个入口,校验内部令牌,返回真实PDF、页数、Content-Disposition和no-store。
|
||||
- `print/src/main/java/com/baiyue/print/dto/FreightA4PdfDtos.java` 仅定义海运托运书和提单确认件A4模板;没有派送BOL专用模板。
|
||||
- `print/src/test/java/com/baiyue/print/service/FreightA4PdfServiceGenerationTest.java` 实际生成字节并用PDFBox解析页数、尺寸与业务字段。
|
||||
- 本轮定向检索 `lastleg-service/src/main/java/com/baiyue/lastleg/delivery/{controller,service}` 未发现调用Print的派送BOL生成链;当前BOL号和三类资料绑定不能视为生成打印能力。已上传BOL的鉴权下载也不等于生成派送BOL。
|
||||
- 派送专属打印权限、状态门禁、字段映射、模板是否复用和输出入口尚不能由现有File/Print证明,本次未新增打印方案。其余五个按钮由Admin/后端16核查。
|
||||
|
||||
## 验证
|
||||
|
||||
- 2026-09-09 11:21(UTC+8),`mvn -pl file -am test`:BUILD SUCCESS,File 195项,0失败/错误/跳过。DeliveryBusinessFileTest 14项覆盖三类各自不可变上传、真实时间、50MiB边界和完整查询隔离,以及五个已移除类型的拒绝;DeliveryFileContractTest 1项锁定顺序、名称、映射及运行枚举一致性。仅本轮三类结果作为当前验收证据。
|
||||
- `mvn -pl print -am -Dtest=FreightA4PdfServiceGenerationTest,InternalPdfControllerTest -Dsurefire.failIfNoSpecifiedTests=false test`:10项通过,含PDFBox真实解析。没有把它作为派送业务打印已实现的证据。
|
||||
- AGENTS索引合同未通过:现有柜费分摊金额守恒、整组操作契约两项索引缺失;不属于本次File变更。
|
||||
- Lastleg绑定/替换删除后的用户可见性需由后端16专项测试及后续获准环境验收确认。
|
||||
@@ -7,8 +7,11 @@ import java.util.Arrays;
|
||||
|
||||
/** File-owned storage policy. Business visibility and lifecycle remain in the calling service. */
|
||||
enum BusinessFileCategory {
|
||||
DELIVERY_LOADING_PHOTO(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
|
||||
DELIVERY_BOL(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
|
||||
DELIVERY_RECEIPT(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
|
||||
TRUCK_APPOINTMENT_ATTACHMENT(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||
OCEAN_BOOKING_DOCUMENT(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
|
||||
OCEAN_BOOKING_DOCUMENT(false, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
|
||||
CUSTOMS_DOCUMENT(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||
CUSTOMS_RECEIPT(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||
FINANCE_VOUCHER(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||
@@ -50,6 +53,9 @@ enum BusinessFileCategory {
|
||||
}
|
||||
|
||||
void validateOwner(long tenantId) {
|
||||
if (this == OCEAN_BOOKING_DOCUMENT && tenantId != 0) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "订舱附件必须归属平台");
|
||||
}
|
||||
if (customerOwned && tenantId <= 0) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "该附件类型必须归属明确客户");
|
||||
}
|
||||
|
||||
@@ -84,10 +84,11 @@ public class InternalBusinessFileService {
|
||||
private static Metadata metadata(FileObjectEntity value) {
|
||||
return new Metadata(value.getId(), value.getTenantId() == 0 ? "PLATFORM" : "CUSTOMER",
|
||||
value.getTenantId(), value.getBusinessType(), value.getBusinessId(),
|
||||
value.getOriginalName(), value.getSha256(), value.getContentType(), value.getSizeBytes());
|
||||
value.getOriginalName(), value.getSha256(), value.getContentType(), value.getSizeBytes(), value.getCreatedAt());
|
||||
}
|
||||
|
||||
public record Metadata(Long fileId, String ownerScope, Long tenantId, String businessType, String businessId,
|
||||
String fileName, String sha256, String contentType, Long sizeBytes) {}
|
||||
String fileName, String sha256, String contentType, Long sizeBytes,
|
||||
LocalDateTime createdAt) {}
|
||||
public record Download(String fileName, String contentType, byte[] content) {}
|
||||
}
|
||||
|
||||
@@ -118,6 +118,7 @@ components:
|
||||
sha256: {type: string}
|
||||
contentType: {type: string}
|
||||
sizeBytes: {type: integer, format: int64, maximum: 52428800}
|
||||
createdAt: {type: string, description: 持久化上传时间LocalDateTime,无时区偏移}
|
||||
ExportBatchRequest:
|
||||
type: object
|
||||
required: [taskId, tenantId, appCode, requestedBy, dispatchVersion, parameters, limit]
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: 派送单三类资料 File 内部契约
|
||||
version: 1.0.0
|
||||
description: >-
|
||||
tenantId为派送单所属客户,businessId为deliveryOrderId内部Long的十进制字符串,不是派送单号或BOL号。
|
||||
任意格式,非空单文件最多50MiB(52428800字节)。每类0至20文件由Lastleg控制,三类不强制必传。
|
||||
totalCategoryCount固定3,计数为非空类别x/3,不是文件数;不增加完成门禁或签名识别。
|
||||
上传只创建不可变对象,不自动绑定。Lastleg原子保存分类绑定与版本,替换/删除仅解绑并保留历史证据,
|
||||
用户下载前由Lastleg检查当前引用或获准历史引用和权限,再调用File精确归属下载。不得按BOL号跨客户复用。
|
||||
x-category-mapping:
|
||||
LOADING_PHOTO: DELIVERY_LOADING_PHOTO
|
||||
BOL: DELIVERY_BOL
|
||||
RECEIPT: DELIVERY_RECEIPT
|
||||
x-category-titles:
|
||||
LOADING_PHOTO: 装车照片
|
||||
BOL: BOL文件(含签名)
|
||||
RECEIPT: 签收文件(POD签收/快递签收)
|
||||
paths:
|
||||
/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}:
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/Token'}
|
||||
- {$ref: '#/components/parameters/Tenant'}
|
||||
- {$ref: '#/components/parameters/Type'}
|
||||
- {$ref: '#/components/parameters/Business'}
|
||||
post:
|
||||
summary: 上传派送资料不可变对象
|
||||
parameters:
|
||||
- {name: X-Operator-Id, in: header, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
multipart/form-data:
|
||||
schema:
|
||||
type: object
|
||||
required: [file]
|
||||
properties:
|
||||
file: {type: string, format: binary}
|
||||
responses:
|
||||
'200': {$ref: '#/components/responses/Metadata'}
|
||||
/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}:
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/Token'}
|
||||
- {$ref: '#/components/parameters/Tenant'}
|
||||
- {$ref: '#/components/parameters/Type'}
|
||||
- {$ref: '#/components/parameters/Business'}
|
||||
- {$ref: '#/components/parameters/File'}
|
||||
get:
|
||||
summary: 校验客户、派送单、类别和文件ID,读取有效未删除文件元数据
|
||||
responses:
|
||||
'200': {$ref: '#/components/responses/Metadata'}
|
||||
/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}/content:
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/Token'}
|
||||
- {$ref: '#/components/parameters/Tenant'}
|
||||
- {$ref: '#/components/parameters/Type'}
|
||||
- {$ref: '#/components/parameters/Business'}
|
||||
- {$ref: '#/components/parameters/File'}
|
||||
get:
|
||||
summary: 按完整归属下载;内部令牌必需,不提供匿名下载
|
||||
responses:
|
||||
'200':
|
||||
description: 二进制成功响应;失败按统一JSON code判断
|
||||
headers:
|
||||
Cache-Control: {schema: {type: string, enum: [no-store]}}
|
||||
Content-Disposition: {schema: {type: string}, description: attachment及UTF-8原文件名}
|
||||
components:
|
||||
parameters:
|
||||
Token: {name: X-Internal-Service-Token, in: header, required: true, schema: {type: string}}
|
||||
Tenant: {name: tenantId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
Business: {name: businessId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
File: {name: fileId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
Type:
|
||||
name: businessType
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
enum: [DELIVERY_LOADING_PHOTO, DELIVERY_BOL, DELIVERY_RECEIPT]
|
||||
responses:
|
||||
Metadata:
|
||||
description: 业务成功/失败使用HTTP200,成功code为字符串200;无效内部令牌拒绝
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
code: {type: string}
|
||||
message: {type: string}
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
fileId: {type: string}
|
||||
ownerScope: {type: string, enum: [CUSTOMER]}
|
||||
tenantId: {type: string}
|
||||
businessType: {type: string}
|
||||
businessId: {type: string}
|
||||
fileName: {type: string}
|
||||
sha256: {type: string}
|
||||
contentType: {type: string}
|
||||
sizeBytes: {type: integer, format: int64, maximum: 52428800}
|
||||
createdAt: {type: string, description: 持久化created_at原值,LocalDateTime格式yyyy-MM-ddTHH:mm:ss,无时区偏移}
|
||||
@@ -3,9 +3,9 @@ info:
|
||||
title: Ocean Booking Document Internal API
|
||||
version: 1.0.0
|
||||
description: >-
|
||||
订舱文件归属订舱聚合,不归属拖车。固定ownerScope=CUSTOMER、tenantId=订舱customerId、
|
||||
订舱文件归属订舱聚合,不归属拖车。固定ownerScope=PLATFORM、tenantId=0、
|
||||
businessType=OCEAN_BOOKING_DOCUMENT、businessId=oceanBookingId。BOOKING_CONFIRMATION与
|
||||
BILL_OF_LADING文档类型、版本和有效状态归Firstleg;File不判断订舱业务状态。
|
||||
BILL_OF_LADING文档类型、版本和有效状态归Firstleg;File不判断订舱业务状态。客户为可选快照,有值、为空或清空均不改变文件归属,客户作用域上传读取一律拒绝。
|
||||
x-firstleg-document-types: [BOOKING_CONFIRMATION, BILL_OF_LADING]
|
||||
x-lifecycle-owner: FIRSTLEG
|
||||
x-file-object-semantics: IMMUTABLE_CREATE_ONLY
|
||||
@@ -70,8 +70,8 @@ components:
|
||||
name: tenantId
|
||||
in: path
|
||||
required: true
|
||||
description: 订舱customerId
|
||||
schema: {$ref: '#/components/schemas/Id'}
|
||||
description: 平台固定0,与订舱客户快照无关
|
||||
schema: {type: string, enum: ['0']}
|
||||
OceanBookingId:
|
||||
name: oceanBookingId
|
||||
in: path
|
||||
@@ -100,10 +100,11 @@ components:
|
||||
required: [fileId, ownerScope, tenantId, businessType, businessId, fileName, sha256, contentType, sizeBytes]
|
||||
properties:
|
||||
fileId: {$ref: '#/components/schemas/Id'}
|
||||
ownerScope: {type: string, enum: [CUSTOMER]}
|
||||
ownerScope: {type: string, enum: [PLATFORM]}
|
||||
tenantId:
|
||||
allOf: [{$ref: '#/components/schemas/Id'}]
|
||||
description: 订舱customerId
|
||||
type: string
|
||||
enum: ['0']
|
||||
description: 平台固定0,与订舱客户快照无关
|
||||
businessType: {type: string, enum: [OCEAN_BOOKING_DOCUMENT]}
|
||||
businessId:
|
||||
allOf: [{$ref: '#/components/schemas/Id'}]
|
||||
@@ -112,3 +113,4 @@ components:
|
||||
sha256: {type: string, pattern: '^[0-9a-f]{64}$'}
|
||||
contentType: {type: string}
|
||||
sizeBytes: {type: integer, format: int64, minimum: 1, maximum: 52428800}
|
||||
createdAt: {type: string, description: 持久化上传时间LocalDateTime,无时区偏移}
|
||||
|
||||
@@ -134,3 +134,4 @@ components:
|
||||
sha256: {type: string, pattern: '^[0-9a-f]{64}$'}
|
||||
contentType: {type: string, enum: [application/vnd.openxmlformats-officedocument.spreadsheetml.sheet]}
|
||||
sizeBytes: {type: integer, format: int64, minimum: 1, maximum: 5242880}
|
||||
createdAt: {type: string, description: 持久化上传时间LocalDateTime,无时区偏移}
|
||||
|
||||
@@ -107,3 +107,4 @@ components:
|
||||
sha256: {type: string, pattern: '^[0-9a-f]{64}$'}
|
||||
contentType: {type: string}
|
||||
sizeBytes: {type: integer, format: int64, minimum: 1, maximum: 10485760}
|
||||
createdAt: {type: string, description: 持久化上传时间LocalDateTime,无时区偏移}
|
||||
|
||||
@@ -9,13 +9,13 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class OceanBookingFileContractTest {
|
||||
@Test
|
||||
void freezesInternalCustomerOwnedBookingDocumentContract() throws Exception {
|
||||
void freezesInternalPlatformOwnedBookingDocumentContract() throws Exception {
|
||||
String yaml = Files.readString(Path.of("src/main/resources/openapi/ocean-booking-file-v1.yaml"));
|
||||
|
||||
assertThat(yaml)
|
||||
.contains("/internal/file/v1/business-files/{tenantId}/OCEAN_BOOKING_DOCUMENT/{oceanBookingId}:")
|
||||
.contains("ownerScope: {type: string, enum: [CUSTOMER]}")
|
||||
.contains("description: 订舱customerId")
|
||||
.contains("ownerScope: {type: string, enum: [PLATFORM]}")
|
||||
.contains("description: 平台固定0,与订舱客户快照无关")
|
||||
.contains("description: oceanBookingId")
|
||||
.contains("x-firstleg-document-types: [BOOKING_CONFIRMATION, BILL_OF_LADING]")
|
||||
.contains("x-lifecycle-owner: FIRSTLEG")
|
||||
|
||||
+3
-3
@@ -24,7 +24,7 @@ class InternalBusinessFileControllerTest {
|
||||
@Test void zeroTenantUploadAndMetadataRemainBehindInternalToken() {
|
||||
var file = new MockMultipartFile("file", "clearance.pdf", "application/pdf", "%PDF-1.4".getBytes());
|
||||
var metadata = new InternalBusinessFileService.Metadata(81L, "PLATFORM", 0L, "CONTAINER_CLEARANCE_DOCUMENT",
|
||||
"41", "clearance.pdf", "hash", "application/pdf", file.getSize());
|
||||
"41", "clearance.pdf", "hash", "application/pdf", file.getSize(), java.time.LocalDateTime.of(2026, 9, 9, 12, 0));
|
||||
when(service.upload(0L, "CONTAINER_CLEARANCE_DOCUMENT", "41", 7L, file)).thenReturn(metadata);
|
||||
when(service.metadata(0L, "CONTAINER_CLEARANCE_DOCUMENT", "41", 81L)).thenReturn(metadata);
|
||||
|
||||
@@ -59,11 +59,11 @@ class InternalBusinessFileControllerTest {
|
||||
|
||||
@Test void unicodeSpecialFileNameUsesUtf8AttachmentDisposition() {
|
||||
String fileName = "订舱 确认 🚢 #[]()+,最终版.xlsx";
|
||||
when(service.download(10001L, "OCEAN_BOOKING_DOCUMENT", "61", 93L))
|
||||
when(service.download(0L, "OCEAN_BOOKING_DOCUMENT", "61", 93L))
|
||||
.thenReturn(new InternalBusinessFileService.Download(fileName,
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", new byte[]{4}));
|
||||
|
||||
var response = controller.download("internal-token", 10001L,
|
||||
var response = controller.download("internal-token", 0L,
|
||||
"OCEAN_BOOKING_DOCUMENT", "61", 93L);
|
||||
|
||||
assertThat(response.getHeaders().getContentDisposition().getType()).isEqualTo("attachment");
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package com.baiyue.file.service;
|
||||
|
||||
import com.baiyue.file.entity.FileObjectEntity;
|
||||
import com.baiyue.file.mapper.FileObjectMapper;
|
||||
import com.baiyue.file.controller.InternalBusinessFileController;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import java.nio.file.Path;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
|
||||
class DeliveryBusinessFileTest {
|
||||
@TempDir Path root;
|
||||
@BeforeAll static void metadata() {
|
||||
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "delivery-files"), FileObjectEntity.class);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings={"DELIVERY_LOADING_PHOTO","DELIVERY_BOL","DELIVERY_RECEIPT"})
|
||||
void immutableUploadsPreserveOldContentAndRealTimestamp(String type) {
|
||||
var storage = storage();
|
||||
var files = mock(FileObjectMapper.class);
|
||||
var service = service(files, storage);
|
||||
var ids = new AtomicLong(800);
|
||||
when(files.insert(any(FileObjectEntity.class))).thenAnswer(call -> {
|
||||
FileObjectEntity row = call.getArgument(0); row.setId(ids.incrementAndGet()); return 1;
|
||||
});
|
||||
var first = service.upload(11, type, "71", 9, new MockMultipartFile("file", "资料 🚢.bin", "application/octet-stream", new byte[]{0,1,2}));
|
||||
var second = service.upload(11, type, "71", 9, new MockMultipartFile("file", "资料 🚢.bin", "application/octet-stream", new byte[]{3,4}));
|
||||
var captor = org.mockito.ArgumentCaptor.forClass(FileObjectEntity.class);
|
||||
verify(files, times(2)).insert(captor.capture());
|
||||
assertThat(first.fileId()).isNotEqualTo(second.fileId());
|
||||
assertThat(first.createdAt()).isEqualTo(captor.getAllValues().get(0).getCreatedAt()).isNotNull();
|
||||
assertThat(first.ownerScope()).isEqualTo("CUSTOMER");
|
||||
assertThat(storage.read(captor.getAllValues().get(0).getObjectKey())).containsExactly(0,1,2);
|
||||
assertThat(storage.read(captor.getAllValues().get(1).getObjectKey())).containsExactly(3,4);
|
||||
verify(files, never()).updateById(any(FileObjectEntity.class));
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings={"DELIVERY_LOADING_PHOTO","DELIVERY_BOL","DELIVERY_RECEIPT"})
|
||||
void enforcesFiftyMibAndNonemptyBoundary(String type) throws Exception {
|
||||
var storage = storage(); var category = BusinessFileCategory.require(type);
|
||||
assertThatThrownBy(() -> storage.store(11,"71",new MockMultipartFile("file",new byte[0]),category)).isInstanceOf(BizException.class);
|
||||
var file = mock(MultipartFile.class);
|
||||
when(file.isEmpty()).thenReturn(false); when(file.getSize()).thenReturn(52428801L);
|
||||
assertThatThrownBy(() -> storage.store(11,"71",file,category)).isInstanceOf(BizException.class).hasMessageContaining("50MB");
|
||||
when(file.getSize()).thenReturn(52428800L); when(file.getContentType()).thenReturn("application/x-custom");
|
||||
when(file.getOriginalFilename()).thenReturn("boundary.custom");
|
||||
doAnswer(call -> { try (var out=new java.io.RandomAccessFile(((Path)call.getArgument(0)).toFile(),"rw")) { out.setLength(52428800L); } return null; }).when(file).transferTo(any(Path.class));
|
||||
var stored=storage.store(11,"71",file,category);
|
||||
assertThat(java.nio.file.Files.size(root.resolve(stored.objectKey()))).isEqualTo(52428800L);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings={"DELIVERY_LOADING_PHOTO","DELIVERY_BOL","DELIVERY_RECEIPT"})
|
||||
void downloadQueriesEveryOwnershipKeyAndRejectsUnauthorizedAccess(String type) {
|
||||
var files=mock(FileObjectMapper.class); var storage=mock(BusinessFileStorage.class); var service=service(files,storage);
|
||||
when(files.selectOne(any())).thenAnswer(call -> {
|
||||
LambdaQueryWrapper<FileObjectEntity> query=call.getArgument(0);
|
||||
assertThat(query.getSqlSegment()).contains("tenant_id", "business_type", "business_id", "id", "status", "deleted");
|
||||
assertThat(query.getParamNameValuePairs().values()).containsExactlyInAnyOrder(12L,type,"72",802L,"ACTIVE",false);
|
||||
return null;
|
||||
});
|
||||
assertThatThrownBy(() -> service.download(12,type,"72",802)).isInstanceOf(BizException.class).hasMessageContaining("归属不匹配");
|
||||
assertThatThrownBy(() -> service.metadata(0,type,"71",801)).isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(() -> service.metadata(11,type,"not-an-id",801)).isInstanceOf(BizException.class);
|
||||
var controller=new InternalBusinessFileController();
|
||||
ReflectionTestUtils.setField(controller,"expected","test-internal"); ReflectionTestUtils.setField(controller,"service",service);
|
||||
assertThatThrownBy(() -> controller.download("bad",11,type,"71",801)).isInstanceOf(BizException.class);
|
||||
verifyNoInteractions(storage);
|
||||
}
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings={"DELIVERY_CUSTOMER_DOCUMENT","DELIVERY_BILL_OF_LADING","DELIVERY_POD","DELIVERY_PHOTO","DELIVERY_EXPRESS_RECEIPT"})
|
||||
void rejectsRemovedCategoriesWithoutAliases(String type) {
|
||||
assertThatThrownBy(() -> BusinessFileCategory.require(type))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("不支持的附件业务类型");
|
||||
}
|
||||
|
||||
private BusinessFileStorage storage() {
|
||||
var storage=new BusinessFileStorage(); ReflectionTestUtils.setField(storage,"configuredRoot",root); storage.initializeRoot(); return storage;
|
||||
}
|
||||
private InternalBusinessFileService service(FileObjectMapper files, BusinessFileStorage storage) {
|
||||
var service=new InternalBusinessFileService(); ReflectionTestUtils.setField(service,"files",files); ReflectionTestUtils.setField(service,"storage",storage); return service;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.baiyue.file.service;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class DeliveryFileContractTest {
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void freezesThreeOrderedCategoriesAndMatchesRuntimePolicy() throws Exception {
|
||||
String text = Files.readString(Path.of("src/main/resources/openapi/delivery-files-v1.yaml"));
|
||||
Map<String, Object> yaml = new Yaml().load(text);
|
||||
Map<String, Object> info = (Map<String, Object>) yaml.get("info");
|
||||
Map<String, String> mapping = (Map<String, String>) info.get("x-category-mapping");
|
||||
assertThat(mapping.keySet()).containsExactly("LOADING_PHOTO", "BOL", "RECEIPT");
|
||||
List<String> types = List.of("DELIVERY_LOADING_PHOTO", "DELIVERY_BOL", "DELIVERY_RECEIPT");
|
||||
assertThat(mapping.values()).containsExactlyElementsOf(types);
|
||||
Map<String, String> titles = (Map<String, String>) info.get("x-category-titles");
|
||||
assertThat(titles.keySet()).containsExactlyElementsOf(mapping.keySet());
|
||||
assertThat(titles.values()).containsExactly("装车照片", "BOL文件(含签名)", "签收文件(POD签收/快递签收)");
|
||||
Map<String, Object> components = (Map<String, Object>) yaml.get("components");
|
||||
Map<String, Object> parameters = (Map<String, Object>) components.get("parameters");
|
||||
Map<String, Object> type = (Map<String, Object>) parameters.get("Type");
|
||||
Map<String, Object> schema = (Map<String, Object>) type.get("schema");
|
||||
assertThat((List<String>) schema.get("enum")).containsExactlyElementsOf(types);
|
||||
assertThat(Arrays.stream(BusinessFileCategory.values()).map(Enum::name)
|
||||
.filter(name -> name.startsWith("DELIVERY_")).toList()).containsExactlyElementsOf(types);
|
||||
assertThat(text).contains("totalCategoryCount固定3", "非空类别x/3", "每类0至20", "不强制必传", "不增加完成门禁或签名识别");
|
||||
}
|
||||
}
|
||||
@@ -75,46 +75,61 @@ class InternalBusinessFileServiceTest {
|
||||
verify(storage).store(11L, "51", file, BusinessFileCategory.OVERSEAS_UNLOAD_PHOTO);
|
||||
}
|
||||
|
||||
@Test void oceanBookingDocumentUsesCustomerAndBookingOwnership() {
|
||||
@Test void oceanBookingDocumentUsesPlatformAndBookingOwnership() {
|
||||
MockMultipartFile file = new MockMultipartFile("file", "booking.xlsx",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "xlsx".getBytes());
|
||||
when(storage.store(10001L, "61", file, BusinessFileCategory.OCEAN_BOOKING_DOCUMENT))
|
||||
.thenReturn(new BusinessFileStorage.Stored("business-files/10001/61/booking",
|
||||
when(storage.store(0L, "61", file, BusinessFileCategory.OCEAN_BOOKING_DOCUMENT))
|
||||
.thenReturn(new BusinessFileStorage.Stored("business-files/0/61/booking",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
file.getSize(), "f".repeat(64), "booking.xlsx"));
|
||||
when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> {
|
||||
((FileObjectEntity) invocation.getArgument(0)).setId(111L); return 1;
|
||||
});
|
||||
|
||||
var metadata = service.upload(10001L, "OCEAN_BOOKING_DOCUMENT", "61", 7L, file);
|
||||
var metadata = service.upload(0L, "OCEAN_BOOKING_DOCUMENT", "61", 7L, file);
|
||||
|
||||
assertThat(metadata).extracting(InternalBusinessFileService.Metadata::ownerScope,
|
||||
InternalBusinessFileService.Metadata::tenantId,
|
||||
InternalBusinessFileService.Metadata::businessType,
|
||||
InternalBusinessFileService.Metadata::businessId)
|
||||
.containsExactly("CUSTOMER", 10001L, "OCEAN_BOOKING_DOCUMENT", "61");
|
||||
assertThatThrownBy(() -> service.upload(0L, "OCEAN_BOOKING_DOCUMENT", "61", 7L, file))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("明确客户");
|
||||
.containsExactly("PLATFORM", 0L, "OCEAN_BOOKING_DOCUMENT", "61");
|
||||
assertThatThrownBy(() -> service.upload(10001L, "OCEAN_BOOKING_DOCUMENT", "61", 7L, file))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("必须归属平台");
|
||||
}
|
||||
|
||||
@Test void oceanBookingDocumentRejectsWrongCustomerBookingAndTruckOwnership() {
|
||||
FileObjectEntity value = new FileObjectEntity(); value.setId(111L); value.setTenantId(10001L);
|
||||
FileObjectEntity value = new FileObjectEntity(); value.setId(111L); value.setTenantId(0L);
|
||||
value.setBusinessType("OCEAN_BOOKING_DOCUMENT"); value.setBusinessId("61");
|
||||
value.setOriginalName("booking.xlsx"); value.setObjectKey("business-files/10001/61/booking");
|
||||
value.setOriginalName("booking.xlsx"); value.setObjectKey("business-files/0/61/booking");
|
||||
value.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
||||
value.setSha256("f".repeat(64)); value.setSizeBytes(8L);
|
||||
when(files.selectOne(any())).thenReturn(value, null, null, null);
|
||||
when(files.selectOne(any())).thenReturn(value, null, null);
|
||||
|
||||
assertThat(service.metadata(10001L, "OCEAN_BOOKING_DOCUMENT", "61", 111L).ownerScope())
|
||||
.isEqualTo("CUSTOMER");
|
||||
assertThat(service.metadata(0L, "OCEAN_BOOKING_DOCUMENT", "61", 111L).ownerScope())
|
||||
.isEqualTo("PLATFORM");
|
||||
assertThatThrownBy(() -> service.metadata(10002L, "OCEAN_BOOKING_DOCUMENT", "61", 111L))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("归属不匹配");
|
||||
assertThatThrownBy(() -> service.metadata(10001L, "OCEAN_BOOKING_DOCUMENT", "62", 111L))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("必须归属平台");
|
||||
assertThatThrownBy(() -> service.metadata(0L, "OCEAN_BOOKING_DOCUMENT", "62", 111L))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("归属不匹配");
|
||||
assertThatThrownBy(() -> service.metadata(10001L, "TRUCK_APPOINTMENT_ATTACHMENT", "61", 111L))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("归属不匹配");
|
||||
}
|
||||
|
||||
@Test void bookingFileScopeRemainsPlatformRegardlessOfOptionalCustomerSnapshot() {
|
||||
FileObjectEntity value = new FileObjectEntity(); value.setId(111L); value.setTenantId(0L);
|
||||
value.setBusinessType("OCEAN_BOOKING_DOCUMENT"); value.setBusinessId("61");
|
||||
value.setOriginalName("booking.bin"); value.setObjectKey("business-files/0/61/object");
|
||||
when(files.selectOne(any())).thenReturn(value);
|
||||
when(storage.read(value.getObjectKey())).thenReturn(new byte[]{1, 2});
|
||||
// Customer snapshot is deliberately absent from File commands before and after clearing it.
|
||||
assertThat(service.metadata(0L, "OCEAN_BOOKING_DOCUMENT", "61", 111L).ownerScope()).isEqualTo("PLATFORM");
|
||||
assertThat(service.download(0L, "OCEAN_BOOKING_DOCUMENT", "61", 111L).content()).containsExactly(1, 2);
|
||||
assertThat(service.metadata(0L, "OCEAN_BOOKING_DOCUMENT", "61", 111L).fileId()).isEqualTo(111L);
|
||||
assertThatThrownBy(() -> service.download(10001L, "OCEAN_BOOKING_DOCUMENT", "61", 111L))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("必须归属平台");
|
||||
verify(storage, times(1)).read(value.getObjectKey());
|
||||
}
|
||||
|
||||
@Test void rejectsNegativeTenantAndInvalidOwnershipInputs() {
|
||||
MockMultipartFile file = new MockMultipartFile("file", "a.txt", "text/plain", "x".getBytes());
|
||||
assertThatThrownBy(() -> service.upload(-1L, "CONTAINER_CLEARANCE_DOCUMENT", "41", 7L, file))
|
||||
@@ -269,7 +284,7 @@ class InternalBusinessFileServiceTest {
|
||||
@Test void concurrentRepeatedUploadsCreateDistinctImmutableObjectsForFirstlegToVersion() throws Exception {
|
||||
MockMultipartFile file = new MockMultipartFile("file", "booking.bin",
|
||||
"application/x-booking", new byte[]{1, 2, 3});
|
||||
when(storage.store(10001L, "61", file, BusinessFileCategory.OCEAN_BOOKING_DOCUMENT))
|
||||
when(storage.store(0L, "61", file, BusinessFileCategory.OCEAN_BOOKING_DOCUMENT))
|
||||
.thenReturn(new BusinessFileStorage.Stored("business-files/10001/61/object",
|
||||
"application/x-booking", 3, "a".repeat(64), "booking.bin"));
|
||||
AtomicLong ids = new AtomicLong(200);
|
||||
@@ -280,9 +295,9 @@ class InternalBusinessFileServiceTest {
|
||||
var executor = Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
var firstFuture = executor.submit(() ->
|
||||
service.upload(10001L, "OCEAN_BOOKING_DOCUMENT", "61", 7L, file));
|
||||
service.upload(0L, "OCEAN_BOOKING_DOCUMENT", "61", 7L, file));
|
||||
var secondFuture = executor.submit(() ->
|
||||
service.upload(10001L, "OCEAN_BOOKING_DOCUMENT", "61", 7L, file));
|
||||
service.upload(0L, "OCEAN_BOOKING_DOCUMENT", "61", 7L, file));
|
||||
|
||||
var first = firstFuture.get();
|
||||
var second = secondFuture.get();
|
||||
|
||||
+12
-2
@@ -81,6 +81,11 @@ public class BusinessFeeSourceEventService {
|
||||
}
|
||||
if ("VOIDED".equals(source.action()))
|
||||
throw error("FIN-EVT-005", "作废事件对应的费用来源版本不存在");
|
||||
List<Map<String, Object>> versions = jdbc.queryForList(
|
||||
"SELECT source_fee_version FROM fin_fee_source WHERE source_system=? AND source_type=? AND source_fee_id=? FOR UPDATE",
|
||||
system, source.sourceType(), feeId);
|
||||
if (versions.stream().anyMatch(row -> ((Number) row.get("source_fee_version")).intValue() > source.sourceVersion()))
|
||||
throw error("FIN-EVT-008", "费用来源版本已过期");
|
||||
requireSourceNotFrozen(system, source.sourceType(), feeId);
|
||||
jdbc.update("UPDATE fin_fee_source SET status='SUPERSEDED' WHERE source_system=? AND source_type=? AND source_fee_id=? AND status='AVAILABLE'",
|
||||
system, source.sourceType(), feeId);
|
||||
@@ -115,6 +120,11 @@ public class BusinessFeeSourceEventService {
|
||||
}
|
||||
positive(value.sourceFeeId(), "sourceFeeId");
|
||||
positive(value.customerId(), "customerId");
|
||||
if ("OVERSEAS_CONTAINER_FEE".equals(value.sourceType())) {
|
||||
if (!"LASTLEG".equals(value.sourceSystem()) || !"OVERSEAS_CONTAINER".equals(value.businessType()))
|
||||
throw error("FIN-EVT-012", "海外柜费来源不一致");
|
||||
positive(value.containerBookingId(), "containerBookingId");
|
||||
}
|
||||
if (!"VOIDED".equals(value.action()) && (!"PUBLISHED".equals(value.publicationStatus())
|
||||
|| !"ACTIVE".equals(value.sourceStatus()))) {
|
||||
throw error("FIN-EVT-015", "费用来源尚未发布或已失效");
|
||||
@@ -125,7 +135,7 @@ public class BusinessFeeSourceEventService {
|
||||
if (blank(value.supplierName()) || !value.supplierName().equals(supplier.name()))
|
||||
throw error("FIN-EVT-012", "应付来源供应商快照不一致");
|
||||
validateSettlement(value);
|
||||
} else if (!blank(value.supplierId()) || !blank(value.supplierName())
|
||||
} else if (!blank(value.supplierId()) || !blank(value.supplierName()) || !blank(value.supplierCategory())
|
||||
|| !blank(value.settlementMode()) || value.paidAt() != null
|
||||
|| !blank(value.paymentMethod()) || !blank(value.paymentReferenceNo())
|
||||
|| (value.paymentVoucherFileIds() != null && !value.paymentVoucherFileIds().isEmpty())) {
|
||||
@@ -169,7 +179,7 @@ public class BusinessFeeSourceEventService {
|
||||
jdbc.update("INSERT INTO fin_cost(tenant_id,cost_no,cost_type,cost_origin,source_system,source_type,source_fee_id,source_fee_version,source_hash,order_id,inbound_id,container_id,warehouse_id,supplier_id,supplier_name_snapshot,fee_item_code,fee_item_name,currency_code,cost_amount,voucher_file_ids,paid_at,payment_method,payment_reference_no,status,confirmed_at,created_by,created_by_name,updated_by) VALUES(?,?,'SOURCE_DIRECT_PAID','BUSINESS_DIRECT_PAID',?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'CONFIRMED',CURRENT_TIMESTAMP(3),0,'系统',0)",
|
||||
positive(value.customerId(), "customerId"), "DIRECT-" + system + '-' + value.sourceType() + '-' + feeId + "-V" + value.sourceVersion(),
|
||||
system, value.sourceType(), feeId, value.sourceVersion(), value.sourceHash(), number(value.orderId()),
|
||||
number(value.inboundId()), number(value.containerId()), number(value.warehouseId()), positive(value.supplierId(), "supplierId"),
|
||||
number(value.inboundId()), number(value.containerBookingId()), number(value.warehouseId()), positive(value.supplierId(), "supplierId"),
|
||||
value.supplierName(), value.feeItemCode(), value.feeItemName(), value.currency(), value.totalAmount(),
|
||||
json(value.paymentVoucherFileIds()), value.paidAt(), value.paymentMethod(), value.paymentReferenceNo());
|
||||
}
|
||||
|
||||
@@ -81,6 +81,12 @@ public class FinanceFeeSourceEventService {
|
||||
if ("FIRSTLEG_FEE_VOIDED".equals(event.eventType())) {
|
||||
throw error("FIN-EVT-005", "作废事件对应的费用来源版本不存在");
|
||||
}
|
||||
List<Map<String, Object>> versions = jdbc.queryForList(
|
||||
"SELECT source_fee_version FROM fin_fee_source WHERE source_system='FIRSTLEG' AND source_type=? AND source_fee_id=? FOR UPDATE",
|
||||
snapshot.sourceType(), snapshot.feeId());
|
||||
if (versions.stream().anyMatch(row -> ((Number) row.get("source_fee_version")).intValue() > snapshot.feeVersion())) {
|
||||
throw error("FIN-EVT-008", "费用来源版本已过期");
|
||||
}
|
||||
requireSourceNotFrozen(snapshot.sourceType(), snapshot.feeId());
|
||||
jdbc.update("UPDATE fin_fee_source SET status='SUPERSEDED' WHERE source_system='FIRSTLEG' AND source_type=? AND source_fee_id=? AND status='AVAILABLE'",
|
||||
snapshot.sourceType(), snapshot.feeId());
|
||||
|
||||
@@ -18,6 +18,10 @@ import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.never;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
@@ -82,4 +86,155 @@ class BusinessFeeSourceEventServiceTest {
|
||||
null, "123e4567-e89b-12d3-a456-426614174702", "LASTLEG:FEE:701:1", null,
|
||||
node, payloadHash);
|
||||
}
|
||||
|
||||
@Test
|
||||
void overseasDirectPaidCostUsesPhysicalContainerAndKeepsSourceIdentity() {
|
||||
EventEnvelope event = containerEvent("CNY", "61", "PUBLISHED", 1);
|
||||
when(suppliers.requireActive(71L, null)).thenReturn(
|
||||
new com.baiyue.finance.client.WarehouseSupplierClient.SupplierSnapshot(71L, "供应商", null, "ACTIVE"));
|
||||
when(jdbc.queryForObject(startsWith("SELECT id FROM fin_fee_source"), eq(Long.class),
|
||||
eq("LASTLEG"), eq("OVERSEAS_CONTAINER_FEE"), eq(701L), eq(1))).thenReturn(901L);
|
||||
assertThat(service.consume(event)).isEqualTo(901L);
|
||||
ArgumentCaptor<Object[]> source = ArgumentCaptor.forClass(Object[].class);
|
||||
verify(jdbc).update(startsWith("INSERT INTO fin_fee_source"), source.capture());
|
||||
assertThat(source.getValue()[0]).isEqualTo("LASTLEG");
|
||||
assertThat(source.getValue()[4]).isEqualTo("OVERSEAS_CONTAINER_FEE");
|
||||
assertThat(source.getValue()[14]).isEqualTo(41L);
|
||||
assertThat(source.getValue()[15]).isEqualTo(61L);
|
||||
ArgumentCaptor<Object[]> cost = ArgumentCaptor.forClass(Object[].class);
|
||||
verify(jdbc).update(startsWith("INSERT INTO fin_cost"), cost.capture());
|
||||
assertThat(cost.getValue()[9]).isEqualTo(61L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsForeignCurrencyAndMissingPhysicalContainerBeforeWriting() {
|
||||
assertThatThrownBy(() -> service.consume(containerEvent("USD", "61", "PUBLISHED", 1)))
|
||||
.isInstanceOf(com.baiyue.finance.service.FinanceEventException.class);
|
||||
assertThatThrownBy(() -> service.consume(containerEvent("CNY", null, "PUBLISHED", 1)))
|
||||
.isInstanceOf(com.baiyue.finance.service.FinanceEventException.class);
|
||||
verify(jdbc, never()).update(anyString(), any(Object[].class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void lateOlderVersionCannotSupersedeNewerSourceOrCost() {
|
||||
when(suppliers.requireActive(71L, null)).thenReturn(
|
||||
new com.baiyue.finance.client.WarehouseSupplierClient.SupplierSnapshot(71L, "供应商", null, "ACTIVE"));
|
||||
when(jdbc.queryForList(startsWith("SELECT source_fee_version"), eq("LASTLEG"),
|
||||
eq("OVERSEAS_CONTAINER_FEE"), eq(701L)))
|
||||
.thenReturn(List.of(Map.of("source_fee_version", 2)));
|
||||
assertThatThrownBy(() -> service.consume(containerEvent("CNY", "61", "PUBLISHED", 1)))
|
||||
.hasMessageContaining("版本已过期");
|
||||
verify(jdbc, never()).update(startsWith("UPDATE fin_fee_source"), any(Object[].class));
|
||||
verify(jdbc, never()).update(startsWith("UPDATE fin_cost"), any(Object[].class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void replayReturnsOriginalSourceWithoutDuplicatingCost() {
|
||||
EventEnvelope event = containerEvent("CNY", "61", "PUBLISHED", 1);
|
||||
when(suppliers.requireActive(71L, null)).thenReturn(
|
||||
new com.baiyue.finance.client.WarehouseSupplierClient.SupplierSnapshot(71L, "供应商", null, "ACTIVE"));
|
||||
when(jdbc.queryForList(startsWith("SELECT payload_hash,status"), eq("LASTLEG"), eq(event.eventId())))
|
||||
.thenReturn(List.of(Map.of("payload_hash", event.payloadHash(), "status", "PROCESSED", "result_resource_id", 901L)));
|
||||
assertThat(service.consume(event)).isEqualTo(901L);
|
||||
verify(jdbc, never()).update(anyString(), any(Object[].class));
|
||||
}
|
||||
|
||||
private EventEnvelope containerEvent(String currency, String containerBookingId, String action, int version) {
|
||||
EventEnvelope base = eventWithoutInbound();
|
||||
ObjectNode payload = base.payload().deepCopy();
|
||||
payload.put("sourceType", "OVERSEAS_CONTAINER_FEE");
|
||||
payload.put("businessType", "OVERSEAS_CONTAINER");
|
||||
payload.put("containerId", "41");
|
||||
payload.put("containerBookingId", containerBookingId);
|
||||
payload.put("currency", currency);
|
||||
payload.put("direction", "PAYABLE");
|
||||
payload.put("supplierId", "71");
|
||||
payload.put("supplierName", "供应商");
|
||||
payload.put("settlementMode", "DIRECT_PAID");
|
||||
payload.set("paidAt", json.valueToTree(Instant.parse("2026-09-02T08:00:00Z")));
|
||||
payload.put("paymentMethod", "BANK_TRANSFER");
|
||||
payload.put("paymentReferenceNo", "PAY-1");
|
||||
payload.put("sourceVersion", version);
|
||||
payload.putNull("action");
|
||||
payload.putNull("sourceHash");
|
||||
payload.put("sourceHash", EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload)));
|
||||
payload.put("action", action);
|
||||
return new EventEnvelope(base.eventId(), base.eventType(), 1, base.occurredAt(),
|
||||
RegionCode.OVERSEAS, RegionCode.CN, "10001", "BUSINESS_FEE", "701", version,
|
||||
null, base.traceId(), "LASTLEG:FEE:701:" + version, null, payload,
|
||||
EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameEventIdWithDifferentPayloadIsRejectedWithoutWrites() {
|
||||
var event = payableEvent("PUBLISHED", 1);
|
||||
when(jdbc.queryForList(startsWith("SELECT payload_hash,status"), eq("LASTLEG"), eq(event.eventId())))
|
||||
.thenReturn(List.of(Map.of("payload_hash", "different", "status", "PROCESSED", "result_resource_id", 901L)));
|
||||
assertThatThrownBy(() -> service.consume(event)).hasMessageContaining("同一事件ID");
|
||||
verify(jdbc, never()).update(anyString(), any(Object[].class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameSourceVersionWithDifferentHashCannotOverwriteCost() {
|
||||
var event = payableEvent("PUBLISHED", 1);
|
||||
existingVersion("different", "AVAILABLE");
|
||||
assertThatThrownBy(() -> service.consume(event)).hasMessageContaining("同一来源版本");
|
||||
noFinancialWrites();
|
||||
}
|
||||
|
||||
@Test
|
||||
void voidedSourceCannotBeRevivedByPublicationReplay() {
|
||||
var event = payableEvent("PUBLISHED", 1);
|
||||
existingVersion(event.payload().get("sourceHash").asText(), "VOIDED");
|
||||
assertThat(service.consume(event)).isEqualTo(901L);
|
||||
noFinancialWrites();
|
||||
}
|
||||
|
||||
@Test
|
||||
void voidTargetsOnlyMatchingOverseasCostVersion() {
|
||||
var event = payableEvent("VOIDED", 1);
|
||||
existingVersion(event.payload().get("sourceHash").asText(), "AVAILABLE");
|
||||
assertThat(service.consume(event)).isEqualTo(901L);
|
||||
verify(jdbc).update(startsWith("UPDATE fin_cost SET status='VOIDED'"),
|
||||
eq("LASTLEG"), eq("OVERSEAS_CONTAINER_FEE"), eq(701L), eq(1));
|
||||
verify(jdbc, never()).update(startsWith("INSERT INTO fin_cost"), any(Object[].class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void frozenSourceRejectsAdjustmentWithoutReplacingSourceOrCost() {
|
||||
var event = payableEvent("ADJUSTED", 2);
|
||||
when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_fee_source f WHERE f.source_system=?"),
|
||||
eq(Integer.class), eq("LASTLEG"), eq("OVERSEAS_CONTAINER_FEE"), eq(701L))).thenReturn(1);
|
||||
assertThatThrownBy(() -> service.consume(event)).hasMessageContaining("禁止调整或替代");
|
||||
noFinancialWrites();
|
||||
}
|
||||
|
||||
@Test
|
||||
void frozenSourceRejectsVoidWithoutChangingSourceOrCost() {
|
||||
var event = payableEvent("VOIDED", 1);
|
||||
existingVersion(event.payload().get("sourceHash").asText(), "AVAILABLE");
|
||||
when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_fee_source f WHERE f.id=?"),
|
||||
eq(Integer.class), eq(901L))).thenReturn(1);
|
||||
assertThatThrownBy(() -> service.consume(event)).hasMessageContaining("禁止作废");
|
||||
noFinancialWrites();
|
||||
}
|
||||
|
||||
private EventEnvelope payableEvent(String action, int version) {
|
||||
when(suppliers.requireActive(71L, null)).thenReturn(
|
||||
new com.baiyue.finance.client.WarehouseSupplierClient.SupplierSnapshot(71L, "供应商", null, "ACTIVE"));
|
||||
return containerEvent("CNY", "61", action, version);
|
||||
}
|
||||
|
||||
private void existingVersion(String hash, String status) {
|
||||
when(jdbc.queryForList(startsWith("SELECT id,source_hash,status FROM fin_fee_source"),
|
||||
eq("LASTLEG"), eq("OVERSEAS_CONTAINER_FEE"), eq(701L), eq(1)))
|
||||
.thenReturn(List.of(Map.of("id", 901L, "source_hash", hash, "status", status)));
|
||||
}
|
||||
|
||||
private void noFinancialWrites() {
|
||||
verify(jdbc, never()).update(startsWith("INSERT INTO fin_fee_source"), any(Object[].class));
|
||||
verify(jdbc, never()).update(startsWith("UPDATE fin_fee_source"), any(Object[].class));
|
||||
verify(jdbc, never()).update(startsWith("INSERT INTO fin_cost"), any(Object[].class));
|
||||
verify(jdbc, never()).update(startsWith("UPDATE fin_cost"), any(Object[].class));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,6 +284,19 @@ class FinanceFeeSourceEventServiceTest {
|
||||
when(jdbc.queryForObject(startsWith("SELECT id FROM fin_fee_source"), eq(Long.class), anyString(), eq(31L), anyInt())).thenReturn(101L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void lateOlderVersionDoesNotReplaceNewerSource() {
|
||||
givenNewEvent();
|
||||
when(firstleg.container("test-token", 41, 31, 1)).thenReturn(
|
||||
ApiResponse.success(snapshot("CONTAINER_FEE", 7, 1, null, 41L)));
|
||||
when(jdbc.queryForList(startsWith("SELECT source_fee_version"), eq("CONTAINER_FEE"), eq(31L)))
|
||||
.thenReturn(List.of(Map.of("source_fee_version", 2)));
|
||||
assertThatThrownBy(() -> service.consume(containerEvent("late-version", "FIRSTLEG_FEE_PUBLISHED", 1, 7)))
|
||||
.hasMessageContaining("版本已过期");
|
||||
verify(jdbc, never()).update(startsWith("UPDATE fin_fee_source"), any(Object[].class));
|
||||
verify(jdbc, never()).update(startsWith("UPDATE fin_cost"), any(Object[].class));
|
||||
}
|
||||
|
||||
private FinanceFeeSourceEvent inboundEvent(String id, String type, int version, long tenant) {
|
||||
return event(id, type, version, tenant, new FinanceFeeSourceEvent.Payload("21", null, "31", version));
|
||||
}
|
||||
|
||||
+2
-1
@@ -24,7 +24,8 @@
|
||||
| 审核与初始计价 | `controller/PlatformInboundMeasurementReviewController.java`、`service/MeasurementReviewService.java`、`service/InitialInboundPricingService.java`、`dto/MeasurementApprovalResponse.java` |
|
||||
| 客户四档箱规与默认算法 | `controller/PlatformCustomerDimensionConfigController.java`、`service/CustomerDimensionConfigService.java`、`repository/CustomerDimensionConfigRepository.java`;固定 `SMALL/MEDIUM/LARGE/EXTRA_LARGE`,默认算法只允许 `ADD/MULTIPLY` |
|
||||
| FBA 主数据/计费与客户规则 | `controller/PlatformFbaMasterDataController.java`、`PlatformFbaPricingController.java`、`service/FbaPricingService.java`、`repository/FbaPricingRepository.java`;客户规则可保存多个版本,但每个客户仅一条默认规则,计算请求可提交已确认的重量/体积选择值 |
|
||||
| 入库费用明细 | `controller/PlatformInboundFeeDetailController.java`、`service/InboundFeeDetailService.java`、`dto/InboundFeeDetailDtos.java`;Admin 消费端见 `../../apps/admin-web/src/modules/unified-orders/api/inboundFeeDetails.ts` |
|
||||
| 客户专属价格簿、柜级阶梯运费与调价边界 | `controller/PlatformPriceBookController.java`、`service/PlatformPriceBookService.java`、`pricing/ActivePriceBookEstimator.java`、`service/FbaPricingService.java`、`pricing/ContainerTierAmountAllocator.java`;公共价格簿固定 `customerId=0`,客户专属版本按客户独立发布与替换,计价优先专属 ACTIVE、缺失才回退公共 ACTIVE。`REGION_WEIGHT_TIER` 对同柜同客户有效成员聚合实际计费量命中一个阶梯,再按成员计费量守恒分摊;装柜成员变化调用 `recalculateUnlockedContainerCustomerFees`,已确认/已发送或客户已确认的版本不得改写。人工调整以系统基准量为中心限制上下 10%(含边界),保存版本、原因和二次确认;一件代发 `WAIT_PALLETIZE` 可在预装确认前试算,`markInboundCalculated` 只更新费用并保留该托盘化状态 |
|
||||
| 入库费用明细 | `controller/PlatformInboundFeeDetailController.java`、`service/InboundFeeDetailService.java`、`dto/InboundFeeDetailDtos.java`;`PRIVATE_LAST_MILE_DELIVERY` 是独立私人仓尾程派送费,只允许 `destinationType=PRIVATE`,按稳定费用代码校验而不按中文名判断;Admin 消费端见 `../../apps/admin-web/src/modules/unified-orders/api/inboundFeeDetails.ts` |
|
||||
| 入库/柜次直接已付快照与凭证校验 | `service/FeePaymentSnapshotValidator.java`、`client/FeePaymentVoucherFileClient.java`;写入入口分别为 `InboundFeeDetailService.java`、`ContainerFeeDetailService.java`,File 业务类型固定 `FINANCE_VOUCHER` |
|
||||
| 客户运营统计/在途柜量 | `customerstatistics/controller/PlatformCustomerTransitStatisticsController.java`、`customerstatistics/service/CustomerTransitStatisticsService.java`;契约见 `src/main/resources/openapi/customer-statistics-v1.yaml` |
|
||||
| 费用项目与价格簿 | `controller/PlatformFeeItemController.java`、`PlatformPriceBookController.java`、`MerchantPriceBookController.java` |
|
||||
|
||||
+9
@@ -92,6 +92,15 @@ public class PlatformDropshipPalletizationController {
|
||||
return ApiResponse.success(service.bindPending(taskId, request, user.userId(), user.username()));
|
||||
}
|
||||
|
||||
@PostMapping("/tasks/{taskId}/pallets/create-and-bind")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:pda:work')")
|
||||
public ApiResponse<Task> createAndBind(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long taskId,
|
||||
@Valid @RequestBody DropshipPalletizationDtos.CreateAndBindRequest request) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.createAndBind(taskId, request, user.userId(), user.username()));
|
||||
}
|
||||
|
||||
@PostMapping("/tasks/{taskId}/cartons/{cartonId}/move")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:pda:work')")
|
||||
public ApiResponse<Task> moveCarton(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
|
||||
@@ -15,6 +15,9 @@ public final class DropshipPalletizationDtos {
|
||||
|
||||
public record ClaimRequest(@Positive long warehouseId) {}
|
||||
|
||||
public record CreateAndBindRequest(@Positive long warehouseId,
|
||||
@NotBlank @Size(max = 128) String operationId) {}
|
||||
|
||||
public enum DiscrepancyAction { MATCH, ACCEPT_VARIANCE }
|
||||
|
||||
public record CountCartonRequest(@Positive long warehouseId,
|
||||
|
||||
@@ -13,6 +13,11 @@ import java.util.List;
|
||||
/** 一件代发逐箱清点、待绑托与封托事实。 */
|
||||
@Mapper
|
||||
public interface DropshipPalletizationMapper {
|
||||
@Select("SELECT id FROM fl_dropship_pallet_task WHERE id=#{taskId} AND warehouse_id=#{warehouseId} FOR UPDATE")
|
||||
Long lockTask(@Param("taskId") long taskId,@Param("warehouseId") long warehouseId);
|
||||
|
||||
@Select("SELECT * FROM fl_dropship_pallet WHERE open_operation_id=#{operationId}")
|
||||
PalletRow palletByOpenOperation(@Param("operationId") String operationId);
|
||||
@Insert("""
|
||||
INSERT IGNORE INTO fl_dropship_pallet_task
|
||||
(id,tenant_id,inbound_id,warehouse_id,task_status,total_cartons,counted_cartons,allocated_cartons,discrepancy_cartons,version,created_at,updated_at)
|
||||
|
||||
+5
-10
@@ -34,6 +34,7 @@ import com.baiyue.firstleg.oceanbooking.entity.OceanBookingOperationEntity;
|
||||
@Service
|
||||
public class OceanBookingDocumentService {
|
||||
public static final String BUSINESS_TYPE = "OCEAN_BOOKING_DOCUMENT";
|
||||
private static final long FILE_TENANT_ID = 0L;
|
||||
private static final Set<String> DOCUMENT_TYPES = Set.of("BOOKING_CONFIRMATION", "BILL_OF_LADING");
|
||||
|
||||
@Resource private OceanBookingRepository repository;
|
||||
@@ -54,7 +55,7 @@ public class OceanBookingDocumentService {
|
||||
OceanBookingEntity booking = lockDraft(oceanBookingId, expectedVersion);
|
||||
OceanBookingFileClient.Metadata metadata;
|
||||
try {
|
||||
var response = files.upload(internalToken, operatorId, booking.getCustomerId(), BUSINESS_TYPE,
|
||||
var response = files.upload(internalToken, operatorId, FILE_TENANT_ID, BUSINESS_TYPE,
|
||||
Long.toString(oceanBookingId), file);
|
||||
if (response == null || !response.successful() || response.data() == null) {
|
||||
throw bad("订舱文件上传失败");
|
||||
@@ -115,7 +116,7 @@ public class OceanBookingDocumentService {
|
||||
}
|
||||
metadata(booking, document.getFileId());
|
||||
try {
|
||||
ResponseEntity<byte[]> response = files.download(internalToken, booking.getCustomerId(), BUSINESS_TYPE,
|
||||
ResponseEntity<byte[]> response = files.download(internalToken, FILE_TENANT_ID, BUSINESS_TYPE,
|
||||
Long.toString(oceanBookingId), document.getFileId());
|
||||
byte[] content = response.getBody();
|
||||
if (!response.getStatusCode().is2xxSuccessful() || content == null || content.length == 0) {
|
||||
@@ -132,9 +133,6 @@ public class OceanBookingDocumentService {
|
||||
}
|
||||
|
||||
public void requireActiveValidDocument(OceanBookingEntity booking) {
|
||||
if (booking.getCustomerId() == null || booking.getCustomerId() <= 0) {
|
||||
throw conflict("订舱缺少客户归属,无法校验文件");
|
||||
}
|
||||
boolean valid = false;
|
||||
for (OceanBookingDocumentEntity document : repository.activeDocuments(booking.getId())) {
|
||||
if (!DOCUMENT_TYPES.contains(document.getDocumentType())) continue;
|
||||
@@ -178,9 +176,6 @@ public class OceanBookingDocumentService {
|
||||
if (booking == null) throw new BizException(ResultCode.NOT_FOUND, "订舱单不存在");
|
||||
if (!Integer.valueOf(expectedVersion).equals(booking.getVersion())) throw concurrent();
|
||||
if (!"DRAFT".equals(booking.getStatus())) throw conflict("已放舱订舱文件永久只读");
|
||||
if (booking.getCustomerId() == null || booking.getCustomerId() <= 0) {
|
||||
throw bad("请先保存订舱客户后再上传文件");
|
||||
}
|
||||
return booking;
|
||||
}
|
||||
|
||||
@@ -192,7 +187,7 @@ public class OceanBookingDocumentService {
|
||||
|
||||
private OceanBookingFileClient.Metadata metadata(OceanBookingEntity booking, long fileId) {
|
||||
try {
|
||||
var response = files.metadata(internalToken, booking.getCustomerId(), BUSINESS_TYPE,
|
||||
var response = files.metadata(internalToken, FILE_TENANT_ID, BUSINESS_TYPE,
|
||||
Long.toString(booking.getId()), fileId);
|
||||
if (response == null || !response.successful() || response.data() == null) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "订舱文件不存在或归属不匹配");
|
||||
@@ -207,7 +202,7 @@ public class OceanBookingDocumentService {
|
||||
}
|
||||
|
||||
private static void validateMetadata(OceanBookingEntity booking, OceanBookingFileClient.Metadata metadata) {
|
||||
if (metadata.fileId() == null || !Objects.equals(metadata.tenantId(), booking.getCustomerId())
|
||||
if (metadata.fileId() == null || !Objects.equals(metadata.tenantId(), FILE_TENANT_ID)
|
||||
|| !BUSINESS_TYPE.equals(metadata.businessType())
|
||||
|| !Long.toString(booking.getId()).equals(metadata.businessId())) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "订舱文件归属不匹配");
|
||||
|
||||
+17
-6
@@ -50,6 +50,7 @@ public class OceanBookingService {
|
||||
@Resource private WarehousePartnerClient partners;
|
||||
@Resource private FirstlegDocumentNumberGenerator documentNumbers;
|
||||
@Resource private OceanBookingDocumentService documents;
|
||||
@Resource private com.baiyue.firstleg.service.IamCustomerSnapshotService customers;
|
||||
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
|
||||
@Value("${baiyue.warehouse.public-tenant-id:0}") private long publicWarehouseTenantId;
|
||||
|
||||
@@ -200,7 +201,7 @@ public class OceanBookingService {
|
||||
private void validateDraftStructure(DraftData data) {
|
||||
boolean hasCustomerId = data.customerId() != null;
|
||||
boolean hasCustomerName = StringUtils.hasText(data.customerName());
|
||||
if (hasCustomerId != hasCustomerName) throw badRequest("客户ID和客户名称必须同时填写或同时为空");
|
||||
if (!hasCustomerId && hasCustomerName) throw badRequest("客户名称不能脱离客户ID提交");
|
||||
if (data.parties() != null) {
|
||||
Set<String> types = new HashSet<>();
|
||||
for (PartyRequest party : data.parties()) {
|
||||
@@ -221,8 +222,7 @@ public class OceanBookingService {
|
||||
private static void validateRange(LocalDateTime from, LocalDateTime to) { if (from != null && to != null && to.isBefore(from)) throw badRequest("预计到港时间不能早于预计开船时间"); }
|
||||
private static void validateRelease(OceanBookingEntity booking, List<OceanBookingPartyEntity> parties,
|
||||
List<OceanBookingContainerRequirementEntity> requirements) {
|
||||
if (booking.getCustomerId() == null || !StringUtils.hasText(booking.getCustomerName())
|
||||
|| !StringUtils.hasText(booking.getChannelCode()) || booking.getWarehouseId() == null
|
||||
if (!StringUtils.hasText(booking.getChannelCode()) || booking.getWarehouseId() == null
|
||||
|| !StringUtils.hasText(booking.getBillOfLadingNo()) || !StringUtils.hasText(booking.getVesselName())
|
||||
|| !StringUtils.hasText(booking.getVoyageNo()) || !StringUtils.hasText(booking.getServiceTerm())
|
||||
|| !StringUtils.hasText(booking.getFreightTerm()) || booking.getEstimatedSailingAt() == null
|
||||
@@ -258,8 +258,9 @@ public class OceanBookingService {
|
||||
}
|
||||
|
||||
private record ResolvedDraft(String warehouseName, String channelName, String shippingCompanyName,
|
||||
String carrierCode, String bookingAgentName) {}
|
||||
String carrierCode, String bookingAgentName, String customerName) {}
|
||||
private ResolvedDraft resolveDraft(DraftData d) {
|
||||
String customerName = resolveCustomer(d.customerId(), d.customerName());
|
||||
String warehouseName = null; String channelName = null;
|
||||
if (d.warehouseId() != null && StringUtils.hasText(d.channelCode())) {
|
||||
WarehouseMasterDataClient.Warehouse warehouse = requireDomesticTransitWarehouse(d.warehouseId());
|
||||
@@ -269,10 +270,10 @@ public class OceanBookingService {
|
||||
var carrier = resolveCarrier(d.shippingCompanyId(), d.shippingCompanyName(), d.carrierCode());
|
||||
var agent = resolveBookingAgent(d.bookingAgentId(), d.bookingAgentName(), carrier == null ? null : carrier.carrierCode());
|
||||
return new ResolvedDraft(warehouseName, channelName, carrier == null ? null : carrier.partnerName(),
|
||||
carrier == null ? null : carrier.carrierCode(), agent == null ? null : agent.name());
|
||||
carrier == null ? null : carrier.carrierCode(), agent == null ? null : agent.name(), customerName);
|
||||
}
|
||||
private static void copy(OceanBookingEntity v, DraftData d, ResolvedDraft resolved) {
|
||||
v.setCustomerId(d.customerId()); v.setCustomerName(trim(d.customerName()));
|
||||
v.setCustomerId(d.customerId()); v.setCustomerName(resolved.customerName());
|
||||
v.setChannelCode(trim(d.channelCode())); v.setChannelName(resolved.channelName());
|
||||
v.setWarehouseId(d.warehouseId()); v.setWarehouseName(resolved.warehouseName());
|
||||
v.setBillOfLadingNo(trim(d.billOfLadingNo()));
|
||||
@@ -288,10 +289,20 @@ public class OceanBookingService {
|
||||
}
|
||||
|
||||
private void validateMasterData(OceanBookingEntity booking) {
|
||||
resolveCustomer(booking.getCustomerId(), booking.getCustomerName());
|
||||
resolveCarrier(booking.getShippingCompanyId(), booking.getShippingCompanyName(), booking.getCarrierCode());
|
||||
resolveBookingAgent(booking.getBookingAgentId(), booking.getBookingAgentName(), booking.getCarrierCode());
|
||||
}
|
||||
|
||||
private String resolveCustomer(Long id, String name) {
|
||||
if (id == null) {
|
||||
if (StringUtils.hasText(name)) throw badRequest("客户名称不能脱离客户ID提交");
|
||||
return null;
|
||||
}
|
||||
if (id <= 0) throw badRequest("请选择有效客户");
|
||||
return customers.active(id, name).tenantName();
|
||||
}
|
||||
|
||||
private WarehousePartnerClient.Snapshot resolveCarrier(Long id, String suppliedName, String suppliedCode) {
|
||||
if (id == null) {
|
||||
if (StringUtils.hasText(suppliedName) || StringUtils.hasText(suppliedCode)) throw badRequest("船公司名称和编码不能脱离shippingCompanyId提交");
|
||||
|
||||
@@ -84,7 +84,7 @@ public class ContainerFeeDetailService {
|
||||
SaveRequest common = request.fee();
|
||||
FeeItemEntity item = requireItem(common.feeItemId(), common.direction());
|
||||
BigDecimal total = common.amount().setScale(2, RoundingMode.HALF_UP);
|
||||
BigDecimal allocated = request.allocations().stream().map(Allocation::amount)
|
||||
BigDecimal allocated = request.allocations().stream().map(value -> allocationAmount(value.amount()))
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add).setScale(2, RoundingMode.HALF_UP);
|
||||
if (allocated.compareTo(total) != 0) throw conflict("柜级费用分摊合计必须等于费用总额");
|
||||
Map<InboundAllocationKey, Long> ownership = new HashMap<>();
|
||||
@@ -158,7 +158,7 @@ public class ContainerFeeDetailService {
|
||||
throw conflict("只有完整的当前已审核分摊组可以调整");
|
||||
Map<Long, BigDecimal> amounts = new HashMap<>();
|
||||
for (AllocationAmount allocation : request.allocations()) {
|
||||
if (amounts.put(allocation.feeId(), allocation.amount().setScale(2, RoundingMode.HALF_UP)) != null)
|
||||
if (amounts.put(allocation.feeId(), allocationAmount(allocation.amount())) != null)
|
||||
throw conflict("分摊费用行不得重复");
|
||||
}
|
||||
if (!amounts.keySet().equals(rows.stream().map(ContainerFeeDetailEntity::getId).collect(java.util.stream.Collectors.toSet())))
|
||||
@@ -372,6 +372,12 @@ public class ContainerFeeDetailService {
|
||||
return new BizException(ResultCode.CONFLICT, message);
|
||||
}
|
||||
|
||||
private static BigDecimal allocationAmount(BigDecimal amount) {
|
||||
BigDecimal rounded = amount.setScale(2, RoundingMode.HALF_UP);
|
||||
if (rounded.signum() <= 0) throw conflict("分摊金额按两位小数取整后必须大于0");
|
||||
return rounded;
|
||||
}
|
||||
|
||||
private static long containerBookingId(ContainerFeeDetailEntity value) {
|
||||
if (value.getContainerBookingId() == null || value.getContainerBookingId() <= 0) {
|
||||
throw conflict("柜次费用缺少真实货柜关联");
|
||||
|
||||
@@ -21,6 +21,30 @@ import java.util.*;
|
||||
@Service
|
||||
public class DropshipPalletizationService {
|
||||
@Resource private DropshipPalletizationMapper mapper;
|
||||
@Resource private FirstlegDocumentNumberGenerator documentNumbers;
|
||||
|
||||
@Transactional
|
||||
public Task createAndBind(long taskId,CreateAndBindRequest request,long operatorId,String operatorName){
|
||||
if(mapper.lockTask(taskId,request.warehouseId())==null) throw new BizException(ResultCode.NOT_FOUND,"未找到当前仓库的一件代发清点组托任务");
|
||||
TaskRow task=requireTask(taskId,request.warehouseId());
|
||||
if(!Objects.equals(task.getAssigneeId(),operatorId)) throw conflict("任务不属于当前操作员");
|
||||
String operation=operation(request.operationId());
|
||||
PalletRow replay=mapper.palletByOpenOperation(operation);
|
||||
if(replay!=null){
|
||||
if(!Objects.equals(replay.getTaskId(),taskId)||!Objects.equals(replay.getWarehouseId(),request.warehouseId())
|
||||
||!Objects.equals(replay.getTenantId(),task.getTenantId())) throw conflict("创建托盘幂等号已用于不同任务");
|
||||
return view(task);
|
||||
}
|
||||
if(!"CLAIMED".equals(task.getTaskStatus())||!"WAIT_PALLETIZE".equals(task.getProcessStatus())) throw conflict("当前任务不允许创建托盘");
|
||||
int expected=mapper.unboundCount(taskId);
|
||||
if(expected==0) throw conflict("当前没有待绑托箱,请先清点箱码");
|
||||
long palletId=IdWorker.getId();
|
||||
String palletNo=documentNumbers.next("DP");
|
||||
mapper.insertPallet(palletId,taskId,task.getTenantId(),task.getInboundId(),task.getWarehouseId(),palletNo,operation,operatorId,operatorName);
|
||||
int bound=mapper.bindPending(taskId,palletId,operation,operatorId,operatorName);
|
||||
if(bound!=expected||mapper.adjustBound(taskId,operatorId,bound)!=1) throw conflict("待绑托箱已变化,请刷新后重试");
|
||||
return view(requireTask(taskId,request.warehouseId()));
|
||||
}
|
||||
|
||||
public void createTask(InboundOrderEntity inbound) {
|
||||
if (!"ONE_CLICK_DROPSHIP".equals(inbound.getFulfillmentType())) return;
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ public final class TerminalDrayageDtos {
|
||||
|
||||
public record ActualTimeRequest(@NotNull @Min(0) Integer expectedVersion,
|
||||
@NotNull LocalDateTime actualAt,
|
||||
@Size(max = 500) String reason) {}
|
||||
@Size(max = 500) String remark) {}
|
||||
|
||||
public record Warning(String status, LocalDateTime deadlineAt,
|
||||
long overdueHours, long overdueDays) {}
|
||||
|
||||
+4
-4
@@ -147,14 +147,14 @@ public class TerminalDrayageService {
|
||||
throw badRequest("实际提柜时间不能早于当前实际船节点时间");
|
||||
}
|
||||
advanceNode(context, CarrierScheduleNode.PICKED_UP, request.actualAt(), action, key,
|
||||
request.reason(), operatorId, operatorName);
|
||||
request.remark(), operatorId, operatorName);
|
||||
int fromVersion = version(value);
|
||||
String fromStatus = status(context, value);
|
||||
value.setActualPickupAt(request.actualAt());
|
||||
applyAuthoritativeDeadline(context, value);
|
||||
persist(value, fromVersion, operatorId, operatorName);
|
||||
return finish(requiredContext(containerBookingId), value, action, key, request, fromStatus,
|
||||
fromVersion, request.reason(), operatorId, operatorName);
|
||||
fromVersion, request.remark(), operatorId, operatorName);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -174,14 +174,14 @@ public class TerminalDrayageService {
|
||||
if (request.actualAt().isBefore(earliest)) throw badRequest("实际还柜时间不能早于提柜或卸柜完成时间");
|
||||
if (actualEmptyReturnAt(context, value) != null) throw conflict("实际还柜时间已确认");
|
||||
advanceNode(context, CarrierScheduleNode.EMPTY_RETURNED, request.actualAt(), action, key,
|
||||
request.reason(), operatorId, operatorName);
|
||||
request.remark(), operatorId, operatorName);
|
||||
int fromVersion = version(value);
|
||||
String fromStatus = status(context, value);
|
||||
applyAuthoritativeDeadline(context, value);
|
||||
value.setActualEmptyReturnAt(request.actualAt());
|
||||
persist(value, fromVersion, operatorId, operatorName);
|
||||
return finish(requiredContext(containerBookingId), value, action, key, request, fromStatus,
|
||||
fromVersion, request.reason(), operatorId, operatorName);
|
||||
fromVersion, request.remark(), operatorId, operatorName);
|
||||
}
|
||||
|
||||
private void advanceNode(WorkContext context, CarrierScheduleNode target, LocalDateTime actualAt,
|
||||
|
||||
@@ -1,45 +1,371 @@
|
||||
openapi: 3.0.3
|
||||
info: { title: Firstleg Container Fee Allocation API, version: 1.0.0 }
|
||||
info:
|
||||
title: Firstleg Container Fee API
|
||||
version: 1.0.0
|
||||
description: |
|
||||
本契约只生产国内段费用来源(订舱、国内拖车、出口报关、海运等)。
|
||||
loadingPlanId 由服务端解析到同一真实 containerBookingId;海外段费用由 Lastleg 的 OVERSEAS_CONTAINER_FEE 独立维护。
|
||||
审核通过后发布不可变来源版本到 Finance,不复制海外费用或财务状态。
|
||||
应付必须选择有效供应商;DIRECT_PAID 是已付快照,不再进入待结算。
|
||||
当前币种仅支持 CNY,不进行跨币种加总。公共费用按柜内真实客户与订单分摊,
|
||||
各行按 HALF_UP 保留两位后必须大于零,落库金额合计必须等于两位小数的总额。
|
||||
成功和业务错误均使用 HTTP 200 包装,业务冲突 code 为 A0409。
|
||||
security: [{bearerAuth: []}]
|
||||
paths:
|
||||
/api/platform/v1/loading-plans/{loadingPlanId}/fee-details:
|
||||
get:
|
||||
operationId: listContainerFees
|
||||
summary: 查询当前客户的国内段柜级费用
|
||||
x-permission: firstleg:inbound:fee-detail:view
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/LoadingPlanId'}
|
||||
- {$ref: '#/components/parameters/AccountId'}
|
||||
responses:
|
||||
'200':
|
||||
description: 通过 code 区分成功与业务失败;分摊组不可经单行入口修改。
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/FeeListResponse'}
|
||||
post:
|
||||
operationId: createContainerFee
|
||||
summary: 创建单一业务归属的国内段柜级费用
|
||||
x-permission: firstleg:inbound:fee-detail:edit
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/LoadingPlanId'}
|
||||
- {$ref: '#/components/parameters/AccountId'}
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/SaveRequest'}
|
||||
responses:
|
||||
'200':
|
||||
description: 通过 code 区分成功与业务失败;分摊组不可经单行入口修改。
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/FeeResponse'}
|
||||
/api/platform/v1/loading-plans/{loadingPlanId}/fee-details/allocation-groups:
|
||||
post:
|
||||
summary: 原子创建柜级公共费用分摊组
|
||||
description: 客户、订单必填;入仓ID与入仓码同时填写或同时留空。各分摊金额必须大于0且合计等于费用总额。
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters: [{ $ref: '#/components/parameters/LoadingPlanId' }]
|
||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/AllocationGroupRequest' } } } }
|
||||
responses: { '200': { description: 按客户和业务归属生成的待审核费用行 }, '409': { description: 分摊合计或柜内真实归属不一致 } }
|
||||
/api/platform/v1/loading-plans/{loadingPlanId}/fee-details/allocation-groups/{groupId}/{action}:
|
||||
post:
|
||||
summary: 整组审核、驳回、调整或作废柜级分摊费用
|
||||
description: action 仅为 approve、reject、adjust、void;单行接口拒绝处理分摊组,避免部分成功。
|
||||
security: [{ bearerAuth: [] }]
|
||||
operationId: createContainerFeeAllocationGroup
|
||||
summary: 原子创建国内段柜级公共费用分摊组
|
||||
x-permission: firstleg:inbound:fee-detail:edit
|
||||
parameters:
|
||||
- { $ref: '#/components/parameters/LoadingPlanId' }
|
||||
- { in: path, name: groupId, required: true, schema: { type: string, pattern: '^[1-9][0-9]{0,19}$' } }
|
||||
- { in: path, name: action, required: true, schema: { type: string, enum: [approve, reject, adjust, void] } }
|
||||
responses: { '200': { description: 整组处理成功 }, '409': { description: 版本、状态或完整性冲突 } }
|
||||
- {$ref: '#/components/parameters/LoadingPlanId'}
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/AllocationGroupRequest'}
|
||||
responses:
|
||||
'200':
|
||||
description: 通过 code 区分成功与业务失败;分摊组不可经单行入口修改。
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/FeeListResponse'}
|
||||
/api/platform/v1/loading-plans/{loadingPlanId}/fee-details/allocation-groups/{groupId}/approve:
|
||||
post:
|
||||
operationId: approveContainerFeeAllocationGroup
|
||||
summary: 整组审核国内段柜级费用
|
||||
x-permission: firstleg:inbound:fee-detail:review
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/LoadingPlanId'}
|
||||
- {$ref: '#/components/parameters/GroupId'}
|
||||
responses:
|
||||
'200':
|
||||
description: 通过 code 区分成功与业务失败;分摊组不可经单行入口修改。
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/FeeListResponse'}
|
||||
/api/platform/v1/loading-plans/{loadingPlanId}/fee-details/allocation-groups/{groupId}/reject:
|
||||
post:
|
||||
operationId: rejectContainerFeeAllocationGroup
|
||||
summary: 整组驳回国内段柜级费用
|
||||
x-permission: firstleg:inbound:fee-detail:review
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/LoadingPlanId'}
|
||||
- {$ref: '#/components/parameters/GroupId'}
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/RejectRequest'}
|
||||
responses:
|
||||
'200':
|
||||
description: 通过 code 区分成功与业务失败;分摊组不可经单行入口修改。
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/FeeListResponse'}
|
||||
/api/platform/v1/loading-plans/{loadingPlanId}/fee-details/allocation-groups/{groupId}/adjust:
|
||||
post:
|
||||
operationId: adjustContainerFeeAllocationGroup
|
||||
summary: 整组调整国内段柜级费用
|
||||
x-permission: firstleg:inbound:fee-detail:edit
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/LoadingPlanId'}
|
||||
- {$ref: '#/components/parameters/GroupId'}
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/AllocationGroupAdjustRequest'}
|
||||
responses:
|
||||
'200':
|
||||
description: 通过 code 区分成功与业务失败;分摊组不可经单行入口修改。
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/FeeListResponse'}
|
||||
/api/platform/v1/loading-plans/{loadingPlanId}/fee-details/allocation-groups/{groupId}/void:
|
||||
post:
|
||||
operationId: voidContainerFeeAllocationGroup
|
||||
summary: 整组作废国内段柜级费用
|
||||
x-permission: firstleg:inbound:fee-detail:review
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/LoadingPlanId'}
|
||||
- {$ref: '#/components/parameters/GroupId'}
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/VersionRequest'}
|
||||
responses:
|
||||
'200':
|
||||
description: 通过 code 区分成功与业务失败;分摊组不可经单行入口修改。
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/EmptyResponse'}
|
||||
/api/platform/v1/loading-plans/{loadingPlanId}/fee-details/{feeId}/approve:
|
||||
post:
|
||||
operationId: approveContainerFee
|
||||
summary: 审核单行国内段柜级费用
|
||||
x-permission: firstleg:inbound:fee-detail:review
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/LoadingPlanId'}
|
||||
- {$ref: '#/components/parameters/AccountId'}
|
||||
- {$ref: '#/components/parameters/FeeId'}
|
||||
responses:
|
||||
'200':
|
||||
description: 通过 code 区分成功与业务失败;分摊组不可经单行入口修改。
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/EmptyResponse'}
|
||||
/api/platform/v1/loading-plans/{loadingPlanId}/fee-details/{feeId}/reject:
|
||||
post:
|
||||
operationId: rejectContainerFee
|
||||
summary: 驳回单行国内段柜级费用
|
||||
x-permission: firstleg:inbound:fee-detail:review
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/LoadingPlanId'}
|
||||
- {$ref: '#/components/parameters/AccountId'}
|
||||
- {$ref: '#/components/parameters/FeeId'}
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/RejectRequest'}
|
||||
responses:
|
||||
'200':
|
||||
description: 通过 code 区分成功与业务失败;分摊组不可经单行入口修改。
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/EmptyResponse'}
|
||||
/api/platform/v1/loading-plans/{loadingPlanId}/fee-details/{feeId}/adjust:
|
||||
post:
|
||||
operationId: adjustContainerFee
|
||||
summary: 调整单行国内段柜级费用
|
||||
x-permission: firstleg:inbound:fee-detail:edit
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/LoadingPlanId'}
|
||||
- {$ref: '#/components/parameters/AccountId'}
|
||||
- {$ref: '#/components/parameters/FeeId'}
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/AdjustRequest'}
|
||||
responses:
|
||||
'200':
|
||||
description: 通过 code 区分成功与业务失败;分摊组不可经单行入口修改。
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/FeeResponse'}
|
||||
/api/platform/v1/loading-plans/{loadingPlanId}/fee-details/{feeId}/void:
|
||||
post:
|
||||
operationId: voidContainerFee
|
||||
summary: 作废单行国内段柜级费用
|
||||
x-permission: firstleg:inbound:fee-detail:review
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/LoadingPlanId'}
|
||||
- {$ref: '#/components/parameters/AccountId'}
|
||||
- {$ref: '#/components/parameters/FeeId'}
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/VersionRequest'}
|
||||
responses:
|
||||
'200':
|
||||
description: 通过 code 区分成功与业务失败;分摊组不可经单行入口修改。
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/EmptyResponse'}
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT}
|
||||
parameters:
|
||||
LoadingPlanId: { in: path, name: loadingPlanId, required: true, schema: { type: string, pattern: '^[1-9][0-9]{0,19}$' } }
|
||||
LoadingPlanId: {in: path, name: loadingPlanId, required: true, schema: {$ref: '#/components/schemas/Id'}}
|
||||
GroupId: {in: path, name: groupId, required: true, schema: {$ref: '#/components/schemas/Id'}}
|
||||
FeeId: {in: path, name: feeId, required: true, schema: {$ref: '#/components/schemas/Id'}}
|
||||
AccountId: {in: query, name: accountId, required: true, schema: {type: integer, format: int64, minimum: 1}, description: 平台选择的客户,服务端校验柜内归属}
|
||||
schemas:
|
||||
Id: {type: string, pattern: '^[1-9][0-9]{0,19}$'}
|
||||
Amount: {type: number, format: decimal, minimum: 0, description: HALF_UP 保留两位小数}
|
||||
SaveRequest:
|
||||
type: object
|
||||
required: [feeItemId, direction, businessDate, amount, currency]
|
||||
properties:
|
||||
feeItemId: {$ref: '#/components/schemas/Id'}
|
||||
direction: {type: string, enum: [RECEIVABLE, PAYABLE]}
|
||||
businessDate: {type: string, format: date}
|
||||
amount: {$ref: '#/components/schemas/Amount'}
|
||||
currency: {type: string, enum: [CNY]}
|
||||
supplierId: {type: string, nullable: true, description: PAYABLE 必填,必须为启用供应商}
|
||||
settlementMode: {type: string, enum: [ON_ACCOUNT, DIRECT_PAID], nullable: true, description: 仅 PAYABLE 必填}
|
||||
paidAt: {type: string, format: date-time, nullable: true}
|
||||
paymentMethod: {type: string, enum: [ONLINE, BANK_TRANSFER, CASH, OTHER], nullable: true}
|
||||
paymentReferenceNo: {type: string, maxLength: 128, nullable: true}
|
||||
paymentVoucherFileIds:
|
||||
type: array
|
||||
maxItems: 20
|
||||
nullable: true
|
||||
items: {$ref: '#/components/schemas/Id'}
|
||||
remark: {type: string, maxLength: 500, nullable: true}
|
||||
description: DIRECT_PAID 必须填写付款时间、方式及流水号或当前客户凭证;RECEIVABLE 和 ON_ACCOUNT 不允许残留付款字段。
|
||||
Allocation:
|
||||
type: object
|
||||
required: [customerId, orderId, orderNo, amount]
|
||||
properties:
|
||||
customerId: {$ref: '#/components/schemas/Id'}
|
||||
orderId: {$ref: '#/components/schemas/Id'}
|
||||
orderNo: {type: string, minLength: 1, maxLength: 64}
|
||||
inboundId: {type: string, nullable: true}
|
||||
inboundCode: {type: string, maxLength: 64, nullable: true}
|
||||
amount: {type: number, format: decimal, minimum: 0, exclusiveMinimum: true}
|
||||
description: 客户和订单必须属于当前柜;入仓ID与入仓码同时填写或同时留空。
|
||||
AllocationGroupRequest:
|
||||
type: object
|
||||
required: [fee, allocations]
|
||||
properties:
|
||||
fee: { type: object, description: 公共费用字段;amount为原始柜级总额 }
|
||||
fee: {$ref: '#/components/schemas/SaveRequest'}
|
||||
allocations:
|
||||
type: array
|
||||
minItems: 1
|
||||
maxItems: 200
|
||||
items:
|
||||
type: object
|
||||
required: [customerId, orderId, orderNo, amount]
|
||||
properties:
|
||||
customerId: { type: string }
|
||||
orderId: { type: string }
|
||||
orderNo: { type: string, maxLength: 64 }
|
||||
inboundId: { type: string, nullable: true }
|
||||
inboundCode: { type: string, maxLength: 64, nullable: true }
|
||||
amount: { type: number, format: decimal, exclusiveMinimum: 0 }
|
||||
securitySchemes: { bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT } }
|
||||
items: {$ref: '#/components/schemas/Allocation'}
|
||||
AdjustRequest:
|
||||
type: object
|
||||
required: [expectedSourceVersion, businessDate, amount]
|
||||
properties:
|
||||
expectedSourceVersion: {type: integer, minimum: 1}
|
||||
businessDate: {type: string, format: date}
|
||||
amount: {$ref: '#/components/schemas/Amount'}
|
||||
description: 只允许调整当前已审核有效版本,成功递增来源版本并重新待审核。
|
||||
AllocationAmount:
|
||||
type: object
|
||||
required: [feeId, amount]
|
||||
properties:
|
||||
feeId: {$ref: '#/components/schemas/Id'}
|
||||
amount: {type: number, format: decimal, minimum: 0, exclusiveMinimum: true}
|
||||
AllocationGroupAdjustRequest:
|
||||
type: object
|
||||
required: [expectedSourceVersion, businessDate, totalAmount, allocations]
|
||||
properties:
|
||||
expectedSourceVersion: {type: integer, minimum: 1}
|
||||
businessDate: {type: string, format: date}
|
||||
totalAmount: {type: number, format: decimal, minimum: 0, exclusiveMinimum: true}
|
||||
allocations:
|
||||
type: array
|
||||
minItems: 1
|
||||
maxItems: 200
|
||||
items: {$ref: '#/components/schemas/AllocationAmount'}
|
||||
description: 必须包含整组全部费用行,逐行取两位后的合计与总额一致;成功后整组重新待审核。
|
||||
VersionRequest:
|
||||
type: object
|
||||
required: [expectedSourceVersion]
|
||||
properties:
|
||||
expectedSourceVersion: {type: integer, minimum: 1}
|
||||
RejectRequest:
|
||||
type: object
|
||||
required: [reason]
|
||||
properties:
|
||||
reason: {type: string, minLength: 1, maxLength: 500}
|
||||
FeeDetail:
|
||||
type: object
|
||||
properties:
|
||||
id: {type: string, nullable: true}
|
||||
tenantId: {type: string, nullable: true}
|
||||
allocationGroupId: {type: string, nullable: true}
|
||||
allocationOrderId: {type: string, nullable: true}
|
||||
allocationInboundId: {type: string, nullable: true}
|
||||
loadingPlanId: {type: string, nullable: true}
|
||||
containerBookingId: {type: string, nullable: true}
|
||||
feeItemId: {type: string, nullable: true}
|
||||
supplierId: {type: string, nullable: true}
|
||||
reviewerId: {type: string, nullable: true}
|
||||
createdBy: {type: string, nullable: true}
|
||||
updatedBy: {type: string, nullable: true}
|
||||
allocationOrderNo: {type: string, nullable: true}
|
||||
allocationInboundCode: {type: string, nullable: true}
|
||||
containerNo: {type: string, nullable: true}
|
||||
feeItemCodeSnapshot: {type: string, nullable: true}
|
||||
feeItemNameSnapshot: {type: string, nullable: true}
|
||||
supplierName: {type: string, nullable: true}
|
||||
supplierCategory: {type: string, nullable: true}
|
||||
remark: {type: string, nullable: true}
|
||||
reviewerName: {type: string, nullable: true}
|
||||
reviewReason: {type: string, nullable: true}
|
||||
paymentReferenceNo: {type: string, nullable: true}
|
||||
paidAt: {type: string, format: date-time, nullable: true}
|
||||
reviewedAt: {type: string, format: date-time, nullable: true}
|
||||
createdAt: {type: string, format: date-time, nullable: true}
|
||||
updatedAt: {type: string, format: date-time, nullable: true}
|
||||
amount: {$ref: '#/components/schemas/Amount'}
|
||||
allocationGroupAmount: {$ref: '#/components/schemas/Amount'}
|
||||
businessDate: {type: string, format: date}
|
||||
currency: {type: string, enum: [CNY]}
|
||||
direction: {type: string, enum: [RECEIVABLE, PAYABLE]}
|
||||
reviewStatus: {type: string, enum: [PENDING, APPROVED, REJECTED]}
|
||||
sourceStatus: {type: string, enum: [ACTIVE, VOIDED]}
|
||||
sourceVersion: {type: integer, minimum: 1}
|
||||
settlementMode: {type: string, enum: [ON_ACCOUNT, DIRECT_PAID], nullable: true}
|
||||
paymentMethod: {type: string, enum: [ONLINE, BANK_TRANSFER, CASH, OTHER], nullable: true}
|
||||
paymentVoucherFileIds: {type: array, nullable: true, items: {$ref: '#/components/schemas/Id'}}
|
||||
FeeResponse:
|
||||
type: object
|
||||
required: [code, message]
|
||||
properties:
|
||||
code: {type: string, description: 成功为200,业务冲突为A0409}
|
||||
message: {type: string}
|
||||
requestId: {type: string, nullable: true}
|
||||
traceId: {type: string, nullable: true}
|
||||
data: {$ref: '#/components/schemas/FeeDetail'}
|
||||
timestamp: {type: string, format: date-time}
|
||||
FeeListResponse:
|
||||
type: object
|
||||
required: [code, message]
|
||||
properties:
|
||||
code: {type: string, description: 成功为200,业务冲突为A0409}
|
||||
message: {type: string}
|
||||
requestId: {type: string, nullable: true}
|
||||
traceId: {type: string, nullable: true}
|
||||
data: {type: array, items: {$ref: '#/components/schemas/FeeDetail'}}
|
||||
timestamp: {type: string, format: date-time}
|
||||
EmptyResponse:
|
||||
type: object
|
||||
required: [code, message]
|
||||
properties:
|
||||
code: {type: string, description: 成功为200,业务冲突为A0409}
|
||||
message: {type: string}
|
||||
requestId: {type: string, nullable: true}
|
||||
traceId: {type: string, nullable: true}
|
||||
data: {type: object, nullable: true}
|
||||
timestamp: {type: string, format: date-time}
|
||||
|
||||
@@ -46,6 +46,14 @@ paths:
|
||||
parameters: [{$ref: '#/components/parameters/TaskId'}]
|
||||
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/BindPendingRequest'}}}}
|
||||
responses: {'200': {description: OK}}
|
||||
/api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/pallets/create-and-bind:
|
||||
post:
|
||||
summary: 系统生成 DP 托盘码并原子绑定全部待绑箱
|
||||
description: 仅当前仓库本人已领取的待组托任务可执行。没有待绑箱时不发码、不建空托。operationId 在同任务和仓库重放不重复创建;不同任务使用同幂等号拒绝。返回当前 Task。
|
||||
x-required-authority: platform:firstleg:pda:work
|
||||
parameters: [{$ref: '#/components/parameters/TaskId'}]
|
||||
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/CreateAndBindRequest'}}}}
|
||||
responses: {'200': {description: Task}}
|
||||
/api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/cartons/{cartonId}/move:
|
||||
post:
|
||||
summary: 换托,targetPalletNo 为空时退回待绑区
|
||||
@@ -150,4 +158,10 @@ components:
|
||||
expectedVersion: {type: integer, minimum: 1}
|
||||
operationId: {type: string, maxLength: 128}
|
||||
PalletNo: {type: string, pattern: '^DP-[0-9]{6}-[0-9]{4}$'}
|
||||
CreateAndBindRequest:
|
||||
type: object
|
||||
required: [warehouseId, operationId]
|
||||
properties:
|
||||
warehouseId: {type: integer, format: int64, minimum: 1}
|
||||
operationId: {type: string, minLength: 1, maxLength: 128}
|
||||
NullablePalletNo: {type: string, pattern: '^DP-[0-9]{6}-[0-9]{4}$', nullable: true}
|
||||
|
||||
@@ -32,7 +32,7 @@ paths:
|
||||
/api/platform/v1/ocean-bookings/{oceanBookingId}/release:
|
||||
post:
|
||||
summary: Validate and permanently release a DRAFT
|
||||
description: Release validates the persisted aggregate plus at least one active booking-owned BOOKING_CONFIRMATION or BILL_OF_LADING file. It never requires or creates a real container number.
|
||||
description: Release validates the persisted aggregate plus at least one active booking-owned BOOKING_CONFIRMATION or BILL_OF_LADING file. The customer is optional; a supplied customer must remain ACTIVE. Null denotes no single customer snapshot, never public access. It never requires or creates a real container number.
|
||||
parameters: [{$ref: '#/components/parameters/OceanBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ReleaseRequest'}}}}
|
||||
responses: {'200': {description: RELEASED detail}}
|
||||
@@ -45,7 +45,7 @@ paths:
|
||||
'200': {description: Booking document history, content: {application/json: {schema: {$ref: '#/components/schemas/DocumentListResponse'}}}}
|
||||
post:
|
||||
summary: Reference an existing File object in a DRAFT booking
|
||||
description: File scope must be customerId + OCEAN_BOOKING_DOCUMENT + oceanBookingId. The booking version is incremented atomically.
|
||||
description: File scope must be platform tenantId=0 + OCEAN_BOOKING_DOCUMENT + oceanBookingId, independent of the optional customer snapshot. The booking version is incremented atomically.
|
||||
x-permission: platform:firstleg:ocean-booking:edit
|
||||
parameters: [{$ref: '#/components/parameters/OceanBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/DocumentRequest'}}}}
|
||||
@@ -162,8 +162,8 @@ components:
|
||||
type: object
|
||||
description: Fields may be incomplete in DRAFT. RELEASE validates the complete persisted aggregate.
|
||||
properties:
|
||||
customerId: {type: integer, format: int64, nullable: true}
|
||||
customerName: {type: string, nullable: true}
|
||||
customerId: {type: integer, format: int64, minimum: 1, nullable: true, description: 可选启用客户ID;多客户订舱留空,空值不是公开访问授权}
|
||||
customerName: {type: string, nullable: true, description: 可选名称提示;有客户ID时由服务端校验并保存客户主数据名称,清空客户时同时置null}
|
||||
channelCode: {type: string, nullable: true}
|
||||
warehouseId: {type: integer, format: int64, nullable: true}
|
||||
billOfLadingNo: {type: string, nullable: true}
|
||||
@@ -279,7 +279,7 @@ components:
|
||||
requiredQuantity: {type: integer, minimum: 1}
|
||||
allocatedQuantity: {type: integer, format: int64, minimum: 0}
|
||||
remainingQuantity: {type: integer, format: int64, minimum: 1}
|
||||
customerName: {type: string}
|
||||
customerName: {type: string, nullable: true}
|
||||
warehouseName: {type: string}
|
||||
channelName: {type: string}
|
||||
LinkCandidatePage:
|
||||
|
||||
@@ -77,7 +77,7 @@ components:
|
||||
properties:
|
||||
expectedVersion: {type: integer, minimum: 0}
|
||||
actualAt: {type: string, format: date-time}
|
||||
reason: {type: string, maxLength: 500, nullable: true}
|
||||
remark: {type: string, maxLength: 500, nullable: true}
|
||||
Warning:
|
||||
type: object
|
||||
required: [status, overdueHours, overdueDays]
|
||||
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
package com.baiyue.firstleg.controller;
|
||||
|
||||
import com.baiyue.firstleg.dto.ContainerFeeDetailDtos;
|
||||
import com.baiyue.firstleg.entity.ContainerFeeDetailEntity;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.lang.reflect.Modifier;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class ContainerFeeOpenApiContractTest {
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void coversEveryControllerOperationPermissionAndResponse() throws Exception {
|
||||
Map<String, Object> api = read();
|
||||
Map<String, Map<String, Map<String, Object>>> paths = (Map<String, Map<String, Map<String, Object>>>) api.get("paths");
|
||||
String base = PlatformContainerFeeDetailController.class.getAnnotation(RequestMapping.class).value()[0];
|
||||
int operationCount = 0;
|
||||
for (var method : PlatformContainerFeeDetailController.class.getDeclaredMethods()) {
|
||||
var get = method.getAnnotation(GetMapping.class);
|
||||
var post = method.getAnnotation(PostMapping.class);
|
||||
if (get == null && post == null) continue;
|
||||
String[] suffix = get != null ? get.value() : post.value();
|
||||
String path = base + (suffix.length == 0 ? "" : suffix[0]);
|
||||
assertThat(paths).containsKey(path);
|
||||
var operation = paths.get(path).get(get != null ? "get" : "post");
|
||||
assertThat(operation).isNotNull();
|
||||
assertThat(method.getAnnotation(PreAuthorize.class).value())
|
||||
.isEqualTo("hasAuthority('" + operation.get("x-permission") + "')");
|
||||
var responses = (Map<String, Object>) operation.get("responses");
|
||||
assertThat((Map<String, Object>) responses.get("200")).containsKey("content");
|
||||
operationCount++;
|
||||
}
|
||||
assertThat(paths.values().stream().mapToInt(Map::size).sum()).isEqualTo(operationCount);
|
||||
validateReferences(api, api);
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void requestAndResponseSchemasMatchJavaFields() throws Exception {
|
||||
var components = (Map<String, Object>) read().get("components");
|
||||
var schemas = (Map<String, Map<String, Object>>) components.get("schemas");
|
||||
for (Class<?> dto : List.of(ContainerFeeDetailDtos.SaveRequest.class,
|
||||
ContainerFeeDetailDtos.AdjustRequest.class, ContainerFeeDetailDtos.Allocation.class,
|
||||
ContainerFeeDetailDtos.AllocationGroupRequest.class, ContainerFeeDetailDtos.AllocationAmount.class,
|
||||
ContainerFeeDetailDtos.AllocationGroupAdjustRequest.class, ContainerFeeDetailDtos.VersionRequest.class,
|
||||
ContainerFeeDetailDtos.RejectRequest.class)) {
|
||||
Set<String> fields = Arrays.stream(dto.getRecordComponents()).map(value -> value.getName()).collect(Collectors.toSet());
|
||||
var properties = (Map<String, Object>) schemas.get(dto.getSimpleName()).get("properties");
|
||||
assertThat(properties.keySet()).containsExactlyInAnyOrderElementsOf(fields);
|
||||
}
|
||||
Set<String> responseFields = Arrays.stream(ContainerFeeDetailEntity.class.getDeclaredFields())
|
||||
.filter(field -> !Modifier.isStatic(field.getModifiers())).map(field -> field.getName()).collect(Collectors.toSet());
|
||||
var properties = (Map<String, Object>) schemas.get("FeeDetail").get("properties");
|
||||
assertThat(properties.keySet()).containsExactlyInAnyOrderElementsOf(responseFields);
|
||||
Set<String> envelopeFields = Arrays.stream(com.baiyue.common.core.api.ApiResponse.class.getRecordComponents())
|
||||
.map(component -> component.getName()).collect(Collectors.toSet());
|
||||
for (String name : List.of("FeeResponse", "FeeListResponse", "EmptyResponse")) {
|
||||
var envelope = (Map<String, Object>) schemas.get(name).get("properties");
|
||||
assertThat(envelope.keySet()).containsExactlyInAnyOrderElementsOf(envelopeFields);
|
||||
}
|
||||
}
|
||||
|
||||
private static Map<String, Object> read() throws Exception {
|
||||
return new Yaml().load(Files.readString(Path.of("src/main/resources/openapi/container-fees-v1.yaml")));
|
||||
}
|
||||
|
||||
private static void validateReferences(Object value, Map<String, Object> root) {
|
||||
if (value instanceof Map<?, ?> map) {
|
||||
if (map.get("$ref") instanceof String ref) {
|
||||
assertThat(ref).startsWith("#/");
|
||||
Object target = root;
|
||||
for (String segment : ref.substring(2).split("/")) target = ((Map<?, ?>) target).get(segment);
|
||||
assertThat(target).as(ref).isNotNull();
|
||||
}
|
||||
map.values().forEach(child -> validateReferences(child, root));
|
||||
} else if (value instanceof List<?> list) {
|
||||
list.forEach(child -> validateReferences(child, root));
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -39,6 +39,8 @@ class OceanBookingMapperContractTest {
|
||||
.flatMap(annotation -> Arrays.stream(annotation.value()))
|
||||
.reduce("", (left, right) -> left + '\n' + right);
|
||||
assertThat(sql)
|
||||
.contains("customer_id=#{value.customerId},customer_name=#{value.customerName}")
|
||||
.doesNotContain("customer_id=COALESCE", "customer_name=COALESCE")
|
||||
.contains("status=#{fromStatus} AND version=#{expectedVersion}")
|
||||
.contains("status='DRAFT' AND version=#{version}")
|
||||
.doesNotContain("CONFIRMED", "CHANGE_PENDING", "CANCELLED", "SUBMITTED", "FAILED");
|
||||
|
||||
+45
-9
@@ -40,11 +40,12 @@ class OceanBookingDocumentServiceTest {
|
||||
|
||||
@Test void uploadIsIdempotentAndDoesNotUploadASecondFileOnReplay() throws Exception {
|
||||
OceanBookingEntity booking = draft(2);
|
||||
booking.setCustomerId(null);
|
||||
MockMultipartFile file = new MockMultipartFile("file", "booking.pdf", "application/pdf", "pdf".getBytes());
|
||||
when(repository.claimOperation(eq(10L), eq(7L), eq("UPLOAD_DOCUMENT"), eq("upload-1"), anyString()))
|
||||
.thenReturn(true, false);
|
||||
when(repository.findForUpdate(10L)).thenReturn(booking);
|
||||
when(files.upload("token", 7L, 9L, "OCEAN_BOOKING_DOCUMENT", "10", file))
|
||||
when(files.upload("token", 7L, 0L, "OCEAN_BOOKING_DOCUMENT", "10", file))
|
||||
.thenReturn(ApiResponse.success(metadata(91L)));
|
||||
when(repository.touchDraft(10L, 2, 7L, "admin")).thenReturn(1);
|
||||
when(repository.completeOperation(eq(10L), eq(7L), eq("UPLOAD_DOCUMENT"), eq("upload-1"), anyString()))
|
||||
@@ -75,9 +76,9 @@ class OceanBookingDocumentServiceTest {
|
||||
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))
|
||||
when(files.upload("token", 7L, 0L, "OCEAN_BOOKING_DOCUMENT", "10", confirmation))
|
||||
.thenReturn(ApiResponse.success(metadata(91L)));
|
||||
when(files.upload("token", 7L, 9L, "OCEAN_BOOKING_DOCUMENT", "10", bill))
|
||||
when(files.upload("token", 7L, 0L, "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()))
|
||||
@@ -101,7 +102,7 @@ class OceanBookingDocumentServiceTest {
|
||||
@Test void attachRejectsCrossScopeFileBeforePersisting() {
|
||||
when(repository.claimOperation(any(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
|
||||
when(repository.findForUpdate(10L)).thenReturn(draft(1));
|
||||
when(files.metadata("token", 9L, "OCEAN_BOOKING_DOCUMENT", "10", 92L))
|
||||
when(files.metadata("token", 0L, "OCEAN_BOOKING_DOCUMENT", "10", 92L))
|
||||
.thenReturn(ApiResponse.success(new OceanBookingFileClient.Metadata(92L, 19L,
|
||||
"OCEAN_BOOKING_DOCUMENT", "10", "x.pdf", "hash", "application/pdf", 3L)));
|
||||
|
||||
@@ -144,12 +145,12 @@ class OceanBookingDocumentServiceTest {
|
||||
document.setId(20L); document.setOceanBookingId(10L); document.setFileId(91L);
|
||||
document.setDocumentType("BILL_OF_LADING"); document.setValid(true);
|
||||
when(repository.activeDocuments(10L)).thenReturn(List.of(document));
|
||||
when(files.metadata("token", 9L, "OCEAN_BOOKING_DOCUMENT", "10", 91L))
|
||||
when(files.metadata("token", 0L, "OCEAN_BOOKING_DOCUMENT", "10", 91L))
|
||||
.thenReturn(ApiResponse.success(metadata(91L)));
|
||||
|
||||
service.requireActiveValidDocument(booking);
|
||||
|
||||
verify(files).metadata("token", 9L, "OCEAN_BOOKING_DOCUMENT", "10", 91L);
|
||||
verify(files).metadata("token", 0L, "OCEAN_BOOKING_DOCUMENT", "10", 91L);
|
||||
}
|
||||
|
||||
@Test void releaseGateRejectsWhenBothActiveFileReferencesNoLongerExist() {
|
||||
@@ -161,8 +162,8 @@ class OceanBookingDocumentServiceTest {
|
||||
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(files).metadata("token", 0L, "OCEAN_BOOKING_DOCUMENT", "10", 91L);
|
||||
verify(files).metadata("token", 0L, "OCEAN_BOOKING_DOCUMENT", "10", 92L);
|
||||
verify(repository, never()).touchDraft(anyLong(), anyInt(), anyLong(), anyString());
|
||||
}
|
||||
|
||||
@@ -173,13 +174,48 @@ class OceanBookingDocumentServiceTest {
|
||||
return document;
|
||||
}
|
||||
|
||||
@Test void changingOrClearingCustomerKeepsPlatformFileDownloadAndReleaseScope() {
|
||||
OceanBookingEntity booking = draft(3);
|
||||
OceanBookingDocumentEntity document = activeDocument(20L, 91L, "BOOKING_CONFIRMATION");
|
||||
document.setFileName("booking.pdf");
|
||||
when(repository.find(10L)).thenReturn(booking);
|
||||
when(repository.document(10L, 20L)).thenReturn(document);
|
||||
when(repository.activeDocuments(10L)).thenReturn(List.of(document));
|
||||
when(files.metadata("token", 0L, "OCEAN_BOOKING_DOCUMENT", "10", 91L))
|
||||
.thenReturn(ApiResponse.success(metadata(91L)));
|
||||
when(files.download("token", 0L, "OCEAN_BOOKING_DOCUMENT", "10", 91L))
|
||||
.thenReturn(org.springframework.http.ResponseEntity.ok(new byte[]{1, 2, 3}));
|
||||
for (Long customer : java.util.Arrays.asList(9L, 19L, null)) {
|
||||
booking.setCustomerId(customer);
|
||||
service.requireActiveValidDocument(booking);
|
||||
assertThat(service.download(10L, 20L).content()).containsExactly(1, 2, 3);
|
||||
}
|
||||
verify(files, times(6)).metadata("token", 0L, "OCEAN_BOOKING_DOCUMENT", "10", 91L);
|
||||
verify(files, times(3)).download("token", 0L, "OCEAN_BOOKING_DOCUMENT", "10", 91L);
|
||||
}
|
||||
|
||||
@Test void platformFileFromAnotherBookingIsRejected() {
|
||||
when(repository.claimOperation(any(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
|
||||
OceanBookingEntity booking = draft(1);
|
||||
booking.setCustomerId(null);
|
||||
when(repository.findForUpdate(10L)).thenReturn(booking);
|
||||
when(files.metadata("token", 0L, "OCEAN_BOOKING_DOCUMENT", "10", 92L))
|
||||
.thenReturn(ApiResponse.success(new OceanBookingFileClient.Metadata(92L, 0L,
|
||||
"OCEAN_BOOKING_DOCUMENT", "99", "x.pdf", "hash", "application/pdf", 3L)));
|
||||
assertThatThrownBy(() -> service.attach(10L,
|
||||
new com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.DocumentRequest(1, 92L, "BILL_OF_LADING"),
|
||||
"other-booking", 7L, "admin"))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("归属不匹配");
|
||||
verify(repository, never()).insert(any(OceanBookingDocumentEntity.class));
|
||||
}
|
||||
|
||||
private static OceanBookingEntity draft(int version) {
|
||||
OceanBookingEntity booking = new OceanBookingEntity();
|
||||
booking.setId(10L); booking.setCustomerId(9L); booking.setStatus("DRAFT"); booking.setVersion(version);
|
||||
return booking;
|
||||
}
|
||||
private static OceanBookingFileClient.Metadata metadata(long fileId) {
|
||||
return new OceanBookingFileClient.Metadata(fileId, 9L, "OCEAN_BOOKING_DOCUMENT", "10",
|
||||
return new OceanBookingFileClient.Metadata(fileId, 0L, "OCEAN_BOOKING_DOCUMENT", "10",
|
||||
"booking.pdf", "hash", "application/pdf", 3L);
|
||||
}
|
||||
}
|
||||
|
||||
+70
@@ -40,6 +40,7 @@ class OceanBookingServiceTest {
|
||||
@Mock private com.baiyue.firstleg.client.WarehousePartnerClient partners;
|
||||
@Mock private FirstlegDocumentNumberGenerator documentNumbers;
|
||||
@Mock private OceanBookingDocumentService documents;
|
||||
@Mock private com.baiyue.firstleg.service.IamCustomerSnapshotService customers;
|
||||
@InjectMocks private OceanBookingService service;
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@@ -47,6 +48,8 @@ class OceanBookingServiceTest {
|
||||
void setUp() throws Exception {
|
||||
MockitoAnnotations.openMocks(this);
|
||||
objectMapper = new ObjectMapper().findAndRegisterModules();
|
||||
lenient().when(customers.active(eq(100L), any())).thenReturn(
|
||||
new com.baiyue.firstleg.client.IamCustomerSnapshotClient.Customer(100L, "C100", "客户", "ACTIVE", null, null));
|
||||
lenient().when(documentNumbers.next("OB")).thenReturn("OB-260818-0001");
|
||||
var field = OceanBookingService.class.getDeclaredField("objectMapper");
|
||||
field.setAccessible(true);
|
||||
@@ -76,6 +79,70 @@ class OceanBookingServiceTest {
|
||||
verifyNoInteractions(priceBooks, suppliers, warehouses);
|
||||
}
|
||||
|
||||
@Test
|
||||
void customerlessCreateSaveAndReleaseRetainsOtherRequirements() throws Exception {
|
||||
saveAndReleaseWithoutCustomer(false);
|
||||
}
|
||||
|
||||
@Test
|
||||
void clearingExistingCustomerPersistsNullAndAllowsRelease() throws Exception {
|
||||
saveAndReleaseWithoutCustomer(true);
|
||||
}
|
||||
|
||||
private void saveAndReleaseWithoutCustomer(boolean initialCustomer) throws Exception {
|
||||
stubMasterData();
|
||||
when(suppliers.booking(81L, "订舱代理")).thenReturn(
|
||||
new WarehouseSupplierClient.Snapshot(81L, "订舱代理", "BOOKING", "ACTIVE", List.of("MATSON")));
|
||||
var tree = objectMapper.valueToTree(completeDraft());
|
||||
((com.fasterxml.jackson.databind.node.ObjectNode) tree).putNull("customerId").putNull("customerName");
|
||||
DraftData emptyCustomer = objectMapper.treeToValue(tree, DraftData.class);
|
||||
AtomicReference<OceanBookingEntity> stored = stubCreatedDraft();
|
||||
var created = service.create(9L, "admin", "optional-create",
|
||||
new CreateRequest(0, initialCustomer ? completeDraft() : emptyCustomer));
|
||||
long id = created.oceanBooking().getId();
|
||||
when(repository.updateDraft(any(), eq(0), eq(9L), eq("admin"))).thenAnswer(invocation -> {
|
||||
OceanBookingEntity value = invocation.getArgument(0);
|
||||
assertThat(value.getCustomerId()).isNull();
|
||||
assertThat(value.getCustomerName()).isNull();
|
||||
value.setVersion(1);
|
||||
return 1;
|
||||
});
|
||||
service.updateDraft(id, 9L, "admin", "optional-save", new UpdateRequest(0, emptyCustomer));
|
||||
when(repository.parties(id)).thenReturn(parties());
|
||||
when(repository.requirements(id)).thenReturn(List.of(requirement()));
|
||||
when(repository.transition(id, "DRAFT", "RELEASED", 1, 9L, "admin")).thenAnswer(invocation -> {
|
||||
stored.get().setStatus("RELEASED"); stored.get().setVersion(2); return 1;
|
||||
});
|
||||
|
||||
var result = service.release(id, 9L, "admin", "optional-release", new ReleaseRequest(1, "确认"));
|
||||
assertThat(result.oceanBooking().getStatus()).isEqualTo("RELEASED");
|
||||
assertThat(result.oceanBooking().getCustomerId()).isNull();
|
||||
assertThat(result.oceanBooking().getCustomerName()).isNull();
|
||||
verify(documents).requireActiveValidDocument(stored.get());
|
||||
verify(customers, times(initialCustomer ? 1 : 0)).active(anyLong(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidSpecifiedCustomerIsRejectedBeforeCreatingDraft() {
|
||||
when(customers.active(100L, "客户")).thenThrow(new BizException(
|
||||
com.baiyue.common.core.api.ResultCode.CONFLICT, "所选客户不存在或未启用"));
|
||||
assertThatThrownBy(() -> service.create(9L, "admin", "bad-customer", new CreateRequest(0, completeDraft())))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("客户不存在或未启用");
|
||||
verify(repository, never()).insert(any(OceanBookingEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void releaseRevalidatesSpecifiedCustomer() {
|
||||
when(repository.find(1L)).thenReturn(completeBooking("DRAFT", 3));
|
||||
when(repository.parties(1L)).thenReturn(parties());
|
||||
when(repository.requirements(1L)).thenReturn(List.of(requirement()));
|
||||
when(customers.active(100L, "客户")).thenThrow(new BizException(
|
||||
com.baiyue.common.core.api.ResultCode.CONFLICT, "所选客户不存在或未启用"));
|
||||
assertThatThrownBy(() -> service.release(1L, 9L, "admin", "inactive-customer", new ReleaseRequest(3, null)))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("客户不存在或未启用");
|
||||
verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt(), anyLong(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void freezesServerSnapshotsAndPersistsQuantityRequirementsOnly() {
|
||||
stubMasterData();
|
||||
@@ -86,6 +153,9 @@ class OceanBookingServiceTest {
|
||||
assertThat(inserted.get().getChannelName()).isEqualTo("美西海运");
|
||||
assertThat(inserted.get().getWarehouseName()).isEqualTo("深圳中转仓");
|
||||
assertThat(inserted.get().getBookingAgentName()).isEqualTo("订舱代理");
|
||||
assertThat(inserted.get().getCustomerId()).isEqualTo(100L);
|
||||
assertThat(inserted.get().getCustomerName()).isEqualTo("客户");
|
||||
verify(customers).active(100L, "客户");
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<List<OceanBookingContainerRequirementEntity>> captor = ArgumentCaptor.forClass(List.class);
|
||||
verify(repository).replaceRequirements(eq(inserted.get().getId()), captor.capture());
|
||||
|
||||
@@ -265,6 +265,48 @@ class ContainerFeeDetailServiceTest {
|
||||
.doesNotContainNull().allMatch(captor.getAllValues().get(0).getAllocationGroupId()::equals);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAllocationWhenPersistedRoundedRowsWouldExceedTotal() {
|
||||
ownedPlan();
|
||||
when(feeItems.findItem(11L)).thenReturn(item());
|
||||
var fee = new ContainerFeeDetailDtos.SaveRequest(11L, "RECEIVABLE",
|
||||
LocalDate.of(2026, 8, 20), new BigDecimal("0.01"), "CNY", null, null);
|
||||
var request = new AllocationGroupRequest(fee, List.of(
|
||||
new Allocation(10001L, 501L, "FL-501", null, null, new BigDecimal("0.005")),
|
||||
new Allocation(10002L, 502L, "FL-502", null, null, new BigDecimal("0.005"))));
|
||||
|
||||
assertThatThrownBy(() -> service.createAllocationGroup(41L, 9L, request))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("分摊合计必须等于费用总额");
|
||||
verify(repository, never()).insert(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAllocationThatRoundsToZero() {
|
||||
ownedPlan();
|
||||
when(feeItems.findItem(11L)).thenReturn(item());
|
||||
var fee = new ContainerFeeDetailDtos.SaveRequest(11L, "RECEIVABLE",
|
||||
LocalDate.of(2026, 8, 20), new BigDecimal("0.00"), "CNY", null, null);
|
||||
var request = new AllocationGroupRequest(fee, List.of(
|
||||
new Allocation(10001L, 501L, "FL-501", null, null, new BigDecimal("0.001"))));
|
||||
|
||||
assertThatThrownBy(() -> service.createAllocationGroup(41L, 9L, request))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("取整后必须大于0");
|
||||
verify(repository, never()).insert(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsGroupAdjustmentThatRoundsAnAllocationToZero() {
|
||||
ownedPlan();
|
||||
when(repository.listGroup(41L, 81L)).thenReturn(List.of(approvedFee(1)));
|
||||
var request = new ContainerFeeDetailDtos.AllocationGroupAdjustRequest(1,
|
||||
LocalDate.of(2026, 8, 20), new BigDecimal("0.001"), List.of(
|
||||
new ContainerFeeDetailDtos.AllocationAmount(51L, new BigDecimal("0.001"))));
|
||||
|
||||
assertThatThrownBy(() -> service.adjustAllocationGroup(41L, 81L, 9L, request))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("取整后必须大于0");
|
||||
verify(repository, never()).adjust(anyLong(), anyLong(), anyLong(), anyInt(), any(), any(), anyLong(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAllocationWhenInboundIdAndCodeAreOnlyPartiallyProvided() {
|
||||
LoadingPlanEntity plan = new LoadingPlanEntity();
|
||||
|
||||
+45
-1
@@ -16,10 +16,54 @@ import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class DropshipPalletizationServiceTest {
|
||||
private final FirstlegDocumentNumberGenerator numbers=mock(FirstlegDocumentNumberGenerator.class);
|
||||
private final DropshipPalletizationMapper mapper=mock(DropshipPalletizationMapper.class);
|
||||
private final DropshipPalletizationService service=new DropshipPalletizationService();
|
||||
|
||||
DropshipPalletizationServiceTest(){ ReflectionTestUtils.setField(service,"mapper",mapper); }
|
||||
DropshipPalletizationServiceTest(){ ReflectionTestUtils.setField(service,"mapper",mapper); ReflectionTestUtils.setField(service,"documentNumbers",numbers); }
|
||||
|
||||
@Test void generatesAndBindsOnlyWhenPendingCartonsExist(){
|
||||
when(mapper.lockTask(10L,100L)).thenReturn(10L); when(mapper.task(10L,100L)).thenReturn(task());
|
||||
when(mapper.unboundCount(10L)).thenReturn(2); when(numbers.next("DP")).thenReturn("DP-260909-0001");
|
||||
when(mapper.bindPending(eq(10L),anyLong(),eq("create-1"),eq(7L),eq("张三"))).thenReturn(2);
|
||||
when(mapper.adjustBound(10L,7L,2)).thenReturn(1);
|
||||
service.createAndBind(10L,new com.baiyue.firstleg.dto.DropshipPalletizationDtos.CreateAndBindRequest(100L,"create-1"),7L,"张三");
|
||||
verify(mapper).insertPallet(anyLong(),eq(10L),eq(1L),eq(30L),eq(100L),eq("DP-260909-0001"),eq("create-1"),eq(7L),eq("张三"));
|
||||
verify(numbers,times(1)).next("DP");
|
||||
}
|
||||
|
||||
@Test void noPendingCartonsNeverAllocatesNumberOrEmptyPallet(){
|
||||
when(mapper.lockTask(10L,100L)).thenReturn(10L); when(mapper.task(10L,100L)).thenReturn(task());
|
||||
assertThatThrownBy(()->service.createAndBind(10L,new com.baiyue.firstleg.dto.DropshipPalletizationDtos.CreateAndBindRequest(100L,"empty"),7L,"张三")).hasMessageContaining("没有待绑托箱");
|
||||
verifyNoInteractions(numbers);
|
||||
verify(mapper,never()).insertPallet(anyLong(),anyLong(),anyLong(),anyLong(),anyLong(),anyString(),anyString(),anyLong(),anyString());
|
||||
}
|
||||
|
||||
@Test void replaysAfterCompletionWithoutGeneratingAnotherPallet(){
|
||||
TaskRow completed=task(); completed.setTaskStatus("COMPLETED"); completed.setProcessStatus("WAIT_SPLIT");
|
||||
when(mapper.lockTask(10L,100L)).thenReturn(10L); when(mapper.task(10L,100L)).thenReturn(completed);
|
||||
when(mapper.palletByOpenOperation("replay")).thenReturn(pallet());
|
||||
service.createAndBind(10L,new com.baiyue.firstleg.dto.DropshipPalletizationDtos.CreateAndBindRequest(100L,"replay"),7L,"张三");
|
||||
verifyNoInteractions(numbers); verify(mapper,never()).unboundCount(anyLong());
|
||||
}
|
||||
|
||||
@Test void rejectsOtherOperatorAndCrossTaskReplay(){
|
||||
when(mapper.lockTask(10L,100L)).thenReturn(10L); when(mapper.task(10L,100L)).thenReturn(task());
|
||||
var request=new com.baiyue.firstleg.dto.DropshipPalletizationDtos.CreateAndBindRequest(100L,"replay");
|
||||
assertThatThrownBy(()->service.createAndBind(10L,request,8L,"李四")).hasMessageContaining("当前操作员");
|
||||
PalletRow other=pallet(); other.setTaskId(11L); when(mapper.palletByOpenOperation("replay")).thenReturn(other);
|
||||
assertThatThrownBy(()->service.createAndBind(10L,request,7L,"张三")).hasMessageContaining("不同任务");
|
||||
verifyNoInteractions(numbers);
|
||||
}
|
||||
|
||||
@Test void rejectsUnknownWarehouseAndChangedBindCount(){
|
||||
var request=new com.baiyue.firstleg.dto.DropshipPalletizationDtos.CreateAndBindRequest(100L,"new");
|
||||
assertThatThrownBy(()->service.createAndBind(10L,request,7L,"张三")).hasMessageContaining("当前仓库");
|
||||
when(mapper.lockTask(10L,100L)).thenReturn(10L); when(mapper.task(10L,100L)).thenReturn(task());
|
||||
when(mapper.unboundCount(10L)).thenReturn(2); when(numbers.next("DP")).thenReturn("DP-260909-0002");
|
||||
assertThatThrownBy(()->service.createAndBind(10L,request,7L,"张三")).hasMessageContaining("待绑托箱已变化");
|
||||
verify(mapper,never()).adjustBound(anyLong(),anyLong(),anyInt());
|
||||
}
|
||||
|
||||
@Test void resolvesUncountedCartonBeforeQuantityConfirmation(){
|
||||
TaskRow task=task(); LabelRow label=label();
|
||||
|
||||
+13
@@ -9,6 +9,19 @@ import java.nio.file.Path;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class TerminalDrayageOpenApiContractTest {
|
||||
@Test
|
||||
void actualTimeRequestBindsRemarkFromPublicJson() throws Exception {
|
||||
var mapper = new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules();
|
||||
var request = mapper.readValue("""
|
||||
{"expectedVersion":3,"actualAt":"2026-09-04T09:00:00","remark":"已还柜"}
|
||||
""", com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.ActualTimeRequest.class);
|
||||
assertThat(request.remark()).isEqualTo("已还柜");
|
||||
assertThat(request.expectedVersion()).isEqualTo(3);
|
||||
String yaml = Files.readString(Path.of("src/main/resources/openapi/terminal-drayage-v1.yaml"));
|
||||
String schema = yaml.substring(yaml.indexOf(" ActualTimeRequest:"), yaml.indexOf(" Warning:"));
|
||||
assertThat(schema).contains("remark:", "expectedVersion:", "actualAt:").doesNotContain("reason:");
|
||||
}
|
||||
|
||||
@Test
|
||||
void controllerUsesFrozenOverseasPermissionsWithoutGenericContainerFallback() throws Exception {
|
||||
assertPermission("views", "platform:firstleg:terminal-drayage:list");
|
||||
|
||||
+59
@@ -84,6 +84,65 @@ class ContainerWorkbenchServiceTest {
|
||||
verifyNoInteractions(carrierSchedules);
|
||||
}
|
||||
|
||||
@Test
|
||||
void realOverseasListAndDetailProjectBothEmptyReturnActions() throws Exception {
|
||||
verifyRealEmptyReturnProjection(true, true);
|
||||
}
|
||||
|
||||
@Test
|
||||
void realOverseasListAndDetailRemoveBothActionsWithoutPermission() throws Exception {
|
||||
verifyRealEmptyReturnProjection(false, true);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unloadedListWithoutDrayageAggregateDoesNotOfferUnexecutableReturn() throws Exception {
|
||||
verifyRealEmptyReturnProjection(true, false);
|
||||
}
|
||||
|
||||
private void verifyRealEmptyReturnProjection(boolean hasPermission, boolean hasAggregate) throws Exception {
|
||||
var mapper = mock(com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper.class);
|
||||
var json = new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules();
|
||||
var realDrayages = new TerminalDrayageService(mapper,
|
||||
mock(com.baiyue.firstleg.service.WarehouseSupplierSnapshotService.class), carrierSchedules, json);
|
||||
ReflectionTestUtils.setField(service, "terminalDrayages", realDrayages);
|
||||
ContainerBookingEntity container = container();
|
||||
container.setUnloadingCompletedAt(LocalDateTime.of(2026, 9, 5, 8, 0));
|
||||
var context = new com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper.Context(
|
||||
11L, "MSCU1234567", 21L, "ACTIVE", container.getUnloadingCompletedAt());
|
||||
var row = new com.baiyue.firstleg.terminaldrayage.entity.TerminalDrayageEntity();
|
||||
row.setId(51L);
|
||||
row.setContainerBookingId(11L);
|
||||
row.setVersion(3);
|
||||
row.setActualPickupAt(LocalDateTime.of(2026, 9, 4, 9, 0));
|
||||
when(repository.page(eq("OVERSEAS"), any())).thenReturn(new PageResult<>(List.of(container), 1, 20, 1, 1));
|
||||
when(repository.find(11L)).thenReturn(container);
|
||||
when(repository.departed(11L)).thenReturn(true);
|
||||
when(mapper.contexts(List.of(11L))).thenReturn(List.of(context));
|
||||
when(mapper.context(11L)).thenReturn(context);
|
||||
when(mapper.findAll(List.of(11L))).thenReturn(hasAggregate ? List.of(row) : List.of());
|
||||
when(mapper.find(11L)).thenReturn(hasAggregate ? row : null);
|
||||
when(carrierSchedules.containerActualFacts(List.of(11L))).thenReturn(List.of(facts("PICKED_UP")));
|
||||
Set<String> permissions = hasPermission
|
||||
? Set.of("platform:firstleg:terminal-drayage:list", "platform:firstleg:terminal-drayage:empty-return:confirm")
|
||||
: Set.of("platform:firstleg:terminal-drayage:list");
|
||||
|
||||
var page = service.overseasPage(query(WorkbenchScope.ACTIVE), permissions);
|
||||
var listed = page.records().get(0);
|
||||
var detail = service.overseasDetail(11L, permissions);
|
||||
List<String> expected = hasPermission && hasAggregate ? List.of("RECORD_EMPTY_RETURN") : List.of();
|
||||
assertThat(listed.workbenchStage()).isEqualTo("UNLOADED");
|
||||
assertThat(listed.terminalDrayage().status()).isEqualTo("UNLOADED");
|
||||
assertThat(listed.allowedActions()).isEqualTo(expected);
|
||||
assertThat(listed.terminalDrayage().allowedActions()).isEqualTo(expected);
|
||||
assertThat(detail.allowedActions()).isEqualTo(listed.allowedActions());
|
||||
assertThat(detail.terminalDrayage().allowedActions()).isEqualTo(listed.terminalDrayage().allowedActions());
|
||||
var serialized = json.readTree(json.writeValueAsString(com.baiyue.common.core.api.ApiResponse.success(page)));
|
||||
assertThat(serialized.at("/data/records/0/allowedActions")).isEqualTo(json.valueToTree(expected));
|
||||
assertThat(serialized.at("/data/records/0/terminalDrayage/allowedActions")).isEqualTo(json.valueToTree(expected));
|
||||
verify(mapper).findAll(List.of(11L));
|
||||
verify(mapper).contexts(List.of(11L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void domesticHistoryDetailKeepsTheDepartedContainerAndItsUniqueLoadingPlan() {
|
||||
ContainerBookingEntity container = container();
|
||||
|
||||
+20
@@ -177,6 +177,23 @@ class TerminalDrayageServiceTest {
|
||||
assertThat(result.allowedActions()).doesNotContain("RECORD_EMPTY_RETURN");
|
||||
}
|
||||
|
||||
@Test
|
||||
void unloadedContainerOffersEmptyReturnOnlyWithDedicatedPermission() {
|
||||
Context context = context("PICKED_UP", LocalDateTime.of(2026, 9, 3, 0, 0));
|
||||
TerminalDrayageEntity row = row(3);
|
||||
row.setActualPickupAt(LocalDateTime.of(2026, 9, 2, 10, 0));
|
||||
when(mapper.context(11L)).thenReturn(context);
|
||||
when(mapper.find(11L)).thenReturn(row);
|
||||
|
||||
var raw = service.detail(11L);
|
||||
assertThat(raw.status()).isEqualTo("UNLOADED");
|
||||
assertThat(service.authorize(raw,
|
||||
Set.of("platform:firstleg:terminal-drayage:empty-return:confirm")).allowedActions())
|
||||
.containsExactly("RECORD_EMPTY_RETURN");
|
||||
assertThat(service.authorize(raw,
|
||||
Set.of("platform:firstleg:terminal-drayage:list")).allowedActions()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void bulkViewOmitsContainerWhenActualFactPortHasNoTrackingRecord() {
|
||||
when(mapper.contexts(List.of(11L))).thenReturn(List.of(
|
||||
@@ -276,6 +293,9 @@ class TerminalDrayageServiceTest {
|
||||
ArgumentCaptor<ManualNodeRequest> node = ArgumentCaptor.forClass(ManualNodeRequest.class);
|
||||
verify(carrierSchedules).addManualNode(anyLong(), anyLong(), anyString(), anyString(), node.capture());
|
||||
assertThat(node.getValue().standardNode()).isEqualTo("EMPTY_RETURNED");
|
||||
assertThat(node.getValue().reason()).isEqualTo("已还柜");
|
||||
assertThat(node.getValue().actualAt()).isEqualTo(LocalDateTime.of(2026, 9, 4, 9, 0));
|
||||
assertThat(result.version()).isEqualTo(4);
|
||||
}
|
||||
|
||||
private Context context(String node, LocalDateTime unloadedAt) {
|
||||
|
||||
@@ -15,6 +15,7 @@ BEGIN
|
||||
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 = 5107094 AND permission_code <> 'platform:overseas-warehouse:delivery:documents')
|
||||
OR (id = 5107093 AND permission_code <> 'platform:overseas-warehouse:outbound:cancel')
|
||||
OR (id = 5107070 AND permission_code <> 'platform:overseas-warehouse:exception-order:list')
|
||||
OR (id = 5107071 AND permission_code <> 'platform:overseas-warehouse:exception-order:create')
|
||||
@@ -34,6 +35,7 @@ BEGIN
|
||||
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:delivery:documents' AND id <> 5107094)
|
||||
OR (permission_code = 'platform:overseas-warehouse:outbound:cancel' AND id <> 5107093)
|
||||
OR (permission_code = 'platform:overseas-warehouse:exception-order:list' AND id <> 5107070)
|
||||
OR (permission_code = 'platform:overseas-warehouse:exception-order:create' AND id <> 5107071)
|
||||
@@ -70,6 +72,7 @@ VALUES
|
||||
(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),
|
||||
(5107094,0,'PLATFORM','BUTTON',5107092,'platform:overseas-warehouse:delivery:documents','管理派送资料',NULL,'POST',769,'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','海外仓派送单','/orders/delivery','GET',60,'ACTIVE',1,0),
|
||||
(5306001,0,'MERCHANT','BUTTON',5306000,'merchant:warehouse:delivery:create','创建海外仓派送单',NULL,'POST',10,'ACTIVE',1,0)
|
||||
@@ -101,6 +104,7 @@ SELECT 0, role.id, permission.id, CURRENT_TIMESTAMP(3)
|
||||
'platform:overseas-warehouse:exception-order:process',
|
||||
'platform:overseas-warehouse:delivery:return',
|
||||
'platform:overseas-warehouse:delivery:status',
|
||||
'platform:overseas-warehouse:delivery:documents',
|
||||
'platform:overseas-warehouse:customer-visit:list',
|
||||
'platform:overseas-warehouse:customer-visit:edit')
|
||||
AND permission.status='ACTIVE' AND permission.deleted=0;
|
||||
@@ -126,6 +130,7 @@ INSERT INTO tmp_phase5_delivery_role_permission (role_code, permission_code) VAL
|
||||
('WAREHOUSE_OPERATOR','platform:overseas-warehouse:exception-order:process'),
|
||||
('WAREHOUSE_OPERATOR','platform:overseas-warehouse:delivery:return'),
|
||||
('WAREHOUSE_OPERATOR','platform:overseas-warehouse:delivery:status'),
|
||||
('WAREHOUSE_OPERATOR','platform:overseas-warehouse:delivery:documents'),
|
||||
('WAREHOUSE_OPERATOR','platform:overseas-warehouse:customer-visit:list'),
|
||||
('WAREHOUSE_OPERATOR','platform:overseas-warehouse:customer-visit:edit'),
|
||||
('WAREHOUSE_TALLY','platform:overseas-warehouse:dispatch:list'),
|
||||
@@ -155,6 +160,7 @@ WHERE role.tenant_id=0 AND role.app_code='PLATFORM'
|
||||
'platform:overseas-warehouse:exception-order:process',
|
||||
'platform:overseas-warehouse:delivery:return',
|
||||
'platform:overseas-warehouse:delivery:status',
|
||||
'platform:overseas-warehouse:delivery:documents',
|
||||
'platform:overseas-warehouse:customer-visit:list',
|
||||
'platform:overseas-warehouse:customer-visit:edit')
|
||||
AND expected.permission_code IS NULL;
|
||||
@@ -203,10 +209,11 @@ BEGIN
|
||||
'platform:overseas-warehouse:exception-order:process',
|
||||
'platform:overseas-warehouse:delivery:return',
|
||||
'platform:overseas-warehouse:delivery:status',
|
||||
'platform:overseas-warehouse:delivery:documents',
|
||||
'platform:overseas-warehouse:customer-visit:list',
|
||||
'platform:overseas-warehouse:customer-visit:edit',
|
||||
'merchant:warehouse:delivery:list',
|
||||
'merchant:warehouse:delivery:create')) <> 17 THEN
|
||||
'merchant:warehouse:delivery:create')) <> 18 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:权限目录不完整';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM iam_role_permission grant_row
|
||||
@@ -227,8 +234,9 @@ BEGIN
|
||||
'platform:overseas-warehouse:exception-order:process',
|
||||
'platform:overseas-warehouse:delivery:return',
|
||||
'platform:overseas-warehouse:delivery:status',
|
||||
'platform:overseas-warehouse:delivery:documents',
|
||||
'platform:overseas-warehouse:customer-visit:list',
|
||||
'platform:overseas-warehouse:customer-visit:edit')) <> 15 THEN
|
||||
'platform:overseas-warehouse:customer-visit:edit')) <> 16 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:平台管理员授权不完整';
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM iam_role role
|
||||
@@ -252,15 +260,16 @@ BEGIN
|
||||
'platform:overseas-warehouse:exception-order:process',
|
||||
'platform:overseas-warehouse:delivery:return',
|
||||
'platform:overseas-warehouse:delivery:status',
|
||||
'platform:overseas-warehouse:delivery:documents',
|
||||
'platform:overseas-warehouse:customer-visit:list',
|
||||
'platform:overseas-warehouse:customer-visit:edit')) <> 15) THEN
|
||||
'platform:overseas-warehouse:customer-visit:edit')) <> 16) 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 <> 15
|
||||
IF v_warehouse_operator_permission_count <> 16
|
||||
OR v_warehouse_tally_permission_count <> 5 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:仓库岗位最小权限矩阵异常';
|
||||
END IF;
|
||||
@@ -282,6 +291,7 @@ BEGIN
|
||||
'platform:overseas-warehouse:exception-order:process',
|
||||
'platform:overseas-warehouse:delivery:return',
|
||||
'platform:overseas-warehouse:delivery:status',
|
||||
'platform:overseas-warehouse:delivery:documents',
|
||||
'platform:overseas-warehouse:customer-visit:list',
|
||||
'platform:overseas-warehouse:customer-visit:edit')) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:PDA岗位错误获得后台权限';
|
||||
|
||||
@@ -124,7 +124,7 @@ paths:
|
||||
get:
|
||||
tags: [IAM Resources / IAM 资源]
|
||||
summary: 查询当前用户有效权限
|
||||
description: 返回当前有效平台成员权限编码;只要求有效 PLATFORM JWT,不依赖 `platform:role:list`。入库单扣货与解除扣货返回独立写权限 `platform:firstleg:inbound:hold`,不会由 `platform:firstleg:inbound:list` 查看权限自动获得。资金账户充值审核与手工调账分别返回 `platform:finance:recharge:review`、`platform:finance:customer-account:adjust`,并与 `platform:finance:customer-account:list` 只读权限分离。海外仓返回 `platform:overseas-warehouse:dispatch: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`、`platform:overseas-warehouse:delivery:return`、`platform:overseas-warehouse:delivery:status`、`platform:overseas-warehouse:exception-order:list`、`platform:overseas-warehouse:exception-order:create`、`platform:overseas-warehouse:exception-order:process`、`platform:overseas-warehouse:customer-visit:list`、`platform:overseas-warehouse:customer-visit:edit`;海外 PDA 盘点、移库、换标和现场查询统一返回 `lastleg:pda:operate`,且仅限 `OVERSEAS_WAREHOUSE_PDA_OPERATOR`。货柜双工作台以 `platform:firstleg:container:list/create/edit` 维护国内送柜报关,国外清关提柜独立返回 `platform:firstleg:terminal-drayage:list`、`platform:firstleg:terminal-drayage:document:edit`、`platform:firstleg:terminal-drayage:fleet:edit`、`platform:firstleg:terminal-drayage:pickup-appointment:edit`、`platform:firstleg:terminal-drayage:pickup:confirm`、`platform:firstleg:terminal-drayage:empty-return:confirm`,不以 `container:list/edit` 兼容放行国外操作。列表权限包含详情和只读辅助接口,写操作独立授权。
|
||||
description: 返回当前有效平台成员权限编码;只要求有效 PLATFORM JWT,不依赖 `platform:role:list`。入库单扣货与解除扣货返回独立写权限 `platform:firstleg:inbound:hold`,不会由 `platform:firstleg:inbound:list` 查看权限自动获得。资金账户充值审核与手工调账分别返回 `platform:finance:recharge:review`、`platform:finance:customer-account:adjust`,并与 `platform:finance:customer-account:list` 只读权限分离。海外仓返回 `platform:overseas-warehouse:dispatch: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`、`platform:overseas-warehouse:delivery:return`、`platform:overseas-warehouse:delivery:status`、`platform:overseas-warehouse:delivery:documents`、`platform:overseas-warehouse:exception-order:list`、`platform:overseas-warehouse:exception-order:create`、`platform:overseas-warehouse:exception-order:process`、`platform:overseas-warehouse:customer-visit:list`、`platform:overseas-warehouse:customer-visit:edit`;海外 PDA 盘点、移库、换标和现场查询统一返回 `lastleg:pda:operate`,且仅限 `OVERSEAS_WAREHOUSE_PDA_OPERATOR`。货柜双工作台以 `platform:firstleg:container:list/create/edit` 维护国内送柜报关,国外清关提柜独立返回 `platform:firstleg:terminal-drayage:list`、`platform:firstleg:terminal-drayage:document:edit`、`platform:firstleg:terminal-drayage:fleet:edit`、`platform:firstleg:terminal-drayage:pickup-appointment:edit`、`platform:firstleg:terminal-drayage:pickup:confirm`、`platform:firstleg:terminal-drayage:empty-return:confirm`,不以 `container:list/edit` 兼容放行国外操作。派送七类资料管理使用独立写权限 `platform:overseas-warehouse:delivery:documents`;读取和下载仍使用 `platform:overseas-warehouse:delivery:list`。列表权限包含详情和只读辅助接口,写操作独立授权。
|
||||
responses:
|
||||
'200':
|
||||
description: 当前平台成员权限查询成功
|
||||
|
||||
@@ -29,6 +29,8 @@ class IamOpenApiContractTest {
|
||||
.contains("platform:overseas-warehouse:delivery:list")
|
||||
.contains("platform:overseas-warehouse:delivery:return")
|
||||
.contains("platform:overseas-warehouse:delivery:status")
|
||||
.contains("platform:overseas-warehouse:delivery:documents")
|
||||
.contains("读取和下载仍使用 `platform:overseas-warehouse:delivery:list`")
|
||||
.contains("platform:overseas-warehouse:exception-order:list")
|
||||
.contains("platform:overseas-warehouse:exception-order:create")
|
||||
.contains("platform:overseas-warehouse:exception-order:process")
|
||||
|
||||
+6
-2
@@ -28,6 +28,7 @@ class Phase5DeliveryPermissionSqlContractTest {
|
||||
"platform:overseas-warehouse:exception-order:process",
|
||||
"platform:overseas-warehouse:delivery:return",
|
||||
"platform:overseas-warehouse:delivery:status",
|
||||
"platform:overseas-warehouse:delivery:documents",
|
||||
"platform:overseas-warehouse:customer-visit:list",
|
||||
"platform:overseas-warehouse:customer-visit:edit",
|
||||
"merchant:warehouse:delivery:list",
|
||||
@@ -58,6 +59,7 @@ class Phase5DeliveryPermissionSqlContractTest {
|
||||
"'platform:overseas-warehouse:exception-order:process','处理海外异常订单',NULL,'POST'",
|
||||
"'platform:overseas-warehouse:delivery:return','派送单标记退货',NULL,'POST'",
|
||||
"'platform:overseas-warehouse:delivery:status','编辑派送单状态',NULL,'PUT'",
|
||||
"(5107094,0,'PLATFORM','BUTTON',5107092,'platform:overseas-warehouse:delivery:documents','管理派送资料',NULL,'POST'",
|
||||
"'platform:overseas-warehouse:customer-visit:list','客户回访','/overseas-warehouse/customer-visits','GET'",
|
||||
"'platform:overseas-warehouse:customer-visit:edit','保存客户回访',NULL,'PUT'",
|
||||
"'merchant:warehouse:delivery:list','海外仓派送单','/orders/delivery','GET'");
|
||||
@@ -80,6 +82,7 @@ class Phase5DeliveryPermissionSqlContractTest {
|
||||
"('WAREHOUSE_OPERATOR','platform:overseas-warehouse:exception-order:process')",
|
||||
"('WAREHOUSE_OPERATOR','platform:overseas-warehouse:delivery:return')",
|
||||
"('WAREHOUSE_OPERATOR','platform:overseas-warehouse:delivery:status')",
|
||||
"('WAREHOUSE_OPERATOR','platform:overseas-warehouse:delivery:documents')",
|
||||
"('WAREHOUSE_OPERATOR','platform:overseas-warehouse:customer-visit:edit')",
|
||||
"('WAREHOUSE_TALLY','platform:overseas-warehouse:dispatch:list')",
|
||||
"('WAREHOUSE_TALLY','platform:overseas-warehouse:outbound:list')",
|
||||
@@ -95,6 +98,7 @@ class Phase5DeliveryPermissionSqlContractTest {
|
||||
"('WAREHOUSE_TALLY','platform:overseas-warehouse:exception-order:process')",
|
||||
"('WAREHOUSE_TALLY','platform:overseas-warehouse:delivery:return')",
|
||||
"('WAREHOUSE_TALLY','platform:overseas-warehouse:delivery:status')",
|
||||
"('WAREHOUSE_TALLY','platform:overseas-warehouse:delivery:documents')",
|
||||
"('WAREHOUSE_TALLY','platform:overseas-warehouse:customer-visit:edit')",
|
||||
"('OVERSEAS_WAREHOUSE_PDA_OPERATOR','platform:overseas-warehouse:",
|
||||
"('CN_FIRSTLEG_PDA_OPERATOR','platform:overseas-warehouse:");
|
||||
@@ -110,7 +114,7 @@ class Phase5DeliveryPermissionSqlContractTest {
|
||||
"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 <> 15",
|
||||
"IF v_warehouse_operator_permission_count <> 16",
|
||||
"OR v_warehouse_tally_permission_count <> 5 THEN",
|
||||
"IF EXISTS (SELECT 1 FROM iam_role role",
|
||||
"AND UPPER(role.role_code)='PLATFORM_CUSTOM_ADMIN'",
|
||||
@@ -164,7 +168,7 @@ class Phase5DeliveryPermissionSqlContractTest {
|
||||
"海外排车、出库与派送权限",
|
||||
"测试和部署只执行固定入口,不再单独执行模块脚本",
|
||||
"排车列表/编辑/取消/转派送",
|
||||
"异常订单列表/创建/处理和客户回访列表/编辑十五项权限",
|
||||
"异常订单列表/创建/处理和客户回访列表/编辑十六项权限",
|
||||
"merchant:warehouse:delivery:list",
|
||||
"merchant:warehouse:delivery:create",
|
||||
"PDA 岗位不得获得上述后台权限");
|
||||
|
||||
+48
-14
@@ -37,12 +37,12 @@ class Phase5DeliveryPermissionSqlExecutionTest {
|
||||
assertThat(customAdministratorViolations(connection)).isEqualTo(1);
|
||||
|
||||
executeGrant(connection);
|
||||
assertThat(grantCount(connection, 11L)).isEqualTo(15);
|
||||
assertThat(grantCount(connection, 11L)).isEqualTo(16);
|
||||
assertThat(customAdministratorViolations(connection)).isZero();
|
||||
|
||||
executeGrant(connection);
|
||||
assertThat(grantCount(connection, 11L)).isEqualTo(15);
|
||||
assertThat(totalGrantCount(connection)).isEqualTo(15);
|
||||
assertThat(grantCount(connection, 11L)).isEqualTo(16);
|
||||
assertThat(totalGrantCount(connection)).isEqualTo(16);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,14 +51,14 @@ class Phase5DeliveryPermissionSqlExecutionTest {
|
||||
try (Connection connection = database()) {
|
||||
insertRole(connection, 21L, "PLATFORM_CUSTOM_ADMIN");
|
||||
insertRole(connection, 22L, "Platform_Custom_Admin");
|
||||
grantFirstPermissions(connection, 21L, 15);
|
||||
grantFirstPermissions(connection, 22L, 14);
|
||||
grantFirstPermissions(connection, 21L, 16);
|
||||
grantFirstPermissions(connection, 22L, 15);
|
||||
|
||||
assertThat(customAdministratorViolations(connection)).isEqualTo(1);
|
||||
|
||||
executeGrant(connection);
|
||||
assertThat(grantCount(connection, 21L)).isEqualTo(15);
|
||||
assertThat(grantCount(connection, 22L)).isEqualTo(15);
|
||||
assertThat(grantCount(connection, 21L)).isEqualTo(16);
|
||||
assertThat(grantCount(connection, 22L)).isEqualTo(16);
|
||||
assertThat(customAdministratorViolations(connection)).isZero();
|
||||
}
|
||||
}
|
||||
@@ -68,16 +68,50 @@ class Phase5DeliveryPermissionSqlExecutionTest {
|
||||
try (Connection connection = database()) {
|
||||
insertRole(connection, 31L, "PLATFORM_CUSTOM_ADMIN");
|
||||
insertRole(connection, 32L, "platform_custom_admin");
|
||||
grantFirstPermissions(connection, 31L, 15);
|
||||
grantFirstPermissions(connection, 32L, 15);
|
||||
grantFirstPermissions(connection, 31L, 16);
|
||||
grantFirstPermissions(connection, 32L, 16);
|
||||
|
||||
assertThat(customAdministratorViolations(connection)).isZero();
|
||||
executeGrant(connection);
|
||||
executeGrant(connection);
|
||||
|
||||
assertThat(grantCount(connection, 31L)).isEqualTo(15);
|
||||
assertThat(grantCount(connection, 32L)).isEqualTo(15);
|
||||
assertThat(totalGrantCount(connection)).isEqualTo(30);
|
||||
assertThat(grantCount(connection, 31L)).isEqualTo(16);
|
||||
assertThat(grantCount(connection, 32L)).isEqualTo(16);
|
||||
assertThat(totalGrantCount(connection)).isEqualTo(32);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void administratorGrantDoesNotEscalateAReadOnlyRoleOrDisabledAdministrator() throws Exception {
|
||||
try (Connection connection = database()) {
|
||||
insertRole(connection, 41L, "PLATFORM_ADMIN");
|
||||
insertRole(connection, 42L, "WAREHOUSE_TALLY");
|
||||
insertRole(connection, 43L, "PLATFORM_CUSTOM_ADMIN");
|
||||
try (Statement statement = connection.createStatement()) {
|
||||
statement.executeUpdate("UPDATE iam_role SET status='INACTIVE' WHERE id=43");
|
||||
statement.executeUpdate("""
|
||||
INSERT INTO iam_role_permission
|
||||
SELECT 0, 42, id, CURRENT_TIMESTAMP FROM iam_permission
|
||||
WHERE permission_code='platform:overseas-warehouse:delivery:list'
|
||||
""");
|
||||
}
|
||||
|
||||
executeGrant(connection);
|
||||
executeGrant(connection);
|
||||
|
||||
assertThat(grantCount(connection, 41L)).isEqualTo(16);
|
||||
assertThat(grantCount(connection, 42L)).isEqualTo(1);
|
||||
assertThat(grantCount(connection, 43L)).isZero();
|
||||
try (Statement statement = connection.createStatement();
|
||||
ResultSet result = statement.executeQuery("""
|
||||
SELECT role_id FROM iam_role_permission grant_row
|
||||
JOIN iam_permission permission ON permission.id=grant_row.permission_id
|
||||
WHERE permission.permission_code='platform:overseas-warehouse:delivery:documents'
|
||||
""")) {
|
||||
assertThat(result.next()).isTrue();
|
||||
assertThat(result.getLong(1)).isEqualTo(41L);
|
||||
assertThat(result.next()).isFalse();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,7 +216,7 @@ class Phase5DeliveryPermissionSqlExecutionTest {
|
||||
AND (SELECT COUNT(*) FROM iam_role_permission grant_row
|
||||
JOIN iam_permission permission ON permission.id=grant_row.permission_id
|
||||
WHERE grant_row.role_id=role.id AND grant_row.tenant_id=role.tenant_id
|
||||
AND permission.permission_code IN (%s)) <> 15
|
||||
AND permission.permission_code IN (%s)) <> 16
|
||||
""".formatted(placeholders);
|
||||
try (PreparedStatement query = connection.prepareStatement(sql)) {
|
||||
int index = 1;
|
||||
@@ -209,7 +243,7 @@ class Phase5DeliveryPermissionSqlExecutionTest {
|
||||
LinkedHashSet<String> codes = new LinkedHashSet<>();
|
||||
String grant = administratorGrantSql();
|
||||
PLATFORM_CODE.matcher(grant).results().map(result -> result.group()).forEach(codes::add);
|
||||
assertThat(codes).hasSize(15);
|
||||
assertThat(codes).hasSize(16);
|
||||
return codes;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
| 功能 | 入口 |
|
||||
|---|---|
|
||||
| 内部模块化基线与依赖分析 | `docs/lastleg-internal-modularization-baseline.md`、`scripts/analyze-lastleg-dependencies.mjs`、`scripts/analyze-lastleg-dependencies.test.mjs`、`src/test/resources/architecture/lastleg-slice-dependencies.json`;只约束本服务内部包依赖,不改变部署单元或数据库边界 |
|
||||
| 事件接收、平台订单列表/详情/导出、柜级进度、目的仓调整 | `src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java`;`/api/platform/v1/last-leg/orders/container-summaries` 按 `sourceContainerBookingId` 服务端聚合全柜订单、入仓码和有效计划,分页前排除全部入仓码均已生成有效独立计划的柜,`/{sourceContainerBookingId}` 返回柜内全部订单/入仓码/计划状态且不受订单分页截断;`plannedArrivalAt` 只取装柜完成事件 `estimatedWarehouseArrivalAt` 冻结到 `ll_order.estimated_warehouse_at` 的正式事实,按其升序、null 最后、稳定柜 ID 排序;目的仓调整仅接受国内 Warehouse Service 平台公共 `ACTIVE + FBA` 主档,写入前由 `service/FbaWarehouseValidator.java` 经内部只读目录校验 ID/编码/名称一致 |
|
||||
| 事件接收、平台订单列表/详情/导出、柜级进度、目的仓调整 | `src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java`;`/api/platform/v1/last-leg/orders/container-summaries` 按 `sourceContainerBookingId` 服务端聚合全柜订单、入仓码和有效计划并分页返回全部稳定来源柜,已完成计划转换的柜仍保留用于接柜仓查询与修改,生成资格只由 `allPlansReady/pendingPlanCount` 表达;`/{sourceContainerBookingId}` 返回柜内全部订单/入仓码/计划状态且不受订单分页截断;`plannedArrivalAt` 只取装柜完成事件 `estimatedWarehouseArrivalAt` 冻结到 `ll_order.estimated_warehouse_at` 的正式事实,按其升序、null 最后、稳定柜 ID 排序;目的仓调整仅接受国内 Warehouse Service 平台公共 `ACTIVE + FBA` 主档,写入前由 `service/FbaWarehouseValidator.java` 经内部只读目录校验 ID/编码/名称一致 |
|
||||
| 尾程订单完整计划、暂扣与人工节点 | `controller/LastlegOrderController.java`、`service/LastlegOrderCommandService.java`、`inboundplan/service/InboundPlanService.java`、`dto/LastlegOrderCommandDtos.java`;单票计划写入校验订单版本、客户范围、订单冻结入仓码、稳定接柜仓 ID、当前仓库主档与幂等键;接柜仓编码/名称以该 ID 当前目录快照为准,不要求订单残留旧展示快照。暂扣/解除写不可变历史并发布 `InboundOrderDetainedOutboxService`,已拣货、已发运或已有出库单时禁止新增暂扣;暂扣期间不得进入排车或出库。人工节点必须一次提交固定八行完整快照且至少一个时间,首次保存后永久使用 `MANUAL` 来源,不得回退柜级节点或反向修改头程船节点 |
|
||||
| 客户尾程订单列表与详情 | `controller/MerchantLastlegOrderController.java`;客户范围只取认证身份,稳定类型/状态见 `model/LastlegOrderType.java`、`LastlegOrderStatus.java` |
|
||||
| 装柜完成事件消费 | `service/LoadingCompletedConsumer.java`;当前生产信封为 `FIRST_LEG_LOADING_PLAN_COMPLETED` V2,消费端冻结 `destinationType`:FBA 可按箱数且不生成逐箱作业账,OVERSEAS 必须逐箱校验并生成箱级事实;历史缺字段事件仅按明确 FBA 单号或目的仓与接柜仓一致性安全推断,歧义直接拒绝 |
|
||||
@@ -23,10 +23,10 @@
|
||||
| 独立尾程入仓单快照 | `entity/LastlegInboundOrder.java`、`mapper/LastlegInboundOrderMapper.java`;按客户与来源入仓标识冻结 inboundCode、来源/根订单及目的仓,不以尾程订单行代替入仓单聚合 |
|
||||
| 内部令牌保护 | `security/InternalServiceTokenGuard.java` |
|
||||
| IAM 海外安全投影与实时校验 | 内部入口 `security/controller/LastlegSecurityController.java`,固定 `/internal/lastleg/security/v1/events`、`/validate` 且复用 `InternalServiceTokenGuard`;投影/校验 `security/service/`,DTO `security/dto/`,表固定 `ll_security_event_inbox`、`ll_security_projection`;MySQL 投影缺失、落后或不可用一律 fail-closed,Redis 仅为提交后可重建镜像;业务切片不得直接依赖投影内部类,边界由 `SecurityProjectionArchitectureTest` 与 Node 合同锁定 |
|
||||
| 入仓计划分页、转换与详情 | 平台 `inboundplan/controller/InboundPlanController.java`,客户只读 `inboundplan/controller/MerchantInboundPlanController.java`;共用 `inboundplan/dto/InboundPlanRequests.java`、`InboundPlanListResponse.java`、`inboundplan/mapper/InboundPlanQueryMapper.java`、`inboundplan/service/InboundPlanService.java`;客户入口固定 `/api/merchant/v1/last-leg/inbound-plans`,范围只取 Token;整柜转换固定为 `/convert-container`,锁定同一 `sourceContainerBookingId` 后每票独立生成计划,整批原子且幂等 |
|
||||
| 入仓计划分页、柜级预览、转换与详情 | 平台 `inboundplan/controller/InboundPlanController.java`,客户只读 `inboundplan/controller/MerchantInboundPlanController.java`;共用 `inboundplan/dto/InboundPlanRequests.java`、`InboundPlanListResponse.java`、`ContainerInboundPlanPreviewResponse.java`、`inboundplan/mapper/InboundPlanQueryMapper.java`、`inboundplan/service/InboundPlanService.java`;客户入口固定 `/api/merchant/v1/last-leg/inbound-plans`,范围只取 Token。`/container-preview` 按全柜返回 FBA/一件代发/私人地址分组、托数、就绪、阻断项及 `snapshotVersion/snapshotHash`;`ContainerInboundPlanConversionCoordinator.java` 用唯一柜级 Saga 锁柜重算,先经 `WarehouseResourceGateway` 批量预留真实托盘,再本地原子生成剩余独立计划并确认资源,本地失败只补偿未确认资源,远端确认失败的重放只重试确认,同柜不同幂等键也不得产生第二批资源 |
|
||||
| 托盘分配算法 | `inboundplan/service/PalletAllocator.java` |
|
||||
| FBA 预约创建、详情与编辑 | `InboundPlanController.java` 的 `/fba-appointments/**`、`inboundplan/service/InboundFbaAppointmentService.java`、`BatchFbaAppointmentRequest.java`、`UpdateFbaAppointmentRequest.java` |
|
||||
| PDA 入仓、实收、上架与完成 | `inboundplan/pda/controller/ReceivingContainerController.java`、`inboundplan/pda/service/ReceivingContainerService.java`、内部编排 `InboundPdaWorkflowService.java`;公网入口固定为 `/api/pda/v1/last-leg/receiving-containers/**`,`/warehouse-profile` 只按 Token 绑定仓解析共享仓库中文名称与编码,旧 planNo/allocationId Controller 已移除 |
|
||||
| PDA 入仓、实收、上架与完成 | `inboundplan/pda/controller/ReceivingContainerController.java`、`inboundplan/pda/service/ReceivingContainerService.java`、内部编排 `InboundPdaWorkflowService.java`;公网入口固定为 `/api/pda/v1/last-leg/receiving-containers/**`,`/warehouse-profile` 只按 Token 绑定仓解析共享仓库中文名称与编码,旧 planNo/allocationId Controller 已移除。多计划同柜返回计划级 `assigned/mergeAllowed/urgent/mergeHint`,合托只作现场可选提示;首扫确认已预留的真实 pallet/location/lock 绑定,不得重新分配计划资源 |
|
||||
| PDA 卸柜照片上传、归属校验与预览 | `ReceivingContainerController.java` 的 `/current/unload-photos/files`、`/current/unload-photos/files/{fileId}/download`,网关 `inboundplan/pda/gateway/UnloadPhotoFileGateway.java`、`HttpUnloadPhotoFileGateway.java`;File 类型固定 `OVERSEAS_UNLOAD_PHOTO`,下载只允许当前会话已签收的 fileId 并返回 `no-store` |
|
||||
| 卸柜照片与海外卸柜完成 Outbox | `ReceivingContainerController.java`、`ReceivingContainerService.java`、`inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`、`entity/LastlegEventOutbox.java`、`mapper/LastlegEventOutboxMapper.java`;多计划同柜只能在全柜完成后调用 `publishContainerCompleted`,按来源柜预订 ID 幂等发布一次,完成时刻与拍照时刻分离;事件模型见 `../common-event-contract/AGENTS.md` |
|
||||
| 拆柜打托成本与客户应收分摊 | `inboundplan/service/DestuffingPalletCostJob.java`、`DestuffingPalletCostService.java`;仅全柜所有 PDA 入仓任务完成后执行,按实物托盘单价汇总并按客户实收 CBM 分摊,尾差落最后一位客户;每份分摊发布 `OVERSEAS_CONTAINER_FEE/DESTUFF_PALLET` 应收费用来源,不直接改国内钱包 |
|
||||
@@ -43,6 +43,7 @@
|
||||
| 一件代发接收、SKU/箱唛库存 | 内部接收入口 `dropship/controller/DropshipOrderController.java` 的 `/internal/v1/lastleg/dropship/receive`,必须经 `InternalServiceTokenGuard`;版本化契约 `src/main/resources/openapi/lastleg-internal-dropship-v1.yaml`;处理 `dropship/service/DropshipOrderService.java`、`dropship/entity/`、`dropship/mapper/` |
|
||||
| 货物废弃、待审锁箱、审批扣减 | `discard/controller/CargoDiscardController.java`、`discard/service/CargoDiscardService.java` |
|
||||
| 客户拜访、派送状态与签收附件 | `customervisit/controller/CustomerVisitController.java`、`customervisit/service/CustomerVisitService.java`、`delivery/model/DeliveryStatusPolicy.java`;派送单返回服务端 `allowedActions/version`,状态只允许单调迁移,同状态幂等,完成后生成回访记录 |
|
||||
| 派送三类资料、摘要与历史证据 | `delivery/controller/DeliveryDocumentController.java`、`delivery/service/DeliveryDocumentService.java`、`delivery/gateway/DeliveryDocumentFileGateway.java`、`src/main/resources/openapi/delivery-documents-v1.yaml`;固定装车照片 `LOADING_PHOTO`、BOL文件(含签名)`BOL`、签收文件(POD签收/快递签收)`RECEIPT`,按已保存类别计数 `x/3`。每类0至20文件,文件精确绑定客户、派送单和类别;全量保存共用派送版本并记录不可变历史,替换删除仅解绑,未保存上传不计数、不授权下载,不增加派送完成门禁或签名识别 |
|
||||
| 异常订单与派送退回入仓 | `exceptionorder/controller/ExceptionOrderController.java`、`exceptionorder/service/ExceptionOrderService.java`、`exceptionorder/gateway/ExceptionEvidenceFileGateway.java`、`inventory/command/service/InventoryCommandService.java`;证据固定按客户、异常单 ID 和 `OVERSEAS_EXCEPTION_EVIDENCE` 校验。退回候选按原出库拣货事实冻结,不接受自由 SKU 或数量;异常按 `PENDING→PROCESSING→COMPLETED`(待处理可取消)、版本与幂等键流转。退回海外仓须把实收按原出库来源恢复到服务端校验的真实托盘/库位并写 `RETURN_STOCK_IN` 流水,库存恢复、异常完成与派送 `RETURNED` 在同一事务完成 |
|
||||
| 海外采购、客户下单、采购审核、客户/采购仓候选与操作流水 | `procurement/controller/`、`procurement/service/OverseasProcurementService.java`;客户入口要求 `merchant:cross-border:procurement` 且只取 Token 客户范围;OpenAPI `src/main/resources/openapi/overseas-business-v1.yaml` |
|
||||
| 海外业务资金指令与结果 | `wallet/WalletCommandOutboxService.java`、`WalletCommandCompletedConsumer.java`、`WalletResultHandler.java`;采购和换标通过 `WALLET_COMMAND_REQUESTED` 请求国内 Finance 冻结、结算或释放,并以 `WALLET_COMMAND_COMPLETED` 幂等回写业务资金状态;不得跨区同步调用 Finance 钱包或在 Lastleg 自建余额账本 |
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# 派送节点交付与六按钮现状核查
|
||||
|
||||
范围:测试项目当前 `test` 分支;后端16派送业务。仅源码和离线测试,不代表真实环境已部署或人工验收通过。
|
||||
|
||||
## 状态错误结论
|
||||
|
||||
用户请求仅提交 `status=DELIVERING, remark=123`,缺少必传 `expectedVersion`,因此返回参数错误。后端 `CustomerVisitRequests.DeliveryStatus` 保留版本必填;列表/详情 `DeliveryRow` 已返回 `version/allowedActions`。当前 Admin 的编辑状态选项将 `DELIVERING` 标为“派送中”,`DELIVERED` 标为“派送完成”,提交代码遗漏版本;不能据用户口述把 DELIVERING 改成完成。
|
||||
|
||||
状态命令保持 `{status,expectedVersion,remark}`,不新增幂等头。`START_DELIVERY→DELIVERING` 记录首次派送时间;`COMPLETE_DELIVERY→DELIVERED` 记录签收时间、更新尾程子单状态并生成待回访。相同状态重放成功,不重复状态写入;非法回退及版本冲突拒绝。未新增资料齐全、签名识别或电子签署门禁。
|
||||
|
||||
## 三类资料最终契约
|
||||
|
||||
完整机器契约:`src/main/resources/openapi/delivery-documents-v1.yaml`。七类方案已撤销,旧类别不接受。
|
||||
|
||||
| 顺序与业务类别 | 显示名称 | File类型 |
|
||||
|---|---|---|
|
||||
| LOADING_PHOTO | 装车照片 | DELIVERY_LOADING_PHOTO |
|
||||
| BOL | BOL文件(含签名) | DELIVERY_BOL |
|
||||
| RECEIPT | 签收文件(POD签收/快递签收) | DELIVERY_RECEIPT |
|
||||
|
||||
每类0至20个文件,沿用 File 任意格式且单文件非空、不超过50MiB规则,按有已保存文件的类别计数0/3至3/3。POD与快递签收合为同一类别,文件数量不改变类别计数。
|
||||
|
||||
- 读取/下载权限:`platform:overseas-warehouse:delivery:list`;上传/保存权限:`platform:overseas-warehouse:delivery:documents`。
|
||||
- GET/PUT `/delivery-orders/{deliveryNo}/documents?customerId=...`;PUT全量保存 `{expectedVersion,idempotencyKey,bolNo,documents:[{category,fileIds}]}`,遗漏类别清空。
|
||||
- 同一派送锁内先检查版本与全部文件归属,再原子写当前快照、增加派送版本、写不可变历史及操作日志。文件保存使用 File 的真实文件名、格式、大小、创建时间,不信任前端元数据。
|
||||
- 上传不绑定、不修改版本或摘要。未保存/取消弹框不落绑定。替换和删除仅改当前引用,已保存历史和物理对象保留。
|
||||
- 当前下载仅允许当前绑定文件;指定 `revisionVersion` 仅可下载该派送单对应历史版本已绑定文件。两者均复核客户、派送内部ID、类别和fileId。
|
||||
- BOL号独立保存,未建立与其他单据的权威关联,不按相同BOL文本或运单号复用其他文件。
|
||||
- 列表和详情行新增 `bolNo/documentSummary`,摘要从本地保存快照读取,不逐文件调用File。
|
||||
|
||||
## 六按钮现状
|
||||
|
||||
| 按钮 | 当前实现与权限 | 判定与边界 |
|
||||
|---|---|---|
|
||||
| 编辑节点 | `CustomerVisitController` 的 PUT `/delivery-orders/{deliveryNo}/status`、POST `/complete`;`delivery:status`;版本与状态机 | 已实现状态命令,前端漏版本交Admin修复。当前策略只校验派送状态,未联查出库实物门禁;若“编辑节点”意为补录节点时间或修改完成前置规则,需进一步确认,不自动实现 |
|
||||
| 打印BOL | 派送Controller未发现生成打印接口;Print现有海运提单确认件不能直接证明派送BOL实现 | 未实现派送专属生成链。上传已签名BOL及下载不等于生成打印;模板、字段、签名来源需确认 |
|
||||
| 详情 | GET `/delivery-orders/{deliveryNo}?customerId=...`,`delivery:list`,返回派送行/货物/费用/操作历史 | 已实现,真实环境待验收 |
|
||||
| 取消 | 通用状态仅允许WAIT_DELIVERY→CANCELLED,更新派送状态/版本/日志。另出库 `/outbound-orders/{outboundNo}/cancel` 属出库专项,校验未执行并取消关联派送、PDA任务和明细 | 部分实现。单改派送状态不等于撤销履约关联;出库取消还会将尾程主状态写RECEIVED,已报测试总控。需确认派送取消与出库/排车的业务关系,不能擅自互相替代 |
|
||||
| 编辑车队 | 派送创建保存carrier/driver/vehicle快照,未发现派送单独立维护接口 | 未实现;可改状态、是否联动排车和来源主档规则需确认 |
|
||||
| 标记退货 | `ExceptionOrderController` `/return-inbound-options`、`/mark-return`;`delivery:return`。从原出库明细选择,冻结可退上限,幂等生成异常;异常处理使用version/幂等与证据 | 已实现后端。退回海外仓必须先按来源恢复实收库存,再同事务完成异常并置派送RETURNED;不能直接改派送状态代替库存。Admin旧自由SKU入参交其收敛 |
|
||||
|
||||
## 验证与部署前提
|
||||
|
||||
- 本次定向69项通过:包含资料版本/重放/异请求冲突、客户/单据/类别/fileId错配、文件计数与保存后重读、未保存下载拒绝、历史解绑下载、旧枚举拒绝、嵌套校验、权限分离、状态开始/完成/重放、现有出库/退货和SQL合同;只读用户的详情/重放/历史不返回管理动作,按当前授权而非历史权限裁剪。
|
||||
- Mapper扫描通过。AGENTS索引检查有两项既存柜费契约缺口:金额守恒、单行拒绝处理分摊组,未修改该域。
|
||||
- 模块基线和获准的海外统一入口同步 `ll_delivery_order.bol_no/documents_json`、`ll_delivery_document_revision`。只维护当前初始化定义,没有旧类别迁移或清理脚本;既有运行库应用方式交测试总控按环境授权处理。
|
||||
- 单元测试验证保存前全部校验无部分写入和快照重读;真实MySQL事务故障注入、跨服务真实上传下载、页面刷新与人工截图尚未验收。
|
||||
- 配套最终三类File提交 `81b72992` 和IAM权限 `cde8acf1` 已进入 `test`;不能使用早期七类File提交作为最终类别合同。
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
package com.baiyue.lastleg.delivery.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos.*;
|
||||
import com.baiyue.lastleg.delivery.model.DeliveryDocumentCategory;
|
||||
import com.baiyue.lastleg.delivery.service.DeliveryDocumentService;
|
||||
import com.baiyue.lastleg.security.OverseasPermissions;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.*;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/platform/v1/last-leg/delivery-orders/{deliveryNo}/documents")
|
||||
public class DeliveryDocumentController {
|
||||
@Resource private DeliveryDocumentService service;
|
||||
@GetMapping
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.DELIVERY_LIST + "')")
|
||||
public ApiResponse<Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String deliveryNo, @RequestParam String customerId) {
|
||||
scope(user, customerId); return ApiResponse.success(authorized(service.detail(customerId, deliveryNo)));
|
||||
}
|
||||
@PutMapping
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.DELIVERY_DOCUMENTS + "')")
|
||||
public ApiResponse<Detail> save(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String deliveryNo, @RequestParam String customerId, @Valid @RequestBody Save request) {
|
||||
scope(user, customerId); return ApiResponse.success(authorized(service.save(customerId, deliveryNo, operator(user), request)));
|
||||
}
|
||||
@PostMapping(value="/{category}/files", consumes=MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.DELIVERY_DOCUMENTS + "')")
|
||||
public ApiResponse<File> upload(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String deliveryNo, @RequestParam String customerId,
|
||||
@PathVariable DeliveryDocumentCategory category, @RequestPart("file") MultipartFile file) {
|
||||
scope(user, customerId); return ApiResponse.success(service.upload(customerId, deliveryNo, category, operator(user), file));
|
||||
}
|
||||
@GetMapping("/history")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.DELIVERY_LIST + "')")
|
||||
public ApiResponse<List<History>> history(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String deliveryNo, @RequestParam String customerId) {
|
||||
scope(user, customerId); return ApiResponse.success(service.history(customerId, deliveryNo).stream()
|
||||
.map(h -> new History(h.version(), h.operatorId(), h.savedAt(), authorized(h.snapshot()))).toList());
|
||||
}
|
||||
@GetMapping("/{category}/files/{fileId}/download")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.DELIVERY_LIST + "')")
|
||||
public ResponseEntity<byte[]> download(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String deliveryNo, @RequestParam String customerId,
|
||||
@PathVariable DeliveryDocumentCategory category, @PathVariable long fileId,
|
||||
@RequestParam(required=false) Long revisionVersion) {
|
||||
scope(user, customerId);
|
||||
DeliveryDocumentService.Download downloaded = service.download(customerId, deliveryNo, category, fileId, revisionVersion);
|
||||
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
|
||||
.contentType(MediaType.APPLICATION_OCTET_STREAM)
|
||||
.header("X-Content-Type-Options", "nosniff")
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment()
|
||||
.filename(downloaded.file().fileName(), StandardCharsets.UTF_8).build().toString())
|
||||
.body(downloaded.content());
|
||||
}
|
||||
private static Detail authorized(Detail detail) {
|
||||
var authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
boolean canManage = authentication != null && authentication.isAuthenticated()
|
||||
&& authentication.getAuthorities().stream().anyMatch(a -> OverseasPermissions.DELIVERY_DOCUMENTS.equals(a.getAuthority()));
|
||||
return new Detail(detail.deliveryNo(), detail.customerId(), detail.version(), detail.bolNo(),
|
||||
detail.uploadedCategoryCount(), detail.totalCategoryCount(), detail.categories(),
|
||||
canManage ? detail.allowedActions() : List.of(), detail.replayed());
|
||||
}
|
||||
private static void scope(AuthenticatedUser user, String customerId) {
|
||||
if (user == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
|
||||
if (!"PLATFORM".equals(user.appCode()) || !Long.valueOf(0).equals(user.tenantId()))
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "仅平台可管理派送资料");
|
||||
try { if (Long.parseLong(customerId) <= 0) throw new NumberFormatException(); }
|
||||
catch (NumberFormatException e) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "请指定有效客户"); }
|
||||
}
|
||||
private static String operator(AuthenticatedUser user) { return user.userId() == null ? user.username() : user.userId().toString(); }
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package com.baiyue.lastleg.delivery.dto;
|
||||
|
||||
import com.baiyue.lastleg.delivery.model.DeliveryDocumentCategory;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.*;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
public final class DeliveryDocumentDtos {
|
||||
private DeliveryDocumentDtos() { }
|
||||
public record Save(@NotNull @Min(0) Long expectedVersion,
|
||||
@NotBlank @Size(max=128) String idempotencyKey,
|
||||
@Size(max=128) String bolNo,
|
||||
@NotNull @Size(max=3) List<@NotNull @Valid Binding> documents) { }
|
||||
public record Binding(@NotNull DeliveryDocumentCategory category,
|
||||
@NotNull @Size(max=20) List<@NotNull @Min(1) Long> fileIds) { }
|
||||
public record File(Long fileId, String fileName, String contentType, long sizeBytes,
|
||||
LocalDateTime uploadedAt) { }
|
||||
public record Category(DeliveryDocumentCategory category, String status, int fileCount, List<File> files) { }
|
||||
public record CategorySummary(DeliveryDocumentCategory category, String status, int fileCount) { }
|
||||
public record Summary(int uploadedCategoryCount, int totalCategoryCount, List<CategorySummary> categories) { }
|
||||
public record Detail(String deliveryNo, String customerId, long version, String bolNo,
|
||||
int uploadedCategoryCount, int totalCategoryCount, List<Category> categories,
|
||||
List<String> allowedActions, boolean replayed) { }
|
||||
public record History(long version, String operatorId, LocalDateTime savedAt, Detail snapshot) { }
|
||||
}
|
||||
+2
-1
@@ -24,7 +24,8 @@ public final class DeliveryResponses {
|
||||
int packageCount, BigDecimal totalWeightKg, BigDecimal totalVolumeCbm,
|
||||
LocalDateTime plannedDeliveryAt, LocalDateTime dispatchedAt,
|
||||
LocalDateTime expectedArrivalAt, LocalDateTime signedAt,
|
||||
String remark, LocalDateTime createdAt, long version, List<String> allowedActions) {}
|
||||
String remark, LocalDateTime createdAt, long version, List<String> allowedActions,
|
||||
String bolNo, DeliveryDocumentDtos.Summary documentSummary) {}
|
||||
|
||||
public record DeliverySku(String inboundCode, String customerName, String destinationWarehouseCode,
|
||||
String receivingAddress, String shipmentNo, String fbaTrackingNo,
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package com.baiyue.lastleg.delivery.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("ll_delivery_document_revision")
|
||||
public class DeliveryDocumentRevision {
|
||||
@TableId(type=IdType.ASSIGN_ID) private Long id;
|
||||
private String customerId;
|
||||
private Long deliveryOrderId;
|
||||
private Long version;
|
||||
private String idempotencyKey;
|
||||
private String requestHash;
|
||||
private String snapshotJson;
|
||||
private String operatorId;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@@ -25,6 +25,8 @@ public class DeliveryOrder {
|
||||
private String customerName;
|
||||
private String status;
|
||||
private String waybillNo;
|
||||
private String bolNo;
|
||||
private String documentsJson;
|
||||
private String deliveryMethod;
|
||||
private String carrierName;
|
||||
private String pickupMethod;
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package com.baiyue.lastleg.delivery.gateway;
|
||||
|
||||
import com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos.File;
|
||||
import com.baiyue.lastleg.delivery.model.DeliveryDocumentCategory;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
public interface DeliveryDocumentFileGateway {
|
||||
File upload(String customerId, long deliveryId, DeliveryDocumentCategory category, String operatorId, MultipartFile file);
|
||||
File metadata(String customerId, long deliveryId, DeliveryDocumentCategory category, long fileId);
|
||||
byte[] download(String customerId, long deliveryId, DeliveryDocumentCategory category, long fileId);
|
||||
}
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
package com.baiyue.lastleg.delivery.gateway;
|
||||
|
||||
import com.baiyue.lastleg.config.LastlegRestClientFactory;
|
||||
import com.baiyue.lastleg.config.LastlegRestClientProperties;
|
||||
import com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos.File;
|
||||
import com.baiyue.lastleg.delivery.model.DeliveryDocumentCategory;
|
||||
import jakarta.annotation.*;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
import org.springframework.http.*;
|
||||
import org.springframework.http.client.MultipartBodyBuilder;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestClient;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import java.io.IOException;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Objects;
|
||||
|
||||
@Component
|
||||
public class HttpDeliveryDocumentFileGateway implements DeliveryDocumentFileGateway {
|
||||
private static final String PATH = "/internal/file/v1/business-files/{customer}/{type}/{delivery}";
|
||||
@Resource private RestClient.Builder restClientBuilder;
|
||||
@Resource private LastlegRestClientFactory restClientFactory;
|
||||
@Resource private LastlegRestClientProperties restClientProperties;
|
||||
@Value("${baiyue.clients.file.url:http://127.0.0.1:9205}") private String baseUrl;
|
||||
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
|
||||
private RestClient client;
|
||||
@PostConstruct void initializeClient() {
|
||||
client = restClientFactory.build(restClientBuilder, baseUrl, restClientProperties.getCustomerVisitFile());
|
||||
}
|
||||
@Override public File upload(String customerId, long deliveryId, DeliveryDocumentCategory category,
|
||||
String operatorId, MultipartFile file) {
|
||||
if (file == null || file.isEmpty() || file.getSize() > 52428800L)
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "派送资料须为非空且不超过50MiB的文件");
|
||||
MultipartBodyBuilder body = new MultipartBodyBuilder();
|
||||
try {
|
||||
body.part("file", new ByteArrayResource(file.getBytes()) {
|
||||
@Override public String getFilename() { return file.getOriginalFilename(); }
|
||||
}).contentType(file.getContentType() == null ? MediaType.APPLICATION_OCTET_STREAM
|
||||
: MediaType.parseMediaType(file.getContentType()));
|
||||
} catch (IOException e) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "无法读取派送资料", e); }
|
||||
Envelope response = client.post().uri(PATH, customerId, category.businessType(), deliveryId)
|
||||
.header("X-Internal-Service-Token", internalToken).header("X-Operator-Id", operatorId)
|
||||
.contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve()
|
||||
.onStatus(HttpStatusCode::isError, (request, result) -> { throw failure(result.getStatusCode()); })
|
||||
.body(Envelope.class);
|
||||
return verified(response, customerId, deliveryId, category, null);
|
||||
}
|
||||
@Override public File metadata(String customerId, long deliveryId, DeliveryDocumentCategory category, long fileId) {
|
||||
Envelope response = client.get().uri(PATH + "/{file}", customerId, category.businessType(), deliveryId, fileId)
|
||||
.header("X-Internal-Service-Token", internalToken).retrieve()
|
||||
.onStatus(HttpStatusCode::isError, (request, result) -> { throw failure(result.getStatusCode()); })
|
||||
.body(Envelope.class);
|
||||
return verified(response, customerId, deliveryId, category, fileId);
|
||||
}
|
||||
@Override public byte[] download(String customerId, long deliveryId, DeliveryDocumentCategory category, long fileId) {
|
||||
return client.get().uri(PATH + "/{file}/content", customerId, category.businessType(), deliveryId, fileId)
|
||||
.header("X-Internal-Service-Token", internalToken).retrieve()
|
||||
.onStatus(HttpStatusCode::isError, (request, result) -> { throw failure(result.getStatusCode()); })
|
||||
.body(byte[].class);
|
||||
}
|
||||
private static File verified(Envelope response, String customerId, long deliveryId,
|
||||
DeliveryDocumentCategory category, Long expectedFileId) {
|
||||
if (response == null) throw failure(HttpStatus.SERVICE_UNAVAILABLE);
|
||||
if (!"200".equals(response.code())) throw new ResponseStatusException(
|
||||
response.code() != null && response.code().startsWith("A04") ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE,
|
||||
"派送资料文件不可用或归属校验失败");
|
||||
Metadata file = response.data();
|
||||
if (file == null || file.fileId() == null || file.fileId() <= 0 || file.createdAt() == null
|
||||
|| file.fileName() == null || file.sizeBytes() == null || file.sizeBytes() <= 0
|
||||
|| file.sizeBytes() > 52428800L || !Objects.equals(String.valueOf(file.tenantId()), customerId)
|
||||
|| !category.businessType().equals(file.businessType()) || !String.valueOf(deliveryId).equals(file.businessId())
|
||||
|| (expectedFileId != null && !expectedFileId.equals(file.fileId())))
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "文件不属于当前客户、派送单或资料类别");
|
||||
return new File(file.fileId(), file.fileName(), file.contentType(), file.sizeBytes(), file.createdAt());
|
||||
}
|
||||
private static ResponseStatusException failure(HttpStatusCode status) {
|
||||
return new ResponseStatusException(status.is4xxClientError() ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE,
|
||||
"派送资料文件请求失败");
|
||||
}
|
||||
private record Envelope(String code, String message, Metadata data) { }
|
||||
private record Metadata(Long fileId, Long tenantId, String businessType, String businessId,
|
||||
String fileName, String contentType, Long sizeBytes, LocalDateTime createdAt) { }
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package com.baiyue.lastleg.delivery.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.lastleg.delivery.entity.DeliveryDocumentRevision;
|
||||
import org.apache.ibatis.annotations.*;
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface DeliveryDocumentRevisionMapper extends BaseMapper<DeliveryDocumentRevision> {
|
||||
@Select("SELECT * FROM ll_delivery_document_revision WHERE customer_id=#{customerId} AND delivery_order_id=#{deliveryId} AND idempotency_key=#{key}")
|
||||
DeliveryDocumentRevision findCommand(@Param("customerId") String customerId, @Param("deliveryId") long deliveryId, @Param("key") String key);
|
||||
@Select("SELECT * FROM ll_delivery_document_revision WHERE customer_id=#{customerId} AND delivery_order_id=#{deliveryId} ORDER BY version DESC")
|
||||
List<DeliveryDocumentRevision> history(@Param("customerId") String customerId, @Param("deliveryId") long deliveryId);
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.baiyue.lastleg.delivery.model;
|
||||
|
||||
public enum DeliveryDocumentCategory {
|
||||
LOADING_PHOTO("DELIVERY_LOADING_PHOTO"), BOL("DELIVERY_BOL"), RECEIPT("DELIVERY_RECEIPT");
|
||||
|
||||
private final String businessType;
|
||||
DeliveryDocumentCategory(String businessType) { this.businessType = businessType; }
|
||||
public String businessType() { return businessType; }
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package com.baiyue.lastleg.delivery.model;
|
||||
|
||||
import com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos.*;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.util.*;
|
||||
|
||||
/** Saved metadata only; listing never calls File and never counts pending uploads. */
|
||||
public final class DeliveryDocumentSnapshot {
|
||||
private static final ObjectMapper JSON = new ObjectMapper().findAndRegisterModules();
|
||||
private DeliveryDocumentSnapshot() { }
|
||||
public static List<Category> categories(String json) {
|
||||
if (json == null || json.isBlank()) return Arrays.stream(DeliveryDocumentCategory.values())
|
||||
.map(category -> new Category(category, "MISSING", 0, List.of())).toList();
|
||||
try { return JSON.readValue(json, new TypeReference<List<Category>>() { }); }
|
||||
catch (JsonProcessingException e) { throw new IllegalStateException("派送资料快照损坏", e); }
|
||||
}
|
||||
public static Summary summary(String json) {
|
||||
List<Category> categories = categories(json);
|
||||
return new Summary((int) categories.stream().filter(c -> !c.files().isEmpty()).count(), 3,
|
||||
categories.stream().map(c -> new CategorySummary(c.category(), c.status(), c.fileCount())).toList());
|
||||
}
|
||||
}
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
package com.baiyue.lastleg.delivery.service;
|
||||
|
||||
import com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos;
|
||||
import com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos.*;
|
||||
import com.baiyue.lastleg.delivery.entity.*;
|
||||
import com.baiyue.lastleg.delivery.gateway.DeliveryDocumentFileGateway;
|
||||
import com.baiyue.lastleg.delivery.mapper.*;
|
||||
import com.baiyue.lastleg.delivery.model.*;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.*;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
|
||||
@Service
|
||||
public class DeliveryDocumentService {
|
||||
@Resource private DeliveryOrderMapper orders;
|
||||
@Resource private DeliveryDocumentRevisionMapper revisions;
|
||||
@Resource private DeliveryOperationMapper operations;
|
||||
@Resource private DeliveryDocumentFileGateway files;
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
|
||||
public Detail detail(String customerId, String deliveryNo) { return detail(require(customerId, deliveryNo), false); }
|
||||
|
||||
public DeliveryDocumentDtos.File upload(String customerId, String deliveryNo, DeliveryDocumentCategory category,
|
||||
String operatorId, MultipartFile file) {
|
||||
DeliveryOrder order = require(customerId, deliveryNo);
|
||||
return files.upload(customerId, order.getId(), category, operatorId, file);
|
||||
}
|
||||
|
||||
/** Lock the same aggregate as status commands, validate the whole set, then commit one snapshot. */
|
||||
@Transactional
|
||||
public Detail save(String customerId, String deliveryNo, String operatorId, Save request) {
|
||||
DeliveryOrder order = orders.lockByNo(customerId, deliveryNo);
|
||||
if (order == null) throw missing();
|
||||
String hash = hash(request);
|
||||
DeliveryDocumentRevision replay = revisions.findCommand(customerId, order.getId(), request.idempotencyKey());
|
||||
if (replay != null) {
|
||||
if (!hash.equals(replay.getRequestHash())) throw conflict("相同幂等键的派送资料内容不同");
|
||||
Detail saved = read(replay.getSnapshotJson());
|
||||
return new Detail(saved.deliveryNo(), saved.customerId(), saved.version(), saved.bolNo(),
|
||||
saved.uploadedCategoryCount(), 3, saved.categories(), saved.allowedActions(), true);
|
||||
}
|
||||
if (!Objects.equals(version(order), request.expectedVersion())) throw conflict("派送单版本已变化,请刷新后重试");
|
||||
Map<DeliveryDocumentCategory, List<DeliveryDocumentDtos.File>> bindings = new EnumMap<>(DeliveryDocumentCategory.class);
|
||||
Set<Long> unique = new HashSet<>();
|
||||
for (Binding binding : request.documents()) {
|
||||
if (bindings.containsKey(binding.category())) throw bad("资料类别不能重复");
|
||||
List<DeliveryDocumentDtos.File> metadata = new ArrayList<>();
|
||||
for (Long fileId : binding.fileIds()) {
|
||||
if (!unique.add(fileId)) throw bad("同一文件不能重复绑定");
|
||||
metadata.add(files.metadata(customerId, order.getId(), binding.category(), fileId));
|
||||
}
|
||||
bindings.put(binding.category(), List.copyOf(metadata));
|
||||
}
|
||||
List<Category> categories = Arrays.stream(DeliveryDocumentCategory.values()).map(category -> {
|
||||
List<DeliveryDocumentDtos.File> values = bindings.getOrDefault(category, List.of());
|
||||
return new Category(category, values.isEmpty() ? "MISSING" : "UPLOADED", values.size(), values);
|
||||
}).toList();
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
order.setBolNo(request.bolNo() == null ? "" : request.bolNo().trim());
|
||||
order.setDocumentsJson(write(categories));
|
||||
order.setVersion(version(order) + 1);
|
||||
order.setUpdatedAt(now);
|
||||
orders.updateById(order);
|
||||
Detail result = detail(order, false);
|
||||
DeliveryDocumentRevision revision = new DeliveryDocumentRevision();
|
||||
revision.setCustomerId(customerId); revision.setDeliveryOrderId(order.getId()); revision.setVersion(order.getVersion());
|
||||
revision.setIdempotencyKey(request.idempotencyKey()); revision.setRequestHash(hash); revision.setSnapshotJson(write(result));
|
||||
revision.setOperatorId(operatorId); revision.setCreatedAt(now); revisions.insert(revision);
|
||||
DeliveryOperation operation = new DeliveryOperation();
|
||||
operation.setCustomerId(customerId); operation.setDeliveryOrderId(order.getId()); operation.setOperatorId(operatorId);
|
||||
operation.setOperationNode("SAVE_DOCUMENTS"); operation.setResult("保存派送资料:" + result.uploadedCategoryCount() + "/3类");
|
||||
operation.setCreatedAt(now); operations.insert(operation);
|
||||
return result;
|
||||
}
|
||||
public List<History> history(String customerId, String deliveryNo) {
|
||||
DeliveryOrder order = require(customerId, deliveryNo);
|
||||
return revisions.history(customerId, order.getId()).stream().map(revision -> new History(revision.getVersion(),
|
||||
revision.getOperatorId(), revision.getCreatedAt(), read(revision.getSnapshotJson()))).toList();
|
||||
}
|
||||
public Download download(String customerId, String deliveryNo, DeliveryDocumentCategory category, long fileId, Long revisionVersion) {
|
||||
DeliveryOrder order = require(customerId, deliveryNo);
|
||||
Detail snapshot = detail(order, false);
|
||||
if (revisionVersion != null) {
|
||||
snapshot = revisions.history(customerId, order.getId()).stream().filter(r -> revisionVersion.equals(r.getVersion()))
|
||||
.findFirst().map(r -> read(r.getSnapshotJson())).orElseThrow(DeliveryDocumentService::missing);
|
||||
}
|
||||
DeliveryDocumentDtos.File file = snapshot.categories().stream().filter(c -> c.category() == category)
|
||||
.flatMap(c -> c.files().stream()).filter(f -> f.fileId() == fileId).findFirst().orElseThrow(DeliveryDocumentService::missing);
|
||||
files.metadata(customerId, order.getId(), category, fileId);
|
||||
return new Download(file, files.download(customerId, order.getId(), category, fileId));
|
||||
}
|
||||
public record Download(DeliveryDocumentDtos.File file, byte[] content) { }
|
||||
private DeliveryOrder require(String customerId, String deliveryNo) {
|
||||
DeliveryOrder order = orders.findByNo(customerId, deliveryNo);
|
||||
if (order == null) throw missing();
|
||||
return order;
|
||||
}
|
||||
private Detail detail(DeliveryOrder order, boolean replayed) {
|
||||
List<Category> categories = DeliveryDocumentSnapshot.categories(order.getDocumentsJson());
|
||||
return new Detail(order.getDeliveryNo(), order.getCustomerId(), version(order), order.getBolNo(),
|
||||
(int) categories.stream().filter(c -> !c.files().isEmpty()).count(), 3, categories, List.of("MANAGE_DOCUMENTS"), replayed);
|
||||
}
|
||||
private static long version(DeliveryOrder order) { return Objects.requireNonNullElse(order.getVersion(), 0L); }
|
||||
private String hash(Object value) {
|
||||
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(write(value).getBytes(StandardCharsets.UTF_8))); }
|
||||
catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); }
|
||||
}
|
||||
private String write(Object value) {
|
||||
try { return objectMapper.writeValueAsString(value); }
|
||||
catch (JsonProcessingException e) { throw new IllegalStateException("无法保存派送资料", e); }
|
||||
}
|
||||
private Detail read(String value) {
|
||||
try { return objectMapper.readValue(value, Detail.class); }
|
||||
catch (JsonProcessingException e) { throw new IllegalStateException("派送资料历史损坏", e); }
|
||||
}
|
||||
private static ResponseStatusException missing() { return new ResponseStatusException(HttpStatus.NOT_FOUND, "派送单或已绑定资料不存在"); }
|
||||
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
|
||||
private static ResponseStatusException bad(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
|
||||
}
|
||||
+1
-1
@@ -638,7 +638,7 @@ public class DeliveryTransferService {
|
||||
candidate.getInboundCode(), candidate.getSkuCode(), candidate.getProductName(),
|
||||
value(candidate.getAvailablePackages()));
|
||||
}
|
||||
private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getCustomerId(), d.getCustomerName(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getOverseasWarehouseName(), d.getStatus(), d.getWaybillNo(), d.getDeliveryMethod(), d.getCarrierName(), d.getPickupMethod(), d.getLoadingType(), d.getVehicleType(), d.getVehicleSize(), d.getVehiclePlate(), d.getDriverName(), d.getDriverPhone(), d.getAppointmentAccount(), d.getSellerName(), value(d.getPalletCount()), value(d.getPackageCount()), d.getTotalWeightKg(), d.getTotalVolumeCbm(), d.getPlannedDeliveryAt(), d.getDispatchedAt(), d.getExpectedArrivalAt(), d.getSignedAt(), d.getRemark(), d.getCreatedAt(), value(d.getVersion()), DeliveryStatusPolicy.allowedActions(d.getStatus())); }
|
||||
private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getCustomerId(), d.getCustomerName(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getOverseasWarehouseName(), d.getStatus(), d.getWaybillNo(), d.getDeliveryMethod(), d.getCarrierName(), d.getPickupMethod(), d.getLoadingType(), d.getVehicleType(), d.getVehicleSize(), d.getVehiclePlate(), d.getDriverName(), d.getDriverPhone(), d.getAppointmentAccount(), d.getSellerName(), value(d.getPalletCount()), value(d.getPackageCount()), d.getTotalWeightKg(), d.getTotalVolumeCbm(), d.getPlannedDeliveryAt(), d.getDispatchedAt(), d.getExpectedArrivalAt(), d.getSignedAt(), d.getRemark(), d.getCreatedAt(), value(d.getVersion()), DeliveryStatusPolicy.allowedActions(d.getStatus()), d.getBolNo(), com.baiyue.lastleg.delivery.model.DeliveryDocumentSnapshot.summary(d.getDocumentsJson())); }
|
||||
private DeliveryResponses.DeliverySku sku(LastlegOrderDetail d, LastlegOrder order) { return new DeliveryResponses.DeliverySku(order == null ? null : order.getInboundCode(), order == null ? null : order.getCustomerName(), d.getDestinationWarehouseCode(), null, d.getShipmentNo(), d.getFbaTrackingNo(), d.getSkuCode(), firstText(d.getProductNameZh(), d.getProductNameEn()), value(d.getPackageCount()), d.getGrossWeightKg(), d.getVolumeCbm()); }
|
||||
private DeliveryResponses.DeliveryFeeRow feeRow(DeliveryFee f) { return new DeliveryResponses.DeliveryFeeRow(f.getId(), f.getFeeDirection(), f.getFeeType(), f.getCurrency(), f.getAmount(), f.getRemark()); }
|
||||
private DeliveryResponses.DeliveryOperationRow operationRow(DeliveryOperation o) { return new DeliveryResponses.DeliveryOperationRow(o.getId(), o.getCreatedAt(), o.getOperationNode(), firstText(o.getOperatorName(), o.getOperatorId()), o.getResult()); }
|
||||
|
||||
@@ -22,6 +22,7 @@ public final class OverseasPermissions {
|
||||
public static final String DELIVERY_LIST = "platform:overseas-warehouse:delivery:list";
|
||||
public static final String DELIVERY_RETURN = "platform:overseas-warehouse:delivery:return";
|
||||
public static final String DELIVERY_STATUS = "platform:overseas-warehouse:delivery:status";
|
||||
public static final String DELIVERY_DOCUMENTS = "platform:overseas-warehouse:delivery:documents";
|
||||
public static final String DISPATCH_LIST = "platform:overseas-warehouse:dispatch:list";
|
||||
public static final String DISPATCH_EDIT = "platform:overseas-warehouse:dispatch:edit";
|
||||
public static final String DISPATCH_CANCEL = "platform:overseas-warehouse:dispatch:cancel";
|
||||
|
||||
@@ -260,6 +260,8 @@ CREATE TABLE IF NOT EXISTS ll_delivery_order (
|
||||
customer_name VARCHAR(255) NULL COMMENT '客户名称快照',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'WAIT_DELIVERY' COMMENT '派送状态:WAIT_DELIVERY待派送及后续正式状态',
|
||||
waybill_no VARCHAR(128) NULL COMMENT '承运运单号',
|
||||
bol_no VARCHAR(128) NULL COMMENT '派送资料BOL号,不作为文件归属标识',
|
||||
documents_json JSON NULL COMMENT '派送三类资料当前绑定及权威文件元数据快照',
|
||||
delivery_method VARCHAR(32) NULL COMMENT '派送方式',
|
||||
carrier_name VARCHAR(255) NULL COMMENT '派送承运商',
|
||||
pickup_method VARCHAR(64) NULL COMMENT '提货方式',
|
||||
@@ -306,6 +308,21 @@ CREATE TABLE IF NOT EXISTS ll_delivery_fee (
|
||||
amount DECIMAL(18,2) NOT NULL COMMENT '费用金额', remark VARCHAR(500) NULL COMMENT '费用备注', created_by VARCHAR(128) NULL COMMENT '创建人ID',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), KEY idx_ll_delivery_fee_order(customer_id,delivery_order_id,id)
|
||||
) ENGINE=InnoDB COMMENT='派送单费用明细';
|
||||
CREATE TABLE IF NOT EXISTS ll_delivery_document_revision (
|
||||
id BIGINT NOT NULL COMMENT '派送资料历史ID',
|
||||
customer_id VARCHAR(128) NOT NULL COMMENT '派送所属客户隔离键',
|
||||
delivery_order_id BIGINT NOT NULL COMMENT '派送单ID',
|
||||
version BIGINT NOT NULL COMMENT '保存成功时派送单版本',
|
||||
idempotency_key VARCHAR(128) NOT NULL COMMENT '资料保存幂等键',
|
||||
request_hash VARCHAR(64) NOT NULL COMMENT '完整保存请求哈希',
|
||||
snapshot_json JSON NOT NULL COMMENT '不可变三类资料及BOL号结果快照',
|
||||
operator_id VARCHAR(128) NOT NULL COMMENT '保存操作人ID',
|
||||
created_at DATETIME(3) NOT NULL COMMENT '资料保存时间',
|
||||
PRIMARY KEY(id),
|
||||
UNIQUE KEY uk_delivery_document_command(customer_id,delivery_order_id,idempotency_key),
|
||||
UNIQUE KEY uk_delivery_document_version(customer_id,delivery_order_id,version)
|
||||
) ENGINE=InnoDB COMMENT='派送资料版本历史与幂等保存记录';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_delivery_operation (
|
||||
id BIGINT NOT NULL COMMENT '派送操作主键', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', delivery_order_id BIGINT NOT NULL COMMENT '派送单ID',
|
||||
operation_node VARCHAR(64) NOT NULL COMMENT '操作节点', operator_id VARCHAR(128) NULL COMMENT '操作人ID', operator_name VARCHAR(128) NULL COMMENT '操作人名称',
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: 派送单三类资料
|
||||
version: 1.0.0
|
||||
description: |
|
||||
所有入口只允许平台,customerId 必须为真实派送所属客户;不得按 BOL 文本查找或复用其他客户/派送单文件。
|
||||
GET /delivery-orders 及 GET /delivery-orders/{deliveryNo} 的 order 行新增 bolNo 和 documentSummary,version/allowedActions 保持原契约。
|
||||
状态 PUT /delivery-orders/{deliveryNo}/status 请求保持 {status,expectedVersion,remark},expectedVersion 必传。
|
||||
START_DELIVERY=DELIVERING(派送中);COMPLETE_DELIVERY=DELIVERED(完成并记录签收时间、生成待回访)。同状态重放不重复副作用。
|
||||
资料不设必传门禁,不以三类齐全作为派送完成条件。业务失败使用统一 ApiResponse(A0422参数、A0409冲突),通常HTTP200。
|
||||
security: [{ bearerAuth: [] }]
|
||||
paths:
|
||||
/api/platform/v1/last-leg/delivery-orders/{deliveryNo}/documents:
|
||||
parameters:
|
||||
- { $ref: '#/components/parameters/DeliveryNo' }
|
||||
- { $ref: '#/components/parameters/CustomerId' }
|
||||
get:
|
||||
summary: 查询已保存三类资料
|
||||
description: 权限 platform:overseas-warehouse:delivery:list;空资料仍返回全部三类MISSING。上传未保存不计入摘要。
|
||||
responses:
|
||||
'200': { description: 当前快照, content: { application/json: { schema: { $ref: '#/components/schemas/DetailResponse' } } } }
|
||||
put:
|
||||
summary: 原子保存全部类别绑定与BOL号
|
||||
description: |
|
||||
权限 platform:overseas-warehouse:delivery:documents。documents为全量快照,遗漏类别清空;Admin须提交完整三类。
|
||||
同一派送单锁下校验expectedVersion、全部文件客户/派送单/类别归属后一起提交,任一失败不写任何绑定。
|
||||
保存增加派送version,与状态修改共享版本;幂等键按客户+派送单限定,同键同请求重放原结果、异请求冲突。
|
||||
替换/删除通过修改fileIds并保存表达,只解除当前绑定,历史对象和版本快照不可变保留。
|
||||
requestBody:
|
||||
required: true
|
||||
content: { application/json: { schema: { $ref: '#/components/schemas/Save' } } }
|
||||
responses:
|
||||
'200': { description: 保存结果或统一业务错误, content: { application/json: { schema: { $ref: '#/components/schemas/DetailResponse' } } } }
|
||||
/api/platform/v1/last-leg/delivery-orders/{deliveryNo}/documents/{category}/files:
|
||||
parameters:
|
||||
- { $ref: '#/components/parameters/DeliveryNo' }
|
||||
- { $ref: '#/components/parameters/CustomerId' }
|
||||
- { $ref: '#/components/parameters/Category' }
|
||||
post:
|
||||
summary: 上传一个待绑定文件
|
||||
description: |
|
||||
权限 platform:overseas-warehouse:delivery:documents。FormData唯一file,无额外幂等头。
|
||||
File沿用ARBITRARY任意格式、非空且不超过52428800字节(50MiB),每类别最多20个文件由保存校验。
|
||||
上传仅产生不可变对象,不修改派送version或摘要;取消弹框/未保存不落绑定,不物理删除File对象。
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
multipart/form-data:
|
||||
schema: { type: object, required: [file], properties: { file: { type: string, format: binary } } }
|
||||
responses:
|
||||
'200':
|
||||
description: 单文件权威元数据
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
allOf:
|
||||
- { $ref: '#/components/schemas/Envelope' }
|
||||
- type: object
|
||||
properties: { data: { $ref: '#/components/schemas/File' } }
|
||||
/api/platform/v1/last-leg/delivery-orders/{deliveryNo}/documents/history:
|
||||
parameters:
|
||||
- { $ref: '#/components/parameters/DeliveryNo' }
|
||||
- { $ref: '#/components/parameters/CustomerId' }
|
||||
get:
|
||||
summary: 查看不可变资料保存历史
|
||||
description: 权限 platform:overseas-warehouse:delivery:list;版本倒序,仅当前客户和派送单。
|
||||
responses:
|
||||
'200':
|
||||
description: 历史快照
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
allOf:
|
||||
- { $ref: '#/components/schemas/Envelope' }
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
version: { type: integer, format: int64 }
|
||||
operatorId: { type: string }
|
||||
savedAt: { type: string, format: date-time }
|
||||
snapshot: { $ref: '#/components/schemas/Detail' }
|
||||
/api/platform/v1/last-leg/delivery-orders/{deliveryNo}/documents/{category}/files/{fileId}/download:
|
||||
parameters:
|
||||
- { $ref: '#/components/parameters/DeliveryNo' }
|
||||
- { $ref: '#/components/parameters/CustomerId' }
|
||||
- { $ref: '#/components/parameters/Category' }
|
||||
- { in: path, name: fileId, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } }
|
||||
- { in: query, name: revisionVersion, required: false, schema: { type: integer, format: int64, minimum: 1 } }
|
||||
get:
|
||||
summary: 下载当前或指定保存版本已绑定文件
|
||||
description: 权限 platform:overseas-warehouse:delivery:list;省略revisionVersion只允许当前绑定。历史版本仍严格校验客户/单据/类别。未保存或跨单文件拒绝;附件响应no-store/nosniff。
|
||||
responses:
|
||||
'200': { description: 文件内容, content: { application/octet-stream: { schema: { type: string, format: binary } } } }
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT }
|
||||
parameters:
|
||||
DeliveryNo: { in: path, name: deliveryNo, required: true, schema: { type: string } }
|
||||
CustomerId: { in: query, name: customerId, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } }
|
||||
Category: { in: path, name: category, required: true, schema: { $ref: '#/components/schemas/CategoryCode' } }
|
||||
schemas:
|
||||
CategoryCode:
|
||||
type: string
|
||||
enum: [LOADING_PHOTO, BOL, RECEIPT]
|
||||
description: 依次为装车照片、BOL文件(含签名)、签收文件(POD签收/快递签收)。BOL签名由用户提供,不进行OCR或电子签署验证。
|
||||
Save:
|
||||
type: object
|
||||
required: [expectedVersion, idempotencyKey, documents]
|
||||
properties:
|
||||
expectedVersion: { type: integer, format: int64, minimum: 0 }
|
||||
idempotencyKey: { type: string, minLength: 1, maxLength: 128 }
|
||||
bolNo: { type: string, nullable: true, maxLength: 128, description: 不作为文件复用或归属匹配依据;空值清空 }
|
||||
documents:
|
||||
type: array
|
||||
maxItems: 3
|
||||
items:
|
||||
type: object
|
||||
required: [category, fileIds]
|
||||
properties:
|
||||
category: { $ref: '#/components/schemas/CategoryCode' }
|
||||
fileIds: { type: array, maxItems: 20, uniqueItems: true, items: { type: string, pattern: '^[1-9][0-9]*$' } }
|
||||
File:
|
||||
type: object
|
||||
required: [fileId, fileName, sizeBytes, uploadedAt]
|
||||
properties:
|
||||
fileId: { type: string, pattern: '^[1-9][0-9]*$' }
|
||||
fileName: { type: string }
|
||||
contentType: { type: string, nullable: true }
|
||||
sizeBytes: { type: integer, format: int64, minimum: 1, maximum: 52428800 }
|
||||
uploadedAt: { type: string, format: date-time, description: File持久化的真实创建时间 }
|
||||
CategorySummary:
|
||||
type: object
|
||||
required: [category, status, fileCount]
|
||||
properties:
|
||||
category: { $ref: '#/components/schemas/CategoryCode' }
|
||||
status: { type: string, enum: [UPLOADED, MISSING] }
|
||||
fileCount: { type: integer, minimum: 0, maximum: 20 }
|
||||
DocumentSummary:
|
||||
type: object
|
||||
required: [uploadedCategoryCount, totalCategoryCount, categories]
|
||||
properties:
|
||||
uploadedCategoryCount: { type: integer, minimum: 0, maximum: 3 }
|
||||
totalCategoryCount: { type: integer, enum: [3] }
|
||||
categories: { type: array, minItems: 3, maxItems: 3, items: { $ref: '#/components/schemas/CategorySummary' } }
|
||||
Detail:
|
||||
type: object
|
||||
required: [deliveryNo, customerId, version, uploadedCategoryCount, totalCategoryCount, categories, allowedActions, replayed]
|
||||
properties:
|
||||
deliveryNo: { type: string }
|
||||
customerId: { type: string }
|
||||
version: { type: integer, format: int64 }
|
||||
bolNo: { type: string, nullable: true }
|
||||
uploadedCategoryCount: { type: integer, minimum: 0, maximum: 3 }
|
||||
totalCategoryCount: { type: integer, enum: [3] }
|
||||
categories:
|
||||
type: array
|
||||
minItems: 3
|
||||
maxItems: 3
|
||||
items:
|
||||
allOf:
|
||||
- { $ref: '#/components/schemas/CategorySummary' }
|
||||
- type: object
|
||||
required: [files]
|
||||
properties: { files: { type: array, maxItems: 20, items: { $ref: '#/components/schemas/File' } } }
|
||||
allowedActions: { type: array, items: { type: string, enum: [MANAGE_DOCUMENTS] }, description: 返回时按当前认证授权裁剪;只读用户为空,历史和幂等重放也不能沿用旧权限 }
|
||||
replayed: { type: boolean }
|
||||
Envelope:
|
||||
type: object
|
||||
properties:
|
||||
code: { type: string }
|
||||
message: { type: string }
|
||||
DetailResponse:
|
||||
allOf:
|
||||
- { $ref: '#/components/schemas/Envelope' }
|
||||
- type: object
|
||||
properties: { data: { $ref: '#/components/schemas/Detail' } }
|
||||
@@ -1,6 +1,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Lastleg Overseas Business API
|
||||
description: 派送资料完整契约见同目录 delivery-documents-v1.yaml;派送列表/详情行返回 bolNo、documentSummary(三类状态与x/3),状态仍使用 version 和 allowedActions。
|
||||
version: 1.0.0
|
||||
paths:
|
||||
/api/platform/v1/last-leg/inventory/balances/{balanceId}:
|
||||
|
||||
+24
@@ -107,6 +107,30 @@ class CustomerVisitServiceTest {
|
||||
assertThat(tail.getSubOrderStatus()).isEqualTo("DELIVERY:DELIVERED");
|
||||
}
|
||||
|
||||
@Test
|
||||
void startMeansDeliveringAndOnlyCompleteSignsAndCreatesVisit() {
|
||||
DeliveryOrder delivery = delivery("WAIT_DELIVERY"); delivery.setVersion(0L);
|
||||
when(deliveryOrders.lockByNo("1001", "DL-1")).thenReturn(delivery);
|
||||
var started = service.updateDeliveryStatus("1001", "9001", "DL-1",
|
||||
new CustomerVisitRequests.DeliveryStatus("DELIVERING", 0L, "开始派送"));
|
||||
assertThat(started.deliveryStatus()).isEqualTo("DELIVERING");
|
||||
assertThat(delivery.getVersion()).isEqualTo(1);
|
||||
assertThat(delivery.getDispatchedAt()).isNotNull();
|
||||
assertThat(delivery.getSignedAt()).isNull();
|
||||
verify(visits, never()).insert(org.mockito.ArgumentMatchers.any(CustomerVisit.class));
|
||||
CustomerVisit visit = new CustomerVisit(); visit.setId(33L); visit.setStatus("PENDING");
|
||||
when(visits.findByDelivery("1001", 11L)).thenReturn(visit);
|
||||
var completed = service.updateDeliveryStatus("1001", "9001", "DL-1",
|
||||
new CustomerVisitRequests.DeliveryStatus("DELIVERED", 1L, null));
|
||||
assertThat(completed.deliveryStatus()).isEqualTo("DELIVERED");
|
||||
assertThat(delivery.getSignedAt()).isNotNull();
|
||||
assertThat(delivery.getVersion()).isEqualTo(2);
|
||||
assertThat(completed.visitId()).isEqualTo(33);
|
||||
assertThat(service.updateDeliveryStatus("1001", "9001", "DL-1",
|
||||
new CustomerVisitRequests.DeliveryStatus("DELIVERED", 1L, null)).replayed()).isTrue();
|
||||
verify(deliveryOrders, org.mockito.Mockito.times(2)).updateById(delivery);
|
||||
}
|
||||
|
||||
private DeliveryOrder delivery(String status) {
|
||||
DeliveryOrder delivery = new DeliveryOrder();
|
||||
delivery.setId(11L);
|
||||
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
package com.baiyue.lastleg.delivery;
|
||||
|
||||
import com.baiyue.lastleg.delivery.controller.DeliveryDocumentController;
|
||||
import com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos.*;
|
||||
import com.baiyue.lastleg.delivery.model.DeliveryDocumentCategory;
|
||||
import com.baiyue.lastleg.customervisit.dto.CustomerVisitRequests;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.validation.Validation;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import java.util.*;
|
||||
import java.nio.file.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
class DeliveryDocumentContractTest {
|
||||
@Test void missingVersionNestedNullIdsAndTooManyFilesAreRejected() {
|
||||
try(var factory=Validation.buildDefaultValidatorFactory()) {
|
||||
var validator=factory.getValidator();
|
||||
assertThat(validator.validate(new CustomerVisitRequests.DeliveryStatus("DELIVERING",null,"123")))
|
||||
.anyMatch(v->v.getPropertyPath().toString().equals("expectedVersion"));
|
||||
assertThat(validator.validate(new Save(null,"key",null,List.of()))).isNotEmpty();
|
||||
assertThat(validator.validate(new Save(0L,"key",null,List.of(new Binding(DeliveryDocumentCategory.BOL,
|
||||
Arrays.asList((Long)null)))))).isNotEmpty();
|
||||
assertThat(validator.validate(new Save(0L,"key",null,List.of(new Binding(DeliveryDocumentCategory.BOL,
|
||||
java.util.stream.LongStream.rangeClosed(1,21).boxed().toList()))))).isNotEmpty();
|
||||
}
|
||||
}
|
||||
@Test void removedCategoriesAreNotAcceptedAndOnlyThreeRemainInOrder() {
|
||||
assertThat(DeliveryDocumentCategory.values()).containsExactly(DeliveryDocumentCategory.LOADING_PHOTO,
|
||||
DeliveryDocumentCategory.BOL, DeliveryDocumentCategory.RECEIPT);
|
||||
for(String category:List.of("POD","EXPRESS_RECEIPT","CUSTOMER_DOCUMENT","BILL_OF_LADING","DELIVERY_PHOTO")) {
|
||||
assertThatThrownBy(()->new ObjectMapper().readValue("\""+category+"\"",DeliveryDocumentCategory.class))
|
||||
.isInstanceOf(com.fasterxml.jackson.core.JsonProcessingException.class);
|
||||
}
|
||||
}
|
||||
@Test void readDownloadAndWritePermissionsAreSeparate() {
|
||||
for(var method:DeliveryDocumentController.class.getDeclaredMethods()) {
|
||||
if(!Set.of("detail","history","download","save","upload").contains(method.getName())) continue;
|
||||
String suffix=Set.of("save","upload").contains(method.getName())?"documents":"list";
|
||||
assertThat(method.getAnnotation(PreAuthorize.class).value())
|
||||
.isEqualTo("hasAuthority('platform:overseas-warehouse:delivery:"+suffix+"')");
|
||||
}
|
||||
}
|
||||
@Test void baselineAndOverseasEntryContainSameImmutableRevisionTable() throws Exception {
|
||||
String baseline=Files.readString(Path.of("src/main/resources/db/lastleg.sql"));
|
||||
String overseas=Files.readString(Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql"));
|
||||
String marker="CREATE TABLE IF NOT EXISTS ll_delivery_document_revision (";
|
||||
String a=baseline.substring(baseline.indexOf(marker),baseline.indexOf(";",baseline.indexOf(marker))+1);
|
||||
String b=overseas.substring(overseas.indexOf(marker),overseas.indexOf(";",overseas.indexOf(marker))+1);
|
||||
assertThat(a.replaceAll("\\s+","")).isEqualTo(b.replaceAll("\\s+",""));
|
||||
assertThat(a).contains("uk_delivery_document_command","uk_delivery_document_version");
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -66,6 +66,7 @@ class DeliveryLongIdJsonContractTest {
|
||||
DELIVERY_ID,
|
||||
null, null, "PS-1", null, null, null, null, "CREATED", null,
|
||||
null, null, null, null, null, null, null, null, null, null, null,
|
||||
0, 0, null, null, null, null, null, null, null, null, 0, java.util.List.of());
|
||||
0, 0, null, null, null, null, null, null, null, null, 0, java.util.List.of(), null,
|
||||
com.baiyue.lastleg.delivery.model.DeliveryDocumentSnapshot.summary(null));
|
||||
}
|
||||
}
|
||||
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package com.baiyue.lastleg.delivery.controller;
|
||||
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos.*;
|
||||
import com.baiyue.lastleg.delivery.service.DeliveryDocumentService;
|
||||
import com.baiyue.lastleg.security.OverseasPermissions;
|
||||
import org.junit.jupiter.api.*;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class DeliveryDocumentControllerTest {
|
||||
@AfterEach void clear() { SecurityContextHolder.clearContext(); }
|
||||
@Test void readonlyUserGetsNoWriteActionsInCurrentReplayOrHistory() throws Exception { verifyActions(false); }
|
||||
@Test void currentWritePermissionRetainsManagementAction() throws Exception { verifyActions(true); }
|
||||
private void verifyActions(boolean writer) throws Exception {
|
||||
var service=mock(DeliveryDocumentService.class);
|
||||
var controller=new DeliveryDocumentController(); ReflectionTestUtils.setField(controller,"service",service);
|
||||
var user=new AuthenticatedUser(9L,0L,null,null,"reader","PLATFORM","WEB",Set.of());
|
||||
var authorities=writer?List.of(new SimpleGrantedAuthority(OverseasPermissions.DELIVERY_DOCUMENTS)):
|
||||
List.of(new SimpleGrantedAuthority(OverseasPermissions.DELIVERY_LIST));
|
||||
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user,null,authorities));
|
||||
var snapshot=new Detail("DL-1","100",1,null,0,3,List.of(),List.of("MANAGE_DOCUMENTS"),true);
|
||||
when(service.detail("100","DL-1")).thenReturn(snapshot);
|
||||
when(service.history("100","DL-1")).thenReturn(List.of(new History(1,"9",LocalDateTime.now(),snapshot)));
|
||||
var request=new Save(0L,"key",null,List.of());
|
||||
when(service.save("100","DL-1","9",request)).thenReturn(snapshot);
|
||||
var json=new ObjectMapper().findAndRegisterModules();
|
||||
assertThat(json.valueToTree(controller.detail(user,"DL-1","100")).at("/data/allowedActions").size()).isEqualTo(writer?1:0);
|
||||
assertThat(json.valueToTree(controller.save(user,"DL-1","100",request)).at("/data/allowedActions").size()).isEqualTo(writer?1:0);
|
||||
assertThat(json.valueToTree(controller.history(user,"DL-1","100")).at("/data/0/snapshot/allowedActions").size()).isEqualTo(writer?1:0);
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package com.baiyue.lastleg.delivery.gateway;
|
||||
|
||||
import com.baiyue.lastleg.delivery.model.DeliveryDocumentCategory;
|
||||
import org.junit.jupiter.api.*;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.test.web.client.MockRestServiceServer;
|
||||
import org.springframework.web.client.RestClient;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.springframework.test.web.client.match.MockRestRequestMatchers.*;
|
||||
import static org.springframework.test.web.client.response.MockRestResponseCreators.*;
|
||||
|
||||
class HttpDeliveryDocumentFileGatewayTest {
|
||||
HttpDeliveryDocumentFileGateway gateway;
|
||||
MockRestServiceServer server;
|
||||
@BeforeEach void setUp() {
|
||||
var builder=RestClient.builder().baseUrl("http://file.test");
|
||||
server=MockRestServiceServer.bindTo(builder).build();
|
||||
gateway=new HttpDeliveryDocumentFileGateway();
|
||||
ReflectionTestUtils.setField(gateway,"client",builder.build());
|
||||
ReflectionTestUtils.setField(gateway,"internalToken","test-service-token");
|
||||
}
|
||||
@Test void validatesExactOwnershipAndUsesFileCreationTime() {
|
||||
expect(metadata("100","10","DELIVERY_BOL","11"));
|
||||
var file=gateway.metadata("100",10,DeliveryDocumentCategory.BOL,11);
|
||||
assertThat(file.fileId()).isEqualTo(11);
|
||||
assertThat(file.uploadedAt().toString()).isEqualTo("2026-09-09T08:00");
|
||||
server.verify();
|
||||
}
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings={"customer","delivery","category","file"})
|
||||
void rejectsCrossCustomerCrossDeliveryWrongCategoryAndWrongFile(String mismatch) {
|
||||
expect(metadata(mismatch.equals("customer")?"200":"100",mismatch.equals("delivery")?"20":"10",
|
||||
mismatch.equals("category")?"DELIVERY_RECEIPT":"DELIVERY_BOL",mismatch.equals("file")?"12":"11"));
|
||||
assertThatThrownBy(()->gateway.metadata("100",10,DeliveryDocumentCategory.BOL,11))
|
||||
.hasMessageContaining("不属于");
|
||||
server.verify();
|
||||
}
|
||||
private void expect(String json) {
|
||||
server.expect(requestTo("http://file.test/internal/file/v1/business-files/100/DELIVERY_BOL/10/11"))
|
||||
.andExpect(header("X-Internal-Service-Token","test-service-token"))
|
||||
.andRespond(withSuccess(json,MediaType.APPLICATION_JSON));
|
||||
}
|
||||
private String metadata(String customer,String delivery,String type,String file) {
|
||||
return "{\"code\":\"200\",\"data\":{\"fileId\":"+file+",\"tenantId\":"+customer+
|
||||
",\"businessType\":\""+type+"\",\"businessId\":\""+delivery+"\",\"fileName\":\"test.pdf\","+
|
||||
"\"contentType\":\"application/pdf\",\"sizeBytes\":10,\"createdAt\":\"2026-09-09T08:00:00\"}}";
|
||||
}
|
||||
}
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
package com.baiyue.lastleg.delivery.service;
|
||||
|
||||
import com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos.*;
|
||||
import com.baiyue.lastleg.delivery.entity.*;
|
||||
import com.baiyue.lastleg.delivery.gateway.DeliveryDocumentFileGateway;
|
||||
import com.baiyue.lastleg.delivery.mapper.*;
|
||||
import com.baiyue.lastleg.delivery.model.*;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.*;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.*;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static com.baiyue.lastleg.delivery.model.DeliveryDocumentCategory.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class DeliveryDocumentServiceTest {
|
||||
@Mock DeliveryOrderMapper orders;
|
||||
@Mock DeliveryDocumentRevisionMapper revisions;
|
||||
@Mock DeliveryOperationMapper operations;
|
||||
@Mock DeliveryDocumentFileGateway files;
|
||||
DeliveryDocumentService service;
|
||||
DeliveryOrder order;
|
||||
@BeforeEach void setUp() {
|
||||
service = new DeliveryDocumentService();
|
||||
ReflectionTestUtils.setField(service,"orders",orders);
|
||||
ReflectionTestUtils.setField(service,"revisions",revisions);
|
||||
ReflectionTestUtils.setField(service,"operations",operations);
|
||||
ReflectionTestUtils.setField(service,"files",files);
|
||||
ReflectionTestUtils.setField(service,"objectMapper",new ObjectMapper().findAndRegisterModules());
|
||||
order = new DeliveryOrder(); order.setId(10L); order.setCustomerId("100");
|
||||
order.setDeliveryNo("DL-1"); order.setVersion(0L); order.setStatus("DELIVERED");
|
||||
}
|
||||
@Test void savedCategoryCountCountsCategoriesNotFilesAndSurvivesReload() {
|
||||
when(orders.lockByNo("100","DL-1")).thenReturn(order);
|
||||
when(files.metadata("100",10,BOL,11)).thenReturn(file(11));
|
||||
when(files.metadata("100",10,BOL,12)).thenReturn(file(12));
|
||||
var result=service.save("100","DL-1","9",request(0,"save",List.of(new Binding(BOL,List.of(11L,12L)))));
|
||||
assertThat(result.uploadedCategoryCount()).isEqualTo(1);
|
||||
assertThat(result.categories()).hasSize(3);
|
||||
assertThat(result.categories().get(1).fileCount()).isEqualTo(2);
|
||||
assertThat(result.version()).isEqualTo(1);
|
||||
assertThat(result.categories().get(1).files().get(0).uploadedAt()).isEqualTo(file(11).uploadedAt());
|
||||
when(orders.findByNo("100","DL-1")).thenReturn(order);
|
||||
assertThat(service.detail("100","DL-1")).isEqualTo(result);
|
||||
assertThat(DeliveryDocumentSnapshot.summary(order.getDocumentsJson()).uploadedCategoryCount()).isEqualTo(1);
|
||||
verify(revisions).insert(any(DeliveryDocumentRevision.class)); verify(operations).insert(any(DeliveryOperation.class));
|
||||
}
|
||||
@Test void staleVersionDoesNotValidateFilesOrWrite() {
|
||||
order.setVersion(2L); when(orders.lockByNo("100","DL-1")).thenReturn(order);
|
||||
assertThatThrownBy(()->service.save("100","DL-1","9",request(1,"stale",List.of())))
|
||||
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("版本");
|
||||
verifyNoInteractions(files,operations); verify(orders,never()).updateById(any(DeliveryOrder.class));
|
||||
verify(revisions,never()).insert(any(DeliveryDocumentRevision.class));
|
||||
}
|
||||
@Test void anyInvalidOwnershipRejectsWholeSnapshotBeforeMutation() {
|
||||
when(orders.lockByNo("100","DL-1")).thenReturn(order);
|
||||
when(files.metadata("100",10,BOL,11)).thenReturn(file(11));
|
||||
when(files.metadata("100",10,RECEIPT,12)).thenThrow(new ResponseStatusException(HttpStatus.BAD_REQUEST,"归属错误"));
|
||||
assertThatThrownBy(()->service.save("100","DL-1","9",request(0,"bad",List.of(
|
||||
new Binding(BOL,List.of(11L)),new Binding(RECEIPT,List.of(12L)))))).hasMessageContaining("归属");
|
||||
assertThat(order.getDocumentsJson()).isNull(); assertThat(order.getVersion()).isZero();
|
||||
verify(orders,never()).updateById(any(DeliveryOrder.class)); verifyNoInteractions(operations);
|
||||
verify(revisions,never()).insert(any(DeliveryDocumentRevision.class));
|
||||
}
|
||||
@Test void exactReplayDoesNotWriteOrRevalidateAndChangedPayloadConflicts() {
|
||||
when(orders.lockByNo("100","DL-1")).thenReturn(order);
|
||||
var request=request(0,"same",List.of());
|
||||
var first=service.save("100","DL-1","9",request);
|
||||
ArgumentCaptor<DeliveryDocumentRevision> saved=ArgumentCaptor.forClass(DeliveryDocumentRevision.class);
|
||||
verify(revisions).insert(saved.capture());
|
||||
when(revisions.findCommand("100",10,"same")).thenReturn(saved.getValue());
|
||||
assertThat(service.save("100","DL-1","9",request).replayed()).isTrue();
|
||||
assertThat(first.version()).isEqualTo(1);
|
||||
verify(orders,times(1)).updateById(any(DeliveryOrder.class));
|
||||
assertThatThrownBy(()->service.save("100","DL-1","9",request(1,"same",List.of())))
|
||||
.hasMessageContaining("幂等键");
|
||||
}
|
||||
@Test void replacementRemovalPreservesHistoryAndCurrentDownloadRejectsRemovedFile() {
|
||||
when(orders.lockByNo("100","DL-1")).thenReturn(order);
|
||||
when(files.metadata("100",10,BOL,11)).thenReturn(file(11));
|
||||
service.save("100","DL-1","9",request(0,"first",List.of(new Binding(BOL,List.of(11L)))));
|
||||
ArgumentCaptor<DeliveryDocumentRevision> first=ArgumentCaptor.forClass(DeliveryDocumentRevision.class);
|
||||
verify(revisions).insert(first.capture());
|
||||
assertThat(service.save("100","DL-1","9",request(1,"clear",List.of())).uploadedCategoryCount()).isZero();
|
||||
when(orders.findByNo("100","DL-1")).thenReturn(order);
|
||||
assertThatThrownBy(()->service.download("100","DL-1",BOL,11,null)).isInstanceOf(ResponseStatusException.class);
|
||||
when(revisions.history("100",10)).thenReturn(List.of(first.getValue()));
|
||||
when(files.download("100",10,BOL,11)).thenReturn(new byte[]{1});
|
||||
assertThat(service.download("100","DL-1",BOL,11,1L).content()).containsExactly((byte)1);
|
||||
}
|
||||
@Test void uploadWithoutSaveNeverChangesSummaryAndUnboundFileCannotDownload() {
|
||||
when(orders.findByNo("100","DL-1")).thenReturn(order);
|
||||
var input=new MockMultipartFile("file","a.pdf","application/pdf",new byte[]{1});
|
||||
when(files.upload("100",10,BOL,"9",input)).thenReturn(file(11));
|
||||
assertThat(service.upload("100","DL-1",BOL,"9",input).fileId()).isEqualTo(11);
|
||||
assertThat(service.detail("100","DL-1").uploadedCategoryCount()).isZero();
|
||||
assertThatThrownBy(()->service.download("100","DL-1",BOL,11,null)).isInstanceOf(ResponseStatusException.class);
|
||||
verifyNoInteractions(revisions,operations); verify(orders,never()).updateById(any(DeliveryOrder.class));
|
||||
}
|
||||
@Test void anotherCustomerOrDeliveryCannotLoadOrBindFiles() {
|
||||
assertThatThrownBy(()->service.detail("200","DL-1")).isInstanceOf(ResponseStatusException.class);
|
||||
assertThatThrownBy(()->service.save("100","DL-2","9",request(0,"wrong",List.of())))
|
||||
.isInstanceOf(ResponseStatusException.class);
|
||||
verifyNoInteractions(files,revisions,operations);
|
||||
}
|
||||
@Test void allThreeCategoriesCountToThreeRegardlessOfFileMultiplicity() {
|
||||
when(orders.lockByNo("100","DL-1")).thenReturn(order);
|
||||
when(files.metadata("100",10,LOADING_PHOTO,11)).thenReturn(file(11));
|
||||
when(files.metadata("100",10,BOL,12)).thenReturn(file(12));
|
||||
when(files.metadata("100",10,RECEIPT,13)).thenReturn(file(13));
|
||||
var result=service.save("100","DL-1","9",request(0,"all",List.of(
|
||||
new Binding(LOADING_PHOTO,List.of(11L)),new Binding(BOL,List.of(12L)),new Binding(RECEIPT,List.of(13L)))));
|
||||
assertThat(result.uploadedCategoryCount()).isEqualTo(3); assertThat(result.totalCategoryCount()).isEqualTo(3);
|
||||
assertThat(result.categories()).allMatch(c->c.status().equals("UPLOADED"));
|
||||
}
|
||||
private Save request(long version,String key,List<Binding> bindings) { return new Save(version,key,"BOL-1",bindings); }
|
||||
private File file(long id) { return new File(id,"a.pdf","application/pdf",1,LocalDateTime.of(2026,9,9,8,0)); }
|
||||
}
|
||||
+2
-1
@@ -12,6 +12,7 @@
|
||||
| 目标 | 入口 |
|
||||
|---|---|
|
||||
| 平台订单 API | `controller/PlatformFirstLegOrderController.java` |
|
||||
| 平台批量审核预览 | `controller/PlatformFirstLegOrderController.java` 的 `/batch-review-preview`、`service/BatchOrderReviewPreviewService.java`、`dto/BatchOrderReviewPreviewResponse.java`;按目标客户和请求顺序一次返回订单、中文货物摘要、渠道、目的仓及逐项 `canApprove/blockReason`,实现固定两次批量查询,禁止前端逐票读取详情 |
|
||||
| 统一订单查询、审核优先状态与稳定渠道编码 | `service/UnifiedOrderQueryService.java`、`dto/UnifiedOrderResponse.java`、`src/main/resources/openapi/order-v1.yaml`;`channelCode` 直接来自 Order/Firstleg 权威快照,只用于业务校验,不得由 `channelName` 推导。Order 为 `SUBMITTED` 时即使已有 Firstleg 入库投影,统一主状态/下一动作仍固定为 `PENDING_REVIEW/APPROVE_ORDER` |
|
||||
| Firstleg 智能装柜待审核候选 | `controller/InternalPendingReviewOrderController.java`、`service/PendingReviewOrderQueryService.java`、`dto/PendingReviewOrderDtos.java`、`repository/OrderRepository.java`;内部入口 `/internal/order/v1/pending-review-orders` 只接受服务令牌,只分页返回未删除的 `SUBMITTED` 头程订单并固定投影为 `PENDING_REVIEW`,按 `Asia/Shanghai` 自然日过滤、`created_at DESC, id DESC` 稳定排序,不读取或修复 Firstleg 投影 |
|
||||
| 平台版本化修改客户原始申报 | `controller/PlatformUnifiedOrderController.java` 的 `PUT /api/platform/v1/orders/{orderId}/declaration`、`service/OrderDeclarationUpdateService.java`、`dto/OrderDeclarationDtos.java`;权限 `platform:order:declaration:edit`,货物以订单内 `itemNo` 定位并保持行集合完整,契约 `src/main/resources/openapi/order-v1.yaml` |
|
||||
@@ -23,7 +24,7 @@
|
||||
| 下单计价渠道校验 | `service/OrderPricingChannelGuard.java`、`client/FirstlegPricingClient.java` |
|
||||
| 下单仓库主数据守卫 | `service/OrderMasterDataGuard.java`、`client/WarehouseClient.java`;校验公共收货仓、目的仓类型、状态、地址与收货能力,并保存服务端快照 |
|
||||
| 统一订单七状态与操作映射 | `../../docs/统一订单主状态与操作契约.md`;`LOADED` 硬门禁和 `DELIVERED` 可信回传同时核对国内货柜履约需求确认表与开工门禁 |
|
||||
| 货物内部查询与箱唛绑定来源 | `controller/InternalOrderCargoController.java` 向 Firstleg 提供箱唛生成的 SKU 快照来源;`CargoLabelBinding.skuNo` 对 `ONE_CLICK_DROPSHIP` 为强制字段。Firstleg 消费端为 `../firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java`,变更两端 DTO 必须同步。 |
|
||||
| 货物内部查询与箱唛绑定来源 | `controller/InternalOrderCargoController.java` 向 Firstleg 批量提供箱唛生成的 SKU、尺寸与货件快照;`CargoLabelBinding.skuNo` 对 `ONE_CLICK_DROPSHIP` 为强制字段,`shipmentId` 只取对应货物行,不回退订单顶层字段。Firstleg 消费端为 `../firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java`,变更两端 DTO 必须同步。 |
|
||||
| 智能装柜待审核候选内部查询 | `controller/InternalPendingReviewOrderController.java`、`service/PendingReviewOrderQueryService.java`、`dto/PendingReviewOrderDtos.java`;Firstleg 消费端为 `../firstleg/src/main/java/com/baiyue/firstleg/client/PendingReviewOrderClient.java`,契约 `src/main/resources/openapi/order-v1.yaml` |
|
||||
| 快照/Outbox | `service/`、`repository/`、`event/` |
|
||||
| DTO/持久化 | `dto/`、`entity/`、`mapper/`、`repository/` |
|
||||
|
||||
@@ -37,6 +37,10 @@ export async function run(context) {
|
||||
|
||||
## 可用上下文
|
||||
|
||||
货柜查询使用 `/api/platform/v1/containers/domestic` 列表和 `/domestic/{id}` 详情,响应为 `WorkbenchItem`(`container`、`workbenchStage`、`statusSummary`、`allowedActions`),不接受旧扁平实体。默认 `phase3-container-loading` 同时验证页面的 ACTIVE、状态、关键字组合查询及详情结构;`container-detail-route-contract.test.mjs` 离线核对 Admin、Controller 和人工分段/同柜脚本读路径,并反证旧 DTO。定向真实 HTTP 使用总入口 `--modules phase3-container-loading`,仍自动展开本轮前置并创建测试资源;不借历史柜。
|
||||
|
||||
一件代发的 `inbound-measurement` 前置通过 PDA 正式 `pallets/create-and-bind` 创建系统 DP 码并绑定待清点箱,不再按测试 marker 拼托盘号;默认一件代发后续链复用该前置。验证清点前及绑定后无待绑箱请求均拒绝且无空托、系统发码同操作重放保持托盘/计数不变、封托完成后原操作可重放;离线场景反向注入重复发码和空托,保证回归确实识别这两类故障。
|
||||
|
||||
- `request(method, path, body?, token?, extraHeaders?)`:JSON HTTP 请求,自动使用平台 Token;幂等写操作通过 `extraHeaders` 传 `Idempotency-Key`。
|
||||
- `requestBinary(method, path)`:文件下载,不把二进制写入报告。
|
||||
- `apiSuccess(module, name, response, evidence?, options?)`:断言 HTTP 200 + body code `200`,成功时返回 data。
|
||||
|
||||
@@ -224,9 +224,9 @@ async function createReleasedBookingAndLink(context, fixture, chains) {
|
||||
if (!requirement?.id) return null
|
||||
for (let index = 0; index < chains.length; index += 1) {
|
||||
const chain = chains[index]
|
||||
const detailResponse = await context.request('GET', `${CONTAINERS}/${chain.containerBookingId}`)
|
||||
const detailResponse = await context.request('GET', `${CONTAINERS}/domestic/${chain.containerBookingId}`)
|
||||
const detail = context.apiSuccess(MODULE, `${chain.label} 关联前读取柜版本`, detailResponse,
|
||||
evidence(detailResponse), OPTIONS)
|
||||
evidence(detailResponse), OPTIONS)?.container
|
||||
const containerNo = iso6346ContainerNumber(`${context.marker}-coload-${index + 1}`)
|
||||
const linkResponse = await context.request('POST', `${CONTAINERS}/${chain.containerBookingId}/ocean-booking-link`, {
|
||||
oceanBookingId, requirementId: id(requirement.id), containerNo,
|
||||
|
||||
@@ -1,9 +1,52 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { containerWorkbenchGaps } from './phase3-container-loading.mjs'
|
||||
|
||||
const read = (name) => readFileSync(new URL(name, import.meta.url), 'utf8')
|
||||
|
||||
test('workbench DTO rejects the old flat entity and incomplete action/state projections', () => {
|
||||
const container = { id: '71', cabinetNo: 'CB-20260909-01', version: 0 }
|
||||
const row = { container, workbenchStage: 'WAIT_LOADING', statusSummary: {}, allowedActions: [] }
|
||||
assert.deepEqual(containerWorkbenchGaps(row), [])
|
||||
assert.ok(containerWorkbenchGaps(container).includes('container.id'))
|
||||
for (const field of ['container', 'workbenchStage', 'statusSummary', 'allowedActions']) {
|
||||
const invalid = { ...row }
|
||||
delete invalid[field]
|
||||
assert.ok(containerWorkbenchGaps(invalid).length > 0, `missing ${field} must fail`)
|
||||
}
|
||||
assert.ok(containerWorkbenchGaps({ ...row, container: { ...container, version: null } }).includes('container.version'))
|
||||
})
|
||||
|
||||
test('Admin read routes match controller workbench endpoints and default HTTP regression exercises the page DTO', () => {
|
||||
const controller = read('../../firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java')
|
||||
const admin = read('../../../apps/admin-web/src/modules/container-loading/api/platformContainerLoading.ts')
|
||||
assert.match(controller, /@GetMapping\("\/domestic"\)/)
|
||||
assert.match(controller, /@GetMapping\("\/domestic\/\{id\}"\)/)
|
||||
assert.match(controller, /ApiResponse<PageResult<WorkbenchItem>>/)
|
||||
assert.match(admin, /listContainers\([^\n]*workbench: ContainerWorkbenchKind = 'DOMESTIC'/)
|
||||
assert.ok(admin.includes('`${containerPath}/${workbench.toLowerCase()}`'))
|
||||
assert.ok(admin.includes('`${containerPath}/${workbench.toLowerCase()}/${encodeURIComponent(containerId)}`'))
|
||||
const loading = read('./phase3-container-loading.mjs')
|
||||
assert.ok(loading.includes('size=20&scope=ACTIVE&status='))
|
||||
assert.ok(loading.includes('containerWorkbenchGaps(containerDetailResponse.body?.data)'))
|
||||
assert.ok(loading.includes('records(page).every((item) => containerWorkbenchGaps(item).length === 0)'))
|
||||
assert.ok(!loading.includes('value?.container || value'))
|
||||
})
|
||||
|
||||
test('manual and coload consumers read domestic WorkbenchItem rather than the removed flat routes', () => {
|
||||
for (const name of ['manual-loading-complete-fixture', 'manual-order-splitting-verify',
|
||||
'manual-pda-ready-fixture', 'manual-pda-ready-verify', 'manual-preload-push-fixture', 'manual-preload-push-verify', 'coload-groups']) {
|
||||
const source = read(`./${name}.mjs`)
|
||||
assert.ok(source.includes("context.request('GET', `${CONTAINERS}/domestic/${"), name)
|
||||
assert.ok(!source.includes("context.request('GET', `${CONTAINERS}/${"), name)
|
||||
assert.match(source, /OPTIONS\)\?\.container/, name)
|
||||
}
|
||||
const states = read('./manual-state-loading-pda.mjs')
|
||||
assert.ok(states.includes('`${CONTAINERS}/domestic?scope=ACTIVE&status='))
|
||||
assert.ok(states.includes('item.container?.planStatus === status'))
|
||||
})
|
||||
|
||||
test('domestic container fixtures use the current workbench detail route', () => {
|
||||
const containerLoading = read('./phase3-container-loading.mjs')
|
||||
assert.ok(containerLoading.includes("context.request('GET', `${DOMESTIC_CONTAINERS}/${containerId}`)"))
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { completeDropshipPalletization } from './inbound-measurement.mjs'
|
||||
|
||||
const source = readFileSync(new URL('inbound-measurement.mjs', import.meta.url), 'utf8')
|
||||
const read = (relativeUrl) => readFileSync(new URL(relativeUrl, import.meta.url), 'utf8')
|
||||
@@ -102,7 +103,10 @@ test('dropship measurement completes the formal palletization workflow before sm
|
||||
assert.match(source, /pda-dropship\/tasks\/\$\{taskId\}\/claim/)
|
||||
assert.match(source, /cartons\/resolve/)
|
||||
assert.match(source, /cartons\/count/)
|
||||
assert.match(source, /pallets\/bind-pending/)
|
||||
assert.match(source, /pallets\/create-and-bind/)
|
||||
assert.match(source, /系统发码重放不新增托盘且业务码和计数稳定/)
|
||||
assert.match(source, /拒绝创建没有空托或计数漂移/)
|
||||
assert.doesNotMatch(source, /palletHash|palletDate/)
|
||||
assert.match(source, /pallets\/\$\{palletId\}\/seal/)
|
||||
assert.match(source, /重复清点操作按operationId幂等返回/)
|
||||
assert.doesNotMatch(source, /pallets\/open/)
|
||||
@@ -117,3 +121,73 @@ test('confirmed package count reconciles an uncertain transport result before re
|
||||
assert.match(source, /Number\(detail\?\.version\) > Number\(body\.measurementVersion\)/)
|
||||
assert.match(source, /await updateConfirmedPackageCount\(context, measurementPath/)
|
||||
})
|
||||
|
||||
async function exerciseSystemPalletScenario(fault = '') {
|
||||
const task = { taskId: '81', inboundId: '82', warehouseId: '83', taskStatus: 'CLAIMED',
|
||||
totalCartons: 2, countedCartons: 0, boundCartons: 0, unboundCartons: 0, pallets: [] }
|
||||
const seenCounts = new Map()
|
||||
const seenCreates = new Set()
|
||||
const checks = []
|
||||
const response = (data, code = '200') => ({ http: 200, body: { code, data: structuredClone(data) } })
|
||||
const context = {
|
||||
marker: 'fixture-system-pallet',
|
||||
apiSuccess(_module, name, result) {
|
||||
assert.equal(result.body.code, '200', name)
|
||||
return result.body.data
|
||||
},
|
||||
assert(_module, name, _response, condition) { checks.push({ name, passed: Boolean(condition) }) },
|
||||
async request(method, path, body) {
|
||||
if (path.includes('/pending?')) return response([task])
|
||||
if (path.endsWith('/claim') || (method === 'GET' && path.includes('/tasks/81?'))) return response(task)
|
||||
if (path.includes('/cartons/resolve?')) return response({ boxCode: new URL(path, 'http://fixture').searchParams.get('boxCode'), canCount: true, expectedSkuQuantity: 10 })
|
||||
if (path.endsWith('/cartons/count')) {
|
||||
if (seenCounts.has(body.operationId)) return seenCounts.get(body.operationId)
|
||||
task.countedCartons += 1
|
||||
const targetPallet = task.pallets.find(pallet => pallet.palletNo === body.palletNo)
|
||||
if (body.palletNo) { task.boundCartons += 1; targetPallet.cartonCount += 1; targetPallet.version += 1 }
|
||||
else task.unboundCartons += 1
|
||||
const result = response({ ...task, carton: { boxCode: body.boxCode, expectedSkuQuantity: 10, actualSkuQuantity: 10,
|
||||
discrepancyStatus: 'MATCH' }, pallet: body.palletNo ? targetPallet : null })
|
||||
seenCounts.set(body.operationId, result)
|
||||
return result
|
||||
}
|
||||
if (path.endsWith('/create-and-bind')) {
|
||||
assert.deepEqual(Object.keys(body).sort(), ['operationId', 'warehouseId'])
|
||||
if (seenCreates.has(body.operationId)) {
|
||||
if (fault === 'duplicate-replay') task.pallets.push({ ...task.pallets[0], palletId: '999', palletNo: 'DP-260909-0999' })
|
||||
return response(task)
|
||||
}
|
||||
if (!task.unboundCartons) {
|
||||
if (fault === 'empty-pallet') task.pallets.push({ palletId: '998', palletNo: 'DP-260909-0998', cartonCount: 0, status: 'OPEN', version: 0 })
|
||||
return response(null, 'A0409')
|
||||
}
|
||||
seenCreates.add(body.operationId)
|
||||
task.pallets.push({ palletId: '84', palletNo: 'DP-260909-0001', cartonCount: task.unboundCartons, status: 'OPEN', version: 0 })
|
||||
task.boundCartons += task.unboundCartons
|
||||
task.unboundCartons = 0
|
||||
return response(task)
|
||||
}
|
||||
if (path.endsWith('/84/seal')) {
|
||||
task.taskStatus = 'COMPLETED'
|
||||
task.pallets.forEach(pallet => { pallet.status = 'SEALED'; pallet.version += 1 })
|
||||
return response(task)
|
||||
}
|
||||
throw new Error(`Unexpected scenario request: ${method} ${path}`)
|
||||
},
|
||||
}
|
||||
await completeDropshipPalletization(context, { inboundId: '82', inboundCode: 'CODE', warehouseId: '83', measurement: { actualPackageCount: 2 } })
|
||||
return checks
|
||||
}
|
||||
|
||||
test('system-issued pallet HTTP scenario covers count, replay, empty rejection and completed replay', async () => {
|
||||
const checks = await exerciseSystemPalletScenario()
|
||||
assert.ok(checks.length >= 15)
|
||||
assert.deepEqual(checks.filter(check => !check.passed), [])
|
||||
})
|
||||
|
||||
test('system-issued pallet HTTP regression detects duplicate replay and orphan empty pallets', async () => {
|
||||
const replayChecks = await exerciseSystemPalletScenario('duplicate-replay')
|
||||
assert.ok(replayChecks.some(check => check.name.includes('系统发码重放不新增托盘') && !check.passed))
|
||||
const emptyChecks = await exerciseSystemPalletScenario('empty-pallet')
|
||||
assert.ok(emptyChecks.some(check => check.name.includes('拒绝创建没有空托或计数漂移') && !check.passed))
|
||||
})
|
||||
|
||||
@@ -113,7 +113,7 @@ async function waitForCompletedMeasurement(context, measurementPath, expectedSta
|
||||
return { response, measurement, attempts }
|
||||
}
|
||||
|
||||
async function completeDropshipPalletization(context, { inboundId, inboundCode, warehouseId, measurement }) {
|
||||
export async function completeDropshipPalletization(context, { inboundId, inboundCode, warehouseId, measurement }) {
|
||||
const pendingResponse = await context.request('GET',
|
||||
`/api/platform/v1/first-leg/pda-dropship/tasks/pending?keyword=${encodeURIComponent(inboundCode)}`)
|
||||
const pending = context.apiSuccess(MODULE, '查询一件代发待合托任务', pendingResponse,
|
||||
@@ -135,15 +135,31 @@ async function completeDropshipPalletization(context, { inboundId, inboundCode,
|
||||
`taskStatus=${claimed?.taskStatus ?? 'missing'}`, OPTIONS)
|
||||
|
||||
const totalCartons = Number(measurement?.actualPackageCount)
|
||||
const palletDate = new Date().toISOString().slice(2, 10).replace(/-/g, '')
|
||||
let palletHash = 0
|
||||
for (const character of `${context.marker}:${taskId}`) {
|
||||
palletHash = (Math.imul(palletHash, 31) + character.charCodeAt(0)) >>> 0
|
||||
}
|
||||
const palletNo = `DP-${palletDate}-${String(palletHash % 10_000).padStart(4, '0')}`
|
||||
const createPath = `/api/platform/v1/first-leg/pda-dropship/tasks/${taskId}/pallets/create-and-bind`
|
||||
const taskPath = `/api/platform/v1/first-leg/pda-dropship/tasks/${taskId}?warehouseId=${warehouseId}`
|
||||
const createPayload = { warehouseId, operationId: `${context.marker}-dropship-create-bind`.slice(0, 128) }
|
||||
let palletNo = ''
|
||||
let countedCartons = 0
|
||||
let pallet = null
|
||||
|
||||
const palletSnapshot = (value) => JSON.stringify({
|
||||
counted: value?.countedCartons, bound: value?.boundCartons, unbound: value?.unboundCartons,
|
||||
pallets: value?.pallets?.map((item) => ({ id: asId(item.palletId), no: item.palletNo,
|
||||
count: item.cartonCount, status: item.status, version: item.version })),
|
||||
})
|
||||
async function rejectEmptyCreation(before, suffix) {
|
||||
const response = await context.request('POST', createPath,
|
||||
{ ...createPayload, operationId: `${context.marker}-dropship-empty-${suffix}`.slice(0, 128) })
|
||||
context.assert(MODULE, `无待绑箱创建托盘被拒绝-${suffix}`, response,
|
||||
response.http === 200 && String(response.body?.code) === 'A0409',
|
||||
`HTTP=${response.http}, code=${response.body?.code || 'missing'}`, OPTIONS)
|
||||
const afterResponse = await context.request('GET', taskPath)
|
||||
const after = context.apiSuccess(MODULE, `拒绝创建后回查任务-${suffix}`, afterResponse, '', OPTIONS)
|
||||
context.assert(MODULE, `拒绝创建没有空托或计数漂移-${suffix}`, afterResponse,
|
||||
palletSnapshot(after) === palletSnapshot(before), `pallets=${after?.pallets?.length ?? 'missing'}`, OPTIONS)
|
||||
}
|
||||
await rejectEmptyCreation(claimed, 'before-count')
|
||||
|
||||
for (let index = 0; index < totalCartons; index += 1) {
|
||||
const boxCode = `${inboundCode}#${totalCartons}-${index + 1}`
|
||||
const resolveResponse = await context.request('GET',
|
||||
@@ -189,18 +205,21 @@ async function completeDropshipPalletization(context, { inboundId, inboundCode,
|
||||
&& Number(replayed?.unboundCartons) === Number(counted?.unboundCartons),
|
||||
`counted=${replayed?.countedCartons ?? 'missing'}, unbound=${replayed?.unboundCartons ?? 'missing'}`, OPTIONS)
|
||||
|
||||
const bindResponse = await context.request('POST',
|
||||
`/api/platform/v1/first-leg/pda-dropship/tasks/${taskId}/pallets/bind-pending`, {
|
||||
warehouseId, palletNo,
|
||||
operationId: `${context.marker}-dropship-bind-pending`.slice(0, 128),
|
||||
})
|
||||
const bound = context.apiSuccess(MODULE, '扫描托盘并绑定全部待组托箱', bindResponse,
|
||||
`taskId=${taskId}, palletNo=${palletNo}`, OPTIONS)
|
||||
pallet = bound?.pallets?.find((item) => item?.palletNo === palletNo) || null
|
||||
const bindResponse = await context.request('POST', createPath, createPayload)
|
||||
const bound = context.apiSuccess(MODULE, '系统发码创建托盘并绑定全部待组托箱', bindResponse,
|
||||
`taskId=${taskId}`, OPTIONS)
|
||||
pallet = bound?.pallets?.find((item) => Number(item.cartonCount) === 1) || null
|
||||
palletNo = pallet?.palletNo || ''
|
||||
context.assert(MODULE, '待组托箱绑定到OPEN托盘且不再游离', bindResponse,
|
||||
Number(bound?.unboundCartons) === 0 && Number(pallet?.cartonCount) === 1
|
||||
&& pallet?.status === 'OPEN',
|
||||
&& pallet?.status === 'OPEN' && /^DP-\d{6}-\d{4}$/.test(palletNo)
|
||||
&& bound?.pallets?.length === 1,
|
||||
`unbound=${bound?.unboundCartons ?? 'missing'}, palletStatus=${pallet?.status ?? 'missing'}`, OPTIONS)
|
||||
const createReplayResponse = await context.request('POST', createPath, createPayload)
|
||||
const createReplay = context.apiSuccess(MODULE, '系统发码同operationId重放', createReplayResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '系统发码重放不新增托盘且业务码和计数稳定', createReplayResponse,
|
||||
palletSnapshot(createReplay) === palletSnapshot(bound), `palletNo=${palletNo}`, OPTIONS)
|
||||
await rejectEmptyCreation(bound, 'after-bind')
|
||||
} else {
|
||||
pallet = counted?.pallet || pallet
|
||||
context.assert(MODULE, `第${index + 1}箱直接进入当前OPEN托盘`, countResponse,
|
||||
@@ -229,6 +248,11 @@ async function completeDropshipPalletization(context, { inboundId, inboundCode,
|
||||
&& Number(sealed?.unboundCartons) === 0
|
||||
&& sealed?.pallets?.every((item) => item?.status === 'SEALED'),
|
||||
`taskStatus=${sealed?.taskStatus ?? 'missing'}, counted=${sealed?.countedCartons ?? 'missing'}, bound=${sealed?.boundCartons ?? 'missing'}`, OPTIONS)
|
||||
const sealedReplayResponse = await context.request('POST', createPath, createPayload)
|
||||
const sealedReplay = context.apiSuccess(MODULE, '封托完成后系统发码原操作仍可重放', sealedReplayResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '完成后重放不重开任务或新建空托', sealedReplayResponse,
|
||||
sealedReplay?.taskStatus === 'COMPLETED' && palletSnapshot(sealedReplay) === palletSnapshot(sealed),
|
||||
`taskStatus=${sealedReplay?.taskStatus || 'missing'}, palletNo=${palletNo}`, OPTIONS)
|
||||
return sealed
|
||||
}
|
||||
|
||||
|
||||
@@ -86,8 +86,8 @@ export async function run(context) {
|
||||
&& !rows(operations).some((item) => ['REVIEW', 'COMPLETE_LOADING'].includes(item.actionType)),
|
||||
`actions=${rows(operations).map((item) => item.actionType).join(',') || 'none'}`, OPTIONS)
|
||||
|
||||
const containerResponse = await context.request('GET', `${CONTAINERS}/${encodeURIComponent(source.containerBookingId)}`)
|
||||
const container = context.apiSuccess(MODULE, '最终完成前回查 RELEASED 订舱柜次', containerResponse, '', OPTIONS)
|
||||
const containerResponse = await context.request('GET', `${CONTAINERS}/domestic/${encodeURIComponent(source.containerBookingId)}`)
|
||||
const container = context.apiSuccess(MODULE, '最终完成前回查 RELEASED 订舱柜次', containerResponse, '', OPTIONS)?.container
|
||||
const bookingResponse = container?.oceanBookingId
|
||||
? await context.request('GET', `/api/platform/v1/ocean-bookings/${encodeURIComponent(container.oceanBookingId)}`)
|
||||
: containerResponse
|
||||
|
||||
@@ -41,8 +41,8 @@ export async function run(context) {
|
||||
OPTIONS)
|
||||
if (!bookingId) return
|
||||
|
||||
const containerResponse = await context.request('GET', `${CONTAINERS}/${encodeURIComponent(bookingId)}`)
|
||||
const container = context.apiSuccess(MODULE, '读取人工分柜关联柜次', containerResponse, '', OPTIONS)
|
||||
const containerResponse = await context.request('GET', `${CONTAINERS}/domestic/${encodeURIComponent(bookingId)}`)
|
||||
const container = context.apiSuccess(MODULE, '读取人工分柜关联柜次', containerResponse, '', OPTIONS)?.container
|
||||
context.assert(MODULE, '柜次保持准备清单的仓库和渠道范围', containerResponse,
|
||||
id(container?.id) === bookingId && id(container?.warehouseId) === id(manifest.warehouseId)
|
||||
&& container?.channelCode === manifest.channelCode,
|
||||
|
||||
@@ -46,8 +46,8 @@ export async function run(context) {
|
||||
OPTIONS)
|
||||
if (!plan || beforeItems.length === 0) return
|
||||
|
||||
const containerResponse = await context.request('GET', `${CONTAINERS}/${encodeURIComponent(containerId)}`)
|
||||
const container = context.apiSuccess(MODULE, 'PDA 就绪操作前读取内部模拟柜', containerResponse, '', OPTIONS)
|
||||
const containerResponse = await context.request('GET', `${CONTAINERS}/domestic/${encodeURIComponent(containerId)}`)
|
||||
const container = context.apiSuccess(MODULE, 'PDA 就绪操作前读取内部模拟柜', containerResponse, '', OPTIONS)?.container
|
||||
context.assert(MODULE, '操作前柜次仍无真实柜号和订舱关联', containerResponse,
|
||||
id(container?.id) === containerId && container?.cabinetNo === source.cabinetNo
|
||||
&& container?.containerNo == null && container?.oceanBookingId == null
|
||||
|
||||
@@ -47,8 +47,8 @@ export async function run(context) {
|
||||
`status=${plan?.status || 'missing'}, beforeVersion=${fixture.beforePlanVersion}, afterVersion=${plan?.version ?? 'missing'}, items=${afterItems.length}`,
|
||||
OPTIONS)
|
||||
|
||||
const containerResponse = await context.request('GET', `${CONTAINERS}/${encodeURIComponent(containerId)}`)
|
||||
const container = context.apiSuccess(MODULE, '读取关联真实柜号后的稳定柜次', containerResponse, '', OPTIONS)
|
||||
const containerResponse = await context.request('GET', `${CONTAINERS}/domestic/${encodeURIComponent(containerId)}`)
|
||||
const container = context.apiSuccess(MODULE, '读取关联真实柜号后的稳定柜次', containerResponse, '', OPTIONS)?.container
|
||||
const oceanBookingId = id(container?.oceanBookingId)
|
||||
const realContainerNo = String(container?.containerNo || '')
|
||||
context.assert(MODULE, '真实柜号和订舱原地补全且内部柜次身份不变', containerResponse,
|
||||
|
||||
@@ -47,8 +47,8 @@ export async function run(context) {
|
||||
context.assert(MODULE, '未点击推送前不存在预装 bridge', linksResponse,
|
||||
rows(links).length === 0, `linkCount=${rows(links).length}`, OPTIONS)
|
||||
|
||||
const containerResponse = await context.request('GET', `${CONTAINERS}/${encodeURIComponent(containerId)}`)
|
||||
const container = context.apiSuccess(MODULE, '推送前读取内部模拟柜', containerResponse, '', OPTIONS)
|
||||
const containerResponse = await context.request('GET', `${CONTAINERS}/domestic/${encodeURIComponent(containerId)}`)
|
||||
const container = context.apiSuccess(MODULE, '推送前读取内部模拟柜', containerResponse, '', OPTIONS)?.container
|
||||
context.assert(MODULE, '当前仍是无真实柜号且未关联订舱的内部模拟柜', containerResponse,
|
||||
id(container?.id) === containerId && container?.containerNo == null && container?.oceanBookingId == null
|
||||
&& id(container?.warehouseId) === id(source.warehouseId) && container?.channelCode === source.channelCode,
|
||||
|
||||
@@ -87,8 +87,8 @@ export async function run(context) {
|
||||
`HTTP=${taskResponse.http}, code=${codeOf(taskResponse)}, requestId=${taskResponse.requestId || 'missing'}, traceId=${taskResponse.traceId || 'missing'}`,
|
||||
OPTIONS)
|
||||
|
||||
const containerResponse = await context.request('GET', `${CONTAINERS}/${encodeURIComponent(containerId)}`)
|
||||
const container = context.apiSuccess(MODULE, '回查推送后内部柜次身份', containerResponse, '', OPTIONS)
|
||||
const containerResponse = await context.request('GET', `${CONTAINERS}/domestic/${encodeURIComponent(containerId)}`)
|
||||
const container = context.apiSuccess(MODULE, '回查推送后内部柜次身份', containerResponse, '', OPTIONS)?.container
|
||||
context.assert(MODULE, '推送预装不伪造真实柜号或订舱关联', containerResponse,
|
||||
id(container?.id) === containerId && container?.cabinetNo === fixture.cabinetNo
|
||||
&& container?.containerNo == null && container?.oceanBookingId == null,
|
||||
|
||||
@@ -316,10 +316,10 @@ async function complete(context, chain, label) {
|
||||
}
|
||||
|
||||
async function verifyContainer(context, status, fixture) {
|
||||
const response = await context.request('GET', `${CONTAINERS}?status=${status}&page=1&size=100`)
|
||||
const response = await context.request('GET', `${CONTAINERS}/domestic?scope=ACTIVE&status=${status}&page=1&size=100`)
|
||||
const page = context.apiSuccess(MODULE, `${status} 货柜可按列表状态筛选`, response, '', OPTIONS)
|
||||
context.assert(MODULE, `${status} 货柜样本保留`, response,
|
||||
records(page).some((item) => id(item.id) === fixture.id && item.planStatus === status),
|
||||
records(page).some((item) => id(item.container?.id) === fixture.id && item.container?.planStatus === status),
|
||||
`id=${fixture.id}`, OPTIONS)
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,18 @@ function failure(response) {
|
||||
|| (response?.http === 200 && codeOf(response) !== '200')
|
||||
}
|
||||
function records(page) { return Array.isArray(page?.records) ? page.records : [] }
|
||||
function containerRecord(value) { return value?.container || value }
|
||||
function containerRecord(value) { return value?.container }
|
||||
|
||||
export function containerWorkbenchGaps(value) {
|
||||
return [
|
||||
!value?.container?.id && 'container.id',
|
||||
!value?.container?.cabinetNo && 'container.cabinetNo',
|
||||
!Number.isInteger(value?.container?.version) && 'container.version',
|
||||
!value?.workbenchStage && 'workbenchStage',
|
||||
(!value?.statusSummary || typeof value.statusSummary !== 'object') && 'statusSummary',
|
||||
!Array.isArray(value?.allowedActions) && 'allowedActions',
|
||||
].filter(Boolean)
|
||||
}
|
||||
|
||||
async function waitForCompletedTask(context, taskId, warehouseId, attempts = 45) {
|
||||
let response = null
|
||||
@@ -262,6 +273,18 @@ export async function run(context) {
|
||||
|
||||
const containerDetailResponse = await context.request('GET', `${DOMESTIC_CONTAINERS}/${containerId}`)
|
||||
const containerDetail = containerRecord(context.apiSuccess(MODULE, '回查货柜详情', containerDetailResponse, '', OPTIONS))
|
||||
const workbenchGaps = containerWorkbenchGaps(containerDetailResponse.body?.data)
|
||||
context.assert(MODULE, '货柜详情返回正式工作台 DTO 而非旧扁平实体', containerDetailResponse,
|
||||
workbenchGaps.length === 0, `missing=${workbenchGaps.join('|') || 'none'}`, OPTIONS)
|
||||
const pageResponse = await context.request('GET',
|
||||
`${DOMESTIC_CONTAINERS}?page=1&size=20&scope=ACTIVE&status=${encodeURIComponent(containerDetail?.planStatus || '')}&keyword=${encodeURIComponent(containerDetail?.cabinetNo || '')}`)
|
||||
const page = context.apiSuccess(MODULE, '按货柜页面筛选参数查询本轮模拟柜', pageResponse, '', OPTIONS)
|
||||
const ownRows = records(page).filter((item) => id(item?.container?.id) === containerId)
|
||||
context.assert(MODULE, '货柜页面列表返回唯一模拟柜和工作台动作状态', pageResponse,
|
||||
ownRows.length === 1 && records(page).every((item) => containerWorkbenchGaps(item).length === 0)
|
||||
&& ownRows[0]?.container?.cabinetNo === containerDetail?.cabinetNo
|
||||
&& ownRows[0]?.container?.planStatus === containerDetail?.planStatus,
|
||||
`matches=${ownRows.length}, rows=${records(page).length}`, OPTIONS)
|
||||
context.assert(MODULE, '货柜详情保留候选货物路线约束', containerDetailResponse,
|
||||
(!containerDetail?.destinationWarehouseCode || !candidate.destinationWarehouseCode
|
||||
|| containerDetail.destinationWarehouseCode === candidate.destinationWarehouseCode)
|
||||
@@ -475,7 +498,7 @@ export async function run(context) {
|
||||
const activeContainersResponse = await context.request('GET',
|
||||
`${DOMESTIC_CONTAINERS}?page=1&size=50&scope=ACTIVE&keyword=${encodeURIComponent(realContainerNo)}`)
|
||||
const activeContainers = context.apiSuccess(MODULE, '关联后按真实柜号回查正常货柜列表', activeContainersResponse, '', OPTIONS)
|
||||
const activeRealRows = records(activeContainers).map(containerRecord).filter((item) => item.containerNo === realContainerNo)
|
||||
const activeRealRows = records(activeContainers).map(containerRecord).filter((item) => item?.containerNo === realContainerNo)
|
||||
context.assert(MODULE, '关联后列表仅保留模拟柜原地补全的一条 ACTIVE 操作身份', activeContainersResponse,
|
||||
activeRealRows.length === 1 && id(activeRealRows[0]?.id) === containerId,
|
||||
`activeIds=${activeRealRows.map((item) => id(item?.id)).join('|') || 'none'}`, OPTIONS)
|
||||
@@ -592,7 +615,7 @@ export async function run(context) {
|
||||
if (!taskId || !pickItem?.id) return
|
||||
|
||||
const releasedContainerResponse = await context.request('GET', `${DOMESTIC_CONTAINERS}/${containerId}`)
|
||||
const releasedContainer = context.apiSuccess(MODULE, '任务生成后回查货柜运输版本', releasedContainerResponse, '', OPTIONS)
|
||||
const releasedContainer = containerRecord(context.apiSuccess(MODULE, '任务生成后回查货柜运输版本', releasedContainerResponse, '', OPTIONS))
|
||||
|
||||
const stableIdentity = {
|
||||
taskId, loadingPlanId: id(task.loadingPlanId), containerBookingId: id(task.containerBookingId),
|
||||
|
||||
@@ -26,7 +26,8 @@ const matrix = read('../../../docs/国内货柜履约/货柜管理全链验收
|
||||
|
||||
test('container acceptance reads the canonical domestic workbench route', () => {
|
||||
assert.match(containerLoadingAcceptance, /const DOMESTIC_CONTAINERS = `\$\{CONTAINERS\}\/domestic`/)
|
||||
assert.match(containerLoadingAcceptance, /function containerRecord\(value\) \{ return value\?\.container \|\| value \}/)
|
||||
assert.match(containerLoadingAcceptance, /function containerRecord\(value\) \{ return value\?\.container \}/)
|
||||
assert.doesNotMatch(containerLoadingAcceptance, /value\?\.container \|\| value/)
|
||||
assert.match(containerLoadingAcceptance, /DOMESTIC_CONTAINERS\}\?page=1&size=50&scope=ACTIVE&keyword=/)
|
||||
assert.doesNotMatch(containerLoadingAcceptance, /request\('GET', `\$\{CONTAINERS\}\/\$\{containerId\}`\)/)
|
||||
assert.match(phase3LoadingAcceptance, /\/api\/platform\/v1\/containers\/domestic\/\$\{containerBookingId\}/)
|
||||
|
||||
@@ -25,7 +25,7 @@ function failure(response) {
|
||||
return [400, 401, 403, 404, 409, 503].includes(response?.http)
|
||||
|| (response?.http === 200 && codeOf(response) !== '200')
|
||||
}
|
||||
function containerRecord(value) { return value?.container || value }
|
||||
function containerRecord(value) { return value?.container }
|
||||
|
||||
async function ensureMatsonSupplier(context, records) {
|
||||
const existing = records.find((value) => value?.id && value?.name === MATSON_SUPPLIER_NAME)
|
||||
|
||||
@@ -79,7 +79,8 @@ test('container API uses one stable long string identity', () => {
|
||||
assert.match(endpoint, /sourceContainerBookingId:\s*\{ type: string/)
|
||||
assert.match(controller, /convertContainer/)
|
||||
assert.match(adminApi, /sourceContainerBookingId: string/)
|
||||
assert.match(adminView, /sourceContainerBookingId: container\.sourceContainerBookingId/)
|
||||
assert.match(adminView, /const bookingId = preview\.sourceContainerBookingId/)
|
||||
assert.match(adminView, /convertLastMileContainerToInboundPlans\(\{\s*sourceContainerBookingId: bookingId,\s*snapshotVersion: preview\.snapshotVersion,\s*snapshotHash: preview\.snapshotHash,/)
|
||||
})
|
||||
|
||||
test('backend batch conversion is one transaction and never merges same-customer orders', () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
[CmdletBinding()]
|
||||
[CmdletBinding()]
|
||||
param()
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
@@ -126,13 +126,28 @@ Assert-Contains $docsIndex '[测试环境独立任务协作登记](独立任务
|
||||
Assert-Contains $docsIndex '[开发环境独立任务协作登记](开发环境独立任务协作登记.md)' '文档开发登记入口'
|
||||
Assert-Contains $testTaskRegistry 'projectId `c64729ff-6710-43c6-8ef2-d84e9666cc1e`' '测试登记保存项目'
|
||||
Assert-Contains $testTaskRegistry 'C:\Users\BY-OY\Desktop\code\chongchong-global`' '测试登记工作目录'
|
||||
Assert-Contains $testTaskRegistry '已重新启用测试环境总控调度' '测试登记启用状态'
|
||||
Assert-Contains $testTaskRegistry '`01a05b10-a5fc-7d51-8203-c671f3a823ad`' '测试环境全流程总控'
|
||||
Assert-Contains $testTaskRegistry '`01a046ea-cfc4-73a1-85f0-c69c714bff22`' '测试环境海外接柜仓总控'
|
||||
Assert-Contains $testTaskRegistry '| 海外接柜后端 |' '测试环境海外接柜后端专项'
|
||||
Assert-Contains $testTaskRegistry '| 海外接柜Admin前端 |' '测试环境海外接柜 Admin 专项'
|
||||
Assert-Contains $testTaskRegistry '| 海外接柜Client前端 |' '测试环境海外接柜 Client 专项'
|
||||
Assert-Contains $testTaskRegistry '| 海外接柜PDA前端 |' '测试环境海外接柜 PDA 专项'
|
||||
# 启用状态属于各环境当前登记事实;不得用测试分支的历史任务名称/ID强制重新启用本地清空表。
|
||||
$registeredTestRows = @([regex]::Matches($testTaskRegistry, '(?m)^\|\s*`([0-9a-f-]{36})`\s*\|'))
|
||||
if ($registeredTestRows.Count -eq 0) {
|
||||
Assert-Contains $testTaskRegistry '不启用总控调度' '测试登记清空状态'
|
||||
Assert-Contains $testTaskRegistry '当前无已登记总控或长期专项' '测试登记清空明示'
|
||||
Assert-Contains $testTaskRegistry '持续授权同步失效' '测试登记清空授权边界'
|
||||
} else {
|
||||
Assert-Contains $testTaskRegistry '已重新启用测试环境总控调度' '测试登记启用状态'
|
||||
Assert-Contains $testTaskRegistry 'parent_task_id' '测试登记父任务字段'
|
||||
Assert-Contains $testTaskRegistry 'project_id' '测试登记项目字段'
|
||||
Assert-Matches $testTaskRegistry '(?m)^\|[^\r\n]*【总控】[^\r\n]*\|' '测试登记总控职责'
|
||||
$registeredIds = @($registeredTestRows | ForEach-Object { $_.Groups[1].Value })
|
||||
if (@($registeredIds | Select-Object -Unique).Count -ne $registeredIds.Count) {
|
||||
Add-Failure '测试登记存在重复 task_id'
|
||||
}
|
||||
$taskLines = @($testTaskRegistry -split '\r?\n' | Where-Object { $_ -match '^\|\s*`[0-9a-f-]{36}`\s*\|' })
|
||||
$controlLines = @($taskLines | Where-Object { $_.Contains('【总控】') })
|
||||
if ($controlLines.Count -ne 1) { Add-Failure '测试登记必须有且仅有一个总控' }
|
||||
foreach ($taskLine in $taskLines) {
|
||||
Assert-Contains $taskLine '`c64729ff-6710-43c6-8ef2-d84e9666cc1e`' '测试任务项目隔离'
|
||||
}
|
||||
}
|
||||
Assert-NotContains $testTaskRegistry '01a0194a-259c-7171-8967-074731a27618' '测试登记历史总控'
|
||||
Assert-NotContains $testTaskRegistry '01a06c0b-0515-7c60-b329-77342b54e666' '测试登记当前开发总控'
|
||||
Assert-NotContains $testTaskRegistry '6f874ec3-d3b2-49bd-8625-327f4d685f98' '测试登记开发 projectId'
|
||||
@@ -157,13 +172,13 @@ foreach ($removedPhaseDocsEntry in @('docs/一期/', 'docs/二期/', 'docs/三
|
||||
Assert-NotContains $rootAgents '不含出库和派送' '根海外仓全流程范围'
|
||||
|
||||
# 新增稳定业务能力必须从根表和目标部署单元直接命中。
|
||||
Assert-Contains $rootAgents '入库扣货、解除与凭证' '根入库扣货路由'
|
||||
Assert-Contains $rootAgents '入库单扣货、解除与履约门禁' '根入库扣货路由'
|
||||
Assert-Contains $adminAgents '入库扣货、解除与凭证历史' 'Admin 入库扣货路由'
|
||||
Assert-Contains $backendAgents '入库扣货、解除、履约门禁与凭证' '后端入库扣货路由'
|
||||
Assert-Contains $rootAgents '客户资金账户、充值、授信与业务扣费' '根客户资金账户路由'
|
||||
Assert-Contains $backendAgents '入库单扣货、解除、凭证与履约门禁' '后端入库扣货路由'
|
||||
Assert-Contains $rootAgents '客户资金账户、充值与海外业务资金指令' '根客户资金账户路由'
|
||||
Assert-Contains $adminAgents '客户资金账户与柜级公共费用分摊' 'Admin 客户资金账户路由'
|
||||
Assert-Contains $clientAgents '客户资金账户、授信与充值申请' 'Client 客户资金账户路由'
|
||||
Assert-Contains $backendAgents '客户资金账户、充值、授信、冻结与结算' '后端客户资金账户路由'
|
||||
Assert-Contains $backendAgents '客户资金账户、充值、授信、冻结、结算与跨区资金指令' '后端客户资金账户路由'
|
||||
Assert-Contains $rootAgents '顶层授权快照' '根海外顶层授权快照'
|
||||
Assert-Contains $backendAgents '顶层授权快照' '后端海外顶层授权快照'
|
||||
Assert-Contains $rootAgents '目的港码头拖车与还空预警' '根目的港码头拖车路由'
|
||||
@@ -172,7 +187,7 @@ Assert-Contains $rootAgents '尾程订单控制与柜级入仓计划转换' '根
|
||||
Assert-Contains $adminAgents '目的港码头拖车与还空预警' 'Admin 目的港码头拖车路由'
|
||||
Assert-Contains $adminAgents '尾程订单控制与柜级入仓计划转换' 'Admin 尾程订单控制路由'
|
||||
Assert-Contains $backendAgents '目的港码头拖车与还空预警' '后端目的港码头拖车路由'
|
||||
Assert-Contains $backendAgents '尾程订单控制与柜级入仓计划转换' '后端尾程订单控制路由'
|
||||
Assert-Contains $backendAgents '尾程订单控制、柜级预览与入仓计划转换' '后端尾程订单控制路由'
|
||||
$testTaskIds = @([regex]::Matches($testTaskRegistry, '(?m)^\|\s*`([0-9a-f-]{36})`\s*\|') | ForEach-Object { $_.Groups[1].Value })
|
||||
$devTaskIds = @([regex]::Matches($devTaskRegistry, '(?m)^\|\s*`([0-9a-f-]{36})`\s*\|') | ForEach-Object { $_.Groups[1].Value })
|
||||
foreach ($sharedTaskId in @($testTaskIds | Where-Object { $devTaskIds -contains $_ } | Select-Object -Unique)) {
|
||||
@@ -263,6 +278,48 @@ Assert-Contains $unifiedOrderQueryService 'order.getStatus() == OrderStatus.SUBM
|
||||
Assert-Contains $unifiedOrderQueryService 'pendingReview ? "PENDING_REVIEW" : summary.mainStatus()' 'Order 待审核主状态优先'
|
||||
Assert-Contains $unifiedOrderQueryService 'pendingReview ? "APPROVE_ORDER" : summary.nextAction()' 'Order 待审核动作优先'
|
||||
|
||||
# 批量审核、客户专属价格簿和柜级阶梯计价已经成为稳定跨端能力,必须从根表直达且锁住服务端权威规则。
|
||||
$batchReviewPreviewService = Read-RequiredText (Join-Path $backendRoot 'order/src/main/java/com/baiyue/order/service/BatchOrderReviewPreviewService.java')
|
||||
$fbaPricingOpenApi = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/resources/openapi/fba-pricing-v1.yaml')
|
||||
$fbaPricingService = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java')
|
||||
$inboundFeeDetailService = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/java/com/baiyue/firstleg/service/InboundFeeDetailService.java')
|
||||
$adminUnifiedOrdersApi = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/unified-orders/api/unifiedOrders.ts')
|
||||
$adminCarrierRatesView = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/fba-pricing/carrier-rates/pages/CarrierRatesView.vue')
|
||||
Assert-Contains $rootAgents '头程订单、批量审核预览与入仓码关系' '根批量审核预览路由'
|
||||
Assert-Contains $adminAgents '订单列表、批量审核预览、详情、商品图片与入库费用' 'Admin 批量审核预览路由'
|
||||
Assert-Contains $backendAgents '平台订单批量审核预览' '后端批量审核预览路由'
|
||||
Assert-Contains $orderAgents '平台批量审核预览' 'Order 批量审核预览入口'
|
||||
Assert-Contains $orderOpenApi '/api/platform/v1/orders/first-leg/batch-review-preview:' 'Order 批量审核预览 OpenAPI'
|
||||
Assert-Contains $batchReviewPreviewService '固定两次批量查询生成审核弹窗快照' 'Order 批量审核禁止逐票查询'
|
||||
Assert-Contains $adminUnifiedOrdersApi '/platform/v1/orders/first-leg/batch-review-preview' 'Admin 批量审核预览 API'
|
||||
Assert-Contains $rootAgents '客户专属报价、柜级阶梯运费与费用调整' '根客户专属报价路由'
|
||||
Assert-Contains $adminAgents '客户专属价格簿、柜级阶梯运费与受控调价' 'Admin 客户专属报价路由'
|
||||
Assert-Contains $backendAgents '客户专属价格簿、柜级阶梯运费与受控调价' '后端客户专属报价路由'
|
||||
Assert-Contains $firstlegAgents '客户专属价格簿、柜级阶梯运费与调价边界' 'Firstleg 客户专属报价入口'
|
||||
Assert-Contains $fbaPricingOpenApi '计价时优先使用客户专属 ACTIVE 版本,不存在时回退公共 ACTIVE 版本' '价格簿作用域优先级'
|
||||
Assert-Contains $fbaPricingOpenApi '计费量只能在系统原计费量上下10%范围内(含边界)' '计费量调整边界'
|
||||
Assert-Contains $fbaPricingService 'recalculateUnlockedContainerCustomerFees' '柜级阶梯费用重算入口'
|
||||
Assert-Contains $firstlegAgents '一件代发 `WAIT_PALLETIZE` 可在预装确认前试算' 'Firstleg 一件代发预装前计价边界'
|
||||
Assert-Contains $fbaPricingService '"WAIT_PALLETIZE"' '一件代发托盘化状态可计价'
|
||||
Assert-Contains $fbaPricingService 'preserveProcessStatusDuringPricing' '计价保留履约状态入口'
|
||||
Assert-Contains $inboundFeeDetailService 'PRIVATE_LAST_MILE_DELIVERY_FEE_CODE' '私人仓尾程派送费稳定代码'
|
||||
Assert-Contains $adminCarrierRatesView 'customerId' 'Admin 价格簿客户作用域'
|
||||
Assert-Path 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/pricing/ContainerTierAmountAllocator.java'
|
||||
|
||||
# 海外异常处理必须贯通证据、原出库来源和库存恢复,不能退化为仅修改派送状态。
|
||||
$exceptionOrderService = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java')
|
||||
$inventoryCommandService = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java')
|
||||
Assert-Contains $rootAgents '退回海外仓时先把实收恢复到指定真实托盘/库位' '根海外异常库存恢复路由'
|
||||
Assert-Contains $adminAgents '库存恢复、异常完成与派送 `RETURNED` 由服务端原子推进' 'Admin 海外异常原子边界'
|
||||
Assert-Contains $backendAgents '海外异常退回、库存恢复与客户回访' '后端海外异常库存恢复路由'
|
||||
Assert-Contains $lastlegAgents 'ExceptionEvidenceFileGateway.java' 'Lastleg 异常证据入口'
|
||||
Assert-Contains $lastlegAgents '`RETURN_STOCK_IN` 流水' 'Lastleg 退回库存流水边界'
|
||||
Assert-Contains $fileAgents 'businessType=OVERSEAS_EXCEPTION_EVIDENCE' 'File 海外异常证据归属'
|
||||
Assert-Contains $exceptionOrderService 'restoreReturnedInventory' '海外异常退回库存恢复调用'
|
||||
Assert-Contains $exceptionOrderService 'delivery.setStatus("RETURNED")' '海外异常完成派送状态'
|
||||
Assert-Contains $inventoryCommandService 'ReturnedInventoryRestoreRequest' '海外退回库存命令入口'
|
||||
Assert-Path 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/gateway/ExceptionEvidenceFileGateway.java'
|
||||
|
||||
# 智能装柜已扩展到四态候选、Order 待审核补齐、提前占位与未拣整批移除;费用确认和会话恢复也必须可定向命中。
|
||||
$pendingReviewOrderService = Read-RequiredText (Join-Path $backendRoot 'order/src/main/java/com/baiyue/order/service/PendingReviewOrderQueryService.java')
|
||||
$merchantInboundBillingService = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/java/com/baiyue/firstleg/service/MerchantInboundBillingService.java')
|
||||
@@ -391,7 +448,7 @@ Assert-Contains $firstlegAgents '按卸柜完成时间加 72 小时' 'Firstleg
|
||||
Assert-Contains $terminalDrayageOpenApi '/api/platform/v1/terminal-drayages/{containerBookingId}/fleet:' '码头拖车车队命令 OpenAPI'
|
||||
Assert-Contains $terminalDrayageOpenApi '/api/platform/v1/terminal-drayages/{containerBookingId}/empty-return:' '码头拖车还柜命令 OpenAPI'
|
||||
Assert-Contains $terminalDrayageService 'EMPTY_RETURN_WARNING_HOURS = 72' '码头拖车 72 小时预警实现'
|
||||
Assert-Contains $terminalDrayageService 'context.unloadingCompletedAt().plusHours(EMPTY_RETURN_WARNING_HOURS)' '码头拖车权威截止时间实现'
|
||||
Assert-Contains $terminalDrayageService 'context.base().unloadingCompletedAt().plusHours(EMPTY_RETURN_WARNING_HOURS)' '码头拖车权威截止时间实现'
|
||||
Assert-Contains $adminTerminalDrayages 'allowedActions' 'Admin 码头拖车服务端动作消费'
|
||||
Assert-Contains $lastlegOrderCommandService '必须提交完整8行人工时间快照' '尾程人工节点完整快照实现'
|
||||
Assert-Contains $lastlegOrderCommandService '尾程订单已进入出库流程,不能新增暂扣' '尾程暂扣出库门禁实现'
|
||||
@@ -675,28 +732,47 @@ Assert-Contains $productImageController "hasAuthority('platform:order:list')" '
|
||||
Assert-Contains $receivingController '/current/unload-photos/files/{fileId}/download' '海外 PDA 签收照片预览'
|
||||
Assert-Contains $receivingController 'CacheControl.noStore()' '海外 PDA 签收照片缓存边界'
|
||||
|
||||
# 尾程订单按来源柜展示服务端计划进度,整柜转换每票独立计划且整批原子。
|
||||
# 尾程订单按来源柜展示服务端计划进度;整柜确认必须绑定预览快照并编排真实托盘批次。
|
||||
$lastlegBusinessOpenApi = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml')
|
||||
$lastlegAgents = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/AGENTS.md')
|
||||
$adminLastMileApi = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/overseas-warehouse/api/lastMileOrders.ts')
|
||||
$containerConversionCoordinator = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinator.java')
|
||||
$warehousePalletBatchService = Read-RequiredText (Join-Path $backendRoot 'warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehousePalletBatchService.java')
|
||||
$overseasWarehouseAgents = Read-RequiredText (Join-Path $backendRoot 'warehouse-overseas-service/AGENTS.md')
|
||||
$overseasPdaReceivingAdapter = Read-RequiredText (Join-Path $workspaceRoot 'apps/pda-overseas-uniapp/src/api/receiving-adapter.js')
|
||||
Assert-Contains $lastlegBusinessOpenApi '/api/platform/v1/last-leg/orders/container-summaries:' 'Lastleg 柜级摘要契约'
|
||||
Assert-Contains $lastlegBusinessOpenApi '/api/platform/v1/last-leg/inbound-plans/convert-container:' 'Lastleg 整柜计划转换契约'
|
||||
Assert-Contains $lastlegBusinessOpenApi '/api/platform/v1/last-leg/inbound-plans/container-preview:' 'Lastleg 整柜计划预览契约'
|
||||
Assert-Contains $lastlegBusinessOpenApi 'snapshotVersion' 'Lastleg 整柜计划快照版本'
|
||||
Assert-Contains $lastlegBusinessOpenApi 'snapshotHash' 'Lastleg 整柜计划快照哈希'
|
||||
Assert-Contains $lastlegBusinessOpenApi 'sourceContainerBookingId' 'Lastleg 稳定来源柜身份'
|
||||
Assert-Contains $adminAgents '/orders/container-summaries' 'Admin 柜级摘要目标入口'
|
||||
Assert-Contains $rootAgents '尾程订单控制与柜级入仓计划转换' '根柜级计划入口'
|
||||
Assert-Contains $backendAgents '分页前排除全部入仓码均已生成有效独立计划的柜' '后端柜级计划待处理筛选'
|
||||
Assert-Contains $adminAgents '返回仍有待生成计划入仓码的柜' 'Admin 柜级计划待处理筛选'
|
||||
Assert-Contains $lastlegAgents '分页前排除全部入仓码均已生成有效独立计划的柜' 'Lastleg 柜级计划待处理筛选'
|
||||
Assert-Contains $lastlegBusinessOpenApi '仅返回仍有入仓码待生成有效独立计划的柜' 'Lastleg 柜级计划待处理 OpenAPI'
|
||||
Assert-Contains $backendAgents '分页返回全部稳定来源柜' '后端全柜工作台范围'
|
||||
Assert-Contains $adminAgents '服务端分页返回全部稳定来源柜' 'Admin 全柜工作台范围'
|
||||
Assert-Contains $lastlegAgents '分页返回全部稳定来源柜' 'Lastleg 全柜工作台范围'
|
||||
Assert-Contains $lastlegBusinessOpenApi '返回全部稳定来源柜' 'Lastleg 全柜工作台 OpenAPI'
|
||||
Assert-Contains $adminAgents 'ACTIVE + FBA' 'Admin FBA 权威仓候选'
|
||||
Assert-Contains $adminAgents '/inbound-plans/convert-container' 'Admin 整柜转换目标入口'
|
||||
Assert-Contains $adminAgents '/inbound-plans/container-preview' 'Admin 整柜预览目标入口'
|
||||
Assert-Contains $adminAgents '同一 `snapshotVersion/snapshotHash` 原样提交' 'Admin 整柜快照确认边界'
|
||||
Assert-Contains $adminLastMileApi '/orders/container-summaries' 'Admin 柜级摘要真实调用'
|
||||
Assert-Contains $adminLastMileApi '/platform/v1/last-leg/inbound-plans/container-preview' 'Admin 柜级预览真实调用'
|
||||
Assert-NotContains $adminLastMileApi '/orders/pending-containers' 'Admin 旧柜级摘要调用'
|
||||
Assert-Contains $lastlegBusinessOpenApi 'ContainerPlanProgressSummary' 'Lastleg 柜级计划进度摘要模型'
|
||||
Assert-Contains $lastlegBusinessOpenApi 'ContainerPlanProgressDetail' 'Lastleg 柜级计划进度详情模型'
|
||||
Assert-Contains $lastlegBusinessOpenApi 'allPlansReady' 'Lastleg 全柜计划就绪事实'
|
||||
Assert-Contains $lastlegBusinessOpenApi 'plannedArrivalAt' 'Lastleg 计划到仓冻结事实'
|
||||
Assert-Contains $lastlegAgents 'service/FbaWarehouseValidator.java' 'Lastleg FBA 权威仓校验入口'
|
||||
Assert-Contains $lastlegAgents 'ContainerInboundPlanConversionCoordinator.java' 'Lastleg 柜级计划 Saga 入口'
|
||||
Assert-Contains $containerConversionCoordinator 'warehouse.ensurePalletBatch' 'Lastleg 托盘批次预留'
|
||||
Assert-Contains $containerConversionCoordinator 'warehouse.confirmPalletBatch' 'Lastleg 托盘批次确认'
|
||||
Assert-Contains $containerConversionCoordinator 'warehouse.compensatePalletBatch' 'Lastleg 托盘批次补偿'
|
||||
Assert-Contains $warehousePalletBatchService 'Only a RESERVED pallet batch can be confirmed' '海外资源批次确认状态门禁'
|
||||
Assert-Contains $overseasWarehouseAgents '同柜重放不得产生第二批资源' '海外资源批次幂等边界'
|
||||
Assert-Contains $overseasPdaAgents 'assigned/mergeAllowed/urgent/mergeHint' '海外 PDA 计划标志索引'
|
||||
Assert-Contains $overseasPdaAgents '不在设备端重新分配' '海外 PDA 预留托盘边界'
|
||||
Assert-Contains $overseasPdaReceivingAdapter 'mergeAllowed: value.mergeAllowed === true' '海外 PDA 合托可选事实适配'
|
||||
|
||||
# 国内 PDA 当前以页面、API 和作业状态合同覆盖部分拣货及库位不足反馈。
|
||||
Assert-Contains $pdaAgents 'tests/page-contract.test.mjs' '国内 PDA 页面合同'
|
||||
@@ -723,8 +799,8 @@ Assert-Contains $backendAgents '海外业务费用来源跨区入池' '后端海
|
||||
Assert-Contains $firstlegAgents '单行入口拒绝分摊组' 'Firstleg 分摊组原子边界'
|
||||
Assert-Contains $adminFinanceAgents '`allocationGroupId` 整组审核、驳回、调整或作废' 'Admin 分摊组操作边界'
|
||||
Assert-Contains $containerFeeOpenApi '/fee-details/allocation-groups:' '柜费分摊组创建 OpenAPI'
|
||||
Assert-Contains $containerFeeOpenApi '各分摊金额必须大于0且合计等于费用总额' '柜费分摊金额守恒'
|
||||
Assert-Contains $containerFeeOpenApi '单行接口拒绝处理分摊组' '柜费分摊整组操作契约'
|
||||
Assert-Contains $containerFeeOpenApi '各行按 HALF_UP 保留两位后必须大于零,落库金额合计必须等于两位小数的总额。' '柜费分摊金额守恒'
|
||||
Assert-Contains $containerFeeOpenApi '分摊组不可经单行入口修改。' '柜费分摊整组操作契约'
|
||||
Assert-Contains $containerFeeController '@PostMapping("/allocation-groups/{groupId}/approve")' '柜费分摊整组审核入口'
|
||||
Assert-Contains $containerFeeController '@PostMapping("/allocation-groups/{groupId}/adjust")' '柜费分摊整组调整入口'
|
||||
Assert-Contains $containerFeeService '分摊客户、订单及入仓信息与柜内真实归属不一致' '柜费分摊真实归属校验'
|
||||
@@ -749,7 +825,7 @@ Assert-Contains $adminAgents '入库单扣货、解除、凭证与历史' 'Admin
|
||||
Assert-Contains $backendAgents '入库单扣货、解除、凭证与履约门禁' '后端入库扣货快速路由'
|
||||
Assert-Contains $firstlegAgents '入库单扣货、履约门禁与凭证' 'Firstleg 入库扣货入口'
|
||||
Assert-Contains $rootAgents '客户资金账户、充值与海外业务资金指令' '根客户钱包快速路由'
|
||||
Assert-Contains $backendAgents '客户资金账户、充值、授信与跨区资金指令' '后端客户钱包快速路由'
|
||||
Assert-Contains $backendAgents '客户资金账户、充值、授信、冻结、结算与跨区资金指令' '后端客户钱包快速路由'
|
||||
Assert-Contains $lastlegAgents '海外业务资金指令与结果' 'Lastleg 跨区钱包入口'
|
||||
Assert-Contains $lastlegAgents '拆柜打托成本与客户应收分摊' 'Lastleg 拆柜打托费用入口'
|
||||
Assert-Contains $clientAgents '换标申请必须提交正数 CNY `chargeAmount`' 'Client 换标钱包金额边界'
|
||||
@@ -1090,5 +1166,5 @@ if ($failures.Count -gt 0) {
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "AGENTS 索引合同通过:$checked 项检查,覆盖快速表唯一性、部署单元、开发/测试任务登记隔离、前端模块、Client active 路由、客户中文批量下单、箱规算法与测量计费、称重整票库位块与暂估箱数校准、入库扣货履约门禁、全局智能装柜组分页、客户账单入仓码联查、客户钱包与跨区资金指令、导出退避任务重试、脚本目录治理、Finance 扩展 HTTP 模块、柜费跨客户原子分摊、海外费用跨区入池与直接已付隔离、企业联系信息与登录标识解耦、选填法人身份证独立上传、待审核订单状态优先、订单申报版本同步与渠道门禁、装柜证据内容哈希重放、业务编号与内部 ID 隔离、装柜清单生命周期与真实柜号后补、订舱级拖车与柜级证据、海外仓全流程范围、柜级待处理计划筛选与范围漂移、仓库主档唯一权威、订舱候选与柜量语义、整柜出仓与船节点单调性、海外小 Token 与安全投影、Gateway 覆盖、PDA 页面/运行配置/独立更新/语言预留/能力开关、人工分段验收链、Maven 模块、同步 Trace、关键入口和排除目录。" -ForegroundColor Green
|
||||
Write-Host "AGENTS 索引合同通过:$checked 项检查,覆盖快速表唯一性、部署单元、开发/测试任务登记隔离、前端模块、Client active 路由、客户中文批量下单、箱规算法与测量计费、一件代发预装前计价、称重整票库位块与暂估箱数校准、入库扣货履约门禁、全局智能装柜组分页、客户账单入仓码联查、客户钱包与跨区资金指令、导出退避任务重试、脚本目录治理、Finance 扩展 HTTP 模块、柜费跨客户原子分摊、海外费用跨区入池与直接已付隔离、企业联系信息与登录标识解耦、选填法人身份证独立上传、待审核订单状态优先、订单申报版本同步与渠道门禁、装柜证据内容哈希重放、业务编号与内部 ID 隔离、装柜清单生命周期与真实柜号后补、订舱级拖车与柜级证据、海外仓全流程范围、柜级待处理计划筛选与范围漂移、柜级全柜工作台、计划快照与托盘 Saga、海外 PDA 多计划标志、仓库主档唯一权威、订舱候选与柜量语义、整柜出仓与船节点单调性、海外小 Token 与安全投影、Gateway 覆盖、PDA 页面/运行配置/独立更新/语言预留/能力开关、人工分段验收链、Maven 模块、同步 Trace、关键入口和排除目录。" -ForegroundColor Green
|
||||
exit 0
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import test from 'node:test'
|
||||
|
||||
const source = readFileSync(new URL('./agents-index-contract.ps1', import.meta.url), 'utf8')
|
||||
const start = source.indexOf('$registeredTestRows =')
|
||||
const end = source.indexOf("Assert-NotContains $testTaskRegistry '01a0194a", start)
|
||||
assert.ok(start > 0 && end > start)
|
||||
const registryContract = source.slice(start, end)
|
||||
const project = 'c64729ff-6710-43c6-8ef2-d84e9666cc1e'
|
||||
const root = '00000000-0000-4000-8000-000000000001'
|
||||
const child = '00000000-0000-4000-8000-000000000002'
|
||||
const enabled = `已重新启用测试环境总控调度\nparent_task_id project_id\n| \`${root}\` | \`${project}\` | 【总控】测试环境全流程 |\n| \`${child}\` | \`${root}\` | \`${project}\` | 【前端】专项 |`
|
||||
|
||||
function checkRegistry(text) {
|
||||
const script = `
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$failures = [System.Collections.Generic.List[string]]::new()
|
||||
function Add-Failure([string]$message) { $script:failures.Add($message) }
|
||||
function Assert-Contains([string]$text, [string]$needle, [string]$scope) { if (-not $text.Contains($needle)) { Add-Failure "$scope 缺少 $needle" } }
|
||||
function Assert-Matches([string]$text, [string]$pattern, [string]$scope) { if ($text -notmatch $pattern) { Add-Failure "$scope 缺少结构" } }
|
||||
$testTaskRegistry = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${Buffer.from(text).toString('base64')}'))
|
||||
${registryContract}
|
||||
Write-Output ('RESULT:' + (ConvertTo-Json -InputObject @($failures) -Compress))
|
||||
`
|
||||
const result = spawnSync('pwsh', ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')], { encoding: 'utf8' })
|
||||
assert.equal(result.status, 0, result.stderr)
|
||||
const output = result.stdout.match(/RESULT:(\[[^\r\n]*\])/)
|
||||
assert.ok(output, result.stdout)
|
||||
return JSON.parse(output[1])
|
||||
}
|
||||
|
||||
test('cleared test registry stays disabled and explicitly revokes inherited authorization', () => {
|
||||
assert.deepEqual(checkRegistry('不启用总控调度\n当前无已登记总控或长期专项\n持续授权同步失效'), [])
|
||||
assert.ok(checkRegistry('当前无已登记总控或长期专项').length > 0)
|
||||
assert.ok(checkRegistry('').length > 0)
|
||||
})
|
||||
|
||||
test('enabled registry requires explicit activation, one controller, unique tasks and its own project', () => {
|
||||
assert.deepEqual(checkRegistry(enabled), [])
|
||||
assert.ok(checkRegistry(enabled.replace('已重新启用测试环境总控调度', '')).length > 0)
|
||||
assert.ok(checkRegistry(enabled.replace('parent_task_id', '')).length > 0)
|
||||
assert.ok(checkRegistry(enabled.replace(child, root)).length > 0)
|
||||
assert.ok(checkRegistry(enabled.replace('【前端】专项', '【总控】其他')).length > 0)
|
||||
assert.ok(checkRegistry(enabled.replaceAll(project, '6f874ec3-d3b2-49bd-8625-327f4d685f98')).length > 0)
|
||||
})
|
||||
@@ -60,6 +60,7 @@ test('builds an order payload owned by the fixed profile', () => {
|
||||
senderLocation: { province: '浙江省', city: '义乌市', district: '稠城街道', postalCode: '322000' },
|
||||
},
|
||||
productImageFileId: '33',
|
||||
pickupAddressId: '55',
|
||||
})
|
||||
assert.equal(payload.receivingWarehouseId, '11')
|
||||
assert.equal(payload.destinationWarehouseId, '22')
|
||||
@@ -67,11 +68,14 @@ test('builds an order payload owned by the fixed profile', () => {
|
||||
assert.equal(payload.customerName, '固定测试客户')
|
||||
assert.equal(payload.cargoes.length, 1)
|
||||
assert.equal(payload.cargoes[0].productImageFileId, '33')
|
||||
assert.equal(payload.cargoes[0].woodenProduct, false)
|
||||
assert.equal(payload.tradeTerm, 'EXW')
|
||||
assert.equal(payload.pickupMethod, 'SELF_TRUCK')
|
||||
assert.equal(payload.taxMode, 'TAX_REFUND')
|
||||
assert.equal(payload.customsInspection, true)
|
||||
assert.equal(payload.sender, null)
|
||||
assert.equal(payload.senderAddressId, null)
|
||||
assert.equal(payload.requestedPickupAt, null)
|
||||
assert.equal(payload.cargoes[0].cargoName, PRODUCT_CATALOG[0].cargoName)
|
||||
assert.equal(payload.cargoes[0].packageCount, 7)
|
||||
assert.equal(payload.cargoes[0].totalQuantity, PRODUCT_CATALOG[0].unitsPerPackage * 7)
|
||||
@@ -79,6 +83,27 @@ test('builds an order payload owned by the fixed profile', () => {
|
||||
assert.equal(payload.cargoes[0].declaredValue, PRODUCT_CATALOG[0].unitDeclaredValue * PRODUCT_CATALOG[0].unitsPerPackage * 7)
|
||||
})
|
||||
|
||||
test('uses the selected customer pickup address and appointment for pickup orders', () => {
|
||||
const payload = buildOrderPayload({
|
||||
marker: 'PENDING-PICKUP', index: 1,
|
||||
profile: { id: '44', tenantName: '固定测试客户' },
|
||||
master: {
|
||||
receiving: { id: '11', warehouseName: '国内仓' },
|
||||
destination: { id: '22', warehouseName: 'ONT8', warehouseCode: 'ONT8', countryCode: 'US' },
|
||||
channel: { channelCode: 'SEA-US', channelName: '美西海运' }, pricingTaxMode: 'TAX_INCLUDED',
|
||||
},
|
||||
product: PRODUCT_CATALOG[0], packageCount: 1, productImageFileId: '33', pickupAddressId: '55',
|
||||
options: {
|
||||
tradeTerm: 'FOB', pickupMethod: 'PICKUP', taxMode: 'BUY_DECLARATION',
|
||||
customsInspection: false, expectedShipDays: 7,
|
||||
senderLocation: { province: '广东省', city: '深圳市', district: '南山区', postalCode: '518000' },
|
||||
},
|
||||
})
|
||||
assert.equal(payload.sender, null)
|
||||
assert.equal(payload.senderAddressId, '55')
|
||||
assert.match(payload.requestedPickupAt, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/)
|
||||
})
|
||||
|
||||
test('randomizes only supported basic-order fields', () => {
|
||||
for (let index = 0; index < 100; index += 1) {
|
||||
const options = randomOrderOptions()
|
||||
|
||||
@@ -247,7 +247,39 @@ async function uploadProductImage(gateway, timeoutMs, token, marker) {
|
||||
return data.fileId
|
||||
}
|
||||
|
||||
export function buildOrderPayload({ marker, index, profile, master, product, packageCount, options = randomOrderOptions(), productImageFileId }) {
|
||||
async function ensurePickupAddress(gateway, timeoutMs, token, profile, marker) {
|
||||
const listResponse = await request(gateway, timeoutMs, 'GET',
|
||||
'/api/merchant/v1/orders/first-leg/addresses?addressType=PICKUP', { token })
|
||||
const existing = items(requireSuccess(listResponse, 'Query pickup addresses'))
|
||||
.find((address) => address?.id)
|
||||
if (existing) return String(existing.id)
|
||||
|
||||
const location = pickRandom(SENDER_LOCATIONS)
|
||||
const createResponse = await request(gateway, timeoutMs, 'POST',
|
||||
'/api/merchant/v1/orders/first-leg/addresses', {
|
||||
token,
|
||||
body: {
|
||||
addressType: 'PICKUP',
|
||||
addressName: '待审核订单测试取货地址',
|
||||
companyName: profile.tenantName || `固定客户-${marker}`,
|
||||
contactName: '订单测试联系人',
|
||||
phone: '13800138000',
|
||||
email: 'pending-order@example.com',
|
||||
countryCode: 'CN',
|
||||
province: location.province,
|
||||
city: location.city,
|
||||
district: location.district,
|
||||
addressLine: `${marker} 固定客户测试地址`,
|
||||
postalCode: location.postalCode,
|
||||
defaultAddress: true,
|
||||
},
|
||||
})
|
||||
const addressId = String(requireSuccess(createResponse, 'Create pickup address') ?? '')
|
||||
if (!addressId) throw new Error('Create pickup address did not return address ID')
|
||||
return addressId
|
||||
}
|
||||
|
||||
export function buildOrderPayload({ marker, index, profile, master, product, packageCount, options = randomOrderOptions(), productImageFileId, pickupAddressId }) {
|
||||
const sequence = String(index).padStart(3, '0')
|
||||
const shipmentId = `SHIP-${sequence}-${marker}`.slice(0, 64)
|
||||
const referenceId = `REF-${sequence}-${marker}`.slice(0, 64)
|
||||
@@ -282,18 +314,10 @@ export function buildOrderPayload({ marker, index, profile, master, product, pac
|
||||
amazonWarehouseCode: master.destination.warehouseCode,
|
||||
shipmentId,
|
||||
referenceId,
|
||||
sender: options.pickupMethod === 'PICKUP' ? {
|
||||
companyName,
|
||||
contactName: '订单测试联系人',
|
||||
phone: '13800138000',
|
||||
email: 'pending-order@example.com',
|
||||
countryCode: 'CN',
|
||||
province: options.senderLocation.province,
|
||||
city: options.senderLocation.city,
|
||||
district: options.senderLocation.district,
|
||||
addressLine: `${marker} 固定客户测试地址`,
|
||||
postalCode: options.senderLocation.postalCode,
|
||||
} : null,
|
||||
requestedPickupAt: options.pickupMethod === 'PICKUP'
|
||||
? new Date(Date.now() + 7 * 86_400_000).toISOString().slice(0, 19) : null,
|
||||
senderAddressId: options.pickupMethod === 'PICKUP' ? pickupAddressId : null,
|
||||
sender: null,
|
||||
receiver: null,
|
||||
cargoes: [{
|
||||
skuNo: `SKU-${sequence}-${marker}`.slice(0, 64),
|
||||
@@ -325,6 +349,7 @@ export function buildOrderPayload({ marker, index, profile, master, product, pac
|
||||
referenceId,
|
||||
upcNo: `${Date.now()}${sequence}`.slice(-12),
|
||||
productImageFileId,
|
||||
woodenProduct: false,
|
||||
dangerousGoods: false,
|
||||
cargoRemark: `待审核订单货物 ${sequence} ${marker}`,
|
||||
}],
|
||||
@@ -383,6 +408,7 @@ export async function main(argv = process.argv.slice(2)) {
|
||||
|
||||
const routePool = await loadPricedRoutes(gateway, timeoutMs, token)
|
||||
const productImageFileId = await uploadProductImage(gateway, timeoutMs, token, marker)
|
||||
const pickupAddressId = await ensurePickupAddress(gateway, timeoutMs, token, profile, marker)
|
||||
const randomizedProducts = shuffled(PRODUCT_CATALOG)
|
||||
const sameChannel = args['same-channel'] === true
|
||||
const randomizedRoutes = sameChannel ? selectSameChannelRoutes(routePool) : diversifyRoutes(routePool)
|
||||
@@ -392,7 +418,7 @@ export async function main(argv = process.argv.slice(2)) {
|
||||
const product = randomizedProducts[(index - 1) % randomizedProducts.length]
|
||||
const packageCount = randomInt(1, 31)
|
||||
const options = randomOrderOptions()
|
||||
const payload = buildOrderPayload({ marker, index, profile, master, product, packageCount, options, productImageFileId })
|
||||
const payload = buildOrderPayload({ marker, index, profile, master, product, packageCount, options, productImageFileId, pickupAddressId })
|
||||
const createResponse = await request(gateway, timeoutMs, 'POST',
|
||||
'/api/merchant/v1/orders/first-leg', {
|
||||
token,
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"services": 15
|
||||
},
|
||||
"openapi": {
|
||||
"documents": 43,
|
||||
"documents": 45,
|
||||
"coveredServices": 10,
|
||||
"codeFirstServices": 5
|
||||
},
|
||||
|
||||
@@ -65,7 +65,10 @@ test('repository freezes the current topology and non-blocking aggregate summary
|
||||
const summary = await buildQualitySummary(repositoryRoot)
|
||||
const baseline = JSON.parse(await readFile(path.join(repositoryRoot, 'scripts', 'java-quality-baseline.json'), 'utf8'))
|
||||
assert.deepEqual(summary.topology, { modules: 22, codeModules: 21, services: 15 })
|
||||
assert.equal(summary.openapi.documents, 43)
|
||||
assert.equal(summary.openapi.documents, 45)
|
||||
assert.ok(summary.openapi.files.includes('finance/src/main/resources/openapi/finance-container-settlement-v1.yaml'))
|
||||
assert.ok(summary.openapi.files.includes('warehouse-overseas-service/src/main/resources/openapi/warehouse-pallet-batch-internal-v1.yaml'))
|
||||
assert.equal(baseline.openapi.documents, summary.openapi.documents)
|
||||
assert.equal(summary.openapi.coveredServices.length, 10)
|
||||
assert.equal(summary.openapi.codeFirstServices.length, 5)
|
||||
assert.equal(baseline.mode, 'report-only')
|
||||
|
||||
@@ -157,6 +157,8 @@ export const SYNCHRONOUS_CLIENTS = Object.freeze([
|
||||
'gateway-overseas', 'auth-service', ['INFRASTRUCTURE'], 'READ', 'SAFE_READ', 'POST PDA 会话校验,失败关闭'),
|
||||
http('lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/gateway/HttpCustomerVisitFileGateway.java',
|
||||
'lastleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '回访附件上传无幂等键,归属校验可重复'),
|
||||
http('lastleg-service/src/main/java/com/baiyue/lastleg/delivery/gateway/HttpDeliveryDocumentFileGateway.java',
|
||||
'lastleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '派送资料上传无幂等键,元数据按客户/派送单/类别校验,当前与历史下载只读'),
|
||||
http('lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/gateway/HttpExceptionEvidenceFileGateway.java',
|
||||
'lastleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '异常处理证据上传无幂等键,归属校验可重复'),
|
||||
http('lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGateway.java',
|
||||
|
||||
@@ -106,7 +106,7 @@ test('all 56 Feign clients have reviewed context, access and idempotency metadat
|
||||
assert.deepEqual(await discoverFeignEdges(), reviewed)
|
||||
})
|
||||
|
||||
test('all 20 raw HTTP transports have reviewed context, access and idempotency metadata', async () => {
|
||||
test('all 21 raw HTTP transports have reviewed context, access and idempotency metadata', async () => {
|
||||
const discovered = []
|
||||
for (const module of ['common-audit', 'event-bridge-service', 'gateway-overseas', 'lastleg-service', 'integration-service']) {
|
||||
for (const file of await javaFiles(path.join(root, module, 'src', 'main', 'java'))) {
|
||||
@@ -118,7 +118,7 @@ test('all 20 raw HTTP transports have reviewed context, access and idempotency m
|
||||
}
|
||||
const reviewed = SYNCHRONOUS_CLIENTS.filter(client => client.transport === 'HTTP')
|
||||
.map(client => client.path).sort()
|
||||
assert.equal(reviewed.length, 20)
|
||||
assert.equal(reviewed.length, 21)
|
||||
assert.deepEqual(discovered.sort(), reviewed)
|
||||
})
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
| 内部资源锁定、释放、报废 | `controller/InternalWarehouseResourceLockController.java` |
|
||||
| PDA 托盘状态流转与补偿 | `controller/InternalPdaPalletTransitionController.java`、`service/WarehousePalletTransitionService.java`、`mapper/WarehousePalletTransitionMapper.java`;内部前缀 `/internal/v1/pda-pallet-transitions` |
|
||||
| 托盘解析与业务绑定 | `controller/InternalWarehousePalletController.java`、`dto/WarehousePalletResolveResponse.java`、`entity/WarehousePalletBindingEntity.java` |
|
||||
| 柜级入库计划批量托盘 | `controller/InternalWarehousePalletBatchController.java`、`service/WarehousePalletBatchService.java`、`warehouse-pallet-batch-internal-v1.yaml`;先预留,Lastleg 本地成功后确认,失败时仅补偿未确认资源 |
|
||||
| 柜级入库计划批量托盘 | `controller/InternalWarehousePalletBatchController.java`、`service/WarehousePalletBatchService.java`、`warehouse-pallet-batch-internal-v1.yaml`;以稳定批次键幂等执行 `ensure-reserved → confirm`,只从可预留真实托盘/库位建立逐项资源锁;Lastleg 本地计划事务失败时仅补偿仍为 `RESERVED` 的未确认资源,已 `CONFIRMED` 批次不得回退,同柜重放不得产生第二批资源 |
|
||||
| 仓储资源服务 | `service/WarehouseResourceService.java`、`WarehouseResourceLockService.java` |
|
||||
| 内部服务令牌 | `security/InternalServiceTokenGuard.java` |
|
||||
| 数据库基线与中文注释契约 | `src/main/resources/db/warehouse.sql`、`src/test/java/com/baiyue/warehouse/schema/SchemaChineseCommentContractTest.java` |
|
||||
|
||||
Reference in New Issue
Block a user