From c3a978b4977042f955ebd8ac26afc55eac838d41 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Thu, 6 Aug 2026 15:48:21 +0800 Subject: [PATCH 1/8] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/头程数据库统一增量-MySQL8.sql | 780 ++++++++++++++++++ firstleg/pom.xml | 1 + .../firstleg/client/OrderCargoClient.java | 10 +- .../client/WarehousePalletLocationClient.java | 11 + .../PlatformContainerController.java | 63 ++ .../PlatformLoadingListController.java | 103 +++ .../PlatformLoadingPdaController.java | 48 ++ .../PlatformSmartLoadingController.java | 29 + .../firstleg/domain/SmartLoadingStatus.java | 18 + .../baiyue/firstleg/dto/ContainerDtos.java | 89 ++ .../baiyue/firstleg/dto/LoadingListDtos.java | 169 ++++ .../baiyue/firstleg/dto/LoadingPickDtos.java | 32 + .../baiyue/firstleg/dto/SmartLoadingDtos.java | 24 + .../entity/ContainerBookingEntity.java | 56 ++ .../entity/InboundPalletAllocationEntity.java | 1 + .../entity/LoadingPickHistoryEntity.java | 23 + .../entity/LoadingPickItemEntity.java | 30 + .../entity/LoadingPickTaskEntity.java | 30 + .../firstleg/entity/LoadingPlanEntity.java | 10 + .../entity/LoadingPlanHistoryEntity.java | 8 + .../entity/LoadingPlanItemEntity.java | 15 + .../mapper/ContainerBookingMapper.java | 82 ++ .../mapper/InboundPalletAllocationMapper.java | 31 + .../mapper/LoadingListQueryMapper.java | 297 +++++++ .../mapper/LoadingPickHistoryMapper.java | 9 + .../mapper/LoadingPickItemMapper.java | 21 + .../mapper/LoadingPickTaskMapper.java | 9 + .../mapper/LoadingPlanHistoryMapper.java | 3 + .../mapper/LoadingPlanItemMapper.java | 12 + .../firstleg/mapper/LoadingPlanMapper.java | 17 + .../mapper/SmartLoadingCandidateMapper.java | 46 ++ .../repository/ContainerRepository.java | 38 + .../InboundPalletAllocationRepository.java | 26 +- .../repository/LoadingListRepository.java | 150 ++++ .../repository/LoadingPickRepository.java | 86 ++ .../repository/SmartLoadingRepository.java | 74 ++ .../firstleg/service/ContainerService.java | 134 +++ .../firstleg/service/LoadingListService.java | 228 +++++ .../firstleg/service/LoadingPickService.java | 174 ++++ .../MeasurementLocationOccupationService.java | 12 +- .../firstleg/service/SmartLoadingService.java | 379 +++++++++ .../db/phase3-smart-loading-schema.sql | 775 +++++++++++++++++ .../main/resources/openapi/firstleg-v1.yaml | 180 ++++ .../resources/openapi/loading-lists-v1.yaml | 340 ++++++++ .../ContainerControllerContractTest.java | 51 ++ .../LoadingListControllerContractTest.java | 120 +++ ...ormSmartLoadingControllerContractTest.java | 23 + .../ContainerBookingMapperContractTest.java | 46 ++ .../LoadingListQueryMapperContractTest.java | 89 ++ ...artLoadingCandidateMapperContractTest.java | 37 + .../SmartLoadingSqlContractTest.java | 176 ++++ .../service/ContainerServiceTest.java | 148 ++++ .../service/LoadingListServiceTest.java | 162 ++++ ...surementLocationOccupationServiceTest.java | 5 +- .../service/SmartLoadingServiceTest.java | 349 ++++++++ .../service/SmartLoadingStatusTest.java | 19 + gateway/src/main/resources/application.yml | 2 +- .../FirstlegContainerRouteContractTest.java | 18 + .../InternalOrderCargoController.java | 14 +- .../InternalOrderCargoControllerTest.java | 12 + .../InternalPalletLocationController.java | 10 + .../baiyue/warehouse/dto/LocationDtos.java | 24 + .../warehouse/dto/PalletAllocationDtos.java | 27 + .../mapper/LocationOccupancyMapper.java | 13 + .../repository/LocationRepository.java | 5 + .../service/LocationResourceService.java | 65 +- .../PalletLocationAllocationService.java | 22 + .../service/PickingPackageAllocator.java | 27 + .../service/LocationResourceServiceTest.java | 101 ++- .../PalletLocationAllocationServiceTest.java | 19 + .../service/PickingPackageAllocatorTest.java | 24 + 71 files changed, 6268 insertions(+), 13 deletions(-) create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformSmartLoadingController.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/domain/SmartLoadingStatus.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerDtos.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerBookingEntity.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPickHistoryEntity.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPickItemEntity.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPickTaskEntity.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPlanEntity.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPlanHistoryEntity.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPlanItemEntity.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickHistoryMapper.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickTaskMapper.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPlanHistoryMapper.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPlanItemMapper.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPlanMapper.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerRepository.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingPickRepository.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java create mode 100644 firstleg/src/main/resources/db/phase3-smart-loading-schema.sql create mode 100644 firstleg/src/main/resources/openapi/loading-lists-v1.yaml create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformSmartLoadingControllerContractTest.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerBookingMapperContractTest.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingStatusTest.java create mode 100644 gateway/src/test/java/com/baiyue/gateway/config/FirstlegContainerRouteContractTest.java create mode 100644 warehouse/src/main/java/com/baiyue/warehouse/service/PickingPackageAllocator.java create mode 100644 warehouse/src/test/java/com/baiyue/warehouse/service/PickingPackageAllocatorTest.java diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 3bf2c83f8..edca4ecc2 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -3761,6 +3761,786 @@ CREATE TABLE IF NOT EXISTS sys_audit_dead_letter ( KEY idx_audit_dead_retry(status,next_retry_at,lease_until) ) ENGINE=InnoDB COMMENT='审计消息死信补偿表'; +-- ============================================================ +-- 三期智能装柜、预装冻结与装柜清单 +-- 来源:firstleg/src/main/resources/db/phase3-smart-loading-schema.sql +-- ============================================================ +SET NAMES utf8mb4; + +CREATE DATABASE IF NOT EXISTS baiyue_firstleg + DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; +USE baiyue_firstleg; + +CREATE TABLE IF NOT EXISTS fl_container_booking ( + id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID(雪花ID)', + booking_no VARCHAR(40) NOT NULL COMMENT '平台唯一货柜预订单号', + container_no VARCHAR(64) NULL COMMENT '集装箱号;确认前允许修订', + cabinet_no VARCHAR(64) NOT NULL COMMENT '平台唯一内部柜号', + warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '装柜仓库ID;平台公共资源', + warehouse_name VARCHAR(128) NOT NULL COMMENT '装柜仓库名称快照', + bill_of_lading_no VARCHAR(128) NULL COMMENT '提单号', + vessel_name VARCHAR(128) NULL COMMENT '船名快照', + voyage_no VARCHAR(64) NULL COMMENT '航次;确认前允许修订', + container_type VARCHAR(32) NOT NULL COMMENT '箱型,例如20GP、40GP、40HQ', + shipping_company_id BIGINT UNSIGNED NULL COMMENT '船司ID快照', + shipping_company_name VARCHAR(128) NULL COMMENT '船司名称快照', + departure_port_code VARCHAR(32) NULL COMMENT '起运港编码快照', + departure_port_name VARCHAR(128) NULL COMMENT '起运港名称快照', + destination_port_code VARCHAR(32) NULL COMMENT '目的港编码快照', + destination_port_name VARCHAR(128) NULL COMMENT '目的港名称快照', + destination_warehouse_code VARCHAR(64) NULL COMMENT '目的仓编码快照', + destination_warehouse_name VARCHAR(128) NULL COMMENT '目的仓名称快照', + capacity_cbm DECIMAL(18,4) NULL COMMENT '货柜容量m3', + max_weight_kg DECIMAL(18,3) NULL COMMENT '货柜最大承重kg', + terminal_trucking_company VARCHAR(128) NULL COMMENT '码头拖车公司', + loading_at DATETIME(3) NULL COMMENT '计划或实际装柜时间', + estimated_sailing_at DATETIME(3) NULL COMMENT '预计开船时间', + actual_sailing_at DATETIME(3) NULL COMMENT '实际开船时间', + estimated_port_arrival_at DATETIME(3) NULL COMMENT '预计到港时间', + actual_port_arrival_at DATETIME(3) NULL COMMENT '实际到港时间', + estimated_warehouse_arrival_at DATETIME(3) NULL COMMENT '预计到仓时间', + actual_warehouse_arrival_at DATETIME(3) NULL COMMENT '实际到仓时间', + created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID', + created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID', + updated_by_name VARCHAR(64) NULL COMMENT '最后修改人名称快照', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) + COMMENT '更新时间', + version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_container_booking_no (booking_no), + UNIQUE KEY uk_container_booking_cabinet (cabinet_no), + KEY idx_container_booking_container (container_no), + KEY idx_container_booking_warehouse (warehouse_id, updated_at, id), + KEY idx_container_booking_voyage (voyage_no, updated_at), + 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) +) ENGINE=InnoDB COMMENT='平台货柜预订资料;预装状态唯一来源为装柜计划'; + +CREATE TABLE IF NOT EXISTS fl_loading_plan ( + id BIGINT UNSIGNED NOT NULL COMMENT '智能装柜计划ID(雪花ID)', + plan_no VARCHAR(40) NOT NULL COMMENT '平台唯一装柜计划号', + container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID', + plan_status VARCHAR(24) NOT NULL DEFAULT 'EDITING' + COMMENT '唯一装柜状态:EDITING、LOADING、COMPLETED', + push_idempotency_key VARCHAR(128) NULL COMMENT '推送预装幂等键', + preload_pushed_by BIGINT UNSIGNED NULL COMMENT '推送预装操作人ID', + preload_pushed_by_name VARCHAR(64) NULL COMMENT '推送预装操作人名称快照', + preload_pushed_at DATETIME(3) NULL COMMENT '开始装柜并进入LOADING的时间', + preload_completed_by BIGINT UNSIGNED NULL COMMENT '预装完成操作人ID', + preload_completed_by_name VARCHAR(64) NULL COMMENT '预装完成操作人名称快照', + preload_completed_at DATETIME(3) NULL COMMENT '预装完成时间', + confirmed_by BIGINT UNSIGNED NULL COMMENT '确认操作人ID', + confirmed_by_name VARCHAR(64) NULL COMMENT '确认操作人名称快照', + confirmed_at DATETIME(3) NULL COMMENT '确认时间', + remark VARCHAR(500) NULL COMMENT '平台装柜计划备注', + created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID', + created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID', + updated_by_name VARCHAR(64) NULL COMMENT '最后修改人名称快照', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) + COMMENT '更新时间', + version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_loading_plan_no (plan_no), + UNIQUE KEY uk_loading_plan_booking (container_booking_id), + UNIQUE KEY uk_loading_plan_push_key (push_idempotency_key), + KEY idx_loading_plan_status (plan_status, updated_at, id), + CONSTRAINT fk_loading_plan_booking FOREIGN KEY (container_booking_id) + REFERENCES fl_container_booking (id), + CONSTRAINT chk_loading_plan_status + CHECK (plan_status IN ('EDITING', 'LOADING', 'COMPLETED')) +) ENGINE=InnoDB COMMENT='平台智能装柜计划与唯一预装状态源'; + +CREATE TABLE IF NOT EXISTS fl_loading_plan_item ( + id BIGINT UNSIGNED NOT NULL COMMENT '装柜计划明细ID(雪花ID)', + loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '智能装柜计划ID', + account_id BIGINT UNSIGNED NOT NULL COMMENT '客户账号ID;装柜业务唯一客户归属字段', + customer_name VARCHAR(128) NOT NULL COMMENT '客户名称快照', + inbound_id BIGINT UNSIGNED NOT NULL COMMENT 'Firstleg入库单ID', + inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '订单货物或入库货物明细ID,不建立跨库外键', + order_id BIGINT UNSIGNED NOT NULL COMMENT '订单服务订单ID快照,不建立跨库外键', + order_no VARCHAR(32) NOT NULL COMMENT '订单号快照', + inbound_code VARCHAR(40) NOT NULL COMMENT '入仓码快照', + destination_warehouse_id BIGINT UNSIGNED NULL COMMENT '目的仓ID快照', + destination_warehouse_code VARCHAR(64) NULL COMMENT '目的仓编码快照', + destination_warehouse_name VARCHAR(128) NULL COMMENT '目的仓名称快照', + sku_code VARCHAR(128) NULL COMMENT 'SKU编码快照', + product_name_zh VARCHAR(255) NULL COMMENT '中文品名快照', + product_name_en VARCHAR(255) NULL COMMENT '英文品名快照', + product_image_file_id BIGINT UNSIGNED NULL COMMENT '产品图片File对象ID', + product_image_url VARCHAR(1000) NULL COMMENT '产品图片访问地址快照', + package_count INT UNSIGNED NOT NULL COMMENT '本次装柜箱数', + product_quantity INT UNSIGNED NULL COMMENT '产品总数量', + units_per_carton INT UNSIGNED NULL COMMENT '每箱产品数', + pallet_count INT UNSIGNED NULL COMMENT '托盘数', + gross_weight_kg DECIMAL(18,3) NULL COMMENT '本次装柜毛重kg', + net_weight_kg DECIMAL(18,3) NULL COMMENT '本次装柜净重kg', + dimensional_weight_kg DECIMAL(18,3) NULL COMMENT '本次装柜材积重kg', + volume_cbm DECIMAL(18,4) NULL COMMENT '本次装柜体积m3', + fba_tracking_no VARCHAR(128) NULL COMMENT 'FBA追踪号快照', + shipment_no VARCHAR(128) NULL COMMENT '货件编号快照', + shipping_mark VARCHAR(500) NULL COMMENT '唛头快照', + load_sequence INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '装柜顺序', + position_snapshot JSON NULL COMMENT '智能装柜位置与摆放结果快照', + internal_remark VARCHAR(1000) NULL COMMENT '平台内部备注', + customer_remark VARCHAR(1000) NULL COMMENT '客户备注快照', + warehouse_remark VARCHAR(1000) NULL COMMENT '仓库备注快照', + frozen_at DATETIME(3) NULL COMMENT '装柜完成后的永久冻结时间', + frozen_by BIGINT UNSIGNED NULL COMMENT '冻结操作人ID', + removed_by BIGINT UNSIGNED NULL COMMENT '推送前逻辑删除操作人ID', + removed_by_name VARCHAR(64) NULL COMMENT '推送前逻辑删除操作人名称快照', + removed_at DATETIME(3) NULL COMMENT '推送前逻辑删除时间', + deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '推送前逻辑删除标记;冻结后不可修改', + active_inbound_detail_id BIGINT UNSIGNED GENERATED ALWAYS AS ( + CASE WHEN deleted = 0 THEN inbound_detail_id ELSE NULL END + ) STORED COMMENT '未删除明细唯一锁;删除后返回NULL释放分配资格', + created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID', + created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID', + updated_by_name VARCHAR(64) NULL COMMENT '最后修改人名称快照', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) + COMMENT '更新时间', + version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_loading_item_active_detail (account_id, active_inbound_detail_id), + KEY idx_loading_item_plan (loading_plan_id, deleted, load_sequence, id), + KEY idx_loading_item_account (account_id, created_at, id), + KEY idx_loading_item_inbound (account_id, inbound_id, deleted), + KEY idx_loading_item_order (account_id, order_id, deleted), + CONSTRAINT fk_loading_item_plan FOREIGN KEY (loading_plan_id) REFERENCES fl_loading_plan (id), + CONSTRAINT chk_loading_item_account CHECK (account_id > 0), + CONSTRAINT chk_loading_item_package CHECK (package_count > 0), + CONSTRAINT chk_loading_item_product_quantity CHECK (product_quantity IS NULL OR product_quantity > 0), + CONSTRAINT chk_loading_item_units CHECK (units_per_carton IS NULL OR units_per_carton > 0), + CONSTRAINT chk_loading_item_pallet CHECK (pallet_count IS NULL OR pallet_count >= 0), + CONSTRAINT chk_loading_item_weight CHECK ( + (gross_weight_kg IS NULL OR gross_weight_kg >= 0) + AND (net_weight_kg IS NULL OR net_weight_kg >= 0) + AND (dimensional_weight_kg IS NULL OR dimensional_weight_kg >= 0) + ), + CONSTRAINT chk_loading_item_volume CHECK (volume_cbm IS NULL OR volume_cbm >= 0), + CONSTRAINT chk_loading_item_deleted CHECK (deleted IN (0, 1)) +) ENGINE=InnoDB COMMENT='智能装柜客户货物明细;客户归属仅在明细层'; + +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 'CUSTOMS_CLEARANCE、DELIVERY_ORDER、PICKUP_ORDER', + file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务文件ID,不建立跨库外键', + file_name VARCHAR(255) NOT NULL COMMENT '文件名称快照', + file_url VARCHAR(1000) NULL COMMENT '文件访问地址快照', + uploaded_by BIGINT UNSIGNED NOT NULL COMMENT '上传操作人ID', + uploaded_by_name VARCHAR(64) NULL COMMENT '上传操作人名称快照', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) + COMMENT '更新时间', + version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_container_document_type (container_booking_id, document_type), + KEY idx_container_document_file (file_id), + CONSTRAINT fk_container_document_booking FOREIGN KEY (container_booking_id) + REFERENCES fl_container_booking (id), + CONSTRAINT chk_container_document_type + CHECK (document_type IN ('CUSTOMS_CLEARANCE', 'DELIVERY_ORDER', 'PICKUP_ORDER')) +) ENGINE=InnoDB COMMENT='平台货柜清关、DO与PU文件关联'; + +CREATE TABLE IF NOT EXISTS fl_loading_plan_history ( + id BIGINT UNSIGNED NOT NULL COMMENT '装柜操作历史ID(雪花ID)', + loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '智能装柜计划ID', + loading_plan_item_id BIGINT UNSIGNED NULL COMMENT '装柜计划明细ID', + operation_id VARCHAR(128) NOT NULL COMMENT '操作幂等ID', + action_type VARCHAR(32) NOT NULL + COMMENT 'CREATE_PLAN、ADD_ITEM、ADJUST_ITEM、REMOVE_ITEM、START_LOADING、COMPLETE_LOADING、PICK_ITEM', + from_status VARCHAR(24) NULL COMMENT '变更前状态', + to_status VARCHAR(24) NULL COMMENT '变更后状态', + before_snapshot JSON NULL COMMENT '变更前快照', + after_snapshot JSON NULL COMMENT '变更后快照', + operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID', + operator_name VARCHAR(64) NULL COMMENT '操作人名称快照', + operation_reason VARCHAR(500) NULL COMMENT '操作原因', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间', + PRIMARY KEY (id), + UNIQUE KEY uk_loading_history_operation (operation_id), + KEY idx_loading_history_plan (loading_plan_id, created_at, id), + KEY idx_loading_history_item (loading_plan_item_id, created_at, id), + CONSTRAINT fk_loading_history_plan FOREIGN KEY (loading_plan_id) REFERENCES fl_loading_plan (id), + CONSTRAINT fk_loading_history_item FOREIGN KEY (loading_plan_item_id) + REFERENCES fl_loading_plan_item (id) +) ENGINE=InnoDB COMMENT='智能装柜追加式不可变操作历史'; + +CREATE TABLE IF NOT EXISTS fl_loading_pick_task ( + id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货任务ID', + loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '装柜计划ID', + container_no VARCHAR(64) NOT NULL COMMENT '集装箱号快照', + task_status VARCHAR(16) NOT NULL DEFAULT 'LOADING' COMMENT 'LOADING、COMPLETED', + started_by BIGINT UNSIGNED NOT NULL COMMENT '开始装柜人', + started_by_name VARCHAR(64) NULL COMMENT '开始装柜人名称', + started_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '开始时间', + completed_by BIGINT UNSIGNED NULL COMMENT '完成装柜人', + completed_by_name VARCHAR(64) NULL COMMENT '完成装柜人名称', + completed_at DATETIME(3) NULL COMMENT '完成时间', + version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_loading_pick_task_plan (loading_plan_id), + KEY idx_loading_pick_container (container_no, task_status, started_at), + CONSTRAINT fk_loading_pick_task_plan FOREIGN KEY (loading_plan_id) REFERENCES fl_loading_plan (id), + CONSTRAINT chk_loading_pick_task_status CHECK (task_status IN ('LOADING', 'COMPLETED')) +) ENGINE=InnoDB COMMENT='PDA装柜拣货任务'; + +CREATE TABLE IF NOT EXISTS fl_loading_pick_item ( + id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货明细ID', + pick_task_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货任务ID', + loading_plan_item_id BIGINT UNSIGNED NOT NULL COMMENT '装柜计划明细ID', + inbound_id BIGINT UNSIGNED NOT NULL COMMENT '父入库单ID', + inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '订单货物明细ID', + planned_package_count INT UNSIGNED NOT NULL COMMENT '计划拣货箱数;上层拆分按业务规则向上取整', + picked_package_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已拣货箱数', + item_status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING、PICKING、PICKED', + version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_loading_pick_item_plan_item (loading_plan_item_id), + KEY idx_loading_pick_item_task (pick_task_id, item_status, id), + CONSTRAINT fk_loading_pick_item_task FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task (id), + CONSTRAINT fk_loading_pick_item_plan_item FOREIGN KEY (loading_plan_item_id) REFERENCES fl_loading_plan_item (id), + CONSTRAINT chk_loading_pick_item_planned CHECK (planned_package_count > 0), + CONSTRAINT chk_loading_pick_item_count CHECK (picked_package_count <= planned_package_count), + CONSTRAINT chk_loading_pick_item_status CHECK (item_status IN ('PENDING', 'PICKING', 'PICKED')) +) ENGINE=InnoDB COMMENT='PDA装柜拣货明细'; + +CREATE TABLE IF NOT EXISTS fl_loading_pick_history ( + id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货操作历史ID', + pick_task_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货任务ID', + pick_item_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货明细ID', + operation_id VARCHAR(128) NOT NULL COMMENT '客户端幂等操作号', + request_hash CHAR(64) NOT NULL COMMENT '请求指纹', + picked_package_count INT UNSIGNED NOT NULL COMMENT '本次拣货箱数', + operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人', + operator_name VARCHAR(64) NULL COMMENT '操作人名称', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_loading_pick_history_operation (operation_id), + KEY idx_loading_pick_history_item (pick_item_id, created_at), + CONSTRAINT fk_loading_pick_history_task FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task (id), + CONSTRAINT fk_loading_pick_history_item FOREIGN KEY (pick_item_id) REFERENCES fl_loading_pick_item (id) +) ENGINE=InnoDB COMMENT='PDA拣货幂等和操作审计历史'; + +DROP TRIGGER IF EXISTS trg_container_booking_before_update; +DELIMITER $$ +CREATE TRIGGER trg_container_booking_before_update +BEFORE UPDATE ON fl_container_booking +FOR EACH ROW +BEGIN + DECLARE current_plan_status VARCHAR(24); + SELECT MAX(plan_status) INTO current_plan_status + FROM fl_loading_plan WHERE container_booking_id = OLD.id; + IF current_plan_status = 'COMPLETED' THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜全部只读,不允许修改'; + END IF; + IF current_plan_status = 'LOADING' AND ( + NOT (OLD.id <=> NEW.id) OR NOT (OLD.booking_no <=> NEW.booking_no) + OR NOT (OLD.cabinet_no <=> NEW.cabinet_no) + OR NOT (OLD.warehouse_id <=> NEW.warehouse_id) + OR NOT (OLD.warehouse_name <=> NEW.warehouse_name) + OR NOT (OLD.bill_of_lading_no <=> NEW.bill_of_lading_no) + OR NOT (OLD.vessel_name <=> NEW.vessel_name) + OR NOT (OLD.container_type <=> NEW.container_type) + OR NOT (OLD.shipping_company_id <=> NEW.shipping_company_id) + OR NOT (OLD.shipping_company_name <=> NEW.shipping_company_name) + OR NOT (OLD.departure_port_code <=> NEW.departure_port_code) + OR NOT (OLD.departure_port_name <=> NEW.departure_port_name) + OR NOT (OLD.destination_port_code <=> NEW.destination_port_code) + OR NOT (OLD.destination_port_name <=> NEW.destination_port_name) + OR NOT (OLD.destination_warehouse_code <=> NEW.destination_warehouse_code) + OR NOT (OLD.destination_warehouse_name <=> NEW.destination_warehouse_name) + OR NOT (OLD.capacity_cbm <=> NEW.capacity_cbm) + OR NOT (OLD.max_weight_kg <=> NEW.max_weight_kg) + OR NOT (OLD.terminal_trucking_company <=> NEW.terminal_trucking_company) + OR NOT (OLD.loading_at <=> NEW.loading_at) + OR NOT (OLD.estimated_sailing_at <=> NEW.estimated_sailing_at) + OR NOT (OLD.actual_sailing_at <=> NEW.actual_sailing_at) + OR NOT (OLD.estimated_port_arrival_at <=> NEW.estimated_port_arrival_at) + OR NOT (OLD.actual_port_arrival_at <=> NEW.actual_port_arrival_at) + OR NOT (OLD.estimated_warehouse_arrival_at <=> NEW.estimated_warehouse_arrival_at) + OR NOT (OLD.actual_warehouse_arrival_at <=> NEW.actual_warehouse_arrival_at) + OR NOT (OLD.created_by <=> NEW.created_by) + OR NOT (OLD.created_by_name <=> NEW.created_by_name) + OR NOT (OLD.created_at <=> NEW.created_at) + ) THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '装柜中仅允许修改集装箱号与航次'; + END IF; +END$$ +DELIMITER ; + +DROP TRIGGER IF EXISTS trg_loading_plan_before_update; +DELIMITER $$ +CREATE TRIGGER trg_loading_plan_before_update +BEFORE UPDATE ON fl_loading_plan +FOR EACH ROW +BEGIN + IF OLD.plan_status = 'COMPLETED' THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已完成装柜计划全部只读'; + END IF; + IF NOT ( + (OLD.plan_status = 'EDITING' AND NEW.plan_status IN ('EDITING', 'LOADING')) + OR (OLD.plan_status = 'LOADING' + AND NEW.plan_status IN ('LOADING', 'COMPLETED')) + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜状态只允许单向推进'; + END IF; + IF OLD.plan_status = 'LOADING' AND ( + NOT (OLD.id <=> NEW.id) OR NOT (OLD.plan_no <=> NEW.plan_no) + OR NOT (OLD.container_booking_id <=> NEW.container_booking_id) + OR NOT (OLD.push_idempotency_key <=> NEW.push_idempotency_key) + OR NOT (OLD.preload_pushed_by <=> NEW.preload_pushed_by) + OR NOT (OLD.preload_pushed_by_name <=> NEW.preload_pushed_by_name) + OR NOT (OLD.preload_pushed_at <=> NEW.preload_pushed_at) + OR NOT (OLD.remark <=> NEW.remark) + OR NOT (OLD.created_by <=> NEW.created_by) + OR NOT (OLD.created_by_name <=> NEW.created_by_name) + OR NOT (OLD.created_at <=> NEW.created_at) + ) THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '装柜中计划仅允许乐观锁触碰或完成装柜'; + END IF; + IF OLD.plan_status = NEW.plan_status + AND OLD.plan_status = 'LOADING' AND ( + NOT (OLD.preload_completed_by <=> NEW.preload_completed_by) + OR NOT (OLD.preload_completed_by_name <=> NEW.preload_completed_by_name) + OR NOT (OLD.preload_completed_at <=> NEW.preload_completed_at) + OR NOT (OLD.confirmed_by <=> NEW.confirmed_by) + OR NOT (OLD.confirmed_by_name <=> NEW.confirmed_by_name) + OR NOT (OLD.confirmed_at <=> NEW.confirmed_at) + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '同状态乐观锁触碰不得修改阶段审计字段'; + END IF; + IF OLD.plan_status = 'EDITING' AND NEW.plan_status = 'LOADING' THEN + IF NEW.push_idempotency_key IS NULL OR CHAR_LENGTH(TRIM(NEW.push_idempotency_key)) = 0 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '开始装柜必须提供幂等键'; + END IF; + SET NEW.preload_pushed_at = COALESCE(NEW.preload_pushed_at, CURRENT_TIMESTAMP(3)); + ELSEIF OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' THEN + IF EXISTS (SELECT 1 FROM fl_loading_plan_item item + LEFT JOIN fl_loading_pick_item pick_item + ON pick_item.loading_plan_item_id = item.id + LEFT JOIN fl_loading_pick_task pick_task + ON pick_task.id = pick_item.pick_task_id + AND pick_task.loading_plan_id = OLD.id + WHERE item.loading_plan_id = OLD.id AND item.deleted = 0 + AND (pick_task.id IS NULL + OR pick_item.picked_package_count <> pick_item.planned_package_count)) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '仍有未完成拣货明细,不能完成装柜'; + END IF; + SET NEW.confirmed_at = COALESCE(NEW.confirmed_at, CURRENT_TIMESTAMP(3)); + END IF; +END$$ +DELIMITER ; + +DROP TRIGGER IF EXISTS trg_loading_plan_after_update; +DELIMITER $$ +CREATE TRIGGER trg_loading_plan_after_update +AFTER UPDATE ON fl_loading_plan +FOR EACH ROW +BEGIN + IF OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' THEN + UPDATE fl_loading_plan_item + SET frozen_at = COALESCE(frozen_at, NEW.confirmed_at, CURRENT_TIMESTAMP(3)), + frozen_by = COALESCE(frozen_by, NEW.updated_by), + updated_by = NEW.updated_by, + updated_by_name = NEW.updated_by_name, + version = version + 1 + WHERE loading_plan_id = NEW.id AND deleted = 0; + UPDATE fl_loading_pick_task + SET task_status = 'COMPLETED', completed_by = NEW.updated_by, + completed_by_name = NEW.updated_by_name, completed_at = CURRENT_TIMESTAMP(3), + version = version + 1 + WHERE loading_plan_id = NEW.id AND task_status = 'LOADING'; + END IF; +END$$ +DELIMITER ; + +DROP TRIGGER IF EXISTS trg_loading_item_before_insert; +DELIMITER $$ +CREATE TRIGGER trg_loading_item_before_insert +BEFORE INSERT ON fl_loading_plan_item +FOR EACH ROW +BEGIN + DECLARE current_plan_status VARCHAR(24); + SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE id = NEW.loading_plan_id; + IF current_plan_status IS NULL OR current_plan_status NOT IN ('EDITING', 'LOADING') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '仅编辑中或装柜中的计划允许新增明细'; + END IF; + IF NEW.deleted <> 0 OR NEW.frozen_at IS NOT NULL OR NEW.frozen_by IS NOT NULL THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '新增装柜明细必须为未删除且未冻结状态'; + END IF; +END$$ +DELIMITER ; + +DROP TRIGGER IF EXISTS trg_loading_item_before_update; +DELIMITER $$ +CREATE TRIGGER trg_loading_item_before_update +BEFORE UPDATE ON fl_loading_plan_item +FOR EACH ROW +BEGIN + DECLARE current_plan_status VARCHAR(24); + SELECT MAX(plan_status) INTO current_plan_status + FROM fl_loading_plan WHERE id = OLD.loading_plan_id; + IF current_plan_status = 'LOADING' AND EXISTS ( + SELECT 1 FROM fl_loading_pick_item + WHERE loading_plan_item_id = OLD.id AND picked_package_count > 0) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已有PDA拣货记录的装柜明细不可修改或删除'; + END IF; + IF OLD.frozen_at IS NOT NULL THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后的明细永久冻结'; + END IF; + IF OLD.deleted = 1 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已删除装柜明细不可恢复或修改'; + END IF; + IF NEW.loading_plan_id <> OLD.loading_plan_id OR NEW.account_id <> OLD.account_id + OR NEW.inbound_detail_id <> OLD.inbound_detail_id THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜明细归属键不可修改,请删除后重新分配'; + END IF; + IF NEW.deleted = 1 THEN + IF NEW.removed_by IS NULL THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '删除装柜明细必须记录操作人'; + END IF; + SET NEW.removed_at = COALESCE(NEW.removed_at, CURRENT_TIMESTAMP(3)); + ELSEIF NEW.removed_by IS NOT NULL OR NEW.removed_by_name IS NOT NULL OR NEW.removed_at IS NOT NULL THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '删除审计字段只能在逻辑删除时写入'; + END IF; +END$$ +DELIMITER ; + +DROP TRIGGER IF EXISTS trg_loading_pick_task_before_update; +DELIMITER $$ +CREATE TRIGGER trg_loading_pick_task_before_update +BEFORE UPDATE ON fl_loading_pick_task +FOR EACH ROW +BEGIN + IF OLD.task_status = 'COMPLETED' THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已完成PDA拣货任务不可修改'; + END IF; + IF NEW.loading_plan_id <> OLD.loading_plan_id OR NOT (NEW.container_no <=> OLD.container_no) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货任务归属不可修改'; + END IF; + IF NOT (OLD.task_status = 'LOADING' AND NEW.task_status IN ('LOADING', 'COMPLETED')) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货任务状态只允许完成'; + END IF; +END$$ +DELIMITER ; + +DROP TRIGGER IF EXISTS trg_loading_pick_item_before_update; +DELIMITER $$ +CREATE TRIGGER trg_loading_pick_item_before_update +BEFORE UPDATE ON fl_loading_pick_item +FOR EACH ROW +BEGIN + DECLARE current_task_status VARCHAR(16); + SELECT MAX(task_status) INTO current_task_status + FROM fl_loading_pick_task WHERE id = OLD.pick_task_id; + IF current_task_status = 'COMPLETED' THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后PDA拣货明细不可修改'; + END IF; + IF NEW.pick_task_id <> OLD.pick_task_id + OR NEW.loading_plan_item_id <> OLD.loading_plan_item_id + OR NEW.inbound_id <> OLD.inbound_id + OR NEW.inbound_detail_id <> OLD.inbound_detail_id THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货明细归属不可修改'; + END IF; +END$$ +DELIMITER ; + +DROP TRIGGER IF EXISTS trg_loading_pick_item_before_delete; +DELIMITER $$ +CREATE TRIGGER trg_loading_pick_item_before_delete +BEFORE DELETE ON fl_loading_pick_item +FOR EACH ROW +BEGIN + DECLARE current_task_status VARCHAR(16); + SELECT MAX(task_status) INTO current_task_status + FROM fl_loading_pick_task WHERE id = OLD.pick_task_id; + IF current_task_status = 'COMPLETED' OR OLD.picked_package_count > 0 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已拣货或已完成的PDA明细不可删除'; + END IF; +END$$ +DELIMITER ; + +DROP TRIGGER IF EXISTS trg_loading_pick_history_before_update; +DELIMITER $$ +CREATE TRIGGER trg_loading_pick_history_before_update +BEFORE UPDATE ON fl_loading_pick_history +FOR EACH ROW +BEGIN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货历史不可修改'; +END$$ +DELIMITER ; + +DROP TRIGGER IF EXISTS trg_loading_pick_history_before_delete; +DELIMITER $$ +CREATE TRIGGER trg_loading_pick_history_before_delete +BEFORE DELETE ON fl_loading_pick_history +FOR EACH ROW +BEGIN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货历史不可删除'; +END$$ +DELIMITER ; + +DROP TRIGGER IF EXISTS trg_loading_item_before_delete; +DELIMITER $$ +CREATE TRIGGER trg_loading_item_before_delete +BEFORE DELETE ON fl_loading_plan_item +FOR EACH ROW +BEGIN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜明细只允许推送前逻辑删除'; +END$$ +DELIMITER ; + +DROP TRIGGER IF EXISTS trg_container_document_before_insert; +DELIMITER $$ +CREATE TRIGGER trg_container_document_before_insert +BEFORE INSERT ON fl_container_document +FOR EACH ROW +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' THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可新增'; + END IF; +END$$ +DELIMITER ; + +DROP TRIGGER IF EXISTS trg_container_document_before_update; +DELIMITER $$ +CREATE TRIGGER trg_container_document_before_update +BEFORE UPDATE ON fl_container_document +FOR EACH ROW +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' THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可修改'; + END IF; +END$$ +DELIMITER ; + +DROP TRIGGER IF EXISTS trg_container_document_before_delete; +DELIMITER $$ +CREATE TRIGGER trg_container_document_before_delete +BEFORE DELETE ON fl_container_document +FOR EACH ROW +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' THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可删除'; + END IF; +END$$ +DELIMITER ; + +DROP TRIGGER IF EXISTS trg_loading_history_before_update; +DELIMITER $$ +CREATE TRIGGER trg_loading_history_before_update +BEFORE UPDATE ON fl_loading_plan_history +FOR EACH ROW +BEGIN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜操作历史不可修改'; +END$$ +DELIMITER ; + +DROP TRIGGER IF EXISTS trg_loading_history_before_delete; +DELIMITER $$ +CREATE TRIGGER trg_loading_history_before_delete +BEFORE DELETE ON fl_loading_plan_history +FOR EACH ROW +BEGIN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜操作历史不可删除'; +END$$ +DELIMITER ; + +-- 三期智能装柜、预装清单和装柜清单平台权限;与一期平台根菜单幂等合并。 +USE baiyue_iam; +INSERT INTO iam_permission + (id, tenant_id, app_code, permission_type, parent_id, permission_code, + permission_name, route_path, http_method, sort_no, status, built_in, deleted) +VALUES + (5102090, 0, 'PLATFORM', 'MENU', 5102000, 'platform:firstleg:container:list', + '货柜', '/first-leg/containers', 'GET', 40, 'ACTIVE', 1, 0), + (5102091, 0, 'PLATFORM', 'BUTTON', 5102090, 'platform:firstleg:container:view', + '查看货柜', NULL, 'GET', 10, 'ACTIVE', 1, 0), + (5102092, 0, 'PLATFORM', 'BUTTON', 5102090, 'platform:firstleg:container:create', + '建立货柜', NULL, 'POST', 20, 'ACTIVE', 1, 0), + (5102093, 0, 'PLATFORM', 'BUTTON', 5102090, 'platform:firstleg:container:edit', + '编辑货柜', NULL, 'PUT', 30, 'ACTIVE', 1, 0), + (5102094, 0, 'PLATFORM', 'MENU', 5102000, 'platform:firstleg:smart-loading:list', + '智能装柜', '/first-leg/smart-loading', 'GET', 50, 'ACTIVE', 1, 0), + (5102096, 0, 'PLATFORM', 'BUTTON', 5102094, 'platform:firstleg:smart-loading:create', + '创建智能装柜计划', NULL, 'POST', 20, 'ACTIVE', 1, 0), + (5102097, 0, 'PLATFORM', 'BUTTON', 5102094, 'platform:firstleg:smart-loading:assign', + '分配装柜明细', NULL, 'POST', 30, 'ACTIVE', 1, 0), + (5102098, 0, 'PLATFORM', 'BUTTON', 5102094, 'platform:firstleg:smart-loading:delete', + '删除装柜明细', NULL, 'DELETE', 40, 'ACTIVE', 1, 0), + (5102099, 0, 'PLATFORM', 'BUTTON', 5102094, 'platform:firstleg:smart-loading:push', + '推送预装', NULL, 'POST', 50, 'ACTIVE', 1, 0), + (5102100, 0, 'PLATFORM', 'MENU', 5102000, 'platform:firstleg:preload:list', + '预装清单', '/first-leg/preload-list', 'GET', 60, 'ACTIVE', 1, 0), + (5102102, 0, 'PLATFORM', 'BUTTON', 5102100, 'platform:firstleg:preload:view', + '查看预装详情', NULL, 'GET', 20, 'ACTIVE', 1, 0), + (5102103, 0, 'PLATFORM', 'BUTTON', 5102100, 'platform:firstleg:preload:edit', + '编辑预装运输字段', NULL, 'PUT', 30, 'ACTIVE', 1, 0), + (5102104, 0, 'PLATFORM', 'BUTTON', 5102100, 'platform:firstleg:preload:complete', + '完成预装', NULL, 'POST', 40, 'ACTIVE', 1, 0), + (5102105, 0, 'PLATFORM', 'BUTTON', 5102100, 'platform:firstleg:preload:confirm', + '确认预装', NULL, 'POST', 50, 'ACTIVE', 1, 0), + (5102106, 0, 'PLATFORM', 'MENU', 5102000, 'platform:firstleg:loading-list:list', + '装柜清单', '/first-leg/loading-list', 'GET', 70, 'ACTIVE', 1, 0), + (5102108, 0, 'PLATFORM', 'BUTTON', 5102106, 'platform:firstleg:loading-list:view', + '查看装柜详情', NULL, 'GET', 20, 'ACTIVE', 1, 0) + ON DUPLICATE KEY UPDATE + permission_type = VALUES(permission_type), parent_id = VALUES(parent_id), + permission_name = VALUES(permission_name), route_path = VALUES(route_path), + http_method = VALUES(http_method), sort_no = VALUES(sort_no), status = 'ACTIVE', + built_in = 1, deleted = 0; + +INSERT IGNORE INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT role.id, permission.id, role.tenant_id +FROM iam_role role + JOIN iam_permission permission + ON permission.tenant_id = 0 + AND permission.permission_code IN ( + 'platform:firstleg:container:list', 'platform:firstleg:container:view', + 'platform:firstleg:container:create', + 'platform:firstleg:container:edit', 'platform:firstleg:smart-loading:list', + 'platform:firstleg:smart-loading:create', 'platform:firstleg:smart-loading:assign', + 'platform:firstleg:smart-loading:delete', 'platform:firstleg:smart-loading:push', + 'platform:firstleg:preload:list', 'platform:firstleg:preload:view', + 'platform:firstleg:preload:edit', 'platform:firstleg:preload:complete', + 'platform:firstleg:preload:confirm', 'platform:firstleg:loading-list:list', + 'platform:firstleg:loading-list:view') +WHERE role.tenant_id = 0 + AND role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR'); + +USE baiyue_firstleg; + +DROP PROCEDURE IF EXISTS assert_phase3_smart_loading_schema; +DELIMITER $$ +CREATE PROCEDURE assert_phase3_smart_loading_schema() +BEGIN + IF (SELECT COUNT(*) FROM information_schema.tables + WHERE table_schema = 'baiyue_firstleg' + AND table_name IN ('fl_container_booking', 'fl_loading_plan', 'fl_loading_plan_item', + 'fl_container_document', 'fl_loading_plan_history', + 'fl_loading_pick_task', 'fl_loading_pick_item', + 'fl_loading_pick_history')) <> 8 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:核心表未完整创建'; + END IF; + IF EXISTS (SELECT 1 FROM fl_loading_plan + WHERE plan_status NOT IN ('EDITING', 'LOADING', 'COMPLETED')) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:存在旧装柜状态'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.check_constraints + WHERE constraint_schema = 'baiyue_firstleg' + AND constraint_name = 'chk_loading_plan_status' + AND check_clause LIKE '%''EDITING''%' + AND check_clause LIKE '%''LOADING''%' + AND check_clause LIKE '%''COMPLETED''%') <> 1 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:装柜状态约束不是最终三状态'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_container_booking' + AND column_name IN ('booking_no', 'cabinet_no', 'warehouse_id', 'bill_of_lading_no', + 'vessel_name', 'voyage_no', 'container_type', 'capacity_cbm', + 'max_weight_kg', 'terminal_trucking_company')) <> 10 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:货柜原型字段缺失'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_plan_item' + AND column_name IN ('account_id', 'customer_name', 'active_inbound_detail_id', 'frozen_at', + 'destination_warehouse_name', 'product_name_zh', 'product_name_en', + 'product_image_file_id', 'pallet_count', 'net_weight_kg', + 'dimensional_weight_kg', 'product_quantity', 'units_per_carton', + 'fba_tracking_no', 'shipment_no', 'shipping_mark', 'version')) <> 17 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:明细归属、清单快照或冻结字段缺失'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_plan_item' + AND index_name = 'uk_loading_item_active_detail' AND non_unique = 0) <> 2 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:唯一活动明细锁缺失'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = 'baiyue_firstleg' + AND table_name = 'fl_loading_pick_item' + AND column_name IN ('pick_task_id', 'loading_plan_item_id', 'inbound_id', + 'inbound_detail_id', 'planned_package_count', + 'picked_package_count', 'item_status', 'version')) <> 8 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:PDA拣货明细字段缺失'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = 'baiyue_firstleg' AND non_unique = 0 + AND index_name IN ('uk_loading_pick_task_plan', + 'uk_loading_pick_item_plan_item', + 'uk_loading_pick_history_operation')) <> 3 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:PDA唯一索引缺失'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.triggers + WHERE trigger_schema = 'baiyue_firstleg' + AND trigger_name IN ('trg_container_booking_before_update', + 'trg_loading_plan_before_update', 'trg_loading_plan_after_update', + 'trg_loading_item_before_insert', 'trg_loading_item_before_update', + 'trg_loading_item_before_delete', 'trg_container_document_before_insert', + 'trg_container_document_before_update', 'trg_container_document_before_delete', + 'trg_loading_history_before_update', 'trg_loading_history_before_delete', + 'trg_loading_pick_task_before_update', + 'trg_loading_pick_item_before_update', + 'trg_loading_pick_item_before_delete', + 'trg_loading_pick_history_before_update', + 'trg_loading_pick_history_before_delete')) <> 16 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:不可逆状态或冻结触发器缺失'; + END IF; + IF (SELECT COUNT(DISTINCT permission_code) FROM baiyue_iam.iam_permission + WHERE tenant_id = 0 AND app_code = 'PLATFORM' AND status = 'ACTIVE' AND deleted = 0 + AND permission_code IN ( + 'platform:firstleg:container:list', 'platform:firstleg:container:view', + 'platform:firstleg:container:create', 'platform:firstleg:container:edit', + 'platform:firstleg:smart-loading:list', 'platform:firstleg:smart-loading:create', + 'platform:firstleg:smart-loading:assign', 'platform:firstleg:smart-loading:delete', + 'platform:firstleg:smart-loading:push', 'platform:firstleg:preload:list', + 'platform:firstleg:preload:view', 'platform:firstleg:preload:edit', + 'platform:firstleg:preload:complete', 'platform:firstleg:preload:confirm', + 'platform:firstleg:loading-list:list', 'platform:firstleg:loading-list:view')) <> 16 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:平台权限资源未完整创建'; + END IF; + IF (SELECT COUNT(DISTINCT CONCAT(role.role_code, ':', permission.permission_code)) + FROM baiyue_iam.iam_role role + JOIN baiyue_iam.iam_role_permission grant_row ON grant_row.role_id = role.id + JOIN baiyue_iam.iam_permission permission ON permission.id = grant_row.permission_id + WHERE role.tenant_id = 0 AND role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR') + AND permission.tenant_id = 0 + AND permission.permission_code IN ( + 'platform:firstleg:container:list', 'platform:firstleg:container:view', + 'platform:firstleg:container:create', + 'platform:firstleg:container:edit', 'platform:firstleg:smart-loading:list', + 'platform:firstleg:smart-loading:create', 'platform:firstleg:smart-loading:assign', + 'platform:firstleg:smart-loading:delete', 'platform:firstleg:smart-loading:push', + 'platform:firstleg:preload:list', 'platform:firstleg:preload:view', + 'platform:firstleg:preload:edit', 'platform:firstleg:preload:complete', + 'platform:firstleg:preload:confirm', 'platform:firstleg:loading-list:list', + 'platform:firstleg:loading-list:view')) <> 32 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:平台角色授权未完整写入'; + END IF; +END$$ +DELIMITER ; +CALL assert_phase3_smart_loading_schema(); +DROP PROCEDURE IF EXISTS assert_phase3_smart_loading_schema; + -- ============================================================ -- 全部增量完成后的最终结构强校验;只有通过此段才输出唯一 PASS -- ============================================================ diff --git a/firstleg/pom.xml b/firstleg/pom.xml index 355127dd0..ea82d81ad 100644 --- a/firstleg/pom.xml +++ b/firstleg/pom.xml @@ -21,6 +21,7 @@ org.springframework.bootspring-boot-starter-actuator com.mysqlmysql-connector-jruntime org.springframework.bootspring-boot-starter-testtest + org.projectlomboklomboktrue org.springframework.bootspring-boot-maven-plugin diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java index 296382585..14d224680 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java @@ -19,6 +19,14 @@ public interface OrderCargoClient { record CargoBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName, Integer originalPackageCount, BigDecimal originalGrossWeightKg, - BigDecimal originalVolumeCbm) { + BigDecimal originalVolumeCbm, String skuNo, String cargoNameEn, + Integer unitsPerPackage, Integer totalQuantity, BigDecimal netWeightKg, + String cargoRemark) { + public CargoBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName, + Integer originalPackageCount, BigDecimal originalGrossWeightKg, + BigDecimal originalVolumeCbm) { + this(cargoId, orderId, itemNo, cargoName, originalPackageCount, + originalGrossWeightKg, originalVolumeCbm, null, null, null, null, null, null); + } } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/WarehousePalletLocationClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/WarehousePalletLocationClient.java index 7c2b5539c..eee112077 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/client/WarehousePalletLocationClient.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/WarehousePalletLocationClient.java @@ -25,6 +25,11 @@ public interface WarehousePalletLocationClient { ApiResponse> release(@RequestHeader("X-Internal-Service-Token") String token, @RequestBody ReleaseRequest request); + @PostMapping("/release-selected") + ApiResponse> releaseSelected( + @RequestHeader("X-Internal-Service-Token") String token, + @RequestBody SelectiveReleaseRequest request); + record ReserveRequest(long tenantId, long warehouseId, String sourceService, long businessId, String inboundCode, String idempotencyKey, long operatorId, BigDecimal totalVolumeCbm, BigDecimal palletCapacityCbm, String palletType, @@ -33,10 +38,16 @@ public interface WarehousePalletLocationClient { long operatorId, BigDecimal totalVolumeCbm, BigDecimal palletCapacityCbm) {} record ReleaseRequest(long tenantId, String sourceService, long businessId, long operatorId, String reason) {} + record SelectiveReleaseRequest(long tenantId, String sourceService, long businessId, + long operatorId, String reason, int totalPackageCount, + List locationIds, Integer releaseLocationCount) {} record AllocationResult(long reservationId, String reservationNo, int allocationNo, long palletId, String palletCode, long locationId, String locationCode, BigDecimal allocatedVolumeCbm, String state) {} record BatchResult(int palletCount, List allocations) {} record ActiveResource(long reservationId, long warehouseId, long palletId, String palletCode, long locationId, String locationCode, String state) {} + record SelectiveReleaseResult(long reservationId, long occupancyId, int allocationNo, + long palletId, long locationId, String locationCode, + int releasedPackageCount) {} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java new file mode 100644 index 000000000..2b6b81b73 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java @@ -0,0 +1,63 @@ +package com.baiyue.firstleg.controller; + +import com.baiyue.common.audit.BusinessAudit; +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest; +import com.baiyue.firstleg.dto.ContainerDtos.Query; +import com.baiyue.firstleg.dto.ContainerDtos.SaveRequest; +import com.baiyue.firstleg.dto.ContainerDtos.TransportUpdateRequest; +import com.baiyue.firstleg.entity.ContainerBookingEntity; +import com.baiyue.firstleg.service.ContainerService; +import com.baiyue.firstleg.service.FirstlegScopeGuard; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Positive; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "货柜基础资料") @Validated @RestController +@RequestMapping("/api/platform/v1/containers") +public class PlatformContainerController { + @Resource private ContainerService service; + @GetMapping @PreAuthorize("hasAuthority('platform:firstleg:container:list')") + public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, + @Valid Query query) { + FirstlegScopeGuard.platform(user); return ApiResponse.success(service.page(query)); + } + @GetMapping("/{id}") @PreAuthorize("hasAuthority('platform:firstleg:container:view')") + public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long id) { + FirstlegScopeGuard.platform(user); return ApiResponse.success(service.detail(id)); + } + @PostMapping @PreAuthorize("hasAuthority('platform:firstleg:container:create')") + @BusinessAudit(event="CONTAINER_CREATE", resourceType="CONTAINER", description="新增货柜基础资料", captureBefore=false) + public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody SaveRequest request) { + FirstlegScopeGuard.platform(user); return ApiResponse.success(service.create(user.userId(), user.username(), request)); + } + @PutMapping("/{id}") @PreAuthorize("hasAuthority('platform:firstleg:container:edit')") + @BusinessAudit(event="CONTAINER_UPDATE", resourceType="CONTAINER", resourceId="#id", description="完整编辑货柜基础资料") + @Operation(summary="完整编辑货柜", description="仅无装柜计划或计划为EDITING时允许") + public ApiResponse updateDraft(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long id, + @Valid @RequestBody FullUpdateRequest request) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.updateDraft(id, user.userId(), user.username(), request)); + } + @PatchMapping("/{id}/transport") @PreAuthorize("hasAuthority('platform:firstleg:container:edit')") + @BusinessAudit(event="CONTAINER_TRANSPORT_UPDATE", resourceType="CONTAINER", resourceId="#id", + description="修改货柜集装箱号和航次") + @Operation(summary="修改集装箱号和航次", description="LOADING仅开放此白名单;COMPLETED全部只读") + public ApiResponse updateTransport(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long id, + @Valid @RequestBody TransportUpdateRequest request) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.updateTransport(id, user.userId(), user.username(), request)); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java new file mode 100644 index 000000000..fca7a552b --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java @@ -0,0 +1,103 @@ +package com.baiyue.firstleg.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.firstleg.dto.LoadingListDtos.AdvanceRequest; +import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedDetail; +import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedQuery; +import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedSummary; +import com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail; +import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery; +import com.baiyue.firstleg.dto.LoadingListDtos.PreloadSummary; +import com.baiyue.firstleg.dto.LoadingListDtos.TransportUpdateRequest; +import com.baiyue.firstleg.service.FirstlegScopeGuard; +import com.baiyue.firstleg.service.LoadingListService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Positive; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** 基于同一装柜计划聚合提供平台预装清单和已确认装柜清单。 */ +@Tag(name = "平台预装与装柜清单", description = "预装单向推进和已确认装柜清单只读查询") +@Validated +@RestController +@RequestMapping("/api/platform/v1/first-leg") +public class PlatformLoadingListController { + @Resource + private LoadingListService service; + + @Operation(summary = "分页查询预装清单") + @GetMapping("/loading-plans") + @PreAuthorize("hasAuthority('platform:firstleg:preload:list')") + public ApiResponse> preloadPage( + @AuthenticationPrincipal AuthenticatedUser user, @Valid PreloadQuery query) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.preloadPage(query)); + } + + @Operation(summary = "查询装柜详情", description = "装柜中可维护未拣货明细;完成装柜后永久只读。") + @GetMapping("/loading-plans/{planId}") + @PreAuthorize("hasAuthority('platform:firstleg:preload:view')") + public ApiResponse preloadDetail( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.preloadDetail(planId)); + } + + @Operation(summary = "修改装柜运输字段", description = "仅装柜中可修改集装箱号与航次。") + @PatchMapping("/loading-plans/{planId}/transport") + @PreAuthorize("hasAuthority('platform:firstleg:preload:edit')") + public ApiResponse updateTransport( + @AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long planId, + @RequestHeader("Idempotency-Key") String idempotencyKey, + @Valid @RequestBody TransportUpdateRequest request) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.updateTransport( + planId, user.userId(), user.username(), idempotencyKey, request)); + } + + @Operation(summary = "完成装柜", description = "状态仅允许 LOADING 推进到 COMPLETED,完成后主单和明细冻结。") + @PostMapping("/loading-plans/{planId}/complete") + @PreAuthorize("hasAuthority('platform:firstleg:preload:complete')") + public ApiResponse complete( + @AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long planId, + @RequestHeader("Idempotency-Key") String idempotencyKey, + @Valid @RequestBody AdvanceRequest request) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.complete( + planId, user.userId(), user.username(), idempotencyKey, request)); + } + + @Operation(summary = "分页查询完成装柜清单", description = "只读取 COMPLETED 状态的装柜计划。") + @GetMapping("/loading-lists") + @PreAuthorize("hasAuthority('platform:firstleg:loading-list:list')") + public ApiResponse> confirmedPage( + @AuthenticationPrincipal AuthenticatedUser user, @Valid ConfirmedQuery query) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.confirmedPage(query)); + } + + @Operation(summary = "查询完成装柜清单明细", description = "已完成主单和明细全部只读。") + @GetMapping("/loading-lists/{planId}") + @PreAuthorize("hasAuthority('platform:firstleg:loading-list:view')") + public ApiResponse confirmedDetail( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.confirmedDetail(planId)); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java new file mode 100644 index 000000000..b3b2473d5 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java @@ -0,0 +1,48 @@ +package com.baiyue.firstleg.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.firstleg.dto.LoadingPickDtos.PickRequest; +import com.baiyue.firstleg.dto.LoadingPickDtos.Task; +import com.baiyue.firstleg.service.FirstlegScopeGuard; +import com.baiyue.firstleg.service.LoadingPickService; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Positive; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** PDA 按集装箱号执行装柜拣货。 */ +@Validated +@RestController +@RequestMapping("/api/platform/v1/first-leg/pda-loading") +public class PlatformLoadingPdaController { + @Resource + private LoadingPickService service; + + @GetMapping("/tasks") + @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')") + public ApiResponse task(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam String containerNo) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.task(containerNo)); + } + + @PostMapping("/tasks/{taskId}/items/{itemId}/pick") + @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')") + public ApiResponse pick(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long taskId, + @PathVariable @Positive long itemId, + @Valid @RequestBody PickRequest request) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.pick(taskId, itemId, request, user.userId(), user.username())); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformSmartLoadingController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformSmartLoadingController.java new file mode 100644 index 000000000..3620f8be2 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformSmartLoadingController.java @@ -0,0 +1,29 @@ +package com.baiyue.firstleg.controller; + +import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.firstleg.dto.SmartLoadingDtos.*; import com.baiyue.firstleg.service.FirstlegScopeGuard; import com.baiyue.firstleg.service.SmartLoadingService; +import jakarta.validation.Valid; import jakarta.validation.constraints.Positive; import jakarta.annotation.Resource; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import java.util.List; + +@Validated @RestController @RequestMapping("/api/platform/v1/first-leg/smart-loading") +public class PlatformSmartLoadingController { + @Resource private SmartLoadingService service; + @GetMapping("/candidates") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')") + public ApiResponse> candidates(@AuthenticationPrincipal AuthenticatedUser u,@RequestParam @Positive long accountId){FirstlegScopeGuard.platform(u,accountId);return ApiResponse.success(service.candidates(accountId));} + @GetMapping("/plans/by-booking/{bookingId}") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')") + public ApiResponse draftByBooking(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long bookingId){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.draftByBooking(bookingId));} + @GetMapping("/plans/{planId}") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')") + public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.detail(planId));} + @PostMapping("/plans") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:create')") + public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser u,@Valid @RequestBody CreateRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.create(r.bookingId(),r.remark(),u.userId(),u.username()));} + @PostMapping("/plans/{planId}/assignments") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')") + public ApiResponse assign(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@Valid @RequestBody AssignRequest r){FirstlegScopeGuard.platform(u,r.accountId());return ApiResponse.success(service.assign(planId,r,u.userId(),u.username()));} + @PostMapping("/plans/{planId}/recommend") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')") + public ApiResponse recommend(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@Valid @RequestBody RecommendRequest r){FirstlegScopeGuard.platform(u,r.accountId());return ApiResponse.success(service.recommend(planId,r,u.userId(),u.username()));} + @PatchMapping("/plans/{planId}/items/{itemId}") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')") + public ApiResponse adjust(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@PathVariable @Positive long itemId,@Valid @RequestBody AdjustRequest r){FirstlegScopeGuard.platform(u,r.accountId());return ApiResponse.success(service.adjust(planId,itemId,r,u.userId(),u.username()));} + @DeleteMapping("/plans/{planId}/items/{itemId}") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:delete')") + public ApiResponse delete(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@PathVariable @Positive long itemId,@Valid @RequestBody DeleteRequest r){FirstlegScopeGuard.platform(u,r.accountId());return ApiResponse.success(service.delete(planId,itemId,r,u.userId(),u.username()));} + @PostMapping("/plans/{planId}/push-preload") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:push')") + public ApiResponse push(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@RequestHeader("Idempotency-Key") String key,@Valid @RequestBody PushRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.pushPreload(planId,key,r,u.userId(),u.username()));} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/domain/SmartLoadingStatus.java b/firstleg/src/main/java/com/baiyue/firstleg/domain/SmartLoadingStatus.java new file mode 100644 index 000000000..0dc401816 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/domain/SmartLoadingStatus.java @@ -0,0 +1,18 @@ +package com.baiyue.firstleg.domain; + +/** 智能装柜状态,只允许沿此方向推进。 */ +public enum SmartLoadingStatus { + EDITING, + LOADING, + COMPLETED; + + /** 装柜完成前可维护集装箱号和航次。 */ + public boolean allowsTransportUpdate() { return this != COMPLETED; } + + /** 编辑中和装柜中均允许维护尚未拣货的明细。 */ + public boolean mutable() { return this != COMPLETED; } + + public boolean canAdvanceTo(SmartLoadingStatus next) { + return next != null && next.ordinal() == ordinal() + 1; + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerDtos.java new file mode 100644 index 000000000..5ea393fdc --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerDtos.java @@ -0,0 +1,89 @@ +package com.baiyue.firstleg.dto; + +import com.baiyue.firstleg.domain.SmartLoadingStatus; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.Size; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** 平台级货柜基础资料请求模型。 */ +public final class ContainerDtos { + private ContainerDtos() {} + + @Schema(name = "ContainerQuery", description = "货柜分页筛选条件") + public record Query( + @Size(max = 64) String containerNo, + @Size(max = 64) String cabinetNo, + @Size(max = 128) String billOfLadingNo, + @Size(max = 128) String keyword, + @Positive Long warehouseId, + @Size(max = 32) String containerType, + @Positive Long shippingCompanyId, + @Size(max = 32) String departurePortCode, + @Size(max = 32) String destinationPortCode, + @Size(max = 64) String destinationWarehouseCode, + SmartLoadingStatus status, + LocalDateTime estimatedSailingFrom, + LocalDateTime estimatedSailingTo, + @Min(1) long page, + @Min(1) @Max(200) long size + ) { + public Query { + if (page == 0) page = 1; + if (size == 0) size = 20; + } + } + + @Schema(name = "ContainerSaveRequest", description = "货柜基础资料;智能装柜草稿阶段可完整编辑") + public record SaveRequest( + @Size(max = 64) String containerNo, + @NotBlank @Size(max = 64) String cabinetNo, + @NotNull @Positive Long warehouseId, + @NotBlank @Size(max = 128) String warehouseName, + @Size(max = 128) String billOfLadingNo, + @Size(max = 128) String vesselName, + @Size(max = 64) String voyageNo, + @NotBlank @Size(max = 32) String containerType, + @Positive Long shippingCompanyId, + @Size(max = 128) String shippingCompanyName, + @Size(max = 32) String departurePortCode, + @Size(max = 128) String departurePortName, + @Size(max = 32) String destinationPortCode, + @Size(max = 128) String destinationPortName, + @Size(max = 64) String destinationWarehouseCode, + @Size(max = 128) String destinationWarehouseName, + @DecimalMin(value = "0", inclusive = false) BigDecimal capacityCbm, + @DecimalMin(value = "0", inclusive = false) BigDecimal maxWeightKg, + @Size(max = 128) String terminalTruckingCompany, + LocalDateTime loadingAt, + LocalDateTime estimatedSailingAt, + LocalDateTime actualSailingAt, + LocalDateTime estimatedPortArrivalAt, + LocalDateTime actualPortArrivalAt, + LocalDateTime estimatedWarehouseArrivalAt, + LocalDateTime actualWarehouseArrivalAt + ) {} + + @Schema(name = "ContainerFullUpdateRequest", description = "智能装柜 EDITING 阶段完整编辑") + public record FullUpdateRequest( + @NotNull @PositiveOrZero Integer version, + @Valid @NotNull SaveRequest data + ) {} + + @Schema(name = "ContainerTransportUpdateRequest", + description = "LOADING 阶段只允许修改集装箱号和航次") + public record TransportUpdateRequest( + @Size(max = 64) String containerNo, + @Size(max = 64) String voyageNo, + @NotNull @PositiveOrZero Integer version + ) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java new file mode 100644 index 000000000..90d1af3da --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java @@ -0,0 +1,169 @@ +package com.baiyue.firstleg.dto; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +/** 同一装柜计划聚合的预装清单和已确认装柜清单接口模型。 */ +public final class LoadingListDtos { + private LoadingListDtos() { + } + + public record PreloadQuery( + @Size(max = 128) String keyword, + String status, + @Min(1) Long accountId, + @Min(1) Long warehouseId, + LocalDateTime createdFrom, + LocalDateTime createdTo, + @Min(1) long page, + @Min(1) @Max(200) long size + ) { + public PreloadQuery { + page = page == 0 ? 1 : page; + size = size == 0 ? 20 : size; + } + } + + public record ConfirmedQuery( + @Size(max = 128) String keyword, + @Min(1) Long accountId, + @Min(1) Long warehouseId, + LocalDateTime createdFrom, + LocalDateTime createdTo, + @Min(1) long page, + @Min(1) @Max(200) long size + ) { + public ConfirmedQuery { + page = page == 0 ? 1 : page; + size = size == 0 ? 20 : size; + } + } + + public record TransportUpdateRequest( + @Size(max = 64) String containerNo, + @Size(max = 64) String voyageNo, + @NotNull @Min(0) Integer expectedVersion, + @Size(max = 500) String operationReason + ) { + } + + public record AdvanceRequest( + @NotNull @Min(0) Integer expectedVersion, + @Size(max = 500) String operationReason + ) { + } + + public record Customer(Long accountId, String customerName) { + } + + public record PreloadSummary( + Long planId, + String preloadNo, + String containerNo, + String internalContainerNo, + List customers, + Long warehouseId, + String warehouseName, + String vesselName, + String voyageNo, + String containerType, + String status, + long skuCount, + long totalPackageCount, + BigDecimal totalVolumeCbm, + BigDecimal totalGrossWeightKg, + LocalDateTime createdAt, + Integer version + ) { + public PreloadSummary { + customers = customers == null ? List.of() : List.copyOf(customers); + } + } + + public record ConfirmedSummary( + Long planId, + String loadingListNo, + Long warehouseId, + String warehouseName, + String containerNo, + String internalContainerNo, + String status, + long totalPackageCount, + BigDecimal totalGrossWeightKg, + BigDecimal totalNetWeightKg, + BigDecimal totalVolumeCbm, + long totalQuantity, + String remark, + Long createdBy, + String createdByName, + LocalDateTime createdAt, + Integer version + ) { + } + + public record Item( + Long itemId, + Long accountId, + String customerName, + Long inboundId, + Long inboundDetailId, + String inboundCode, + String skuCode, + String destinationWarehouse, + String chineseName, + String englishName, + String goodsImageUrl, + int packageCount, + int palletCount, + BigDecimal grossWeightKg, + BigDecimal netWeightKg, + BigDecimal volumeCbm, + BigDecimal dimensionalWeightKg, + long productQuantity, + int unitsPerPackage, + String fbaTrackingNo, + String shipmentNo, + String shippingMark, + String internalRemark, + String customerRemark, + String warehouseRemark, + int loadSequence, + LocalDateTime frozenAt + ) { + } + + public record History( + Long historyId, + Long itemId, + String actionType, + String fromStatus, + String toStatus, + String beforeSnapshot, + String afterSnapshot, + Long operatorId, + String operatorName, + String operationReason, + LocalDateTime occurredAt + ) { + } + + public record PreloadDetail(PreloadSummary summary, List items, List histories) { + public PreloadDetail { + items = items == null ? List.of() : List.copyOf(items); + histories = histories == null ? List.of() : List.copyOf(histories); + } + } + + public record ConfirmedDetail(ConfirmedSummary summary, List items, List histories) { + public ConfirmedDetail { + items = items == null ? List.of() : List.copyOf(items); + histories = histories == null ? List.of() : List.copyOf(histories); + } + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java new file mode 100644 index 000000000..c6477d768 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java @@ -0,0 +1,32 @@ +package com.baiyue.firstleg.dto; + +import com.fasterxml.jackson.annotation.JsonAlias; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.util.List; + +/** PDA 装柜拣货接口模型。 */ +public final class LoadingPickDtos { + private LoadingPickDtos() { + } + + public record PickRequest( + @NotNull @Min(1) Integer pickedPackageCount, + @JsonAlias("reservationIds") List<@Min(1) Long> locationIds, + @NotNull @Min(0) Integer version, + @NotBlank @Size(max = 128) String operationId + ) { + } + + public record PickItem(Long id, Long loadingPlanItemId, Long inboundId, Long inboundDetailId, + Integer plannedPackageCount, Integer pickedPackageCount, + String itemStatus, Integer version) { + } + + public record Task(Long id, Long loadingPlanId, String containerNo, String taskStatus, + Integer version, List items) { + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java new file mode 100644 index 000000000..766e5e3c2 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java @@ -0,0 +1,24 @@ +package com.baiyue.firstleg.dto; +import com.baiyue.firstleg.entity.LoadingPlanItemEntity; import jakarta.validation.constraints.*; import java.math.BigDecimal; import java.util.List; +public final class SmartLoadingDtos { private SmartLoadingDtos(){} + public record Candidate(Long accountId, Long inboundId, Long inboundDetailId, Long orderId, String orderNo, String inboundCode, + Long warehouseId, Long destinationWarehouseId, String destinationWarehouseCode, String destinationWarehouseName, String customerName, String goodsName, Integer availablePackageCount, + BigDecimal unitWeightKg, BigDecimal unitVolumeCbm) { + public Candidate(Long accountId, Long inboundId, Long inboundDetailId, Long orderId, String orderNo, String inboundCode, + Long warehouseId, String destinationWarehouseCode, String customerName, String goodsName, Integer availablePackageCount, + BigDecimal unitWeightKg, BigDecimal unitVolumeCbm) { + this(accountId,inboundId,inboundDetailId,orderId,orderNo,inboundCode,warehouseId,null,destinationWarehouseCode,null,customerName,goodsName,availablePackageCount,unitWeightKg,unitVolumeCbm); + } + } + public record CreateRequest(@NotNull @Positive Long bookingId, @Size(max=500) String remark) {} + public record AssignRequest(@NotNull @Positive Long accountId,@NotNull @Positive Long inboundId,@NotNull @Positive Long inboundDetailId, + @NotNull @Positive Integer packageCount,@NotNull @Min(0) Integer expectedVersion,@NotBlank @Size(max=128) String operationId) {} + public record RecommendRequest(@NotNull @Positive Long accountId,@NotNull @Min(0) Integer expectedVersion, + @NotBlank @Size(max=128) String operationId) {} + public record AdjustRequest(@NotNull @Positive Long accountId,@NotNull @Positive Integer packageCount,@NotNull @Min(0) Integer expectedPlanVersion, + @NotNull @Min(0) Integer expectedItemVersion,@NotBlank @Size(max=128) String operationId) {} + public record DeleteRequest(@NotNull @Positive Long accountId,@NotNull @Min(0) Integer expectedPlanVersion, + @NotNull @Min(0) Integer expectedItemVersion,@NotBlank @Size(max=128) String operationId) {} + public record PushRequest(@NotNull @Min(0) Integer expectedVersion) {} + public record Plan(Long id,String planNo,String status,Long bookingId,String containerNo,String voyageNo,Integer version,List items) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerBookingEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerBookingEntity.java new file mode 100644 index 000000000..778735c24 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerBookingEntity.java @@ -0,0 +1,56 @@ +package com.baiyue.firstleg.entity; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.annotation.Version; +import lombok.Getter; +import lombok.Setter; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** 平台级货柜预订资料;预装状态由关联装柜计划聚合。 */ +@Getter @Setter +@TableName("fl_container_booking") +public class ContainerBookingEntity { + @TableId(type = IdType.ASSIGN_ID) private Long id; + private String bookingNo; + private String containerNo; + private String cabinetNo; + private Long warehouseId; + private String warehouseName; + private String billOfLadingNo; + private String vesselName; + private String voyageNo; + private String containerType; + private Long shippingCompanyId; + private String shippingCompanyName; + private String departurePortCode; + private String departurePortName; + private String destinationPortCode; + private String destinationPortName; + private String destinationWarehouseCode; + private String destinationWarehouseName; + private BigDecimal capacityCbm; + private BigDecimal maxWeightKg; + private String terminalTruckingCompany; + private LocalDateTime loadingAt; + private LocalDateTime estimatedSailingAt; + private LocalDateTime actualSailingAt; + private LocalDateTime estimatedPortArrivalAt; + private LocalDateTime actualPortArrivalAt; + private LocalDateTime estimatedWarehouseArrivalAt; + private LocalDateTime actualWarehouseArrivalAt; + private Long createdBy; + private String createdByName; + @TableField(fill = FieldFill.INSERT) private LocalDateTime createdAt; + private Long updatedBy; + private String updatedByName; + @TableField(fill = FieldFill.INSERT_UPDATE) private LocalDateTime updatedAt; + @Version @TableField(fill = FieldFill.INSERT) private Integer version; + @TableField(exist = false) private Long planId; + @TableField(exist = false) private String planStatus; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundPalletAllocationEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundPalletAllocationEntity.java index e6b6e1940..abb1e4afc 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundPalletAllocationEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundPalletAllocationEntity.java @@ -26,6 +26,7 @@ public class InboundPalletAllocationEntity { private BigDecimal palletCapacityCbm; private BigDecimal allocatedVolumeCbm; private Integer allocatedPackageCount; + private Integer pickedPackageCount; private Long locationId; private String locationCode; private String reservationNo; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPickHistoryEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPickHistoryEntity.java new file mode 100644 index 000000000..3e80f10fd --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPickHistoryEntity.java @@ -0,0 +1,23 @@ +package com.baiyue.firstleg.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Getter; +import lombok.Setter; + +/** PDA 拣货操作幂等历史。 */ +@Getter +@Setter +@TableName("fl_loading_pick_history") +public class LoadingPickHistoryEntity { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private Long pickTaskId; + private Long pickItemId; + private String operationId; + private String requestHash; + private Integer pickedPackageCount; + private Long operatorId; + private String operatorName; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPickItemEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPickItemEntity.java new file mode 100644 index 000000000..1c8349f7b --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPickItemEntity.java @@ -0,0 +1,30 @@ +package com.baiyue.firstleg.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.annotation.Version; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +/** PDA 装柜拣货明细。 */ +@Getter +@Setter +@TableName("fl_loading_pick_item") +public class LoadingPickItemEntity { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private Long pickTaskId; + private Long loadingPlanItemId; + private Long inboundId; + private Long inboundDetailId; + private Integer plannedPackageCount; + private Integer pickedPackageCount; + private String itemStatus; + @Version + private Integer version; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPickTaskEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPickTaskEntity.java new file mode 100644 index 000000000..36f020992 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPickTaskEntity.java @@ -0,0 +1,30 @@ +package com.baiyue.firstleg.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.annotation.Version; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +/** PDA 装柜拣货任务。 */ +@Getter +@Setter +@TableName("fl_loading_pick_task") +public class LoadingPickTaskEntity { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private Long loadingPlanId; + private String containerNo; + private String taskStatus; + private Long startedBy; + private String startedByName; + private LocalDateTime startedAt; + private Long completedBy; + private String completedByName; + private LocalDateTime completedAt; + @Version + private Integer version; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPlanEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPlanEntity.java new file mode 100644 index 000000000..7c699544a --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPlanEntity.java @@ -0,0 +1,10 @@ +package com.baiyue.firstleg.entity; +import com.baomidou.mybatisplus.annotation.*; import lombok.Getter; import lombok.Setter; import java.time.LocalDateTime; +@Getter @Setter @TableName("fl_loading_plan") +public class LoadingPlanEntity { + @TableId(type=IdType.ASSIGN_ID) private Long id; private String planNo; private Long containerBookingId; private String planStatus; + private String pushIdempotencyKey; private Long preloadPushedBy; private String preloadPushedByName; private LocalDateTime preloadPushedAt; + private Long preloadCompletedBy; private String preloadCompletedByName; private LocalDateTime preloadCompletedAt; private Long confirmedBy; + private String confirmedByName; private LocalDateTime confirmedAt; private String remark; private Long createdBy; private String createdByName; + private LocalDateTime createdAt; private Long updatedBy; private String updatedByName; private LocalDateTime updatedAt; @Version private Integer version; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPlanHistoryEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPlanHistoryEntity.java new file mode 100644 index 000000000..f9a1dd613 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPlanHistoryEntity.java @@ -0,0 +1,8 @@ +package com.baiyue.firstleg.entity; +import com.baomidou.mybatisplus.annotation.*; import lombok.Getter; import lombok.Setter; import java.time.LocalDateTime; +@Getter @Setter @TableName("fl_loading_plan_history") +public class LoadingPlanHistoryEntity { + @TableId(type=IdType.ASSIGN_ID) private Long id; private Long loadingPlanId; private Long loadingPlanItemId; private String operationId; + private String actionType; private String fromStatus; private String toStatus; private String beforeSnapshot; private String afterSnapshot; + private Long operatorId; private String operatorName; private String operationReason; private LocalDateTime createdAt; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPlanItemEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPlanItemEntity.java new file mode 100644 index 000000000..957eb1b9e --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPlanItemEntity.java @@ -0,0 +1,15 @@ +package com.baiyue.firstleg.entity; +import com.baomidou.mybatisplus.annotation.*; import lombok.Getter; import lombok.Setter; import java.math.BigDecimal; import java.time.LocalDateTime; +@Getter @Setter @TableName("fl_loading_plan_item") +public class LoadingPlanItemEntity { + @TableId(type=IdType.ASSIGN_ID) private Long id; private Long loadingPlanId; private Long accountId; private String customerName; + private Long inboundId; private Long inboundDetailId; private Long orderId; private String orderNo; private String inboundCode; private Long destinationWarehouseId; + private String destinationWarehouseCode; private String destinationWarehouseName; private String skuCode; private String productNameZh; private String productNameEn; + private Long productImageFileId; private String productImageUrl; private Integer packageCount; private Integer productQuantity; private Integer unitsPerCarton; + private Integer palletCount; private BigDecimal grossWeightKg; private BigDecimal netWeightKg; private BigDecimal dimensionalWeightKg; private BigDecimal volumeCbm; + private String fbaTrackingNo; private String shipmentNo; private String shippingMark; private Integer loadSequence; private String positionSnapshot; + private String internalRemark; private String customerRemark; private String warehouseRemark; private LocalDateTime frozenAt; private Long frozenBy; + private Long removedBy; private String removedByName; private LocalDateTime removedAt; @TableLogic private Boolean deleted; + @TableField(exist=false) private Long activeInboundDetailId; private Long createdBy; private String createdByName; private LocalDateTime createdAt; + private Long updatedBy; private String updatedByName; private LocalDateTime updatedAt; @Version private Integer version; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java new file mode 100644 index 000000000..ad18775f4 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java @@ -0,0 +1,82 @@ +package com.baiyue.firstleg.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baiyue.firstleg.dto.ContainerDtos.Query; +import com.baiyue.firstleg.entity.ContainerBookingEntity; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +@Mapper +public interface ContainerBookingMapper extends BaseMapper { + @Select(""" + + """) + Page selectBookingPage(Page page, @Param("query") Query query); + + @Select(""" + SELECT b.*, p.id AS plan_id, COALESCE(p.plan_status, 'EDITING') AS plan_status + FROM fl_container_booking b LEFT JOIN fl_loading_plan p ON p.container_booking_id = b.id + WHERE b.id = #{id} + """) + ContainerBookingEntity selectBooking(@Param("id") long id); + + @Update(""" + UPDATE fl_container_booking SET + container_no=#{value.containerNo}, cabinet_no=#{value.cabinetNo}, + warehouse_id=#{value.warehouseId}, warehouse_name=#{value.warehouseName}, + bill_of_lading_no=#{value.billOfLadingNo}, vessel_name=#{value.vesselName}, voyage_no=#{value.voyageNo}, + container_type=#{value.containerType}, shipping_company_id=#{value.shippingCompanyId}, + shipping_company_name=#{value.shippingCompanyName}, departure_port_code=#{value.departurePortCode}, + departure_port_name=#{value.departurePortName}, destination_port_code=#{value.destinationPortCode}, + destination_port_name=#{value.destinationPortName}, destination_warehouse_code=#{value.destinationWarehouseCode}, + destination_warehouse_name=#{value.destinationWarehouseName}, capacity_cbm=#{value.capacityCbm}, + max_weight_kg=#{value.maxWeightKg}, terminal_trucking_company=#{value.terminalTruckingCompany}, + loading_at=#{value.loadingAt}, estimated_sailing_at=#{value.estimatedSailingAt}, + actual_sailing_at=#{value.actualSailingAt}, estimated_port_arrival_at=#{value.estimatedPortArrivalAt}, + actual_port_arrival_at=#{value.actualPortArrivalAt}, + estimated_warehouse_arrival_at=#{value.estimatedWarehouseArrivalAt}, + actual_warehouse_arrival_at=#{value.actualWarehouseArrivalAt}, updated_by=#{operatorId}, + updated_by_name=#{operatorName}, updated_at=CURRENT_TIMESTAMP(3), version=version+1 + WHERE id=#{value.id} AND version=#{expectedVersion} + AND NOT EXISTS (SELECT 1 FROM fl_loading_plan p + WHERE p.container_booking_id=fl_container_booking.id AND p.plan_status<>'EDITING') + """) + int updateDraft(@Param("value") ContainerBookingEntity value, @Param("expectedVersion") int expectedVersion, + @Param("operatorId") long operatorId, @Param("operatorName") String operatorName); + + @Update(""" + UPDATE fl_container_booking SET container_no=#{containerNo}, voyage_no=#{voyageNo}, + updated_by=#{operatorId}, updated_by_name=#{operatorName}, + updated_at=CURRENT_TIMESTAMP(3), version=version+1 + WHERE id=#{id} AND version=#{expectedVersion} + AND NOT EXISTS (SELECT 1 FROM fl_loading_plan p + WHERE p.container_booking_id=fl_container_booking.id AND p.plan_status='COMPLETED') + """) + int updateTransport(@Param("id") long id, @Param("containerNo") String containerNo, + @Param("voyageNo") String voyageNo, @Param("expectedVersion") int expectedVersion, + @Param("operatorId") long operatorId, @Param("operatorName") String operatorName); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundPalletAllocationMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundPalletAllocationMapper.java index 8bc179064..52506815a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundPalletAllocationMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundPalletAllocationMapper.java @@ -3,7 +3,38 @@ package com.baiyue.firstleg.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.firstleg.entity.InboundPalletAllocationEntity; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; @Mapper public interface InboundPalletAllocationMapper extends BaseMapper { + @Select(""" + SELECT * + FROM fl_inbound_pallet_allocation + WHERE tenant_id = #{tenantId} + AND inbound_id = #{inboundId} + AND allocation_status = 'CONFIRMED' + ORDER BY allocation_no ASC + FOR UPDATE + """) + java.util.List selectConfirmedForUpdate( + @Param("tenantId") long tenantId, @Param("inboundId") long inboundId); + + @Update(""" + UPDATE fl_inbound_pallet_allocation + SET picked_package_count = picked_package_count + #{count}, + allocation_status = CASE + WHEN picked_package_count + #{count} = allocated_package_count THEN 'RELEASED' + ELSE 'CONFIRMED' + END, + updated_at = CURRENT_TIMESTAMP(3) + WHERE id = #{id} + AND tenant_id = #{tenantId} + AND inbound_id = #{inboundId} + AND allocation_status = 'CONFIRMED' + AND picked_package_count + #{count} <= allocated_package_count + """) + int consume(@Param("id") long id, @Param("tenantId") long tenantId, + @Param("inboundId") long inboundId, @Param("count") int count); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java new file mode 100644 index 000000000..299db9824 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java @@ -0,0 +1,297 @@ +package com.baiyue.firstleg.mapper; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedQuery; +import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery; +import lombok.Getter; +import lombok.Setter; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +/** 基于共享装柜计划表提供清单查询投影和状态推进持久化操作。 */ +@Mapper +public interface LoadingListQueryMapper { + String AGGREGATE_COLUMNS = """ + p.id AS plan_id, p.plan_no, p.container_booking_id, p.plan_status, p.remark, + p.created_by, p.created_by_name, p.created_at, p.version, + b.container_no, b.cabinet_no, b.warehouse_id, b.warehouse_name, + b.vessel_name, b.voyage_no, b.container_type, + COUNT(DISTINCT NULLIF(i.sku_code, '')) AS sku_count, + COALESCE(SUM(i.package_count), 0) AS total_package_count, + COALESCE(SUM(i.gross_weight_kg), 0) AS total_gross_weight_kg, + COALESCE(SUM(i.net_weight_kg), 0) AS total_net_weight_kg, + COALESCE(SUM(i.volume_cbm), 0) AS total_volume_cbm, + COALESCE(SUM(i.product_quantity), 0) AS total_quantity + """; + + String AGGREGATE_GROUP = """ + p.id, p.plan_no, p.container_booking_id, p.plan_status, p.remark, + p.created_by, p.created_by_name, p.created_at, p.version, + b.container_no, b.cabinet_no, b.warehouse_id, b.warehouse_name, + b.vessel_name, b.voyage_no, b.container_type + """; + + @Select(""" + + """) + Page selectPreloadPage(Page page, @Param("query") PreloadQuery query); + + @Select(""" + + """) + Page selectConfirmedPage(Page page, @Param("query") ConfirmedQuery query); + + @Select(""" + SELECT + """ + AGGREGATE_COLUMNS + """ + FROM fl_loading_plan p + JOIN fl_container_booking b ON b.id = p.container_booking_id + LEFT JOIN fl_loading_plan_item i ON i.loading_plan_id = p.id AND i.deleted = 0 + WHERE p.id = #{planId} + GROUP BY + """ + AGGREGATE_GROUP) + AggregateRow selectAggregate(@Param("planId") long planId); + + @Select(""" + SELECT id AS item_id, account_id, customer_name, inbound_id, inbound_detail_id, + inbound_code, sku_code, destination_warehouse_name, product_name_zh, + product_name_en, product_image_url, package_count, pallet_count, + gross_weight_kg, net_weight_kg, volume_cbm, dimensional_weight_kg, + product_quantity, units_per_carton, fba_tracking_no, shipment_no, + shipping_mark, internal_remark, customer_remark, warehouse_remark, + load_sequence, frozen_at + FROM fl_loading_plan_item + WHERE loading_plan_id = #{planId} AND deleted = 0 + ORDER BY load_sequence, id + """) + List selectItems(@Param("planId") long planId); + + @Select(""" + + """) + List selectCustomers(@Param("planIds") List planIds); + + @Select(""" + SELECT id AS history_id, loading_plan_item_id AS item_id, operation_id, + action_type, from_status, to_status, before_snapshot, after_snapshot, + operator_id, operator_name, operation_reason, created_at AS occurred_at + FROM fl_loading_plan_history + WHERE loading_plan_id = #{planId} + ORDER BY created_at DESC, id DESC + """) + List selectHistories(@Param("planId") long planId); + + @Select(""" + SELECT id AS history_id, loading_plan_item_id AS item_id, operation_id, + action_type, from_status, to_status, before_snapshot, after_snapshot, + operator_id, operator_name, operation_reason, created_at AS occurred_at + FROM fl_loading_plan_history WHERE operation_id = #{operationId} + """) + HistoryRow selectHistoryByOperation(@Param("operationId") String operationId); + + @Update(""" + UPDATE fl_loading_plan + SET updated_by = #{operatorId}, updated_by_name = #{operatorName}, + updated_at = CURRENT_TIMESTAMP(3), version = version + 1 + WHERE id = #{planId} AND plan_status = 'LOADING' + AND version = #{expectedVersion} + """) + int touchEditable(@Param("planId") long planId, @Param("expectedVersion") int expectedVersion, + @Param("operatorId") long operatorId, @Param("operatorName") String operatorName); + + @Update(""" + UPDATE fl_container_booking b + JOIN fl_loading_plan p ON p.container_booking_id = b.id + SET b.container_no = #{containerNo}, b.voyage_no = #{voyageNo}, + b.updated_by = #{operatorId}, b.updated_by_name = #{operatorName}, + b.updated_at = CURRENT_TIMESTAMP(3), b.version = b.version + 1 + WHERE p.id = #{planId} AND p.plan_status = 'LOADING' + """) + int updateTransport(@Param("planId") long planId, @Param("containerNo") String containerNo, + @Param("voyageNo") String voyageNo, @Param("operatorId") long operatorId, + @Param("operatorName") String operatorName); + + @Update(""" + UPDATE fl_loading_plan + SET plan_status = #{toStatus}, + preload_completed_by = CASE WHEN #{toStatus} = 'COMPLETED' THEN #{operatorId} + ELSE preload_completed_by END, + preload_completed_by_name = CASE WHEN #{toStatus} = 'COMPLETED' THEN #{operatorName} + ELSE preload_completed_by_name END, + preload_completed_at = CASE WHEN #{toStatus} = 'COMPLETED' THEN CURRENT_TIMESTAMP(3) + ELSE preload_completed_at END, + confirmed_by = CASE WHEN #{toStatus} = 'COMPLETED' THEN #{operatorId} ELSE confirmed_by END, + confirmed_by_name = CASE WHEN #{toStatus} = 'COMPLETED' THEN #{operatorName} + ELSE confirmed_by_name END, + confirmed_at = CASE WHEN #{toStatus} = 'COMPLETED' THEN CURRENT_TIMESTAMP(3) + ELSE confirmed_at END, + updated_by = #{operatorId}, updated_by_name = #{operatorName}, + updated_at = CURRENT_TIMESTAMP(3), version = version + 1 + WHERE id = #{planId} AND plan_status = #{fromStatus} AND version = #{expectedVersion} + """) + int advance(@Param("planId") long planId, @Param("fromStatus") String fromStatus, + @Param("toStatus") String toStatus, @Param("expectedVersion") int expectedVersion, + @Param("operatorId") long operatorId, @Param("operatorName") String operatorName); + + @Insert(""" + INSERT INTO fl_loading_plan_history + (id, loading_plan_id, loading_plan_item_id, operation_id, action_type, + from_status, to_status, before_snapshot, after_snapshot, + operator_id, operator_name, operation_reason, created_at) + VALUES + (#{id}, #{planId}, NULL, #{operationId}, #{actionType}, + #{fromStatus}, #{toStatus}, #{beforeSnapshot}, #{afterSnapshot}, + #{operatorId}, #{operatorName}, #{operationReason}, CURRENT_TIMESTAMP(3)) + """) + int insertHistory(HistoryWrite value); + + @Getter + @Setter + class AggregateRow { + private Long planId; + private String planNo; + private Long containerBookingId; + private String planStatus; + private String remark; + private Long createdBy; + private String createdByName; + private LocalDateTime createdAt; + private Integer version; + private String containerNo; + private String cabinetNo; + private Long warehouseId; + private String warehouseName; + private String vesselName; + private String voyageNo; + private String containerType; + private Long skuCount; + private Long totalPackageCount; + private BigDecimal totalGrossWeightKg; + private BigDecimal totalNetWeightKg; + private BigDecimal totalVolumeCbm; + private Long totalQuantity; + } + + @Getter + @Setter + class ItemRow { + private Long itemId; + private Long accountId; + private String customerName; + private Long inboundId; + private Long inboundDetailId; + private String inboundCode; + private String skuCode; + private String destinationWarehouseName; + private String productNameZh; + private String productNameEn; + private String productImageUrl; + private Integer packageCount; + private Integer palletCount; + private BigDecimal grossWeightKg; + private BigDecimal netWeightKg; + private BigDecimal volumeCbm; + private BigDecimal dimensionalWeightKg; + private Long productQuantity; + private Integer unitsPerCarton; + private String fbaTrackingNo; + private String shipmentNo; + private String shippingMark; + private String internalRemark; + private String customerRemark; + private String warehouseRemark; + private Integer loadSequence; + private LocalDateTime frozenAt; + } + + @Getter + @Setter + class HistoryRow { + private Long historyId; + private Long itemId; + private String operationId; + private String actionType; + private String fromStatus; + private String toStatus; + private String beforeSnapshot; + private String afterSnapshot; + private Long operatorId; + private String operatorName; + private String operationReason; + private LocalDateTime occurredAt; + } + + @Getter + @Setter + class CustomerRow { + private Long planId; + private Long accountId; + private String customerName; + } + + record HistoryWrite(long id, long planId, String operationId, String actionType, + String fromStatus, String toStatus, String beforeSnapshot, + String afterSnapshot, long operatorId, String operatorName, + String operationReason) { + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickHistoryMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickHistoryMapper.java new file mode 100644 index 000000000..278c611f0 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickHistoryMapper.java @@ -0,0 +1,9 @@ +package com.baiyue.firstleg.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.firstleg.entity.LoadingPickHistoryEntity; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface LoadingPickHistoryMapper extends BaseMapper { +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java new file mode 100644 index 000000000..cf35b5333 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java @@ -0,0 +1,21 @@ +package com.baiyue.firstleg.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.firstleg.entity.LoadingPickItemEntity; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Update; + +@Mapper +public interface LoadingPickItemMapper extends BaseMapper { + @Update(""" + UPDATE fl_loading_pick_item + SET picked_package_count = picked_package_count + #{count}, + item_status = CASE WHEN picked_package_count + #{count} = planned_package_count + THEN 'PICKED' ELSE 'PICKING' END, + version = version + 1 + WHERE id = #{id} AND version = #{version} + AND picked_package_count + #{count} <= planned_package_count + """) + int pick(@Param("id") long id, @Param("count") int count, @Param("version") int version); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickTaskMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickTaskMapper.java new file mode 100644 index 000000000..ee5c434a1 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickTaskMapper.java @@ -0,0 +1,9 @@ +package com.baiyue.firstleg.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.firstleg.entity.LoadingPickTaskEntity; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface LoadingPickTaskMapper extends BaseMapper { +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPlanHistoryMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPlanHistoryMapper.java new file mode 100644 index 000000000..f6a0aea3a --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPlanHistoryMapper.java @@ -0,0 +1,3 @@ +package com.baiyue.firstleg.mapper; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.firstleg.entity.LoadingPlanHistoryEntity; import org.apache.ibatis.annotations.Mapper; +@Mapper public interface LoadingPlanHistoryMapper extends BaseMapper {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPlanItemMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPlanItemMapper.java new file mode 100644 index 000000000..75b4eb3b1 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPlanItemMapper.java @@ -0,0 +1,12 @@ +package com.baiyue.firstleg.mapper; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.firstleg.entity.LoadingPlanItemEntity; import org.apache.ibatis.annotations.*; +@Mapper public interface LoadingPlanItemMapper extends BaseMapper { + @Update(""" + UPDATE fl_loading_plan_item SET deleted=1,removed_by=#{operatorId},removed_by_name=#{operatorName}, + removed_at=CURRENT_TIMESTAMP(3),updated_by=#{operatorId},updated_by_name=#{operatorName}, + updated_at=CURRENT_TIMESTAMP(3),version=version+1 + WHERE id=#{itemId} AND loading_plan_id=#{planId} AND deleted=0 AND frozen_at IS NULL + AND version=#{expectedVersion} + """) + int removeEditing(@Param("planId") long planId,@Param("itemId") long itemId,@Param("expectedVersion") int expectedVersion,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPlanMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPlanMapper.java new file mode 100644 index 000000000..0d16a19d8 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPlanMapper.java @@ -0,0 +1,17 @@ +package com.baiyue.firstleg.mapper; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.firstleg.entity.LoadingPlanEntity; import org.apache.ibatis.annotations.*; +@Mapper public interface LoadingPlanMapper extends BaseMapper { + @Update(""" + UPDATE fl_loading_plan SET updated_by=#{operatorId},updated_by_name=#{operatorName}, + updated_at=CURRENT_TIMESTAMP(3),version=version+1 + WHERE id=#{planId} AND plan_status IN ('EDITING','LOADING') AND version=#{expectedVersion} + """) + int touchEditing(@Param("planId") long planId,@Param("expectedVersion") int expectedVersion,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName); + @Update(""" + UPDATE fl_loading_plan SET plan_status='LOADING',push_idempotency_key=#{key}, + preload_pushed_by=#{operatorId},preload_pushed_by_name=#{operatorName},preload_pushed_at=CURRENT_TIMESTAMP(3), + updated_by=#{operatorId},updated_by_name=#{operatorName},updated_at=CURRENT_TIMESTAMP(3),version=version+1 + WHERE id=#{planId} AND plan_status='EDITING' AND version=#{expectedVersion} + """) + int pushPreload(@Param("planId") long planId,@Param("key") String key,@Param("expectedVersion") int expectedVersion,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java new file mode 100644 index 000000000..a1c10da33 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java @@ -0,0 +1,46 @@ +package com.baiyue.firstleg.mapper; +import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.ibatis.annotations.*; import java.util.List; +@Mapper public interface SmartLoadingCandidateMapper { + @Select(""" + SELECT o.tenant_id accountId,o.id inboundId,i.inbound_detail_id inboundDetailId, + o.order_id orderId,o.order_no orderNo,o.inbound_code inboundCode, + o.warehouse_id warehouseId,o.destination_warehouse_id destinationWarehouseId, + o.destination_code destinationWarehouseCode,o.destination_name destinationWarehouseName, + o.customer_name customerName,COALESCE(m.actual_goods_name,'') goodsName, + i.package_count availablePackageCount,COALESCE(i.unit_weight_kg,0) unitWeightKg, + COALESCE(i.unit_volume_cbm,0) unitVolumeCbm + FROM fl_inbound_order o + JOIN fl_inbound_measurement m ON m.tenant_id=o.tenant_id AND m.inbound_id=o.id + AND m.status='APPROVED' + AND m.id=(SELECT MAX(m2.id) FROM fl_inbound_measurement m2 + WHERE m2.tenant_id=o.tenant_id AND m2.inbound_id=o.id AND m2.status='APPROVED') + JOIN fl_inbound_measurement_item i ON i.tenant_id=o.tenant_id AND i.measurement_id=m.id + LEFT JOIN fl_loading_plan_item l + ON l.account_id=o.tenant_id AND l.active_inbound_detail_id=i.inbound_detail_id + WHERE o.tenant_id=#{accountId} AND o.process_status='INBOUNDED' + AND i.inbound_detail_id IS NOT NULL AND l.id IS NULL + ORDER BY o.inbounded_at ASC, o.id ASC, i.id ASC + """) + List candidates(@Param("accountId") long accountId); + + @Select(""" + SELECT o.tenant_id accountId,o.id inboundId,i.inbound_detail_id inboundDetailId, + o.order_id orderId,o.order_no orderNo,o.inbound_code inboundCode, + o.warehouse_id warehouseId,o.destination_warehouse_id destinationWarehouseId, + o.destination_code destinationWarehouseCode,o.destination_name destinationWarehouseName, + o.customer_name customerName,COALESCE(m.actual_goods_name,'') goodsName, + i.package_count availablePackageCount,COALESCE(i.unit_weight_kg,0) unitWeightKg, + COALESCE(i.unit_volume_cbm,0) unitVolumeCbm + FROM fl_inbound_order o + JOIN fl_inbound_measurement m ON m.tenant_id=o.tenant_id AND m.inbound_id=o.id + AND m.status='APPROVED' + AND m.id=(SELECT MAX(m2.id) FROM fl_inbound_measurement m2 + WHERE m2.tenant_id=o.tenant_id AND m2.inbound_id=o.id AND m2.status='APPROVED') + JOIN fl_inbound_measurement_item i ON i.tenant_id=o.tenant_id AND i.measurement_id=m.id + WHERE o.tenant_id=#{accountId} AND o.id=#{inboundId} AND o.process_status='INBOUNDED' + AND i.inbound_detail_id=#{detailId} + ORDER BY m.id DESC LIMIT 1 + """) + Candidate candidate(@Param("accountId") long accountId,@Param("inboundId") long inboundId, + @Param("detailId") long detailId); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerRepository.java new file mode 100644 index 000000000..40f505169 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerRepository.java @@ -0,0 +1,38 @@ +package com.baiyue.firstleg.repository; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.firstleg.dto.ContainerDtos.Query; +import com.baiyue.firstleg.entity.ContainerBookingEntity; +import com.baiyue.firstleg.mapper.ContainerBookingMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Repository; + +@Repository +public class ContainerRepository { + @Resource private ContainerBookingMapper mapper; + public PageResult page(Query query) { + return PageResult.from(mapper.selectBookingPage(new Page<>(query.page(), query.size()), query)); + } + public ContainerBookingEntity find(long id) { return mapper.selectBooking(id); } + public boolean containerNoExists(String no, Long excludedId) { + if (no == null) return false; + return mapper.selectCount(new LambdaQueryWrapper() + .eq(ContainerBookingEntity::getContainerNo, no) + .ne(excludedId != null, ContainerBookingEntity::getId, excludedId)) > 0; + } + public boolean cabinetNoExists(String no, Long excludedId) { + return mapper.selectCount(new LambdaQueryWrapper() + .eq(ContainerBookingEntity::getCabinetNo, no) + .ne(excludedId != null, ContainerBookingEntity::getId, excludedId)) > 0; + } + public void insert(ContainerBookingEntity value) { mapper.insert(value); } + public int updateDraft(ContainerBookingEntity value, int version, long operatorId, String operatorName) { + return mapper.updateDraft(value, version, operatorId, operatorName); + } + public int updateTransport(long id, String containerNo, String voyageNo, int version, + long operatorId, String operatorName) { + return mapper.updateTransport(id, containerNo, voyageNo, version, operatorId, operatorName); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundPalletAllocationRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundPalletAllocationRepository.java index 9c5f5f5ed..f17ae052f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundPalletAllocationRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundPalletAllocationRepository.java @@ -12,6 +12,7 @@ import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; import java.time.LocalDateTime; import java.util.Map; +import java.util.List; import java.util.function.Function; import java.util.stream.Collectors; @@ -21,8 +22,7 @@ public class InboundPalletAllocationRepository { @Transactional public void replace(long tenantId, long inboundId, long warehouseId, long warehouseResourceTenantId, - BigDecimal capacity, - BatchResult result) { + BigDecimal capacity, int totalPackageCount, BatchResult result) { Map reservationNos = mapper.selectList(new LambdaQueryWrapper() .eq(InboundPalletAllocationEntity::getTenantId, tenantId) .eq(InboundPalletAllocationEntity::getInboundId, inboundId)) @@ -32,7 +32,11 @@ public class InboundPalletAllocationRepository { .eq(InboundPalletAllocationEntity::getTenantId, tenantId) .eq(InboundPalletAllocationEntity::getInboundId, inboundId)); LocalDateTime now = LocalDateTime.now(); - result.allocations().forEach(source -> { + int allocationCount = result.allocations().size(); + int perAllocation = allocationCount == 0 ? 0 + : (int) Math.ceil((double) totalPackageCount / allocationCount); + int remaining = totalPackageCount; + for (var source : result.allocations()) { InboundPalletAllocationEntity value = new InboundPalletAllocationEntity(); value.setTenantId(tenantId); value.setInboundId(inboundId); @@ -43,6 +47,10 @@ public class InboundPalletAllocationRepository { value.setPalletCode(source.palletCode()); value.setPalletCapacityCbm(capacity); value.setAllocatedVolumeCbm(source.allocatedVolumeCbm()); + int allocatedPackages = Math.min(perAllocation, remaining); + value.setAllocatedPackageCount(allocatedPackages); + value.setPickedPackageCount(0); + remaining -= allocatedPackages; value.setLocationId(source.locationId()); value.setLocationCode(source.locationCode()); // 仓储确认响应只返回 reservationId;沿用预占阶段的预占单号快照。 @@ -52,7 +60,17 @@ public class InboundPalletAllocationRepository { value.setCreatedAt(now); value.setUpdatedAt(now); mapper.insert(value); - }); + } + } + + public List lockConfirmed(long tenantId, long inboundId) { + return mapper.selectConfirmedForUpdate(tenantId, inboundId); + } + + public void consume(long tenantId, long inboundId, long allocationId, int packageCount) { + if (mapper.consume(allocationId, tenantId, inboundId, packageCount) != 1) { + throw new IllegalStateException("库位箱数已被并发操作修改"); + } } private static String normalize(String state) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java new file mode 100644 index 000000000..a921697c3 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java @@ -0,0 +1,150 @@ +package com.baiyue.firstleg.repository; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedDetail; +import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedQuery; +import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedSummary; +import com.baiyue.firstleg.dto.LoadingListDtos.Customer; +import com.baiyue.firstleg.dto.LoadingListDtos.History; +import com.baiyue.firstleg.dto.LoadingListDtos.Item; +import com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail; +import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery; +import com.baiyue.firstleg.dto.LoadingListDtos.PreloadSummary; +import com.baiyue.firstleg.mapper.LoadingListQueryMapper; +import com.baiyue.firstleg.mapper.LoadingListQueryMapper.AggregateRow; +import com.baiyue.firstleg.mapper.LoadingListQueryMapper.CustomerRow; +import com.baiyue.firstleg.mapper.LoadingListQueryMapper.HistoryRow; +import com.baiyue.firstleg.mapper.LoadingListQueryMapper.HistoryWrite; +import com.baiyue.firstleg.mapper.LoadingListQueryMapper.ItemRow; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Repository; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Repository +public class LoadingListRepository { + @Resource + private LoadingListQueryMapper mapper; + + public PageResult preloadPage(PreloadQuery query) { + Page page = mapper.selectPreloadPage(new Page<>(query.page(), query.size()), query); + Map> customers = customerMap(page.getRecords()); + List records = page.getRecords().stream() + .map(row -> preload(row, customers.get(row.getPlanId()))) + .toList(); + return new PageResult<>(records, page.getCurrent(), page.getSize(), page.getTotal(), page.getPages()); + } + + public PageResult confirmedPage(ConfirmedQuery query) { + Page page = mapper.selectConfirmedPage(new Page<>(query.page(), query.size()), query); + List records = page.getRecords().stream().map(this::confirmed).toList(); + return new PageResult<>(records, page.getCurrent(), page.getSize(), page.getTotal(), page.getPages()); + } + + public AggregateRow aggregate(long planId) { + return mapper.selectAggregate(planId); + } + + public PreloadDetail preloadDetail(AggregateRow row) { + List items = mapper.selectItems(row.getPlanId()); + List customers = items.stream() + .collect(Collectors.toMap(ItemRow::getAccountId, ItemRow::getCustomerName, + (left, right) -> left, java.util.LinkedHashMap::new)) + .entrySet().stream().map(entry -> new Customer(entry.getKey(), entry.getValue())).toList(); + return new PreloadDetail(preload(row, customers), items(items), histories(row.getPlanId())); + } + + public ConfirmedDetail confirmedDetail(AggregateRow row) { + return new ConfirmedDetail(confirmed(row), items(mapper.selectItems(row.getPlanId())), + histories(row.getPlanId())); + } + + public HistoryRow history(String operationId) { + return mapper.selectHistoryByOperation(operationId); + } + + public int updateTransport(long planId, int expectedVersion, String containerNo, String voyageNo, + long operatorId, String operatorName) { + if (mapper.touchEditable(planId, expectedVersion, operatorId, operatorName) != 1) { + return 0; + } + return mapper.updateTransport(planId, containerNo, voyageNo, operatorId, operatorName); + } + + public int advance(long planId, String fromStatus, String toStatus, int expectedVersion, + long operatorId, String operatorName) { + return mapper.advance(planId, fromStatus, toStatus, expectedVersion, operatorId, operatorName); + } + + public void appendHistory(long planId, String operationId, String actionType, + String fromStatus, String toStatus, String beforeSnapshot, + String afterSnapshot, long operatorId, String operatorName, + String operationReason) { + mapper.insertHistory(new HistoryWrite(IdWorker.getId(), planId, operationId, actionType, + fromStatus, toStatus, beforeSnapshot, afterSnapshot, operatorId, operatorName, + operationReason)); + } + + private Map> customerMap(List rows) { + List ids = rows.stream().map(AggregateRow::getPlanId).toList(); + if (ids.isEmpty()) { + return Map.of(); + } + return mapper.selectCustomers(ids).stream().collect(Collectors.groupingBy( + CustomerRow::getPlanId, java.util.LinkedHashMap::new, + Collectors.mapping(row -> new Customer(row.getAccountId(), row.getCustomerName()), + Collectors.toList()))); + } + + private PreloadSummary preload(AggregateRow row, List customers) { + return new PreloadSummary(row.getPlanId(), row.getPlanNo(), row.getContainerNo(), row.getCabinetNo(), + customers, row.getWarehouseId(), row.getWarehouseName(), row.getVesselName(), row.getVoyageNo(), + row.getContainerType(), row.getPlanStatus(), value(row.getSkuCount()), + value(row.getTotalPackageCount()), decimal(row.getTotalVolumeCbm()), + decimal(row.getTotalGrossWeightKg()), row.getCreatedAt(), row.getVersion()); + } + + private ConfirmedSummary confirmed(AggregateRow row) { + return new ConfirmedSummary(row.getPlanId(), row.getPlanNo(), row.getWarehouseId(), row.getWarehouseName(), + row.getContainerNo(), row.getCabinetNo(), row.getPlanStatus(), value(row.getTotalPackageCount()), + decimal(row.getTotalGrossWeightKg()), decimal(row.getTotalNetWeightKg()), + decimal(row.getTotalVolumeCbm()), value(row.getTotalQuantity()), row.getRemark(), + row.getCreatedBy(), row.getCreatedByName(), row.getCreatedAt(), row.getVersion()); + } + + private List items(List rows) { + return rows.stream().map(row -> new Item(row.getItemId(), row.getAccountId(), row.getCustomerName(), + row.getInboundId(), row.getInboundDetailId(), row.getInboundCode(), row.getSkuCode(), + row.getDestinationWarehouseName(), row.getProductNameZh(), row.getProductNameEn(), + row.getProductImageUrl(), integer(row.getPackageCount()), integer(row.getPalletCount()), + decimal(row.getGrossWeightKg()), decimal(row.getNetWeightKg()), decimal(row.getVolumeCbm()), + decimal(row.getDimensionalWeightKg()), value(row.getProductQuantity()), + integer(row.getUnitsPerCarton()), row.getFbaTrackingNo(), row.getShipmentNo(), row.getShippingMark(), + row.getInternalRemark(), row.getCustomerRemark(), row.getWarehouseRemark(), + integer(row.getLoadSequence()), row.getFrozenAt())).toList(); + } + + private List histories(long planId) { + return mapper.selectHistories(planId).stream().map(row -> new History(row.getHistoryId(), row.getItemId(), + row.getActionType(), row.getFromStatus(), row.getToStatus(), row.getBeforeSnapshot(), + row.getAfterSnapshot(), row.getOperatorId(), row.getOperatorName(), row.getOperationReason(), + row.getOccurredAt())).toList(); + } + + private static long value(Long value) { + return value == null ? 0 : value; + } + + private static int integer(Integer value) { + return value == null ? 0 : value; + } + + private static BigDecimal decimal(BigDecimal value) { + return value == null ? BigDecimal.ZERO : value; + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingPickRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingPickRepository.java new file mode 100644 index 000000000..7051e39f6 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingPickRepository.java @@ -0,0 +1,86 @@ +package com.baiyue.firstleg.repository; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baiyue.firstleg.entity.LoadingPickItemEntity; +import com.baiyue.firstleg.entity.LoadingPickHistoryEntity; +import com.baiyue.firstleg.entity.LoadingPickTaskEntity; +import com.baiyue.firstleg.mapper.LoadingPickHistoryMapper; +import com.baiyue.firstleg.entity.LoadingPlanItemEntity; +import com.baiyue.firstleg.mapper.LoadingPickItemMapper; +import com.baiyue.firstleg.mapper.LoadingPickTaskMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Repository; + +import java.util.List; + +/** PDA 拣货任务持久化边界。 */ +@Repository +public class LoadingPickRepository { + @Resource + private LoadingPickTaskMapper tasks; + @Resource + private LoadingPickItemMapper items; + @Resource + private LoadingPickHistoryMapper histories; + + public LoadingPickTaskEntity task(long taskId) { + return tasks.selectById(taskId); + } + + public LoadingPickTaskEntity taskByPlan(long planId) { + return tasks.selectOne(new LambdaQueryWrapper() + .eq(LoadingPickTaskEntity::getLoadingPlanId, planId)); + } + + public LoadingPickTaskEntity taskByContainer(String containerNo) { + return tasks.selectOne(new LambdaQueryWrapper() + .eq(LoadingPickTaskEntity::getContainerNo, containerNo) + .eq(LoadingPickTaskEntity::getTaskStatus, "LOADING")); + } + + public LoadingPickItemEntity item(long taskId, long itemId) { + return items.selectOne(new LambdaQueryWrapper() + .eq(LoadingPickItemEntity::getPickTaskId, taskId) + .eq(LoadingPickItemEntity::getId, itemId)); + } + + public List items(long taskId) { + return items.selectList(new LambdaQueryWrapper() + .eq(LoadingPickItemEntity::getPickTaskId, taskId) + .orderByAsc(LoadingPickItemEntity::getId)); + } + + public void insert(LoadingPickTaskEntity task) { + tasks.insert(task); + } + + public void insert(LoadingPickItemEntity item) { + items.insert(item); + } + + public LoadingPickItemEntity itemByPlanItem(long planItemId) { + return items.selectOne(new LambdaQueryWrapper() + .eq(LoadingPickItemEntity::getLoadingPlanItemId, planItemId)); + } + + public int pick(long itemId, int count, int version) { + return items.pick(itemId, count, version); + } + + public LoadingPickHistoryEntity history(String operationId) { + return histories.selectOne(new LambdaQueryWrapper() + .eq(LoadingPickHistoryEntity::getOperationId, operationId)); + } + + public void insert(LoadingPickHistoryEntity history) { + histories.insert(history); + } + + public void update(LoadingPickItemEntity item) { + items.updateById(item); + } + + public void deleteItem(long itemId) { + items.deleteById(itemId); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java new file mode 100644 index 000000000..b3e9de55d --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java @@ -0,0 +1,74 @@ +package com.baiyue.firstleg.repository; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; +import com.baiyue.firstleg.entity.ContainerBookingEntity; +import com.baiyue.firstleg.entity.LoadingPlanEntity; +import com.baiyue.firstleg.entity.LoadingPlanHistoryEntity; +import com.baiyue.firstleg.entity.LoadingPlanItemEntity; +import com.baiyue.firstleg.mapper.ContainerBookingMapper; +import com.baiyue.firstleg.mapper.LoadingPlanHistoryMapper; +import com.baiyue.firstleg.mapper.LoadingPlanItemMapper; +import com.baiyue.firstleg.mapper.LoadingPlanMapper; +import com.baiyue.firstleg.mapper.SmartLoadingCandidateMapper; +import org.springframework.stereotype.Repository; +import jakarta.annotation.Resource; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; + +import java.util.List; + +/** 智能装柜聚合的真实 MySQL 持久化边界。 */ +@Repository +@ConditionalOnProperty(name="firstleg.smart-loading.repository.enabled", havingValue="true", matchIfMissing=true) +public class SmartLoadingRepository { + @Resource private LoadingPlanMapper plans; + @Resource private LoadingPlanItemMapper items; + @Resource private LoadingPlanHistoryMapper histories; + @Resource private ContainerBookingMapper bookings; + @Resource private SmartLoadingCandidateMapper candidates; + + public List candidates(long accountId) { return candidates.candidates(accountId); } + public Candidate candidate(long accountId, long inboundId, long detailId) { + return candidates.candidate(accountId, inboundId, detailId); + } + public LoadingPlanEntity plan(long planId) { return plans.selectById(planId); } + public LoadingPlanEntity planByBooking(long bookingId) { + return plans.selectOne(new LambdaQueryWrapper() + .eq(LoadingPlanEntity::getContainerBookingId, bookingId)); + } + public ContainerBookingEntity booking(long bookingId) { return bookings.selectById(bookingId); } + public List items(long planId) { + return items.selectList(new LambdaQueryWrapper() + .eq(LoadingPlanItemEntity::getLoadingPlanId, planId) + .eq(LoadingPlanItemEntity::getDeleted, false) + .orderByAsc(LoadingPlanItemEntity::getLoadSequence).orderByAsc(LoadingPlanItemEntity::getId)); + } + public LoadingPlanItemEntity item(long planId, long itemId) { + return items.selectOne(new LambdaQueryWrapper() + .eq(LoadingPlanItemEntity::getLoadingPlanId, planId) + .eq(LoadingPlanItemEntity::getId, itemId).eq(LoadingPlanItemEntity::getDeleted, false)); + } + public LoadingPlanItemEntity activeItem(long accountId, long detailId) { + return items.selectOne(new LambdaQueryWrapper() + .eq(LoadingPlanItemEntity::getAccountId, accountId) + .eq(LoadingPlanItemEntity::getInboundDetailId, detailId) + .eq(LoadingPlanItemEntity::getDeleted, false)); + } + public LoadingPlanHistoryEntity operation(String operationId) { + return histories.selectOne(new LambdaQueryWrapper() + .eq(LoadingPlanHistoryEntity::getOperationId, operationId)); + } + public void insert(LoadingPlanEntity value) { plans.insert(value); } + public void insert(LoadingPlanItemEntity value) { items.insert(value); } + public void insert(LoadingPlanHistoryEntity value) { histories.insert(value); } + public int update(LoadingPlanItemEntity value) { return items.updateById(value); } + public int touch(long planId, int version, long operatorId, String operatorName) { + return plans.touchEditing(planId, version, operatorId, operatorName); + } + public int remove(long planId, long itemId, int version, long operatorId, String operatorName) { + return items.removeEditing(planId, itemId, version, operatorId, operatorName); + } + public int push(long planId, String key, int version, long operatorId, String operatorName) { + return plans.pushPreload(planId, key, version, operatorId, operatorName); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java new file mode 100644 index 000000000..1eabf5bab --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java @@ -0,0 +1,134 @@ +package com.baiyue.firstleg.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.domain.SmartLoadingStatus; +import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest; +import com.baiyue.firstleg.dto.ContainerDtos.Query; +import com.baiyue.firstleg.dto.ContainerDtos.SaveRequest; +import com.baiyue.firstleg.dto.ContainerDtos.TransportUpdateRequest; +import com.baiyue.firstleg.entity.ContainerBookingEntity; +import com.baiyue.firstleg.repository.ContainerRepository; +import jakarta.annotation.Resource; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +@Service +public class ContainerService { + @Resource private ContainerRepository repository; + public PageResult page(Query query) { + validateRange(query.estimatedSailingFrom(), query.estimatedSailingTo(), "预计船期筛选"); + return repository.page(normalize(query)); + } + public ContainerBookingEntity detail(long id) { return required(id); } + + @Transactional + public ContainerBookingEntity create(long operatorId, String operatorName, SaveRequest request) { + validateSchedule(request); + ensureUnique(trim(request.containerNo()), request.cabinetNo().trim(), null); + ContainerBookingEntity value = new ContainerBookingEntity(); + value.setId(IdWorker.getId()); + value.setBookingNo("CB" + value.getId()); + copy(value, request); + value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); + value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0); + try { repository.insert(value); } catch (DuplicateKeyException ex) { throw duplicate(); } + value.setPlanStatus(SmartLoadingStatus.EDITING.name()); + return value; + } + + @Transactional + public ContainerBookingEntity updateDraft(long id, long operatorId, String operatorName, + FullUpdateRequest request) { + ContainerBookingEntity current = required(id); + requireVersion(current, request.version()); + if (status(current) != SmartLoadingStatus.EDITING) { + throw conflict("货柜已进入预装流程,只能修改集装箱号和航次"); + } + validateSchedule(request.data()); + ensureUnique(trim(request.data().containerNo()), request.data().cabinetNo().trim(), id); + copy(current, request.data()); + try { + if (repository.updateDraft(current, request.version(), operatorId, operatorName) != 1) { + throw conflict("货柜状态或版本已变化,请刷新后重试"); + } + } catch (DuplicateKeyException ex) { throw duplicate(); } + return required(id); + } + + @Transactional + public ContainerBookingEntity updateTransport(long id, long operatorId, String operatorName, + TransportUpdateRequest request) { + ContainerBookingEntity current = required(id); + requireVersion(current, request.version()); + if (status(current) == SmartLoadingStatus.COMPLETED) throw conflict("装柜完成后货柜全部只读,不能修改"); + String containerNo = trim(request.containerNo()); + if (repository.containerNoExists(containerNo, id)) throw conflict("集装箱号已存在"); + try { + if (repository.updateTransport(id, containerNo, trim(request.voyageNo()), request.version(), + operatorId, operatorName) != 1) throw conflict("货柜状态或版本已变化,请刷新后重试"); + } catch (DuplicateKeyException ex) { throw conflict("集装箱号已存在"); } + return required(id); + } + + private ContainerBookingEntity required(long id) { + ContainerBookingEntity value = repository.find(id); + if (value == null) throw new BizException(ResultCode.NOT_FOUND, "货柜不存在"); + return value; + } + private void ensureUnique(String containerNo, String cabinetNo, Long excludedId) { + if (repository.containerNoExists(containerNo, excludedId)) throw conflict("集装箱号已存在"); + if (repository.cabinetNoExists(cabinetNo, excludedId)) throw conflict("内部柜号已存在"); + } + private static void requireVersion(ContainerBookingEntity value, Integer version) { + if (!version.equals(value.getVersion())) throw conflict("货柜已被其他人修改,请刷新后重试"); + } + private static SmartLoadingStatus status(ContainerBookingEntity value) { + try { return SmartLoadingStatus.valueOf(value.getPlanStatus()); } + catch (RuntimeException ex) { throw conflict("装柜计划状态无效,不能修改货柜"); } + } + private static void copy(ContainerBookingEntity v, SaveRequest r) { + v.setContainerNo(trim(r.containerNo())); v.setCabinetNo(r.cabinetNo().trim()); + v.setWarehouseId(r.warehouseId()); v.setWarehouseName(r.warehouseName().trim()); + v.setBillOfLadingNo(trim(r.billOfLadingNo())); v.setVesselName(trim(r.vesselName())); + v.setVoyageNo(trim(r.voyageNo())); v.setContainerType(r.containerType().trim()); + v.setShippingCompanyId(r.shippingCompanyId()); v.setShippingCompanyName(trim(r.shippingCompanyName())); + v.setDeparturePortCode(trim(r.departurePortCode())); v.setDeparturePortName(trim(r.departurePortName())); + v.setDestinationPortCode(trim(r.destinationPortCode())); v.setDestinationPortName(trim(r.destinationPortName())); + v.setDestinationWarehouseCode(trim(r.destinationWarehouseCode())); + v.setDestinationWarehouseName(trim(r.destinationWarehouseName())); + v.setCapacityCbm(r.capacityCbm()); v.setMaxWeightKg(r.maxWeightKg()); + v.setTerminalTruckingCompany(trim(r.terminalTruckingCompany())); v.setLoadingAt(r.loadingAt()); + v.setEstimatedSailingAt(r.estimatedSailingAt()); v.setActualSailingAt(r.actualSailingAt()); + v.setEstimatedPortArrivalAt(r.estimatedPortArrivalAt()); v.setActualPortArrivalAt(r.actualPortArrivalAt()); + v.setEstimatedWarehouseArrivalAt(r.estimatedWarehouseArrivalAt()); + v.setActualWarehouseArrivalAt(r.actualWarehouseArrivalAt()); + } + private static void validateSchedule(SaveRequest r) { + validateRange(r.estimatedSailingAt(), r.estimatedPortArrivalAt(), "预计船期和预计到港时间"); + validateRange(r.estimatedPortArrivalAt(), r.estimatedWarehouseArrivalAt(), "预计到港和预计到库时间"); + validateRange(r.actualSailingAt(), r.actualPortArrivalAt(), "实际船期和实际到港时间"); + validateRange(r.actualPortArrivalAt(), r.actualWarehouseArrivalAt(), "实际到港和实际到库时间"); + } + private static void validateRange(LocalDateTime from, LocalDateTime to, String label) { + if (from != null && to != null && to.isBefore(from)) + throw new BizException(ResultCode.VALIDATION_FAILED, label + "先后顺序不正确"); + } + private static Query normalize(Query q) { + return new Query(trim(q.containerNo()), trim(q.cabinetNo()), trim(q.billOfLadingNo()), trim(q.keyword()), + q.warehouseId(), trim(q.containerType()), q.shippingCompanyId(), trim(q.departurePortCode()), + trim(q.destinationPortCode()), trim(q.destinationWarehouseCode()), q.status(), q.estimatedSailingFrom(), + q.estimatedSailingTo(), q.page(), q.size()); + } + private static String trim(String value) { + if (value == null) return null; + String result = value.trim(); return result.isEmpty() ? null : result; + } + private static BizException duplicate() { return conflict("集装箱号或内部柜号已存在"); } + private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT, message); } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java new file mode 100644 index 000000000..2f363d130 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java @@ -0,0 +1,228 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.audit.BusinessAudit; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.dto.LoadingListDtos.AdvanceRequest; +import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedDetail; +import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedQuery; +import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedSummary; +import com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail; +import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery; +import com.baiyue.firstleg.dto.LoadingListDtos.PreloadSummary; +import com.baiyue.firstleg.dto.LoadingListDtos.TransportUpdateRequest; +import com.baiyue.firstleg.mapper.LoadingListQueryMapper.AggregateRow; +import com.baiyue.firstleg.mapper.LoadingListQueryMapper.HistoryRow; +import com.baiyue.firstleg.repository.LoadingListRepository; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; + +@Service +public class LoadingListService { + private static final Set LOADING_STATUSES = Set.of("LOADING"); + @Resource + private LoadingListRepository repository; + @Resource + private ObjectMapper objectMapper; + + public PageResult preloadPage(PreloadQuery query) { + validateRange(query.createdFrom(), query.createdTo()); + String status = trim(query.status()); + if (StringUtils.hasText(status) && !LOADING_STATUSES.contains(status)) { + throw badRequest("装柜清单状态只能是 LOADING"); + } + return repository.preloadPage(new PreloadQuery(trim(query.keyword()), status, query.accountId(), + query.warehouseId(), query.createdFrom(), query.createdTo(), query.page(), query.size())); + } + + public PageResult confirmedPage(ConfirmedQuery query) { + validateRange(query.createdFrom(), query.createdTo()); + return repository.confirmedPage(new ConfirmedQuery(trim(query.keyword()), query.accountId(), + query.warehouseId(), query.createdFrom(), query.createdTo(), query.page(), query.size())); + } + + public PreloadDetail preloadDetail(long planId) { + AggregateRow row = requirePlan(planId); + if (!LOADING_STATUSES.contains(row.getPlanStatus())) { + throw notFound("装柜中清单不存在"); + } + return repository.preloadDetail(row); + } + + public ConfirmedDetail confirmedDetail(long planId) { + AggregateRow row = requirePlan(planId); + if (!"COMPLETED".equals(row.getPlanStatus())) { + throw notFound("装柜清单不存在"); + } + return repository.confirmedDetail(row); + } + + @Transactional + @BusinessAudit(event = "PRELOAD_TRANSPORT_UPDATE", resourceType = "LOADING_PLAN", + resourceId = "#planId", description = "修改预装集装箱号与航次") + public PreloadDetail updateTransport(long planId, long operatorId, String operatorName, + String idempotencyKey, TransportUpdateRequest request) { + String containerNo = nullableTrim(request.containerNo()); + String voyageNo = nullableTrim(request.voyageNo()); + String requestHash = sha256("TRANSPORT|" + planId + '|' + request.expectedVersion() + + '|' + containerNo + '|' + voyageNo + '|' + trim(request.operationReason())); + String operationId = operationId("TRANSPORT", planId, idempotencyKey); + HistoryRow replay = repository.history(operationId); + if (replay != null) { + requireSameRequest(replay, requestHash); + return repository.preloadDetail(requirePlan(planId)); + } + + AggregateRow before = requirePlan(planId); + requireLoadingMutable(before); + if (repository.updateTransport(planId, request.expectedVersion(), containerNo, voyageNo, + operatorId, trim(operatorName)) != 1) { + throw conflict("预装计划已被其他操作修改,请刷新后重试"); + } + repository.appendHistory(planId, operationId, "UPDATE_PRELOAD_TRANSPORT", + before.getPlanStatus(), before.getPlanStatus(), json(snapshot(before)), + json(values("requestHash", requestHash, "status", before.getPlanStatus(), + "containerNo", containerNo, "voyageNo", voyageNo, + "version", request.expectedVersion() + 1)), operatorId, trim(operatorName), + trim(request.operationReason())); + return repository.preloadDetail(requirePlan(planId)); + } + + @Transactional + @BusinessAudit(event = "PRELOAD_COMPLETE", resourceType = "LOADING_PLAN", + resourceId = "#planId", description = "完成预装") + public ConfirmedDetail complete(long planId, long operatorId, String operatorName, + String idempotencyKey, AdvanceRequest request) { + advance(planId, operatorId, operatorName, idempotencyKey, request, + "LOADING", "COMPLETED"); + return repository.confirmedDetail(requirePlan(planId)); + } + + private void advance(long planId, long operatorId, String operatorName, String idempotencyKey, + AdvanceRequest request, String fromStatus, String toStatus) { + String requestHash = sha256(toStatus + '|' + planId + '|' + request.expectedVersion() + + '|' + trim(request.operationReason())); + String operationId = operationId(toStatus, planId, idempotencyKey); + HistoryRow replay = repository.history(operationId); + if (replay != null) { + requireSameRequest(replay, requestHash); + return; + } + + AggregateRow before = requirePlan(planId); + if (!fromStatus.equals(before.getPlanStatus())) { + throw conflict("当前状态为 " + before.getPlanStatus() + ",不能推进到 " + toStatus); + } + if (repository.advance(planId, fromStatus, toStatus, request.expectedVersion(), + operatorId, trim(operatorName)) != 1) { + throw conflict("预装计划已被其他操作修改,请刷新后重试"); + } + repository.appendHistory(planId, operationId, "ADVANCE_PRELOAD_STATUS", + fromStatus, toStatus, json(snapshot(before)), + json(values("requestHash", requestHash, "status", toStatus, + "version", request.expectedVersion() + 1)), operatorId, trim(operatorName), + trim(request.operationReason())); + } + + private AggregateRow requirePlan(long planId) { + AggregateRow row = repository.aggregate(planId); + if (row == null) { + throw notFound("装柜计划不存在"); + } + return row; + } + + private static void requireLoadingMutable(AggregateRow row) { + if (!LOADING_STATUSES.contains(row.getPlanStatus())) { + throw conflict("仅装柜中可修改集装箱号与航次"); + } + } + + private static void validateRange(java.time.LocalDateTime from, java.time.LocalDateTime to) { + if (from != null && to != null && from.isAfter(to)) { + throw badRequest("创建开始时间不能晚于结束时间"); + } + } + + private String operationId(String operation, long planId, String key) { + String normalized = trim(key); + if (!StringUtils.hasText(normalized)) { + throw badRequest("必须提供 Idempotency-Key"); + } + if (normalized.length() > 128) { + throw badRequest("Idempotency-Key 长度不能超过128"); + } + return "PL-" + sha256(operation + '|' + planId + '|' + normalized); + } + + private static void requireSameRequest(HistoryRow replay, String requestHash) { + String snapshot = replay.getAfterSnapshot(); + if (snapshot == null || !snapshot.contains("\"requestHash\":\"" + requestHash + "\"")) { + throw conflict("Idempotency-Key 已用于不同请求"); + } + } + + private Map snapshot(AggregateRow row) { + return values("status", row.getPlanStatus(), "containerNo", row.getContainerNo(), + "voyageNo", row.getVoyageNo(), "version", row.getVersion()); + } + + private static Map values(Object... values) { + Map result = new LinkedHashMap<>(); + for (int index = 0; index < values.length; index += 2) { + result.put((String) values[index], values[index + 1]); + } + return result; + } + + private String json(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (JsonProcessingException exception) { + throw new IllegalStateException("无法生成装柜审计快照", exception); + } + } + + private static String sha256(String value) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException impossible) { + throw new IllegalStateException("JVM 不支持 SHA-256", impossible); + } + } + + private static String nullableTrim(String value) { + String trimmed = trim(value); + return StringUtils.hasText(trimmed) ? trimmed : null; + } + + private static String trim(String value) { + return value == null ? null : value.trim(); + } + + private static BizException badRequest(String message) { + return new BizException(ResultCode.BAD_REQUEST, message); + } + + private static BizException notFound(String message) { + return new BizException(ResultCode.NOT_FOUND, message); + } + + private static BizException conflict(String message) { + return new BizException(ResultCode.CONFLICT, message); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java new file mode 100644 index 000000000..bf75bce82 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java @@ -0,0 +1,174 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.dto.LoadingPickDtos.PickItem; +import com.baiyue.firstleg.dto.LoadingPickDtos.PickRequest; +import com.baiyue.firstleg.dto.LoadingPickDtos.Task; +import com.baiyue.firstleg.entity.LoadingPickHistoryEntity; +import com.baiyue.firstleg.entity.LoadingPickItemEntity; +import com.baiyue.firstleg.entity.LoadingPickTaskEntity; +import com.baiyue.firstleg.entity.LoadingPlanItemEntity; +import com.baiyue.firstleg.repository.LoadingPickRepository; +import jakarta.annotation.Resource; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.List; + +/** 装柜中 PDA 拣货任务及明细同步。 */ +@Service +public class LoadingPickService { + @Resource + private LoadingPickRepository repository; + + @Transactional + public void begin(long planId, String containerNo, long operatorId, String operatorName, + List planItems) { + LoadingPickTaskEntity task = repository.taskByPlan(planId); + if (task == null) { + task = new LoadingPickTaskEntity(); + task.setLoadingPlanId(planId); + task.setContainerNo(containerNo); + task.setTaskStatus("LOADING"); + task.setStartedBy(operatorId); + task.setStartedByName(operatorName); + task.setVersion(0); + try { + repository.insert(task); + } catch (DuplicateKeyException exception) { + task = repository.taskByPlan(planId); + } + } + if (task == null || !"LOADING".equals(task.getTaskStatus())) { + throw conflict("装柜任务已完成,不能继续同步明细"); + } + for (LoadingPlanItemEntity planItem : planItems) { + syncItem(task.getId(), planItem); + } + } + + @Transactional + public void sync(long planId, LoadingPlanItemEntity planItem) { + LoadingPickTaskEntity task = repository.taskByPlan(planId); + if (task != null && "LOADING".equals(task.getTaskStatus())) { + syncItem(task.getId(), planItem); + } + } + + public Task task(String containerNo) { + if (!StringUtils.hasText(containerNo)) { + throw new BizException(ResultCode.BAD_REQUEST, "集装箱号不能为空"); + } + LoadingPickTaskEntity task = repository.taskByContainer(containerNo.trim()); + if (task == null) { + throw new BizException(ResultCode.NOT_FOUND, "未找到装柜中的PDA拣货任务"); + } + return view(task); + } + + @Transactional + public Task pick(long taskId, long itemId, PickRequest request, long operatorId, String operatorName) { + String operationId = request.operationId().trim(); + String requestHash = hash(taskId + "|" + itemId + "|" + request.pickedPackageCount()); + LoadingPickHistoryEntity replay = repository.history(operationId); + if (replay != null) { + if (!replay.getPickTaskId().equals(taskId) || !replay.getPickItemId().equals(itemId) + || !replay.getRequestHash().equals(requestHash)) { + throw conflict("operationId 已用于不同拣货请求"); + } + return view(requiredTask(taskId)); + } + LoadingPickTaskEntity task = requiredTask(taskId); + if (!"LOADING".equals(task.getTaskStatus())) { + throw conflict("装柜任务已完成,不能继续拣货"); + } + LoadingPickItemEntity item = repository.item(taskId, itemId); + if (item == null) { + throw new BizException(ResultCode.NOT_FOUND, "PDA拣货明细不存在"); + } + if (repository.pick(itemId, request.pickedPackageCount(), request.version()) != 1) { + throw conflict("拣货数量超过计划箱数或明细版本已变化"); + } + LoadingPickHistoryEntity history = new LoadingPickHistoryEntity(); + history.setPickTaskId(taskId); + history.setPickItemId(itemId); + history.setOperationId(operationId); + history.setRequestHash(requestHash); + history.setPickedPackageCount(request.pickedPackageCount()); + history.setOperatorId(operatorId); + history.setOperatorName(operatorName); + repository.insert(history); + return view(requiredTask(taskId)); + } + + @Transactional + public void remove(long planItemId) { + LoadingPickItemEntity item = repository.itemByPlanItem(planItemId); + if (item == null) { + return; + } + if (item.getPickedPackageCount() != null && item.getPickedPackageCount() > 0) { + throw conflict("已有拣货记录的明细不能删除"); + } + repository.deleteItem(item.getId()); + } + + private void syncItem(long taskId, LoadingPlanItemEntity planItem) { + LoadingPickItemEntity item = repository.itemByPlanItem(planItem.getId()); + if (item == null) { + item = new LoadingPickItemEntity(); + item.setPickTaskId(taskId); + item.setLoadingPlanItemId(planItem.getId()); + item.setInboundId(planItem.getInboundId()); + item.setInboundDetailId(planItem.getInboundDetailId()); + item.setPlannedPackageCount(planItem.getPackageCount()); + item.setPickedPackageCount(0); + item.setItemStatus("PENDING"); + item.setVersion(0); + repository.insert(item); + return; + } + if (item.getPickedPackageCount() > planItem.getPackageCount()) { + throw conflict("已拣货箱数不能低于计划箱数"); + } + item.setPlannedPackageCount(planItem.getPackageCount()); + item.setItemStatus(item.getPickedPackageCount().equals(planItem.getPackageCount()) ? "PICKED" : "PICKING"); + repository.update(item); + } + + private LoadingPickTaskEntity requiredTask(long taskId) { + LoadingPickTaskEntity task = repository.task(taskId); + if (task == null) { + throw new BizException(ResultCode.NOT_FOUND, "PDA拣货任务不存在"); + } + return task; + } + + private Task view(LoadingPickTaskEntity task) { + List items = repository.items(task.getId()).stream() + .map(item -> new PickItem(item.getId(), item.getLoadingPlanItemId(), item.getInboundId(), + item.getInboundDetailId(), item.getPlannedPackageCount(), item.getPickedPackageCount(), + item.getItemStatus(), item.getVersion())).toList(); + return new Task(task.getId(), task.getLoadingPlanId(), task.getContainerNo(), task.getTaskStatus(), + task.getVersion(), items); + } + + private static String hash(String value) { + try { + return java.util.HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("JVM 不支持 SHA-256", exception); + } + } + + private static BizException conflict(String message) { + return new BizException(ResultCode.CONFLICT, message); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementLocationOccupationService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementLocationOccupationService.java index a713cd696..25b884bce 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementLocationOccupationService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementLocationOccupationService.java @@ -45,15 +45,16 @@ public class MeasurementLocationOccupationService { properties.getDefaultPalletCapacityCbm(), "STANDARD", LocalDateTime.now().plus(properties.getReservationTtl()), true))), "测量完成后库位预占失败"); + int packageCount = requirePackageCount(order); allocations.replace(tenantId, inboundId, warehouseId, resourceTenantId, - properties.getDefaultPalletCapacityCbm(), reserved); + properties.getDefaultPalletCapacityCbm(), packageCount, reserved); try { WarehousePalletLocationClient.BatchResult occupied = requireSuccess(call(() -> warehouse.confirm( internalToken, new WarehousePalletLocationClient.ConfirmRequest(resourceTenantId, warehouseId, SOURCE_SERVICE, inboundId, operatorId, volume, properties.getDefaultPalletCapacityCbm()))), "测量完成后库位占用失败"); allocations.replace(tenantId, inboundId, warehouseId, resourceTenantId, - properties.getDefaultPalletCapacityCbm(), occupied); + properties.getDefaultPalletCapacityCbm(), packageCount, occupied); } catch (RuntimeException failure) { try { warehouse.release(internalToken, new WarehousePalletLocationClient.ReleaseRequest( @@ -85,6 +86,13 @@ public class MeasurementLocationOccupationService { return order.getActualVolumeCbm(); } + private static int requirePackageCount(InboundOrderEntity order) { + if (order.getTotalPackageCount() == null || order.getTotalPackageCount() <= 0) { + throw new BizException(ResultCode.CONFLICT, "入库单缺少有效总箱数"); + } + return order.getTotalPackageCount(); + } + private static ApiResponse call(RemoteCall call) { try { return call.invoke(); } catch (FeignException exception) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java new file mode 100644 index 000000000..2dac22253 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java @@ -0,0 +1,379 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.audit.BusinessAudit; +import com.baiyue.firstleg.dto.SmartLoadingDtos.*; +import com.baiyue.firstleg.client.OrderCargoClient; +import com.baiyue.firstleg.entity.*; +import com.baiyue.firstleg.repository.SmartLoadingRepository; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import jakarta.annotation.Resource; +import org.springframework.beans.factory.annotation.Value; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import feign.FeignException; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDateTime; +import java.util.*; + +/** 智能装柜的候选、推荐/人工分配、删除和不可逆推送编排。 */ +@Service +public class SmartLoadingService { + @Resource private SmartLoadingRepository repository; + @Resource private OrderCargoClient orderCargoClient; + @Resource private LoadingPickService loadingPickService; + @Resource private ObjectMapper objectMapper; + @Value("${baiyue.security.internal-service-token:}") private String internalToken; + public SmartLoadingService() { } + + public List candidates(long accountId) { + requireAccount(accountId); + return repository.candidates(accountId); + } + + public Plan draftByBooking(long bookingId) { + ContainerBookingEntity booking = requireBooking(bookingId); + LoadingPlanEntity plan = repository.planByBooking(bookingId); + return draftView(plan, booking); + } + + public Plan detail(long planId) { + LoadingPlanEntity plan = repository.plan(planId); + if (plan == null) throw new BizException(ResultCode.NOT_FOUND, "装柜计划不存在"); + return draftView(plan, requireBooking(plan.getContainerBookingId())); + } + + @Transactional + @BusinessAudit(event = "SMART_LOADING_PLAN_CREATE", resourceType = "LOADING_PLAN", + description = "创建智能装柜计划", captureBefore = false) + public Plan create(long bookingId, String remark, long operatorId, String operatorName) { + ContainerBookingEntity booking = requireBooking(bookingId); + LoadingPlanEntity existing = repository.planByBooking(bookingId); + if (existing != null) return draftView(existing, booking); + LoadingPlanEntity plan = new LoadingPlanEntity(); + plan.setPlanNo("LP-" + UUID.randomUUID().toString().replace("-", "").substring(0, 20)); + plan.setContainerBookingId(bookingId); plan.setPlanStatus("EDITING"); plan.setRemark(trim(remark)); + plan.setCreatedBy(operatorId); plan.setCreatedByName(operatorName); plan.setUpdatedBy(operatorId); + plan.setUpdatedByName(operatorName); plan.setVersion(0); + try { + repository.insert(plan); + } catch (DuplicateKeyException exception) { + LoadingPlanEntity concurrent = repository.planByBooking(bookingId); + if (concurrent != null) return draftView(concurrent, booking); + throw conflict("装柜计划创建冲突,请刷新后重试"); + } + history(plan.getId(), null, "CREATE_PLAN", null, "EDITING", UUID.randomUUID().toString(), operatorId, operatorName); + return view(plan, booking); + } + + @Transactional + @BusinessAudit(event = "SMART_LOADING_ITEM_ASSIGN", resourceType = "LOADING_PLAN", + resourceId = "#planId", description = "分配智能装柜明细") + public Plan assign(long planId, AssignRequest request, long operatorId, String operatorName) { + String operation = operation(request.operationId()); + String requestHash = sha256("ASSIGN|" + planId + '|' + request.accountId() + '|' + + request.inboundId() + '|' + request.inboundDetailId() + '|' + request.packageCount() + + '|' + request.expectedVersion()); + Plan replay = replay(operation, planId, "ADD_ITEM", null, requestHash); + if (replay != null) return replay; + LoadingPlanEntity plan = editing(planId, request.expectedVersion()); + Candidate candidate = repository.candidate(request.accountId(), request.inboundId(), request.inboundDetailId()); + if (candidate == null) throw conflict("入库货物不存在或尚未完成入库"); + if (!Objects.equals(candidate.accountId(), request.accountId())) throw forbidden(); + OrderCargoClient.CargoBinding cargo = loadCargo(candidate); + LoadingPlanItemEntity existing = repository.activeItem(request.accountId(), request.inboundDetailId()); + if (existing != null && !Objects.equals(existing.getLoadingPlanId(), planId)) throw conflict("货物明细已被有效计划锁定"); + int total = request.packageCount() + (existing == null ? 0 : existing.getPackageCount()); + if (total > candidate.availablePackageCount()) throw validation("累计分配箱数超过可用箱数"); + ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId()); + List lines = new ArrayList<>(repository.items(planId)); + if (existing != null) lines.removeIf(x -> Objects.equals(x.getId(), existing.getId())); + checkRouteAndCapacity(booking, lines, candidate, total); + touch(plan, operatorId, operatorName); + LoadingPlanItemEntity changed; + if (existing == null) { + LoadingPlanItemEntity item = snapshot(planId, candidate, cargo, request.packageCount(), operatorId, operatorName, lines.size() + 1); + try { repository.insert(item); } catch (DuplicateKeyException e) { throw conflict("货物明细已被并发锁定"); } + changed = item; + } else { + existing.setPackageCount(total); existing.setGrossWeightKg(unit(candidate.unitWeightKg()).multiply(BigDecimal.valueOf(total))); + existing.setVolumeCbm(unit(candidate.unitVolumeCbm()).multiply(BigDecimal.valueOf(total))); applyCargo(existing, cargo, total); + existing.setUpdatedBy(operatorId); existing.setUpdatedByName(operatorName); + if (repository.update(existing) != 1) throw conflict("装柜草稿已被并发修改"); + changed = existing; + } + history(planId, changed.getId(), "ADD_ITEM", "EDITING", "EDITING", operation, + json(values("version", request.expectedVersion())), + json(values("requestHash", requestHash, "itemId", changed.getId(), + "packageCount", changed.getPackageCount())), operatorId, operatorName); + loadingPickService.sync(planId, changed); + return view(repository.plan(planId), booking); + } + + @Transactional + @BusinessAudit(event = "SMART_LOADING_RECOMMEND", resourceType = "LOADING_PLAN", + resourceId = "#planId", description = "执行智能装柜推荐") + public Plan recommend(long planId, RecommendRequest request, long operatorId, String operatorName) { + String operation = operation(request.operationId()); + String requestHash = sha256("RECOMMEND|" + planId + '|' + request.accountId() + + '|' + request.expectedVersion()); + Plan replay = replay(operation, planId, "SMART_RECOMMEND", null, requestHash); + if (replay != null) return replay; + LoadingPlanEntity plan = editing(planId, request.expectedVersion()); + ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId()); + for (Candidate candidate : candidates(request.accountId())) { + if (!Objects.equals(candidate.warehouseId(), booking.getWarehouseId())) continue; + if (StringUtils.hasText(booking.getDestinationWarehouseCode()) + && !Objects.equals(booking.getDestinationWarehouseCode(), candidate.destinationWarehouseCode())) continue; + int count = fittingBoxes(booking, repository.items(planId), candidate); + if (count > 0) { + plan = repository.plan(planId); + assign(planId, new AssignRequest(request.accountId(), candidate.inboundId(), candidate.inboundDetailId(), + count, plan.getVersion(), "REC-" + sha256(operation + '|' + candidate.inboundId() + + '|' + candidate.inboundDetailId())), operatorId, operatorName); + } + } + LoadingPlanEntity recommended = repository.plan(planId); + history(planId, null, "SMART_RECOMMEND", "EDITING", "EDITING", operation, + json(values("version", request.expectedVersion())), + json(values("requestHash", requestHash, "version", recommended.getVersion())), + operatorId, operatorName); + return view(recommended, requireBooking(plan.getContainerBookingId())); + } + + @Transactional + @BusinessAudit(event = "SMART_LOADING_ITEM_ADJUST", resourceType = "LOADING_PLAN_ITEM", + resourceId = "#itemId", description = "调整智能装柜明细") + public Plan adjust(long planId, long itemId, AdjustRequest request, long operatorId, String operatorName) { + String operation = operation(request.operationId()); + String requestHash = sha256("ADJUST|" + planId + '|' + itemId + '|' + request.accountId() + + '|' + request.packageCount() + '|' + request.expectedPlanVersion() + + '|' + request.expectedItemVersion()); + Plan replay = replay(operation, planId, "ADJUST_ITEM", itemId, requestHash); + if (replay != null) return replay; + LoadingPlanEntity plan = editing(planId, request.expectedPlanVersion()); + LoadingPlanItemEntity item = repository.item(planId, itemId); + if (item == null) throw new BizException(ResultCode.NOT_FOUND, "装柜明细不存在"); + if (!Objects.equals(item.getAccountId(), request.accountId())) throw forbidden(); + if (!Objects.equals(item.getVersion(), request.expectedItemVersion())) throw conflict("装柜明细版本冲突"); + Candidate candidate = repository.candidate(item.getAccountId(), item.getInboundId(), item.getInboundDetailId()); + if (candidate == null || request.packageCount() > candidate.availablePackageCount()) throw validation("调整箱数超过可用箱数"); + OrderCargoClient.CargoBinding cargo = loadCargo(candidate); + List others = new ArrayList<>(repository.items(planId)); + others.removeIf(x -> Objects.equals(x.getId(), itemId)); + checkRouteAndCapacity(requireBooking(plan.getContainerBookingId()), others, candidate, request.packageCount()); + touch(plan, operatorId, operatorName); + item.setPackageCount(request.packageCount()); + item.setGrossWeightKg(unit(candidate.unitWeightKg()).multiply(BigDecimal.valueOf(request.packageCount()))); + item.setVolumeCbm(unit(candidate.unitVolumeCbm()).multiply(BigDecimal.valueOf(request.packageCount()))); applyCargo(item, cargo, request.packageCount()); + item.setUpdatedBy(operatorId); item.setUpdatedByName(operatorName); + if (repository.update(item) != 1) throw conflict("装柜明细已被并发修改"); + history(planId, itemId, "ADJUST_ITEM", "EDITING", "EDITING", operation, + json(values("version", request.expectedPlanVersion(), "packageCount", item.getPackageCount())), + json(values("requestHash", requestHash, "packageCount", request.packageCount())), + operatorId, operatorName); + loadingPickService.sync(planId, item); + return view(repository.plan(planId), requireBooking(plan.getContainerBookingId())); + } + + @Transactional + @BusinessAudit(event = "SMART_LOADING_ITEM_DELETE", resourceType = "LOADING_PLAN_ITEM", + resourceId = "#itemId", description = "删除智能装柜明细") + public Plan delete(long planId, long itemId, DeleteRequest request, long operatorId, String operatorName) { + String operation = operation(request.operationId()); + String requestHash = sha256("DELETE|" + planId + '|' + itemId + '|' + request.accountId() + + '|' + request.expectedPlanVersion() + '|' + request.expectedItemVersion()); + Plan replay = replay(operation, planId, "REMOVE_ITEM", itemId, requestHash); + if (replay != null) return replay; + LoadingPlanEntity plan = editing(planId, request.expectedPlanVersion()); + LoadingPlanItemEntity item = repository.item(planId, itemId); + if (item == null) throw new BizException(ResultCode.NOT_FOUND, "装柜明细不存在"); + if (!Objects.equals(item.getAccountId(), request.accountId())) throw forbidden(); + loadingPickService.remove(itemId); + touch(plan, operatorId, operatorName); + if (repository.remove(planId, itemId, request.expectedItemVersion(), operatorId, operatorName) != 1) throw conflict("装柜草稿已被并发修改"); + history(planId, itemId, "REMOVE_ITEM", "EDITING", "EDITING", operation, + json(values("version", request.expectedPlanVersion(), "packageCount", item.getPackageCount())), + json(values("requestHash", requestHash, "deleted", true)), operatorId, operatorName); + return view(repository.plan(planId), requireBooking(plan.getContainerBookingId())); + } + + @Transactional + @BusinessAudit(event = "SMART_LOADING_PUSH_PRELOAD", resourceType = "LOADING_PLAN", + resourceId = "#planId", description = "推送智能装柜计划到预装清单") + public Plan pushPreload(long planId, String idempotencyKey, PushRequest request, long operatorId, String operatorName) { + String key = trim(idempotencyKey); + if (!StringUtils.hasText(key) || key.length() > 128) throw new BizException(ResultCode.BAD_REQUEST, "必须提供有效 Idempotency-Key"); + LoadingPlanEntity plan = repository.plan(planId); if (plan == null) throw new BizException(ResultCode.NOT_FOUND, "装柜计划不存在"); + if ("LOADING".equals(plan.getPlanStatus()) && key.equals(plan.getPushIdempotencyKey())) return view(plan, requireBooking(plan.getContainerBookingId())); + if (!"EDITING".equals(plan.getPlanStatus()) || !Objects.equals(plan.getVersion(), request.expectedVersion())) throw conflict("仅编辑中的计划可开始装柜且版本必须匹配"); + ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId()); + if (!StringUtils.hasText(booking.getContainerNo())) throw validation("开始装柜前必须填写集装箱号"); + if (repository.items(planId).isEmpty()) throw validation("装柜明细不能为空"); + loadingPickService.begin(planId, booking.getContainerNo(), operatorId, operatorName, repository.items(planId)); + try { + if (repository.push(planId, key, request.expectedVersion(), operatorId, operatorName) != 1) throw conflict("装柜计划已被并发修改"); + } catch (DuplicateKeyException e) { throw conflict("幂等键已用于其他推送"); } + history(planId, null, "START_LOADING", "EDITING", "LOADING", key, operatorId, operatorName); + LoadingPlanEntity pushed = repository.plan(planId); + return view(pushed, requireBooking(pushed.getContainerBookingId())); + } + + private LoadingPlanItemEntity snapshot(long planId, Candidate c, OrderCargoClient.CargoBinding cargo, int count, long op, String name, int seq) { + LoadingPlanItemEntity i = new LoadingPlanItemEntity(); i.setLoadingPlanId(planId); i.setAccountId(c.accountId()); i.setCustomerName(c.customerName()); + i.setInboundId(c.inboundId()); i.setInboundDetailId(c.inboundDetailId()); i.setOrderId(c.orderId()); i.setOrderNo(c.orderNo()); i.setInboundCode(c.inboundCode()); + i.setDestinationWarehouseId(c.destinationWarehouseId()); i.setDestinationWarehouseCode(c.destinationWarehouseCode()); + i.setDestinationWarehouseName(c.destinationWarehouseName()); i.setProductNameZh(cargo.cargoName()); i.setPackageCount(count); + i.setGrossWeightKg(unit(c.unitWeightKg()).multiply(BigDecimal.valueOf(count))); i.setVolumeCbm(unit(c.unitVolumeCbm()).multiply(BigDecimal.valueOf(count))); + applyCargo(i, cargo, count); i.setLoadSequence(seq); i.setDeleted(false); i.setCreatedBy(op); i.setCreatedByName(name); i.setUpdatedBy(op); i.setUpdatedByName(name); i.setVersion(0); return i; + } + private void applyCargo(LoadingPlanItemEntity item, OrderCargoClient.CargoBinding cargo, int boxes) { + item.setSkuCode(cargo.skuNo()); + item.setProductNameEn(cargo.cargoNameEn()); + item.setUnitsPerCarton(cargo.unitsPerPackage()); + item.setInternalRemark(cargo.cargoRemark()); + int original = cargo.originalPackageCount() == null || cargo.originalPackageCount() <= 0 + ? boxes : cargo.originalPackageCount(); + if (cargo.unitsPerPackage() != null) { + item.setProductQuantity(Math.multiplyExact(cargo.unitsPerPackage(), boxes)); + } else if (cargo.totalQuantity() != null) { + item.setProductQuantity(BigDecimal.valueOf(cargo.totalQuantity()) + .multiply(BigDecimal.valueOf(boxes)) + .divide(BigDecimal.valueOf(original), 0, RoundingMode.HALF_UP).intValueExact()); + } + if (cargo.netWeightKg() != null) { + item.setNetWeightKg(cargo.netWeightKg().multiply(BigDecimal.valueOf(boxes)) + .divide(BigDecimal.valueOf(original), 6, RoundingMode.HALF_UP)); + } + } + + private OrderCargoClient.CargoBinding loadCargo(Candidate candidate) { + try { + ApiResponse response = orderCargoClient.detail(internalToken, + candidate.accountId(), candidate.orderId(), candidate.inboundDetailId()); + if (response == null) { + throw unavailable(); + } + if (!response.successful()) { + throw new BizException(ResultCode.of(response.code()), response.message()); + } + OrderCargoClient.CargoBinding cargo = response.data(); + if (cargo == null || !Objects.equals(cargo.cargoId(), candidate.inboundDetailId()) + || !Objects.equals(cargo.orderId(), candidate.orderId())) { + throw conflict("订单货物明细返回与入库单不匹配"); + } + return cargo; + } catch (BizException exception) { + throw exception; + } catch (FeignException exception) { + throw unavailable(); + } catch (RuntimeException exception) { + throw unavailable(); + } + } + private void checkRouteAndCapacity(ContainerBookingEntity b,List lines,Candidate c,int count){ + if (!Objects.equals(b.getWarehouseId(), c.warehouseId())) throw validation("装柜仓库不一致"); + if (StringUtils.hasText(b.getDestinationWarehouseCode()) && !Objects.equals(b.getDestinationWarehouseCode(), c.destinationWarehouseCode())) throw validation("目的仓/港不一致"); + BigDecimal v=lines.stream().map(x->unit(x.getVolumeCbm())).reduce(BigDecimal.ZERO,BigDecimal::add).add(unit(c.unitVolumeCbm()).multiply(BigDecimal.valueOf(count))); + BigDecimal w=lines.stream().map(x->unit(x.getGrossWeightKg())).reduce(BigDecimal.ZERO,BigDecimal::add).add(unit(c.unitWeightKg()).multiply(BigDecimal.valueOf(count))); + if (b.getCapacityCbm()==null || b.getMaxWeightKg()==null) throw validation("货柜容量和最大承重未配置"); + if (v.compareTo(b.getCapacityCbm())>0 || w.compareTo(b.getMaxWeightKg())>0) throw validation("超过货柜容积或重量上限"); + } + private int fittingBoxes(ContainerBookingEntity b,List lines,Candidate c){ + if(b.getCapacityCbm()==null||b.getMaxWeightKg()==null)return 0; + BigDecimal usedV=lines.stream().map(x->unit(x.getVolumeCbm())).reduce(BigDecimal.ZERO,BigDecimal::add); + BigDecimal usedW=lines.stream().map(x->unit(x.getGrossWeightKg())).reduce(BigDecimal.ZERO,BigDecimal::add); + BigDecimal leftV=b.getCapacityCbm().subtract(usedV),leftW=b.getMaxWeightKg().subtract(usedW); + int byV=unit(c.unitVolumeCbm()).signum()==0?c.availablePackageCount():leftV.max(BigDecimal.ZERO).divideToIntegralValue(c.unitVolumeCbm()).intValue(); + int byW=unit(c.unitWeightKg()).signum()==0?c.availablePackageCount():leftW.max(BigDecimal.ZERO).divideToIntegralValue(c.unitWeightKg()).intValue(); + return Math.max(0,Math.min(c.availablePackageCount(),Math.min(byV,byW))); + } + private Plan view(LoadingPlanEntity p,ContainerBookingEntity b){return new Plan(p.getId(),p.getPlanNo(),p.getPlanStatus(),p.getContainerBookingId(),b.getContainerNo(),b.getVoyageNo(),p.getVersion(),repository.items(p.getId()));} + private Plan draftView(LoadingPlanEntity plan, ContainerBookingEntity booking) { + if (plan == null) throw new BizException(ResultCode.NOT_FOUND, "该货柜尚未创建智能装柜草稿"); + if (!"EDITING".equals(plan.getPlanStatus())) throw conflict("装柜计划已推送预装,请前往预装清单查看"); + return view(plan, booking); + } + private LoadingPlanEntity editing(long id,int version){LoadingPlanEntity p=repository.plan(id);if(p==null)throw new BizException(ResultCode.NOT_FOUND,"装柜计划不存在");if(!Set.of("EDITING", "LOADING").contains(p.getPlanStatus())||!Objects.equals(p.getVersion(),version))throw conflict("计划已完成或版本冲突");return p;} + private ContainerBookingEntity requireBooking(long id){ContainerBookingEntity b=repository.booking(id);if(b==null)throw new BizException(ResultCode.NOT_FOUND,"货柜预订不存在");return b;} + private void touch(LoadingPlanEntity p,long op,String name){if(repository.touch(p.getId(),p.getVersion(),op,name)!=1)throw conflict("计划已被并发修改");} + private Plan replay(String operation, long planId, String action, Long itemId, String requestHash) { + LoadingPlanHistoryEntity history = repository.operation(operation); + if (history == null) return null; + if (!Objects.equals(history.getLoadingPlanId(), planId) + || !Objects.equals(history.getActionType(), action) + || itemId != null && !Objects.equals(history.getLoadingPlanItemId(), itemId) + || history.getAfterSnapshot() == null + || !history.getAfterSnapshot().contains("\"requestHash\":\"" + requestHash + "\"")) { + throw conflict("operationId 已用于不同请求"); + } + LoadingPlanEntity plan = repository.plan(planId); + if (plan == null) throw new BizException(ResultCode.NOT_FOUND, "装柜计划不存在"); + return view(plan, requireBooking(plan.getContainerBookingId())); + } + + private void history(long planId, Long itemId, String action, String from, String to, + String operation, long operatorId, String operatorName) { + history(planId, itemId, action, from, to, operation, null, null, operatorId, operatorName); + } + + private void history(long planId, Long itemId, String action, String from, String to, + String operation, String before, String after, + long operatorId, String operatorName) { + LoadingPlanHistoryEntity history = new LoadingPlanHistoryEntity(); + history.setLoadingPlanId(planId); history.setLoadingPlanItemId(itemId); + history.setActionType(action); history.setFromStatus(from); history.setToStatus(to); + history.setOperationId(operation); history.setBeforeSnapshot(before); history.setAfterSnapshot(after); + history.setOperatorId(operatorId); history.setOperatorName(operatorName); + history.setCreatedAt(LocalDateTime.now()); repository.insert(history); + } + + private static String operation(String value) { + String normalized = trim(value); + if (!StringUtils.hasText(normalized)) throw new BizException(ResultCode.BAD_REQUEST, "必须提供 operationId"); + if (normalized.length() > 128) throw new BizException(ResultCode.BAD_REQUEST, "operationId 长度不能超过128"); + return normalized; + } + + private String json(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (JsonProcessingException exception) { + throw new IllegalStateException("无法生成智能装柜审计快照", exception); + } + } + + private static Map values(Object... values) { + Map result = new LinkedHashMap<>(); + for (int index = 0; index < values.length; index += 2) { + result.put((String) values[index], values[index + 1]); + } + return result; + } + + private static String sha256(String value) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException impossible) { + throw new IllegalStateException("JVM 不支持 SHA-256", impossible); + } + } + + private static BizException unavailable() { + return new BizException(ResultCode.SERVICE_UNAVAILABLE, "订单货物服务暂不可用"); + } + + private static String trim(String x){return x==null?null:x.trim();} private static BigDecimal unit(BigDecimal x){return x==null?BigDecimal.ZERO:x.max(BigDecimal.ZERO);} + private static void requireAccount(long x){if(x<=0)throw new BizException(ResultCode.BAD_REQUEST,"accountId必须大于零");} private static BizException conflict(String x){return new BizException(ResultCode.CONFLICT,x);} private static BizException validation(String x){return new BizException(ResultCode.VALIDATION_FAILED,x);} private static BizException forbidden(){return new BizException(ResultCode.FORBIDDEN,"客户归属不匹配");} +} diff --git a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql new file mode 100644 index 000000000..f11c7df8f --- /dev/null +++ b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql @@ -0,0 +1,775 @@ +SET NAMES utf8mb4; + +CREATE DATABASE IF NOT EXISTS baiyue_firstleg + DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; +USE baiyue_firstleg; + +CREATE TABLE IF NOT EXISTS fl_container_booking ( + id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID(雪花ID)', + booking_no VARCHAR(40) NOT NULL COMMENT '平台唯一货柜预订单号', + container_no VARCHAR(64) NULL COMMENT '集装箱号;确认前允许修订', + cabinet_no VARCHAR(64) NOT NULL COMMENT '平台唯一内部柜号', + warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '装柜仓库ID;平台公共资源', + warehouse_name VARCHAR(128) NOT NULL COMMENT '装柜仓库名称快照', + bill_of_lading_no VARCHAR(128) NULL COMMENT '提单号', + vessel_name VARCHAR(128) NULL COMMENT '船名快照', + voyage_no VARCHAR(64) NULL COMMENT '航次;确认前允许修订', + container_type VARCHAR(32) NOT NULL COMMENT '箱型,例如20GP、40GP、40HQ', + shipping_company_id BIGINT UNSIGNED NULL COMMENT '船司ID快照', + shipping_company_name VARCHAR(128) NULL COMMENT '船司名称快照', + departure_port_code VARCHAR(32) NULL COMMENT '起运港编码快照', + departure_port_name VARCHAR(128) NULL COMMENT '起运港名称快照', + destination_port_code VARCHAR(32) NULL COMMENT '目的港编码快照', + destination_port_name VARCHAR(128) NULL COMMENT '目的港名称快照', + destination_warehouse_code VARCHAR(64) NULL COMMENT '目的仓编码快照', + destination_warehouse_name VARCHAR(128) NULL COMMENT '目的仓名称快照', + capacity_cbm DECIMAL(18,4) NULL COMMENT '货柜容量m3', + max_weight_kg DECIMAL(18,3) NULL COMMENT '货柜最大承重kg', + terminal_trucking_company VARCHAR(128) NULL COMMENT '码头拖车公司', + loading_at DATETIME(3) NULL COMMENT '计划或实际装柜时间', + estimated_sailing_at DATETIME(3) NULL COMMENT '预计开船时间', + actual_sailing_at DATETIME(3) NULL COMMENT '实际开船时间', + estimated_port_arrival_at DATETIME(3) NULL COMMENT '预计到港时间', + actual_port_arrival_at DATETIME(3) NULL COMMENT '实际到港时间', + estimated_warehouse_arrival_at DATETIME(3) NULL COMMENT '预计到仓时间', + actual_warehouse_arrival_at DATETIME(3) NULL COMMENT '实际到仓时间', + created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID', + created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID', + updated_by_name VARCHAR(64) NULL COMMENT '最后修改人名称快照', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) + COMMENT '更新时间', + version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_container_booking_no (booking_no), + UNIQUE KEY uk_container_booking_cabinet (cabinet_no), + KEY idx_container_booking_container (container_no), + KEY idx_container_booking_warehouse (warehouse_id, updated_at, id), + KEY idx_container_booking_voyage (voyage_no, updated_at), + 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) +) ENGINE=InnoDB COMMENT='平台货柜预订资料;预装状态唯一来源为装柜计划'; + +CREATE TABLE IF NOT EXISTS fl_loading_plan ( + id BIGINT UNSIGNED NOT NULL COMMENT '智能装柜计划ID(雪花ID)', + plan_no VARCHAR(40) NOT NULL COMMENT '平台唯一装柜计划号', + container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID', + plan_status VARCHAR(24) NOT NULL DEFAULT 'EDITING' + COMMENT '唯一装柜状态:EDITING、LOADING、COMPLETED', + push_idempotency_key VARCHAR(128) NULL COMMENT '推送预装幂等键', + preload_pushed_by BIGINT UNSIGNED NULL COMMENT '推送预装操作人ID', + preload_pushed_by_name VARCHAR(64) NULL COMMENT '推送预装操作人名称快照', + preload_pushed_at DATETIME(3) NULL COMMENT '开始装柜并进入LOADING的时间', + preload_completed_by BIGINT UNSIGNED NULL COMMENT '预装完成操作人ID', + preload_completed_by_name VARCHAR(64) NULL COMMENT '预装完成操作人名称快照', + preload_completed_at DATETIME(3) NULL COMMENT '预装完成时间', + confirmed_by BIGINT UNSIGNED NULL COMMENT '确认操作人ID', + confirmed_by_name VARCHAR(64) NULL COMMENT '确认操作人名称快照', + confirmed_at DATETIME(3) NULL COMMENT '确认时间', + remark VARCHAR(500) NULL COMMENT '平台装柜计划备注', + created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID', + created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID', + updated_by_name VARCHAR(64) NULL COMMENT '最后修改人名称快照', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) + COMMENT '更新时间', + version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_loading_plan_no (plan_no), + UNIQUE KEY uk_loading_plan_booking (container_booking_id), + UNIQUE KEY uk_loading_plan_push_key (push_idempotency_key), + KEY idx_loading_plan_status (plan_status, updated_at, id), + CONSTRAINT fk_loading_plan_booking FOREIGN KEY (container_booking_id) + REFERENCES fl_container_booking (id), + CONSTRAINT chk_loading_plan_status + CHECK (plan_status IN ('EDITING', 'LOADING', 'COMPLETED')) +) ENGINE=InnoDB COMMENT='平台智能装柜计划与唯一预装状态源'; + +CREATE TABLE IF NOT EXISTS fl_loading_plan_item ( + id BIGINT UNSIGNED NOT NULL COMMENT '装柜计划明细ID(雪花ID)', + loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '智能装柜计划ID', + account_id BIGINT UNSIGNED NOT NULL COMMENT '客户账号ID;装柜业务唯一客户归属字段', + customer_name VARCHAR(128) NOT NULL COMMENT '客户名称快照', + inbound_id BIGINT UNSIGNED NOT NULL COMMENT 'Firstleg入库单ID', + inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '订单货物或入库货物明细ID,不建立跨库外键', + order_id BIGINT UNSIGNED NOT NULL COMMENT '订单服务订单ID快照,不建立跨库外键', + order_no VARCHAR(32) NOT NULL COMMENT '订单号快照', + inbound_code VARCHAR(40) NOT NULL COMMENT '入仓码快照', + destination_warehouse_id BIGINT UNSIGNED NULL COMMENT '目的仓ID快照', + destination_warehouse_code VARCHAR(64) NULL COMMENT '目的仓编码快照', + destination_warehouse_name VARCHAR(128) NULL COMMENT '目的仓名称快照', + sku_code VARCHAR(128) NULL COMMENT 'SKU编码快照', + product_name_zh VARCHAR(255) NULL COMMENT '中文品名快照', + product_name_en VARCHAR(255) NULL COMMENT '英文品名快照', + product_image_file_id BIGINT UNSIGNED NULL COMMENT '产品图片File对象ID', + product_image_url VARCHAR(1000) NULL COMMENT '产品图片访问地址快照', + package_count INT UNSIGNED NOT NULL COMMENT '本次装柜箱数', + product_quantity INT UNSIGNED NULL COMMENT '产品总数量', + units_per_carton INT UNSIGNED NULL COMMENT '每箱产品数', + pallet_count INT UNSIGNED NULL COMMENT '托盘数', + gross_weight_kg DECIMAL(18,3) NULL COMMENT '本次装柜毛重kg', + net_weight_kg DECIMAL(18,3) NULL COMMENT '本次装柜净重kg', + dimensional_weight_kg DECIMAL(18,3) NULL COMMENT '本次装柜材积重kg', + volume_cbm DECIMAL(18,4) NULL COMMENT '本次装柜体积m3', + fba_tracking_no VARCHAR(128) NULL COMMENT 'FBA追踪号快照', + shipment_no VARCHAR(128) NULL COMMENT '货件编号快照', + shipping_mark VARCHAR(500) NULL COMMENT '唛头快照', + load_sequence INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '装柜顺序', + position_snapshot JSON NULL COMMENT '智能装柜位置与摆放结果快照', + internal_remark VARCHAR(1000) NULL COMMENT '平台内部备注', + customer_remark VARCHAR(1000) NULL COMMENT '客户备注快照', + warehouse_remark VARCHAR(1000) NULL COMMENT '仓库备注快照', + frozen_at DATETIME(3) NULL COMMENT '装柜完成后的永久冻结时间', + frozen_by BIGINT UNSIGNED NULL COMMENT '冻结操作人ID', + removed_by BIGINT UNSIGNED NULL COMMENT '推送前逻辑删除操作人ID', + removed_by_name VARCHAR(64) NULL COMMENT '推送前逻辑删除操作人名称快照', + removed_at DATETIME(3) NULL COMMENT '推送前逻辑删除时间', + deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '推送前逻辑删除标记;冻结后不可修改', + active_inbound_detail_id BIGINT UNSIGNED GENERATED ALWAYS AS ( + CASE WHEN deleted = 0 THEN inbound_detail_id ELSE NULL END + ) STORED COMMENT '未删除明细唯一锁;删除后返回NULL释放分配资格', + created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID', + created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID', + updated_by_name VARCHAR(64) NULL COMMENT '最后修改人名称快照', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) + COMMENT '更新时间', + version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_loading_item_active_detail (account_id, active_inbound_detail_id), + KEY idx_loading_item_plan (loading_plan_id, deleted, load_sequence, id), + KEY idx_loading_item_account (account_id, created_at, id), + KEY idx_loading_item_inbound (account_id, inbound_id, deleted), + KEY idx_loading_item_order (account_id, order_id, deleted), + CONSTRAINT fk_loading_item_plan FOREIGN KEY (loading_plan_id) REFERENCES fl_loading_plan (id), + CONSTRAINT chk_loading_item_account CHECK (account_id > 0), + CONSTRAINT chk_loading_item_package CHECK (package_count > 0), + CONSTRAINT chk_loading_item_product_quantity CHECK (product_quantity IS NULL OR product_quantity > 0), + CONSTRAINT chk_loading_item_units CHECK (units_per_carton IS NULL OR units_per_carton > 0), + CONSTRAINT chk_loading_item_pallet CHECK (pallet_count IS NULL OR pallet_count >= 0), + CONSTRAINT chk_loading_item_weight CHECK ( + (gross_weight_kg IS NULL OR gross_weight_kg >= 0) + AND (net_weight_kg IS NULL OR net_weight_kg >= 0) + AND (dimensional_weight_kg IS NULL OR dimensional_weight_kg >= 0) + ), + CONSTRAINT chk_loading_item_volume CHECK (volume_cbm IS NULL OR volume_cbm >= 0), + CONSTRAINT chk_loading_item_deleted CHECK (deleted IN (0, 1)) +) ENGINE=InnoDB COMMENT='智能装柜客户货物明细;客户归属仅在明细层'; + +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 'CUSTOMS_CLEARANCE、DELIVERY_ORDER、PICKUP_ORDER', + file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务文件ID,不建立跨库外键', + file_name VARCHAR(255) NOT NULL COMMENT '文件名称快照', + file_url VARCHAR(1000) NULL COMMENT '文件访问地址快照', + uploaded_by BIGINT UNSIGNED NOT NULL COMMENT '上传操作人ID', + uploaded_by_name VARCHAR(64) NULL COMMENT '上传操作人名称快照', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) + COMMENT '更新时间', + version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_container_document_type (container_booking_id, document_type), + KEY idx_container_document_file (file_id), + CONSTRAINT fk_container_document_booking FOREIGN KEY (container_booking_id) + REFERENCES fl_container_booking (id), + CONSTRAINT chk_container_document_type + CHECK (document_type IN ('CUSTOMS_CLEARANCE', 'DELIVERY_ORDER', 'PICKUP_ORDER')) +) ENGINE=InnoDB COMMENT='平台货柜清关、DO与PU文件关联'; + +CREATE TABLE IF NOT EXISTS fl_loading_plan_history ( + id BIGINT UNSIGNED NOT NULL COMMENT '装柜操作历史ID(雪花ID)', + loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '智能装柜计划ID', + loading_plan_item_id BIGINT UNSIGNED NULL COMMENT '装柜计划明细ID', + operation_id VARCHAR(128) NOT NULL COMMENT '操作幂等ID', + action_type VARCHAR(32) NOT NULL + COMMENT 'CREATE_PLAN、ADD_ITEM、ADJUST_ITEM、REMOVE_ITEM、START_LOADING、COMPLETE_LOADING、PICK_ITEM', + from_status VARCHAR(24) NULL COMMENT '变更前状态', + to_status VARCHAR(24) NULL COMMENT '变更后状态', + before_snapshot JSON NULL COMMENT '变更前快照', + after_snapshot JSON NULL COMMENT '变更后快照', + operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID', + operator_name VARCHAR(64) NULL COMMENT '操作人名称快照', + operation_reason VARCHAR(500) NULL COMMENT '操作原因', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间', + PRIMARY KEY (id), + UNIQUE KEY uk_loading_history_operation (operation_id), + KEY idx_loading_history_plan (loading_plan_id, created_at, id), + KEY idx_loading_history_item (loading_plan_item_id, created_at, id), + CONSTRAINT fk_loading_history_plan FOREIGN KEY (loading_plan_id) REFERENCES fl_loading_plan (id), + CONSTRAINT fk_loading_history_item FOREIGN KEY (loading_plan_item_id) + REFERENCES fl_loading_plan_item (id) +) ENGINE=InnoDB COMMENT='智能装柜追加式不可变操作历史'; + +CREATE TABLE IF NOT EXISTS fl_loading_pick_task ( + id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货任务ID', + loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '装柜计划ID', + container_no VARCHAR(64) NOT NULL COMMENT '集装箱号快照', + task_status VARCHAR(16) NOT NULL DEFAULT 'LOADING' COMMENT 'LOADING、COMPLETED', + started_by BIGINT UNSIGNED NOT NULL COMMENT '开始装柜人', + started_by_name VARCHAR(64) NULL COMMENT '开始装柜人名称', + started_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '开始时间', + completed_by BIGINT UNSIGNED NULL COMMENT '完成装柜人', + completed_by_name VARCHAR(64) NULL COMMENT '完成装柜人名称', + completed_at DATETIME(3) NULL COMMENT '完成时间', + version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_loading_pick_task_plan (loading_plan_id), + KEY idx_loading_pick_container (container_no, task_status, started_at), + CONSTRAINT fk_loading_pick_task_plan FOREIGN KEY (loading_plan_id) REFERENCES fl_loading_plan (id), + CONSTRAINT chk_loading_pick_task_status CHECK (task_status IN ('LOADING', 'COMPLETED')) +) ENGINE=InnoDB COMMENT='PDA装柜拣货任务'; + +CREATE TABLE IF NOT EXISTS fl_loading_pick_item ( + id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货明细ID', + pick_task_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货任务ID', + loading_plan_item_id BIGINT UNSIGNED NOT NULL COMMENT '装柜计划明细ID', + inbound_id BIGINT UNSIGNED NOT NULL COMMENT '父入库单ID', + inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '订单货物明细ID', + planned_package_count INT UNSIGNED NOT NULL COMMENT '计划拣货箱数;上层拆分按业务规则向上取整', + picked_package_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已拣货箱数', + item_status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING、PICKING、PICKED', + version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_loading_pick_item_plan_item (loading_plan_item_id), + KEY idx_loading_pick_item_task (pick_task_id, item_status, id), + CONSTRAINT fk_loading_pick_item_task FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task (id), + CONSTRAINT fk_loading_pick_item_plan_item FOREIGN KEY (loading_plan_item_id) REFERENCES fl_loading_plan_item (id), + CONSTRAINT chk_loading_pick_item_planned CHECK (planned_package_count > 0), + CONSTRAINT chk_loading_pick_item_count CHECK (picked_package_count <= planned_package_count), + CONSTRAINT chk_loading_pick_item_status CHECK (item_status IN ('PENDING', 'PICKING', 'PICKED')) +) ENGINE=InnoDB COMMENT='PDA装柜拣货明细'; + +CREATE TABLE IF NOT EXISTS fl_loading_pick_history ( + id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货操作历史ID', + pick_task_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货任务ID', + pick_item_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货明细ID', + operation_id VARCHAR(128) NOT NULL COMMENT '客户端幂等操作号', + request_hash CHAR(64) NOT NULL COMMENT '请求指纹', + picked_package_count INT UNSIGNED NOT NULL COMMENT '本次拣货箱数', + operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人', + operator_name VARCHAR(64) NULL COMMENT '操作人名称', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_loading_pick_history_operation (operation_id), + KEY idx_loading_pick_history_item (pick_item_id, created_at), + CONSTRAINT fk_loading_pick_history_task FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task (id), + CONSTRAINT fk_loading_pick_history_item FOREIGN KEY (pick_item_id) REFERENCES fl_loading_pick_item (id) +) ENGINE=InnoDB COMMENT='PDA拣货幂等和操作审计历史'; + +DROP TRIGGER IF EXISTS trg_container_booking_before_update; +DELIMITER $$ +CREATE TRIGGER trg_container_booking_before_update +BEFORE UPDATE ON fl_container_booking +FOR EACH ROW +BEGIN + DECLARE current_plan_status VARCHAR(24); + SELECT MAX(plan_status) INTO current_plan_status + FROM fl_loading_plan WHERE container_booking_id = OLD.id; + IF current_plan_status = 'COMPLETED' THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜全部只读,不允许修改'; + END IF; + IF current_plan_status = 'LOADING' AND ( + NOT (OLD.id <=> NEW.id) OR NOT (OLD.booking_no <=> NEW.booking_no) + OR NOT (OLD.cabinet_no <=> NEW.cabinet_no) + OR NOT (OLD.warehouse_id <=> NEW.warehouse_id) + OR NOT (OLD.warehouse_name <=> NEW.warehouse_name) + OR NOT (OLD.bill_of_lading_no <=> NEW.bill_of_lading_no) + OR NOT (OLD.vessel_name <=> NEW.vessel_name) + OR NOT (OLD.container_type <=> NEW.container_type) + OR NOT (OLD.shipping_company_id <=> NEW.shipping_company_id) + OR NOT (OLD.shipping_company_name <=> NEW.shipping_company_name) + OR NOT (OLD.departure_port_code <=> NEW.departure_port_code) + OR NOT (OLD.departure_port_name <=> NEW.departure_port_name) + OR NOT (OLD.destination_port_code <=> NEW.destination_port_code) + OR NOT (OLD.destination_port_name <=> NEW.destination_port_name) + OR NOT (OLD.destination_warehouse_code <=> NEW.destination_warehouse_code) + OR NOT (OLD.destination_warehouse_name <=> NEW.destination_warehouse_name) + OR NOT (OLD.capacity_cbm <=> NEW.capacity_cbm) + OR NOT (OLD.max_weight_kg <=> NEW.max_weight_kg) + OR NOT (OLD.terminal_trucking_company <=> NEW.terminal_trucking_company) + OR NOT (OLD.loading_at <=> NEW.loading_at) + OR NOT (OLD.estimated_sailing_at <=> NEW.estimated_sailing_at) + OR NOT (OLD.actual_sailing_at <=> NEW.actual_sailing_at) + OR NOT (OLD.estimated_port_arrival_at <=> NEW.estimated_port_arrival_at) + OR NOT (OLD.actual_port_arrival_at <=> NEW.actual_port_arrival_at) + OR NOT (OLD.estimated_warehouse_arrival_at <=> NEW.estimated_warehouse_arrival_at) + OR NOT (OLD.actual_warehouse_arrival_at <=> NEW.actual_warehouse_arrival_at) + OR NOT (OLD.created_by <=> NEW.created_by) + OR NOT (OLD.created_by_name <=> NEW.created_by_name) + OR NOT (OLD.created_at <=> NEW.created_at) + ) THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '装柜中仅允许修改集装箱号与航次'; + END IF; +END$$ +DELIMITER ; + +DROP TRIGGER IF EXISTS trg_loading_plan_before_update; +DELIMITER $$ +CREATE TRIGGER trg_loading_plan_before_update +BEFORE UPDATE ON fl_loading_plan +FOR EACH ROW +BEGIN + IF OLD.plan_status = 'COMPLETED' THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已完成装柜计划全部只读'; + END IF; + IF NOT ( + (OLD.plan_status = 'EDITING' AND NEW.plan_status IN ('EDITING', 'LOADING')) + OR (OLD.plan_status = 'LOADING' + AND NEW.plan_status IN ('LOADING', 'COMPLETED')) + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜状态只允许单向推进'; + END IF; + IF OLD.plan_status = 'LOADING' AND ( + NOT (OLD.id <=> NEW.id) OR NOT (OLD.plan_no <=> NEW.plan_no) + OR NOT (OLD.container_booking_id <=> NEW.container_booking_id) + OR NOT (OLD.push_idempotency_key <=> NEW.push_idempotency_key) + OR NOT (OLD.preload_pushed_by <=> NEW.preload_pushed_by) + OR NOT (OLD.preload_pushed_by_name <=> NEW.preload_pushed_by_name) + OR NOT (OLD.preload_pushed_at <=> NEW.preload_pushed_at) + OR NOT (OLD.remark <=> NEW.remark) + OR NOT (OLD.created_by <=> NEW.created_by) + OR NOT (OLD.created_by_name <=> NEW.created_by_name) + OR NOT (OLD.created_at <=> NEW.created_at) + ) THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '装柜中计划仅允许乐观锁触碰或完成装柜'; + END IF; + IF OLD.plan_status = NEW.plan_status + AND OLD.plan_status = 'LOADING' AND ( + NOT (OLD.preload_completed_by <=> NEW.preload_completed_by) + OR NOT (OLD.preload_completed_by_name <=> NEW.preload_completed_by_name) + OR NOT (OLD.preload_completed_at <=> NEW.preload_completed_at) + OR NOT (OLD.confirmed_by <=> NEW.confirmed_by) + OR NOT (OLD.confirmed_by_name <=> NEW.confirmed_by_name) + OR NOT (OLD.confirmed_at <=> NEW.confirmed_at) + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '同状态乐观锁触碰不得修改阶段审计字段'; + END IF; + IF OLD.plan_status = 'EDITING' AND NEW.plan_status = 'LOADING' THEN + IF NEW.push_idempotency_key IS NULL OR CHAR_LENGTH(TRIM(NEW.push_idempotency_key)) = 0 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '开始装柜必须提供幂等键'; + END IF; + SET NEW.preload_pushed_at = COALESCE(NEW.preload_pushed_at, CURRENT_TIMESTAMP(3)); + ELSEIF OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' THEN + IF EXISTS (SELECT 1 FROM fl_loading_plan_item item + LEFT JOIN fl_loading_pick_item pick_item + ON pick_item.loading_plan_item_id = item.id + LEFT JOIN fl_loading_pick_task pick_task + ON pick_task.id = pick_item.pick_task_id + AND pick_task.loading_plan_id = OLD.id + WHERE item.loading_plan_id = OLD.id AND item.deleted = 0 + AND (pick_task.id IS NULL + OR pick_item.picked_package_count <> pick_item.planned_package_count)) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '仍有未完成拣货明细,不能完成装柜'; + END IF; + SET NEW.confirmed_at = COALESCE(NEW.confirmed_at, CURRENT_TIMESTAMP(3)); + END IF; +END$$ +DELIMITER ; + +DROP TRIGGER IF EXISTS trg_loading_plan_after_update; +DELIMITER $$ +CREATE TRIGGER trg_loading_plan_after_update +AFTER UPDATE ON fl_loading_plan +FOR EACH ROW +BEGIN + IF OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' THEN + UPDATE fl_loading_plan_item + SET frozen_at = COALESCE(frozen_at, NEW.confirmed_at, CURRENT_TIMESTAMP(3)), + frozen_by = COALESCE(frozen_by, NEW.updated_by), + updated_by = NEW.updated_by, + updated_by_name = NEW.updated_by_name, + version = version + 1 + WHERE loading_plan_id = NEW.id AND deleted = 0; + UPDATE fl_loading_pick_task + SET task_status = 'COMPLETED', completed_by = NEW.updated_by, + completed_by_name = NEW.updated_by_name, completed_at = CURRENT_TIMESTAMP(3), + version = version + 1 + WHERE loading_plan_id = NEW.id AND task_status = 'LOADING'; + END IF; +END$$ +DELIMITER ; + +DROP TRIGGER IF EXISTS trg_loading_item_before_insert; +DELIMITER $$ +CREATE TRIGGER trg_loading_item_before_insert +BEFORE INSERT ON fl_loading_plan_item +FOR EACH ROW +BEGIN + DECLARE current_plan_status VARCHAR(24); + SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE id = NEW.loading_plan_id; + IF current_plan_status IS NULL OR current_plan_status NOT IN ('EDITING', 'LOADING') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '仅编辑中或装柜中的计划允许新增明细'; + END IF; + IF NEW.deleted <> 0 OR NEW.frozen_at IS NOT NULL OR NEW.frozen_by IS NOT NULL THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '新增装柜明细必须为未删除且未冻结状态'; + END IF; +END$$ +DELIMITER ; + +DROP TRIGGER IF EXISTS trg_loading_item_before_update; +DELIMITER $$ +CREATE TRIGGER trg_loading_item_before_update +BEFORE UPDATE ON fl_loading_plan_item +FOR EACH ROW +BEGIN + DECLARE current_plan_status VARCHAR(24); + SELECT MAX(plan_status) INTO current_plan_status + FROM fl_loading_plan WHERE id = OLD.loading_plan_id; + IF current_plan_status = 'LOADING' AND EXISTS ( + SELECT 1 FROM fl_loading_pick_item + WHERE loading_plan_item_id = OLD.id AND picked_package_count > 0) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已有PDA拣货记录的装柜明细不可修改或删除'; + END IF; + IF OLD.frozen_at IS NOT NULL THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后的明细永久冻结'; + END IF; + IF OLD.deleted = 1 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已删除装柜明细不可恢复或修改'; + END IF; + IF NEW.loading_plan_id <> OLD.loading_plan_id OR NEW.account_id <> OLD.account_id + OR NEW.inbound_detail_id <> OLD.inbound_detail_id THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜明细归属键不可修改,请删除后重新分配'; + END IF; + IF NEW.deleted = 1 THEN + IF NEW.removed_by IS NULL THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '删除装柜明细必须记录操作人'; + END IF; + SET NEW.removed_at = COALESCE(NEW.removed_at, CURRENT_TIMESTAMP(3)); + ELSEIF NEW.removed_by IS NOT NULL OR NEW.removed_by_name IS NOT NULL OR NEW.removed_at IS NOT NULL THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '删除审计字段只能在逻辑删除时写入'; + END IF; +END$$ +DELIMITER ; + +DROP TRIGGER IF EXISTS trg_loading_pick_task_before_update; +DELIMITER $$ +CREATE TRIGGER trg_loading_pick_task_before_update +BEFORE UPDATE ON fl_loading_pick_task +FOR EACH ROW +BEGIN + IF OLD.task_status = 'COMPLETED' THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已完成PDA拣货任务不可修改'; + END IF; + IF NEW.loading_plan_id <> OLD.loading_plan_id OR NOT (NEW.container_no <=> OLD.container_no) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货任务归属不可修改'; + END IF; + IF NOT (OLD.task_status = 'LOADING' AND NEW.task_status IN ('LOADING', 'COMPLETED')) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货任务状态只允许完成'; + END IF; +END$$ +DELIMITER ; + +DROP TRIGGER IF EXISTS trg_loading_pick_item_before_update; +DELIMITER $$ +CREATE TRIGGER trg_loading_pick_item_before_update +BEFORE UPDATE ON fl_loading_pick_item +FOR EACH ROW +BEGIN + DECLARE current_task_status VARCHAR(16); + SELECT MAX(task_status) INTO current_task_status + FROM fl_loading_pick_task WHERE id = OLD.pick_task_id; + IF current_task_status = 'COMPLETED' THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后PDA拣货明细不可修改'; + END IF; + IF NEW.pick_task_id <> OLD.pick_task_id + OR NEW.loading_plan_item_id <> OLD.loading_plan_item_id + OR NEW.inbound_id <> OLD.inbound_id + OR NEW.inbound_detail_id <> OLD.inbound_detail_id THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货明细归属不可修改'; + END IF; +END$$ +DELIMITER ; + +DROP TRIGGER IF EXISTS trg_loading_pick_item_before_delete; +DELIMITER $$ +CREATE TRIGGER trg_loading_pick_item_before_delete +BEFORE DELETE ON fl_loading_pick_item +FOR EACH ROW +BEGIN + DECLARE current_task_status VARCHAR(16); + SELECT MAX(task_status) INTO current_task_status + FROM fl_loading_pick_task WHERE id = OLD.pick_task_id; + IF current_task_status = 'COMPLETED' OR OLD.picked_package_count > 0 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已拣货或已完成的PDA明细不可删除'; + END IF; +END$$ +DELIMITER ; + +DROP TRIGGER IF EXISTS trg_loading_pick_history_before_update; +DELIMITER $$ +CREATE TRIGGER trg_loading_pick_history_before_update +BEFORE UPDATE ON fl_loading_pick_history +FOR EACH ROW +BEGIN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货历史不可修改'; +END$$ +DELIMITER ; + +DROP TRIGGER IF EXISTS trg_loading_pick_history_before_delete; +DELIMITER $$ +CREATE TRIGGER trg_loading_pick_history_before_delete +BEFORE DELETE ON fl_loading_pick_history +FOR EACH ROW +BEGIN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货历史不可删除'; +END$$ +DELIMITER ; + +DROP TRIGGER IF EXISTS trg_loading_item_before_delete; +DELIMITER $$ +CREATE TRIGGER trg_loading_item_before_delete +BEFORE DELETE ON fl_loading_plan_item +FOR EACH ROW +BEGIN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜明细只允许推送前逻辑删除'; +END$$ +DELIMITER ; + +DROP TRIGGER IF EXISTS trg_container_document_before_insert; +DELIMITER $$ +CREATE TRIGGER trg_container_document_before_insert +BEFORE INSERT ON fl_container_document +FOR EACH ROW +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' THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可新增'; + END IF; +END$$ +DELIMITER ; + +DROP TRIGGER IF EXISTS trg_container_document_before_update; +DELIMITER $$ +CREATE TRIGGER trg_container_document_before_update +BEFORE UPDATE ON fl_container_document +FOR EACH ROW +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' THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可修改'; + END IF; +END$$ +DELIMITER ; + +DROP TRIGGER IF EXISTS trg_container_document_before_delete; +DELIMITER $$ +CREATE TRIGGER trg_container_document_before_delete +BEFORE DELETE ON fl_container_document +FOR EACH ROW +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' THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可删除'; + END IF; +END$$ +DELIMITER ; + +DROP TRIGGER IF EXISTS trg_loading_history_before_update; +DELIMITER $$ +CREATE TRIGGER trg_loading_history_before_update +BEFORE UPDATE ON fl_loading_plan_history +FOR EACH ROW +BEGIN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜操作历史不可修改'; +END$$ +DELIMITER ; + +DROP TRIGGER IF EXISTS trg_loading_history_before_delete; +DELIMITER $$ +CREATE TRIGGER trg_loading_history_before_delete +BEFORE DELETE ON fl_loading_plan_history +FOR EACH ROW +BEGIN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜操作历史不可删除'; +END$$ +DELIMITER ; + +-- 三期智能装柜、预装清单和装柜清单平台权限;与一期平台根菜单幂等合并。 +USE baiyue_iam; +INSERT INTO iam_permission + (id, tenant_id, app_code, permission_type, parent_id, permission_code, + permission_name, route_path, http_method, sort_no, status, built_in, deleted) +VALUES + (5102090, 0, 'PLATFORM', 'MENU', 5102000, 'platform:firstleg:container:list', + '货柜', '/first-leg/containers', 'GET', 40, 'ACTIVE', 1, 0), + (5102091, 0, 'PLATFORM', 'BUTTON', 5102090, 'platform:firstleg:container:view', + '查看货柜', NULL, 'GET', 10, 'ACTIVE', 1, 0), + (5102092, 0, 'PLATFORM', 'BUTTON', 5102090, 'platform:firstleg:container:create', + '建立货柜', NULL, 'POST', 20, 'ACTIVE', 1, 0), + (5102093, 0, 'PLATFORM', 'BUTTON', 5102090, 'platform:firstleg:container:edit', + '编辑货柜', NULL, 'PUT', 30, 'ACTIVE', 1, 0), + (5102094, 0, 'PLATFORM', 'MENU', 5102000, 'platform:firstleg:smart-loading:list', + '智能装柜', '/first-leg/smart-loading', 'GET', 50, 'ACTIVE', 1, 0), + (5102096, 0, 'PLATFORM', 'BUTTON', 5102094, 'platform:firstleg:smart-loading:create', + '创建智能装柜计划', NULL, 'POST', 20, 'ACTIVE', 1, 0), + (5102097, 0, 'PLATFORM', 'BUTTON', 5102094, 'platform:firstleg:smart-loading:assign', + '分配装柜明细', NULL, 'POST', 30, 'ACTIVE', 1, 0), + (5102098, 0, 'PLATFORM', 'BUTTON', 5102094, 'platform:firstleg:smart-loading:delete', + '删除装柜明细', NULL, 'DELETE', 40, 'ACTIVE', 1, 0), + (5102099, 0, 'PLATFORM', 'BUTTON', 5102094, 'platform:firstleg:smart-loading:push', + '推送预装', NULL, 'POST', 50, 'ACTIVE', 1, 0), + (5102100, 0, 'PLATFORM', 'MENU', 5102000, 'platform:firstleg:preload:list', + '预装清单', '/first-leg/preload-list', 'GET', 60, 'ACTIVE', 1, 0), + (5102102, 0, 'PLATFORM', 'BUTTON', 5102100, 'platform:firstleg:preload:view', + '查看预装详情', NULL, 'GET', 20, 'ACTIVE', 1, 0), + (5102103, 0, 'PLATFORM', 'BUTTON', 5102100, 'platform:firstleg:preload:edit', + '编辑预装运输字段', NULL, 'PUT', 30, 'ACTIVE', 1, 0), + (5102104, 0, 'PLATFORM', 'BUTTON', 5102100, 'platform:firstleg:preload:complete', + '完成预装', NULL, 'POST', 40, 'ACTIVE', 1, 0), + (5102105, 0, 'PLATFORM', 'BUTTON', 5102100, 'platform:firstleg:preload:confirm', + '确认预装', NULL, 'POST', 50, 'ACTIVE', 1, 0), + (5102106, 0, 'PLATFORM', 'MENU', 5102000, 'platform:firstleg:loading-list:list', + '装柜清单', '/first-leg/loading-list', 'GET', 70, 'ACTIVE', 1, 0), + (5102108, 0, 'PLATFORM', 'BUTTON', 5102106, 'platform:firstleg:loading-list:view', + '查看装柜详情', NULL, 'GET', 20, 'ACTIVE', 1, 0) + ON DUPLICATE KEY UPDATE + permission_type = VALUES(permission_type), parent_id = VALUES(parent_id), + permission_name = VALUES(permission_name), route_path = VALUES(route_path), + http_method = VALUES(http_method), sort_no = VALUES(sort_no), status = 'ACTIVE', + built_in = 1, deleted = 0; + +INSERT IGNORE INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT role.id, permission.id, role.tenant_id +FROM iam_role role + JOIN iam_permission permission + ON permission.tenant_id = 0 + AND permission.permission_code IN ( + 'platform:firstleg:container:list', 'platform:firstleg:container:view', + 'platform:firstleg:container:create', + 'platform:firstleg:container:edit', 'platform:firstleg:smart-loading:list', + 'platform:firstleg:smart-loading:create', 'platform:firstleg:smart-loading:assign', + 'platform:firstleg:smart-loading:delete', 'platform:firstleg:smart-loading:push', + 'platform:firstleg:preload:list', 'platform:firstleg:preload:view', + 'platform:firstleg:preload:edit', 'platform:firstleg:preload:complete', + 'platform:firstleg:preload:confirm', 'platform:firstleg:loading-list:list', + 'platform:firstleg:loading-list:view') +WHERE role.tenant_id = 0 + AND role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR'); + +USE baiyue_firstleg; + +DROP PROCEDURE IF EXISTS assert_phase3_smart_loading_schema; +DELIMITER $$ +CREATE PROCEDURE assert_phase3_smart_loading_schema() +BEGIN + IF (SELECT COUNT(*) FROM information_schema.tables + WHERE table_schema = 'baiyue_firstleg' + AND table_name IN ('fl_container_booking', 'fl_loading_plan', 'fl_loading_plan_item', + 'fl_container_document', 'fl_loading_plan_history', + 'fl_loading_pick_task', 'fl_loading_pick_item', + 'fl_loading_pick_history')) <> 8 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:核心表未完整创建'; + END IF; + IF EXISTS (SELECT 1 FROM fl_loading_plan + WHERE plan_status NOT IN ('EDITING', 'LOADING', 'COMPLETED')) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:存在旧装柜状态'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.check_constraints + WHERE constraint_schema = 'baiyue_firstleg' + AND constraint_name = 'chk_loading_plan_status' + AND check_clause LIKE '%''EDITING''%' + AND check_clause LIKE '%''LOADING''%' + AND check_clause LIKE '%''COMPLETED''%') <> 1 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:装柜状态约束不是最终三状态'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_container_booking' + AND column_name IN ('booking_no', 'cabinet_no', 'warehouse_id', 'bill_of_lading_no', + 'vessel_name', 'voyage_no', 'container_type', 'capacity_cbm', + 'max_weight_kg', 'terminal_trucking_company')) <> 10 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:货柜原型字段缺失'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_plan_item' + AND column_name IN ('account_id', 'customer_name', 'active_inbound_detail_id', 'frozen_at', + 'destination_warehouse_name', 'product_name_zh', 'product_name_en', + 'product_image_file_id', 'pallet_count', 'net_weight_kg', + 'dimensional_weight_kg', 'product_quantity', 'units_per_carton', + 'fba_tracking_no', 'shipment_no', 'shipping_mark', 'version')) <> 17 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:明细归属、清单快照或冻结字段缺失'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_plan_item' + AND index_name = 'uk_loading_item_active_detail' AND non_unique = 0) <> 2 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:唯一活动明细锁缺失'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = 'baiyue_firstleg' + AND table_name = 'fl_loading_pick_item' + AND column_name IN ('pick_task_id', 'loading_plan_item_id', 'inbound_id', + 'inbound_detail_id', 'planned_package_count', + 'picked_package_count', 'item_status', 'version')) <> 8 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:PDA拣货明细字段缺失'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = 'baiyue_firstleg' AND non_unique = 0 + AND index_name IN ('uk_loading_pick_task_plan', + 'uk_loading_pick_item_plan_item', + 'uk_loading_pick_history_operation')) <> 3 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:PDA唯一索引缺失'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.triggers + WHERE trigger_schema = 'baiyue_firstleg' + AND trigger_name IN ('trg_container_booking_before_update', + 'trg_loading_plan_before_update', 'trg_loading_plan_after_update', + 'trg_loading_item_before_insert', 'trg_loading_item_before_update', + 'trg_loading_item_before_delete', 'trg_container_document_before_insert', + 'trg_container_document_before_update', 'trg_container_document_before_delete', + 'trg_loading_history_before_update', 'trg_loading_history_before_delete', + 'trg_loading_pick_task_before_update', + 'trg_loading_pick_item_before_update', + 'trg_loading_pick_item_before_delete', + 'trg_loading_pick_history_before_update', + 'trg_loading_pick_history_before_delete')) <> 16 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:不可逆状态或冻结触发器缺失'; + END IF; + IF (SELECT COUNT(DISTINCT permission_code) FROM baiyue_iam.iam_permission + WHERE tenant_id = 0 AND app_code = 'PLATFORM' AND status = 'ACTIVE' AND deleted = 0 + AND permission_code IN ( + 'platform:firstleg:container:list', 'platform:firstleg:container:view', + 'platform:firstleg:container:create', 'platform:firstleg:container:edit', + 'platform:firstleg:smart-loading:list', 'platform:firstleg:smart-loading:create', + 'platform:firstleg:smart-loading:assign', 'platform:firstleg:smart-loading:delete', + 'platform:firstleg:smart-loading:push', 'platform:firstleg:preload:list', + 'platform:firstleg:preload:view', 'platform:firstleg:preload:edit', + 'platform:firstleg:preload:complete', 'platform:firstleg:preload:confirm', + 'platform:firstleg:loading-list:list', 'platform:firstleg:loading-list:view')) <> 16 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:平台权限资源未完整创建'; + END IF; + IF (SELECT COUNT(DISTINCT CONCAT(role.role_code, ':', permission.permission_code)) + FROM baiyue_iam.iam_role role + JOIN baiyue_iam.iam_role_permission grant_row ON grant_row.role_id = role.id + JOIN baiyue_iam.iam_permission permission ON permission.id = grant_row.permission_id + WHERE role.tenant_id = 0 AND role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR') + AND permission.tenant_id = 0 + AND permission.permission_code IN ( + 'platform:firstleg:container:list', 'platform:firstleg:container:view', + 'platform:firstleg:container:create', + 'platform:firstleg:container:edit', 'platform:firstleg:smart-loading:list', + 'platform:firstleg:smart-loading:create', 'platform:firstleg:smart-loading:assign', + 'platform:firstleg:smart-loading:delete', 'platform:firstleg:smart-loading:push', + 'platform:firstleg:preload:list', 'platform:firstleg:preload:view', + 'platform:firstleg:preload:edit', 'platform:firstleg:preload:complete', + 'platform:firstleg:preload:confirm', 'platform:firstleg:loading-list:list', + 'platform:firstleg:loading-list:view')) <> 32 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:平台角色授权未完整写入'; + END IF; +END$$ +DELIMITER ; +CALL assert_phase3_smart_loading_schema(); +DROP PROCEDURE IF EXISTS assert_phase3_smart_loading_schema; diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index d237a107b..a0caea8f3 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -67,6 +67,90 @@ paths: responses: '200': {description: 成功或业务失败,按响应体 code 判断} '403': {description: 无分配权限} + /api/platform/v1/containers: + get: + summary: 分页查询平台货柜 + description: 需要 platform:firstleg:container:list 权限;货柜为平台级数据,不传 tenant/account + parameters: + - {$ref: '#/components/parameters/Page'} + - {$ref: '#/components/parameters/Size'} + - {name: keyword, in: query, schema: {type: string, maxLength: 128}} + - {name: containerNo, in: query, schema: {type: string, maxLength: 64}} + - {name: cabinetNo, in: query, schema: {type: string, maxLength: 64}} + - {name: warehouseId, in: query, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {name: status, in: query, schema: {type: string, enum: [EDITING, LOADING, COMPLETED]}} + responses: + '200': + description: 成功或业务失败,按响应体 code 判断 + content: + application/json: + schema: {$ref: '#/components/schemas/ContainerBookingPageEnvelope'} + '403': {description: 无货柜查询权限} + post: + summary: 新增平台货柜 + description: 需要 platform:firstleg:container:create 权限;新增后聚合状态为 EDITING + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/ContainerSaveRequest'} + responses: + '200': {description: 成功或重复号、参数校验等业务失败} + '403': {description: 无新增按钮权限} + /api/platform/v1/containers/{id}: + get: + summary: 查询平台货柜详情 + description: 需要 platform:firstleg:container:view 权限 + parameters: + - {$ref: '#/components/parameters/ContainerBookingId'} + responses: + '200': {description: 成功或货柜不存在} + '403': {description: 无货柜查询权限} + put: + summary: 草稿阶段完整编辑货柜 + description: 需要 platform:firstleg:container:edit 权限;仅无装柜计划或计划为 EDITING 时允许 + parameters: + - {$ref: '#/components/parameters/ContainerBookingId'} + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/ContainerFullUpdateRequest'} + responses: + '200': {description: 成功或状态、版本、重复号冲突} + '403': {description: 无货柜编辑权限} + /api/platform/v1/containers/{id}/transport: + patch: + summary: 修改集装箱号和航次 + description: 需要 platform:firstleg:container:edit 权限;LOADING 仅开放此白名单,COMPLETED 全部只读 + parameters: + - {$ref: '#/components/parameters/ContainerBookingId'} + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/ContainerTransportUpdateRequest'} + responses: + '200': {description: 成功或状态、版本、重复号冲突} + '403': {description: 无货柜编辑权限} + /api/platform/v1/first-leg/smart-loading/plans/by-booking/{bookingId}: + get: + summary: 按货柜恢复智能装柜草稿 + description: 需要 platform:firstleg:smart-loading:list 权限;仅返回 EDITING 草稿 + parameters: + - {name: bookingId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + responses: + '200': {description: 成功、草稿不存在或计划已进入预装流程} + '403': {description: 无智能装柜查询权限} + /api/platform/v1/first-leg/smart-loading/plans/{planId}: + get: + summary: 查询智能装柜草稿详情 + description: 需要 platform:firstleg:smart-loading:list 权限;返回草稿及当前未删除明细 + parameters: + - {name: planId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + responses: + '200': {description: 成功、计划不存在或计划已进入预装流程} + '403': {description: 无智能装柜查询权限} /openapi/v1/inbound-measurements: post: summary: 提交一条第三方箱规测量结果和一张图片 @@ -258,7 +342,103 @@ components: name: size in: query schema: {type: integer, format: int64, minimum: 1, maximum: 200, default: 20} + ContainerBookingId: + name: id + in: path + required: true + schema: {type: string, pattern: '^[1-9][0-9]*$'} schemas: + ContainerBookingPageEnvelope: + type: object + properties: + code: {type: string, example: '200'} + message: {type: string} + data: + type: object + properties: + records: {type: array, items: {$ref: '#/components/schemas/ContainerBooking'}} + page: {type: string, pattern: '^[1-9][0-9]*$'} + size: {type: string, pattern: '^[1-9][0-9]*$'} + total: {type: string, pattern: '^[0-9]+$'} + pages: {type: string, pattern: '^[0-9]+$'} + ContainerBooking: + type: object + required: [id, bookingNo, cabinetNo, warehouseId, warehouseName, containerType, planStatus, version] + properties: + id: {type: string, pattern: '^[1-9][0-9]*$'} + planId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 关联智能装柜计划ID;未创建计划时为空} + bookingNo: {type: string, maxLength: 40} + containerNo: {type: string, maxLength: 64, nullable: true} + cabinetNo: {type: string, maxLength: 64} + warehouseId: {type: string, pattern: '^[1-9][0-9]*$'} + warehouseName: {type: string, maxLength: 128} + billOfLadingNo: {type: string, maxLength: 128, nullable: true} + vesselName: {type: string, maxLength: 128, nullable: true} + voyageNo: {type: string, maxLength: 64, nullable: true} + containerType: {type: string, maxLength: 32} + shippingCompanyId: {type: string, nullable: true} + shippingCompanyName: {type: string, maxLength: 128, nullable: true} + departurePortCode: {type: string, maxLength: 32, nullable: true} + departurePortName: {type: string, maxLength: 128, nullable: true} + destinationPortCode: {type: string, maxLength: 32, nullable: true} + destinationPortName: {type: string, maxLength: 128, nullable: true} + destinationWarehouseCode: {type: string, maxLength: 64, nullable: true} + destinationWarehouseName: {type: string, maxLength: 128, nullable: true} + capacityCbm: {type: number, exclusiveMinimum: true, minimum: 0, nullable: true} + maxWeightKg: {type: number, exclusiveMinimum: true, minimum: 0, nullable: true} + terminalTruckingCompany: {type: string, maxLength: 128, nullable: true} + loadingAt: {type: string, format: date-time, nullable: true} + estimatedSailingAt: {type: string, format: date-time, nullable: true} + actualSailingAt: {type: string, format: date-time, nullable: true} + estimatedPortArrivalAt: {type: string, format: date-time, nullable: true} + actualPortArrivalAt: {type: string, format: date-time, nullable: true} + estimatedWarehouseArrivalAt: {type: string, format: date-time, nullable: true} + actualWarehouseArrivalAt: {type: string, format: date-time, nullable: true} + planStatus: {type: string, enum: [EDITING, LOADING, COMPLETED]} + version: {type: integer, minimum: 0} + ContainerSaveRequest: + type: object + required: [cabinetNo, warehouseId, warehouseName, containerType] + properties: + containerNo: {type: string, maxLength: 64, nullable: true} + cabinetNo: {type: string, maxLength: 64} + warehouseId: {type: string, pattern: '^[1-9][0-9]*$'} + warehouseName: {type: string, maxLength: 128} + billOfLadingNo: {type: string, maxLength: 128, nullable: true} + vesselName: {type: string, maxLength: 128, nullable: true} + voyageNo: {type: string, maxLength: 64, nullable: true} + containerType: {type: string, maxLength: 32} + shippingCompanyId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true} + shippingCompanyName: {type: string, maxLength: 128, nullable: true} + departurePortCode: {type: string, maxLength: 32, nullable: true} + departurePortName: {type: string, maxLength: 128, nullable: true} + destinationPortCode: {type: string, maxLength: 32, nullable: true} + destinationPortName: {type: string, maxLength: 128, nullable: true} + destinationWarehouseCode: {type: string, maxLength: 64, nullable: true} + destinationWarehouseName: {type: string, maxLength: 128, nullable: true} + capacityCbm: {type: number, exclusiveMinimum: true, minimum: 0, nullable: true} + maxWeightKg: {type: number, exclusiveMinimum: true, minimum: 0, nullable: true} + terminalTruckingCompany: {type: string, maxLength: 128, nullable: true} + loadingAt: {type: string, format: date-time, nullable: true} + estimatedSailingAt: {type: string, format: date-time, nullable: true} + actualSailingAt: {type: string, format: date-time, nullable: true} + estimatedPortArrivalAt: {type: string, format: date-time, nullable: true} + actualPortArrivalAt: {type: string, format: date-time, nullable: true} + estimatedWarehouseArrivalAt: {type: string, format: date-time, nullable: true} + actualWarehouseArrivalAt: {type: string, format: date-time, nullable: true} + ContainerFullUpdateRequest: + type: object + required: [version, data] + properties: + version: {type: integer, minimum: 0} + data: {$ref: '#/components/schemas/ContainerSaveRequest'} + ContainerTransportUpdateRequest: + type: object + required: [version] + properties: + containerNo: {type: string, maxLength: 64, nullable: true} + voyageNo: {type: string, maxLength: 64, nullable: true} + version: {type: integer, minimum: 0} MerchantInboundBillingEnvelope: type: object properties: diff --git a/firstleg/src/main/resources/openapi/loading-lists-v1.yaml b/firstleg/src/main/resources/openapi/loading-lists-v1.yaml new file mode 100644 index 000000000..2b8423152 --- /dev/null +++ b/firstleg/src/main/resources/openapi/loading-lists-v1.yaml @@ -0,0 +1,340 @@ +openapi: 3.0.3 +info: + title: 头程装柜与完成装柜清单接口 + version: 1.0.0 + description: 装柜计划仅允许 EDITING 到 LOADING 再到 COMPLETED 单向推进;EDITING 和 LOADING 均可维护未拣货明细,COMPLETED 后计划、明细与货柜运输信息永久只读,不提供回退或撤回接口。 +paths: + /api/platform/v1/first-leg/loading-plans: + get: + summary: 分页查询预装清单 + description: 仅返回预装柜中或预装柜完成的计划,计划为平台作业数据,客户账号仅作为明细筛选条件。 + x-permission: platform:firstleg:preload:list + parameters: + - {$ref: '#/components/parameters/Page'} + - {$ref: '#/components/parameters/Size'} + - {$ref: '#/components/parameters/Keyword'} + - name: status + in: query + description: 预装状态 + schema: {type: string, enum: [LOADING]} + - {$ref: '#/components/parameters/AccountId'} + - {$ref: '#/components/parameters/WarehouseId'} + - {$ref: '#/components/parameters/CreatedFrom'} + - {$ref: '#/components/parameters/CreatedTo'} + responses: + '200': {$ref: '#/components/responses/PreloadPageResponse'} + /api/platform/v1/first-leg/loading-plans/{planId}: + get: + summary: 查询预装清单详情 + description: 返回同一装柜计划的汇总、冻结明细和完整操作历史。 + x-permission: platform:firstleg:preload:view + parameters: [{$ref: '#/components/parameters/PlanId'}] + responses: + '200': {$ref: '#/components/responses/PreloadDetailResponse'} + /api/platform/v1/first-leg/loading-plans/{planId}/transport: + patch: + summary: 修改预装运输信息 + description: 仅 LOADING 状态允许修改集装箱号与航次;装柜中可以维护未拣货明细。 + x-permission: platform:firstleg:preload:edit + parameters: + - {$ref: '#/components/parameters/PlanId'} + - {$ref: '#/components/parameters/IdempotencyKey'} + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/TransportUpdateRequest'} + responses: + '200': {$ref: '#/components/responses/PreloadDetailResponse'} + /api/platform/v1/first-leg/loading-plans/{planId}/complete: + post: + summary: 完成预装 + description: 仅允许将 LOADING 单向推进为 COMPLETED;完成前必须完成全部 PDA 拣货。 + x-permission: platform:firstleg:preload:complete + parameters: + - {$ref: '#/components/parameters/PlanId'} + - {$ref: '#/components/parameters/IdempotencyKey'} + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/AdvanceRequest'} + responses: + '200': {$ref: '#/components/responses/PreloadDetailResponse'} + /api/platform/v1/first-leg/loading-lists: + get: + summary: 分页查询装柜清单 + description: 读取同一个装柜计划聚合,仅返回 COMPLETED 状态记录。 + x-permission: platform:firstleg:loading-list:list + parameters: + - {$ref: '#/components/parameters/Page'} + - {$ref: '#/components/parameters/Size'} + - {$ref: '#/components/parameters/Keyword'} + - {$ref: '#/components/parameters/AccountId'} + - {$ref: '#/components/parameters/WarehouseId'} + - {$ref: '#/components/parameters/CreatedFrom'} + - {$ref: '#/components/parameters/CreatedTo'} + responses: + '200': {$ref: '#/components/responses/ConfirmedPageResponse'} + /api/platform/v1/first-leg/loading-lists/{planId}: + get: + summary: 查询装柜清单详情 + description: 返回已确认计划的汇总、冻结明细和操作历史,全部只读。 + x-permission: platform:firstleg:loading-list:view + parameters: [{$ref: '#/components/parameters/PlanId'}] + responses: + '200': {$ref: '#/components/responses/ConfirmedDetailResponse'} +components: + parameters: + PlanId: + name: planId + in: path + required: true + description: 装柜计划 ID + schema: {$ref: '#/components/schemas/LongId'} + IdempotencyKey: + name: Idempotency-Key + in: header + required: true + description: 写操作幂等键,同一业务操作重试时必须保持不变 + schema: {type: string, minLength: 1, maxLength: 128} + Page: + name: page + in: query + description: 页码,从 1 开始 + schema: {type: integer, format: int64, minimum: 1, default: 1} + Size: + name: size + in: query + description: 每页条数 + schema: {type: integer, format: int64, minimum: 1, maximum: 200, default: 20} + Keyword: + name: keyword + in: query + description: 按预装单号、集装箱号或柜号模糊查询 + schema: {type: string, maxLength: 128} + AccountId: + name: accountId + in: query + description: 客户账号 ID 筛选条件,计划本身仍属于平台作业数据 + schema: {$ref: '#/components/schemas/LongId'} + WarehouseId: + name: warehouseId + in: query + description: 仓库 ID + schema: {$ref: '#/components/schemas/LongId'} + CreatedFrom: + name: createdFrom + in: query + description: 创建时间起点,包含该时间 + schema: {type: string, format: date-time} + CreatedTo: + name: createdTo + in: query + description: 创建时间终点,包含该时间 + schema: {type: string, format: date-time} + responses: + PreloadPageResponse: + description: 预装清单分页查询成功或业务失败,按响应体 code 判断 + content: {application/json: {schema: {$ref: '#/components/schemas/PreloadPageEnvelope'}}} + PreloadDetailResponse: + description: 预装清单详情查询或操作成功,也可能返回业务失败 + content: {application/json: {schema: {$ref: '#/components/schemas/PreloadDetailEnvelope'}}} + ConfirmedPageResponse: + description: 装柜清单分页查询成功或业务失败,按响应体 code 判断 + content: {application/json: {schema: {$ref: '#/components/schemas/ConfirmedPageEnvelope'}}} + ConfirmedDetailResponse: + description: 装柜清单详情查询或确认成功,也可能返回业务失败 + content: {application/json: {schema: {$ref: '#/components/schemas/ConfirmedDetailEnvelope'}}} + schemas: + LongId: + type: string + pattern: '^[1-9][0-9]*$' + example: '1900000000000000001' + description: 长整型 ID,JSON 中使用字符串避免前端精度丢失 + NullableLongId: + allOf: [{$ref: '#/components/schemas/LongId'}] + nullable: true + ApiEnvelope: + type: object + required: [code, message, timestamp] + properties: + code: {type: string, description: 业务结果码,仅字符串 200 表示成功, example: '200'} + message: {type: string, description: 结果说明} + requestId: {type: string, nullable: true, description: 请求标识} + traceId: {type: string, nullable: true, description: 链路标识} + timestamp: {type: string, format: date-time, description: 服务端响应时间} + PreloadPageEnvelope: + allOf: + - {$ref: '#/components/schemas/ApiEnvelope'} + - type: object + properties: + data: + allOf: [{$ref: '#/components/schemas/PreloadPage'}] + nullable: true + PreloadDetailEnvelope: + allOf: + - {$ref: '#/components/schemas/ApiEnvelope'} + - type: object + properties: + data: + allOf: [{$ref: '#/components/schemas/PreloadDetail'}] + nullable: true + ConfirmedPageEnvelope: + allOf: + - {$ref: '#/components/schemas/ApiEnvelope'} + - type: object + properties: + data: + allOf: [{$ref: '#/components/schemas/ConfirmedPage'}] + nullable: true + ConfirmedDetailEnvelope: + allOf: + - {$ref: '#/components/schemas/ApiEnvelope'} + - type: object + properties: + data: + allOf: [{$ref: '#/components/schemas/ConfirmedDetail'}] + nullable: true + PreloadPage: + type: object + required: [records, page, size, total, pages] + properties: + records: {type: array, items: {$ref: '#/components/schemas/PreloadSummary'}} + page: {type: string, pattern: '^[1-9][0-9]*$', description: 当前页码} + size: {type: string, pattern: '^[1-9][0-9]*$', description: 每页条数} + total: {type: string, pattern: '^[0-9]+$', description: 总记录数} + pages: {type: string, pattern: '^[0-9]+$', description: 总页数} + ConfirmedPage: + type: object + required: [records, page, size, total, pages] + properties: + records: {type: array, items: {$ref: '#/components/schemas/ConfirmedSummary'}} + page: {type: string, pattern: '^[1-9][0-9]*$', description: 当前页码} + size: {type: string, pattern: '^[1-9][0-9]*$', description: 每页条数} + total: {type: string, pattern: '^[0-9]+$', description: 总记录数} + pages: {type: string, pattern: '^[0-9]+$', description: 总页数} + Customer: + type: object + required: [accountId, customerName] + properties: + accountId: {$ref: '#/components/schemas/LongId'} + customerName: {type: string, nullable: true, description: 客户名称} + PreloadSummary: + type: object + required: [planId, preloadNo, customers, status, skuCount, totalPackageCount, totalVolumeCbm, totalGrossWeightKg, createdAt, version] + properties: + planId: {$ref: '#/components/schemas/LongId'} + preloadNo: {type: string, description: 预装单号} + containerNo: {type: string, nullable: true, description: 集装箱号} + internalContainerNo: {type: string, nullable: true, description: 内部柜号} + customers: {type: array, description: 计划包含的客户, items: {$ref: '#/components/schemas/Customer'}} + warehouseId: {$ref: '#/components/schemas/NullableLongId'} + warehouseName: {type: string, nullable: true, description: 仓库名称} + vesselName: {type: string, nullable: true, description: 船名} + voyageNo: {type: string, nullable: true, description: 航次} + containerType: {type: string, nullable: true, description: 货柜类型} + status: {type: string, enum: [LOADING], description: 装柜状态} + skuCount: {type: string, pattern: '^[0-9]+$', description: SKU 种类数} + totalPackageCount: {type: string, pattern: '^[0-9]+$', description: 总箱数} + totalVolumeCbm: {type: number, description: 总体积,单位立方米} + totalGrossWeightKg: {type: number, description: 总毛重,单位千克} + createdAt: {type: string, format: date-time, description: 创建时间} + version: {type: integer, format: int32, minimum: 0, description: 乐观锁版本} + ConfirmedSummary: + type: object + required: [planId, loadingListNo, status, totalPackageCount, totalGrossWeightKg, totalNetWeightKg, totalVolumeCbm, totalQuantity, createdAt, version] + properties: + planId: {$ref: '#/components/schemas/LongId'} + loadingListNo: {type: string, description: 装柜清单号} + warehouseId: {$ref: '#/components/schemas/NullableLongId'} + warehouseName: {type: string, nullable: true, description: 仓库名称} + containerNo: {type: string, nullable: true, description: 集装箱号} + internalContainerNo: {type: string, nullable: true, description: 内部柜号} + status: {type: string, enum: [COMPLETED], description: 装柜清单状态} + totalPackageCount: {type: string, pattern: '^[0-9]+$', description: 总箱数} + totalGrossWeightKg: {type: number, description: 总毛重,单位千克} + totalNetWeightKg: {type: number, description: 总净重,单位千克} + totalVolumeCbm: {type: number, description: 总体积,单位立方米} + totalQuantity: {type: string, pattern: '^[0-9]+$', description: 产品总数量} + remark: {type: string, nullable: true, description: 备注} + createdBy: {$ref: '#/components/schemas/NullableLongId'} + createdByName: {type: string, nullable: true, description: 创建人姓名} + createdAt: {type: string, format: date-time, description: 创建时间} + version: {type: integer, format: int32, minimum: 0, description: 乐观锁版本} + LoadingItem: + type: object + required: [itemId, accountId, inboundId, inboundDetailId, packageCount, palletCount, grossWeightKg, netWeightKg, volumeCbm, dimensionalWeightKg, productQuantity, unitsPerPackage, loadSequence] + properties: + itemId: {$ref: '#/components/schemas/LongId'} + accountId: {$ref: '#/components/schemas/LongId'} + customerName: {type: string, nullable: true, description: 客户名称快照} + inboundId: {$ref: '#/components/schemas/LongId'} + inboundDetailId: {$ref: '#/components/schemas/LongId'} + inboundCode: {type: string, nullable: true, description: 入仓码} + skuCode: {type: string, nullable: true, description: SKU 编码快照} + destinationWarehouse: {type: string, nullable: true, description: 目的仓名称快照} + chineseName: {type: string, nullable: true, description: 中文品名快照} + englishName: {type: string, nullable: true, description: 英文品名快照} + goodsImageUrl: {type: string, nullable: true, description: 产品图片地址快照} + packageCount: {type: integer, format: int32, minimum: 0, description: 箱数} + palletCount: {type: integer, format: int32, minimum: 0, description: 托盘数} + grossWeightKg: {type: number, description: 毛重,单位千克} + netWeightKg: {type: number, description: 净重,单位千克} + volumeCbm: {type: number, description: 体积,单位立方米} + dimensionalWeightKg: {type: number, description: 材积重,单位千克} + productQuantity: {type: string, pattern: '^[0-9]+$', description: 产品数量} + unitsPerPackage: {type: integer, format: int32, minimum: 0, description: 每箱数量} + fbaTrackingNo: {type: string, nullable: true, description: FBA 跟踪编号} + shipmentNo: {type: string, nullable: true, description: 货件号} + shippingMark: {type: string, nullable: true, description: 唛头} + internalRemark: {type: string, nullable: true, description: 内部备注} + customerRemark: {type: string, nullable: true, description: 客户备注} + warehouseRemark: {type: string, nullable: true, description: 仓库备注} + loadSequence: {type: integer, format: int32, minimum: 0, description: 装柜顺序} + frozenAt: {type: string, format: date-time, nullable: true, description: 明细冻结时间} + OperationHistory: + type: object + required: [historyId, actionType, operatorId, occurredAt] + properties: + historyId: {$ref: '#/components/schemas/LongId'} + itemId: {$ref: '#/components/schemas/NullableLongId'} + actionType: {type: string, description: 操作类型} + fromStatus: {type: string, nullable: true, description: 操作前状态} + toStatus: {type: string, nullable: true, description: 操作后状态} + beforeSnapshot: {type: string, nullable: true, description: 操作前快照} + afterSnapshot: {type: string, nullable: true, description: 操作后快照} + operatorId: {$ref: '#/components/schemas/LongId'} + operatorName: {type: string, nullable: true, description: 操作人姓名} + operationReason: {type: string, nullable: true, description: 操作原因} + occurredAt: {type: string, format: date-time, description: 操作时间} + PreloadDetail: + type: object + required: [summary, items, histories] + properties: + summary: {$ref: '#/components/schemas/PreloadSummary'} + items: {type: array, description: 装柜中明细,可维护未拣货项, items: {$ref: '#/components/schemas/LoadingItem'}} + histories: {type: array, description: 计划操作历史, items: {$ref: '#/components/schemas/OperationHistory'}} + ConfirmedDetail: + type: object + required: [summary, items, histories] + properties: + summary: {$ref: '#/components/schemas/ConfirmedSummary'} + items: {type: array, description: 已完成且永久只读的装柜明细, items: {$ref: '#/components/schemas/LoadingItem'}} + histories: {type: array, description: 计划操作历史, items: {$ref: '#/components/schemas/OperationHistory'}} + TransportUpdateRequest: + type: object + required: [expectedVersion] + additionalProperties: false + properties: + containerNo: {type: string, maxLength: 64, nullable: true, description: 集装箱号} + voyageNo: {type: string, maxLength: 64, nullable: true, description: 航次} + expectedVersion: {type: integer, format: int32, minimum: 0, description: 当前乐观锁版本} + operationReason: {type: string, maxLength: 500, nullable: true, description: 操作原因} + AdvanceRequest: + type: object + required: [expectedVersion] + additionalProperties: false + properties: + expectedVersion: {type: integer, format: int32, minimum: 0, description: 当前乐观锁版本} + operationReason: {type: string, maxLength: 500, nullable: true, description: 操作原因} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java new file mode 100644 index 000000000..b84db2745 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java @@ -0,0 +1,51 @@ +package com.baiyue.firstleg.controller; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.core.io.ClassPathResource; + +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("货柜基础资料接口契约") +class ContainerControllerContractTest { + @Test + @DisplayName("查询、新增和编辑按钮权限彼此独立") + void separatesButtonPermissions() { + assertPermission("page", "platform:firstleg:container:list"); + assertPermission("detail", "platform:firstleg:container:view"); + assertPermission("create", "platform:firstleg:container:create"); + assertPermission("updateDraft", "platform:firstleg:container:edit"); + assertPermission("updateTransport", "platform:firstleg:container:edit"); + } + + @Test + @DisplayName("OpenAPI声明平台级货柜路径和冻结规则") + void documentsContainerContract() throws Exception { + String openapi = new ClassPathResource("openapi/firstleg-v1.yaml") + .getContentAsString(StandardCharsets.UTF_8); + String section = openapi.substring(openapi.indexOf(" /api/platform/v1/containers:"), + openapi.indexOf(" /openapi/v1/inbound-measurements:")); + + assertThat(section) + .contains("/api/platform/v1/containers/{id}/transport:") + .contains("platform:firstleg:container:list") + .contains("EDITING") + .contains("LOADING") + .contains("COMPLETED") + .doesNotContain("tenantId") + .doesNotContain("accountId"); + } + + private static void assertPermission(String methodName, String authority) { + Method method = Arrays.stream(PlatformContainerController.class.getDeclaredMethods()) + .filter(candidate -> candidate.getName().equals(methodName)) + .findFirst().orElseThrow(); + assertThat(method.getAnnotation(PreAuthorize.class).value()) + .isEqualTo("hasAuthority('" + authority + "')"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java new file mode 100644 index 000000000..7cd2228db --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java @@ -0,0 +1,120 @@ +package com.baiyue.firstleg.controller; + +import com.baiyue.common.audit.BusinessAudit; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.firstleg.dto.LoadingListDtos.AdvanceRequest; +import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedQuery; +import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery; +import com.baiyue.firstleg.dto.LoadingListDtos.TransportUpdateRequest; +import com.baiyue.firstleg.service.LoadingListService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.yaml.snakeyaml.Yaml; +import org.springframework.core.io.ClassPathResource; +import org.springframework.security.access.prepost.PreAuthorize; + +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("预装与装柜清单控制器契约") +class LoadingListControllerContractTest { + @Test + void permissionsMatch() throws Exception { + authority("preloadPage", "platform:firstleg:preload:list", AuthenticatedUser.class, PreloadQuery.class); + authority("preloadDetail", "platform:firstleg:preload:view", AuthenticatedUser.class, long.class); + authority("updateTransport", "platform:firstleg:preload:edit", AuthenticatedUser.class, + long.class, String.class, TransportUpdateRequest.class); + authority("complete", "platform:firstleg:preload:complete", AuthenticatedUser.class, + long.class, String.class, AdvanceRequest.class); + authority("confirmedPage", "platform:firstleg:loading-list:list", AuthenticatedUser.class, + ConfirmedQuery.class); + authority("confirmedDetail", "platform:firstleg:loading-list:view", AuthenticatedUser.class, long.class); + } + + @Test + void writesAudited() throws Exception { + audit("updateTransport", "PRELOAD_TRANSPORT_UPDATE", long.class, long.class, String.class, + String.class, TransportUpdateRequest.class); + audit("complete", "PRELOAD_COMPLETE", long.class, long.class, String.class, + String.class, AdvanceRequest.class); + } + + @Test + void openApiComplete() throws Exception { + String yaml = new ClassPathResource("openapi/loading-lists-v1.yaml") + .getContentAsString(StandardCharsets.UTF_8); + assertThat(yaml) + .contains("EDITING 到 LOADING 再到 COMPLETED") + .contains("Idempotency-Key") + .contains("expectedVersion") + .contains("createdFrom") + .contains("createdTo") + .contains("platform:firstleg:loading-list:view") + .contains("PreloadPageEnvelope") + .contains("ConfirmedPageEnvelope") + .contains("PreloadSummary") + .contains("ConfirmedSummary") + .contains("LoadingItem") + .contains("OperationHistory") + .contains("customers") + .contains("productQuantity") + .contains("beforeSnapshot") + .contains("containerNo") + .contains("voyageNo") + .doesNotContain("rollback") + .doesNotContain("delete:") + .doesNotContain("/items/{itemId}"); + } + + @Test + void yamlParses() throws Exception { + String yaml = new ClassPathResource("openapi/loading-lists-v1.yaml") + .getContentAsString(StandardCharsets.UTF_8); + Map document = stringMap(new Yaml().load(yaml)); + assertThat(document.get("openapi")).isEqualTo("3.0.3"); + assertThat((Map) document.get("paths")).hasSize(6); + Map components = stringMap(document.get("components")); + Map schemas = stringMap(components.get("schemas")); + assertThat(schemas).containsKeys( + "LongId", "PreloadPage", "ConfirmedPage", "Customer", "PreloadSummary", + "ConfirmedSummary", "LoadingItem", "OperationHistory", "PreloadDetail", + "ConfirmedDetail", "TransportUpdateRequest", "AdvanceRequest"); + assertThat(stringMap(schemas.get("LongId"))).containsEntry("type", "string"); + assertThat(properties(schemas, "PreloadPage")).containsKeys("records", "page", "size", "total", "pages"); + assertThat(properties(schemas, "PreloadSummary")).containsKeys("planId", "customers", "skuCount"); + assertThat(properties(schemas, "LoadingItem")).containsKeys( + "itemId", "accountId", "inboundId", "inboundDetailId", "skuCode", + "destinationWarehouse", "productQuantity", "frozenAt"); + assertThat(properties(schemas, "OperationHistory")).containsKeys( + "historyId", "itemId", "actionType", "fromStatus", "toStatus", + "beforeSnapshot", "afterSnapshot", "operatorId", "occurredAt"); + assertThat(properties(schemas, "TransportUpdateRequest")).containsOnlyKeys( + "containerNo", "voyageNo", "expectedVersion", "operationReason"); + } + + private static Map properties(Map schemas, String schemaName) { + return stringMap(stringMap(schemas.get(schemaName)).get("properties")); + } + + @SuppressWarnings("unchecked") + private static Map stringMap(Object value) { + return (Map) value; + } + + private static void authority(String name, String permission, Class... parameterTypes) throws Exception { + Method method = PlatformLoadingListController.class.getDeclaredMethod(name, parameterTypes); + assertThat(method.getAnnotation(PreAuthorize.class).value()) + .isEqualTo("hasAuthority('" + permission + "')"); + } + + private static void audit(String name, String event, Class... parameterTypes) throws Exception { + Method method = LoadingListService.class.getDeclaredMethod(name, parameterTypes); + BusinessAudit audit = method.getAnnotation(BusinessAudit.class); + assertThat(audit.event()).isEqualTo(event); + assertThat(audit.resourceType()).isEqualTo("LOADING_PLAN"); + assertThat(audit.resourceId()).isEqualTo("#planId"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformSmartLoadingControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformSmartLoadingControllerContractTest.java new file mode 100644 index 000000000..36d5b5f01 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformSmartLoadingControllerContractTest.java @@ -0,0 +1,23 @@ +package com.baiyue.firstleg.controller; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.firstleg.dto.SmartLoadingDtos.*; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import java.lang.reflect.Method; +import static org.assertj.core.api.Assertions.assertThat; + +class PlatformSmartLoadingControllerContractTest { + @Test void draftQueriesUseListPermission() throws Exception { + authority("draftByBooking","platform:firstleg:smart-loading:list",AuthenticatedUser.class,long.class); + authority("detail","platform:firstleg:smart-loading:list",AuthenticatedUser.class,long.class); + } + @Test void everyMutationDeclaresDedicatedPermission() throws Exception { + authority("assign","platform:firstleg:smart-loading:assign",AuthenticatedUser.class,long.class,AssignRequest.class); + authority("recommend","platform:firstleg:smart-loading:assign",AuthenticatedUser.class,long.class,RecommendRequest.class); + authority("adjust","platform:firstleg:smart-loading:assign",AuthenticatedUser.class,long.class,long.class,AdjustRequest.class); + authority("delete","platform:firstleg:smart-loading:delete",AuthenticatedUser.class,long.class,long.class,DeleteRequest.class); + authority("push","platform:firstleg:smart-loading:push",AuthenticatedUser.class,long.class,String.class,PushRequest.class); + } + private void authority(String method,String permission,Class...types)throws Exception{Method m=PlatformSmartLoadingController.class.getDeclaredMethod(method,types);assertThat(m.getAnnotation(PreAuthorize.class).value()).isEqualTo("hasAuthority('"+permission+"')");} +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerBookingMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerBookingMapperContractTest.java new file mode 100644 index 000000000..edf2bbbf9 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerBookingMapperContractTest.java @@ -0,0 +1,46 @@ +package com.baiyue.firstleg.repository; + +import com.baomidou.mybatisplus.annotation.TableName; +import com.baiyue.firstleg.entity.ContainerBookingEntity; +import com.baiyue.firstleg.mapper.ContainerBookingMapper; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("货柜数据库映射契约") +class ContainerBookingMapperContractTest { + @Test + @DisplayName("货柜映射平台级最终表且状态只从装柜计划读取") + void mapsFinalBookingContract() throws Exception { + assertThat(ContainerBookingEntity.class.getAnnotation(TableName.class).value()) + .isEqualTo("fl_container_booking"); + String select = String.join("\n", ContainerBookingMapper.class + .getDeclaredMethod("selectBooking", long.class).getAnnotation(Select.class).value()); + assertThat(select) + .contains("fl_loading_plan") + .contains("p.id AS plan_id") + .contains("plan_status") + .doesNotContain("tenant_id") + .doesNotContain("account_id"); + } + + @Test + @DisplayName("草稿完整更新和预装运输白名单都带版本与状态门禁") + void guardsUpdatesInSql() { + String allSql = Arrays.stream(ContainerBookingMapper.class.getDeclaredMethods()) + .map(method -> method.getAnnotation(Update.class)) + .filter(annotation -> annotation != null) + .flatMap(annotation -> Arrays.stream(annotation.value())) + .reduce("", (left, right) -> left + "\n" + right); + assertThat(allSql) + .contains("capacity_cbm") + .contains("p.plan_status<>'EDITING'") + .contains("p.plan_status='COMPLETED'") + .contains("version=#{expectedVersion}"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java new file mode 100644 index 000000000..a4ee03ac2 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java @@ -0,0 +1,89 @@ +package com.baiyue.firstleg.repository; + +import com.baiyue.firstleg.mapper.LoadingListQueryMapper; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; +import org.apache.ibatis.session.Configuration; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("清单持久化SQL契约") +class LoadingListQueryMapperContractTest { + @Test + void annotationScriptsShouldParseAsMyBatisStatements() { + Configuration configuration = new Configuration(); + configuration.addMapper(LoadingListQueryMapper.class); + + assertThat(configuration.hasStatement( + LoadingListQueryMapper.class.getName() + ".selectPreloadPage")).isTrue(); + assertThat(configuration.hasStatement( + LoadingListQueryMapper.class.getName() + ".advance")).isTrue(); + } + + @Test + void bothListsShouldProjectTheSameLoadingPlanAggregate() throws Exception { + String preload = sql("selectPreloadPage", Select.class); + String confirmed = sql("selectConfirmedPage", Select.class); + + assertThat(preload) + .contains("FROM fl_loading_plan p") + .contains("JOIN fl_container_booking b") + .contains("LEFT JOIN fl_loading_plan_item i") + .contains("p.plan_status = 'LOADING'") + .doesNotContain("PRELOAD_PUSHED"); + assertThat(confirmed) + .contains("FROM fl_loading_plan p") + .contains("p.plan_status = 'COMPLETED'") + .doesNotContain("fl_loading_list"); + } + + @Test + void mutationsShouldUseStatusAndOptimisticVersionConditions() throws Exception { + String touch = sql("touchEditable", Update.class); + String transport = sql("updateTransport", Update.class); + String advance = sql("advance", Update.class); + + assertThat(touch) + .contains("plan_status = 'LOADING'") + .contains("version = #{expectedVersion}") + .contains("version = version + 1"); + assertThat(transport) + .contains("SET b.container_no = #{containerNo}, b.voyage_no = #{voyageNo}") + .doesNotContain("DELETE") + .doesNotContain("fl_loading_plan_item"); + assertThat(advance) + .contains("plan_status = #{fromStatus}") + .contains("plan_status = #{toStatus}") + .contains("version = #{expectedVersion}") + .doesNotContain("ROLLED_BACK"); + } + + @Test + void everyWriteShouldAppendAnImmutableHistoryOperation() throws Exception { + String insert = sql("insertHistory", Insert.class); + assertThat(insert) + .contains("INSERT INTO fl_loading_plan_history") + .contains("operation_id") + .contains("before_snapshot") + .contains("after_snapshot") + .contains("operator_id"); + } + + private static String sql(String methodName, Class annotationType) throws Exception { + Method method = java.util.Arrays.stream(LoadingListQueryMapper.class.getDeclaredMethods()) + .filter(candidate -> candidate.getName().equals(methodName)) + .findFirst().orElseThrow(); + if (annotationType == Select.class) { + return String.join("\n", method.getAnnotation(Select.class).value()); + } + if (annotationType == Update.class) { + return String.join("\n", method.getAnnotation(Update.class).value()); + } + return String.join("\n", method.getAnnotation(Insert.class).value()); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java new file mode 100644 index 000000000..4dcf91f3b --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java @@ -0,0 +1,37 @@ +package com.baiyue.firstleg.repository; + +import com.baiyue.firstleg.mapper.SmartLoadingCandidateMapper; +import org.apache.ibatis.annotations.Select; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; + +import static org.assertj.core.api.Assertions.assertThat; + +class SmartLoadingCandidateMapperContractTest { + @Test + void selectsLatestApprovedBatch() throws Exception { + Method list = SmartLoadingCandidateMapper.class.getDeclaredMethod("candidates", long.class); + String sql = String.join(" ", list.getAnnotation(Select.class).value()); + + assertThat(sql) + .contains("o.destination_warehouse_id destinationWarehouseId") + .contains("m.status='APPROVED'") + .contains("SELECT MAX(m2.id)") + .contains("m2.status='APPROVED'") + .contains("i.inbound_detail_id IS NOT NULL") + .contains("ORDER BY o.inbounded_at ASC, o.id ASC, i.id ASC"); + } + + @Test + void detailUsesSameBatchRule() throws Exception { + Method detail = SmartLoadingCandidateMapper.class.getDeclaredMethod( + "candidate", long.class, long.class, long.class); + String sql = String.join(" ", detail.getAnnotation(Select.class).value()); + + assertThat(sql) + .contains("o.destination_warehouse_id destinationWarehouseId") + .contains("m.status='APPROVED'") + .contains("SELECT MAX(m2.id)"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java new file mode 100644 index 000000000..3d278e8e2 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java @@ -0,0 +1,176 @@ +package com.baiyue.firstleg.repository; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("智能装柜SQL契约") +class SmartLoadingSqlContractTest { + private static final Pattern PERMISSION_PATTERN = Pattern.compile("'((?:platform:firstleg:)[^']+)'"); + private static final Set PHASE3_PERMISSIONS = Set.of( + "platform:firstleg:container:list", "platform:firstleg:container:view", + "platform:firstleg:container:create", "platform:firstleg:container:edit", + "platform:firstleg:smart-loading:list", "platform:firstleg:smart-loading:create", + "platform:firstleg:smart-loading:assign", "platform:firstleg:smart-loading:delete", + "platform:firstleg:smart-loading:push", "platform:firstleg:preload:list", + "platform:firstleg:preload:view", "platform:firstleg:preload:edit", + "platform:firstleg:preload:complete", "platform:firstleg:preload:confirm", + "platform:firstleg:loading-list:list", "platform:firstleg:loading-list:view"); + + @Test + @DisplayName("平台聚合与客户明细边界及原型快照字段完整") + void shouldProvidePlatformAggregatesAndAccountScopedSnapshots() throws Exception { + assertThat(migration()) + .contains("CREATE TABLE IF NOT EXISTS fl_container_booking") + .contains("CREATE TABLE IF NOT EXISTS fl_loading_plan") + .contains("CREATE TABLE IF NOT EXISTS fl_loading_plan_item") + .contains("CREATE TABLE IF NOT EXISTS fl_container_document") + .contains("CREATE TABLE IF NOT EXISTS fl_loading_plan_history") + .contains("account_id BIGINT UNSIGNED NOT NULL") + .contains("customer_name VARCHAR(128) NOT NULL") + .contains("warehouse_id BIGINT UNSIGNED NOT NULL") + .contains("cabinet_no VARCHAR(64) NOT NULL") + .contains("bill_of_lading_no VARCHAR(128)") + .contains("destination_warehouse_name VARCHAR(128)") + .contains("product_name_zh VARCHAR(255)") + .contains("product_name_en VARCHAR(255)") + .contains("product_image_file_id BIGINT UNSIGNED") + .contains("pallet_count INT UNSIGNED") + .contains("net_weight_kg DECIMAL(18,3)") + .contains("dimensional_weight_kg DECIMAL(18,3)") + .contains("product_quantity INT UNSIGNED") + .contains("units_per_carton INT UNSIGNED") + .contains("fba_tracking_no VARCHAR(128)") + .contains("shipment_no VARCHAR(128)") + .contains("shipping_mark VARCHAR(500)") + .contains("internal_remark VARCHAR(1000)") + .contains("customer_remark VARCHAR(1000)") + .contains("warehouse_remark VARCHAR(1000)"); + } + + @Test + @DisplayName("活动明细唯一锁、乐观锁和双层幂等字段完整") + void shouldProvideConcurrencyAndIdempotencyBoundaries() throws Exception { + assertThat(migration()) + .contains("active_inbound_detail_id BIGINT UNSIGNED GENERATED ALWAYS AS") + .contains("CASE WHEN deleted = 0 THEN inbound_detail_id ELSE NULL END") + .contains("UNIQUE KEY uk_loading_item_active_detail (account_id, active_inbound_detail_id)") + .contains("UNIQUE KEY uk_container_booking_no (booking_no)") + .contains("UNIQUE KEY uk_container_booking_cabinet (cabinet_no)") + .contains("UNIQUE KEY uk_loading_plan_push_key (push_idempotency_key)") + .contains("UNIQUE KEY uk_loading_history_operation (operation_id)") + .contains("version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'"); + } + + @Test + @DisplayName("平台菜单按钮权限使用统一前缀并授权运营角色") + void shouldProvidePlatformPermissions() throws Exception { + assertThat(migration()) + .contains("platform:firstleg:container:view") + .contains("platform:firstleg:smart-loading:push") + .contains("platform:firstleg:preload:confirm") + .contains("platform:firstleg:loading-list:view") + .contains("role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR')") + .contains("智能装柜迁移失败:平台权限资源未完整创建") + .contains("智能装柜迁移失败:平台角色授权未完整写入"); + } + + @Test + @DisplayName("模块脚本和统一脚本均校验16项权限与两个角色32项授权") + void shouldAssertCompletePermissionGrants() throws Exception { + assertPermissionContract(migration()); + assertPermissionContract(unifiedMigration()); + assertThat(unifiedMigration()).contains(migration().trim()); + } + + @Test + @DisplayName("计划状态只允许编辑中、装柜中和已完成单向推进") + void shouldEnforceOneWayStatusAndPermanentFreeze() throws Exception { + assertThat(migration()) + .contains("CHECK (plan_status IN ('EDITING', 'LOADING', 'COMPLETED'))") + .contains("OLD.plan_status = 'EDITING' AND NEW.plan_status IN ('EDITING', 'LOADING')") + .contains("NEW.plan_status IN ('LOADING', 'COMPLETED')") + .contains("SET MESSAGE_TEXT = '装柜状态只允许单向推进'") + .contains("IF OLD.plan_status = 'EDITING' AND NEW.plan_status = 'LOADING' THEN") + .contains("ELSEIF OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' THEN") + .contains("LEFT JOIN fl_loading_pick_item pick_item") + .contains("pick_item.picked_package_count <> pick_item.planned_package_count") + .contains("IF OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' THEN") + .contains("SET frozen_at = COALESCE(frozen_at, NEW.confirmed_at, CURRENT_TIMESTAMP(3))") + .contains("已有PDA拣货记录的装柜明细不可修改或删除") + .contains("装柜完成后的明细永久冻结") + .contains("装柜中仅允许修改集装箱号与航次") + .contains("装柜完成后货柜全部只读,不允许修改") + .doesNotContain("preload_status") + .doesNotContain("PRELOAD_PUSHED") + .doesNotContain("PRELOADING") + .doesNotContain("PRELOAD_COMPLETED") + .doesNotContain("CONFIRMED") + .doesNotContain("ROLLED_BACK") + .doesNotContain("WITHDRAWN"); + } + + @Test + @DisplayName("PDA三表、唯一索引、向上取整和不可变审计契约完整") + void shouldProvidePdaContract() throws Exception { + assertThat(migration()) + .contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_task") + .contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_item") + .contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_history") + .contains("UNIQUE KEY uk_loading_pick_task_plan (loading_plan_id)") + .contains("UNIQUE KEY uk_loading_pick_item_plan_item (loading_plan_item_id)") + .contains("UNIQUE KEY uk_loading_pick_history_operation (operation_id)") + .contains("上层拆分按业务规则向上取整") + .contains("CONSTRAINT chk_loading_pick_item_planned CHECK (planned_package_count > 0)") + .contains("trg_loading_pick_task_before_update") + .contains("trg_loading_pick_item_before_update") + .contains("trg_loading_pick_item_before_delete") + .contains("trg_loading_pick_history_before_update") + .contains("trg_loading_pick_history_before_delete") + .contains("PDA唯一索引缺失") + .contains(")) <> 16 THEN"); + } + + private static String migration() throws Exception { + return new ClassPathResource("db/phase3-smart-loading-schema.sql") + .getContentAsString(StandardCharsets.UTF_8); + } + + private static String unifiedMigration() throws Exception { + Path working = Path.of("").toAbsolutePath(); + Path file = working.resolve("deploy/sql/头程数据库统一增量-MySQL8.sql"); + if (!Files.exists(file)) { + file = working.resolve("../deploy/sql/头程数据库统一增量-MySQL8.sql").normalize(); + } + return Files.readString(file, StandardCharsets.UTF_8); + } + + private static void assertPermissionContract(String sql) { + int message = sql.indexOf("智能装柜迁移失败:平台角色授权未完整写入"); + assertThat(message).isGreaterThan(0); + + int grantStart = sql.lastIndexOf("INSERT IGNORE INTO iam_role_permission", message); + int grantEnd = sql.indexOf(';', grantStart); + assertThat(permissions(sql.substring(grantStart, grantEnd))).isEqualTo(PHASE3_PERMISSIONS); + + int assertionStart = sql.lastIndexOf("IF (SELECT COUNT(DISTINCT CONCAT", message); + String assertion = sql.substring(assertionStart, message); + assertThat(permissions(assertion)).isEqualTo(PHASE3_PERMISSIONS); + assertThat(assertion).contains("<> 32 THEN"); + } + + private static Set permissions(String sql) { + Matcher matcher = PERMISSION_PATTERN.matcher(sql); + return matcher.results().map(result -> result.group(1)).collect(Collectors.toSet()); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java new file mode 100644 index 000000000..6979c2053 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java @@ -0,0 +1,148 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest; +import com.baiyue.firstleg.dto.ContainerDtos.SaveRequest; +import com.baiyue.firstleg.dto.ContainerDtos.TransportUpdateRequest; +import com.baiyue.firstleg.entity.ContainerBookingEntity; +import com.baiyue.firstleg.repository.ContainerRepository; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@DisplayName("货柜基础资料服务") +@SpringJUnitConfig(ContainerService.class) +class ContainerServiceTest { + @MockBean private ContainerRepository repository; + @Resource private ContainerService service; + + @Test + @DisplayName("新增货柜保存最终数据库契约字段并生成平台预订单号") + void createsContainerBooking() { + ContainerBookingEntity created = service.create(9L, "admin", request("柜-001")); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ContainerBookingEntity.class); + verify(repository).insert(captor.capture()); + ContainerBookingEntity saved = captor.getValue(); + assertThat(created).isSameAs(saved); + assertThat(saved.getBookingNo()).startsWith("CB"); + assertThat(saved.getCabinetNo()).isEqualTo("柜-001"); + assertThat(saved.getDeparturePortCode()).isEqualTo("CNSHA"); + assertThat(saved.getCapacityCbm()).isEqualByComparingTo("68.0000"); + assertThat(saved.getPlanStatus()).isEqualTo("EDITING"); + } + + @Test + @DisplayName("智能装柜草稿阶段允许完整编辑") + void updatesAllFieldsWhileEditing() { + ContainerBookingEntity current = booking("EDITING", 2); + when(repository.find(11L)).thenReturn(current); + when(repository.updateDraft(any(), org.mockito.ArgumentMatchers.eq(2), + org.mockito.ArgumentMatchers.eq(9L), org.mockito.ArgumentMatchers.eq("admin"))).thenReturn(1); + + service.updateDraft(11L, 9L, "admin", new FullUpdateRequest(2, request("柜-NEW"))); + + assertThat(current.getCabinetNo()).isEqualTo("柜-NEW"); + verify(repository).updateDraft(current, 2, 9L, "admin"); + } + + @Test + @DisplayName("进入预装柜中后拒绝完整编辑") + void rejectsFullUpdateAfterPreloadPush() { + when(repository.find(11L)).thenReturn(booking("LOADING", 2)); + + assertThatThrownBy(() -> service.updateDraft( + 11L, 9L, "admin", new FullUpdateRequest(2, request("柜-NEW")))) + .isInstanceOf(BizException.class) + .hasMessageContaining("只能修改集装箱号和航次"); + + verify(repository, never()).updateDraft(any(), any(Integer.class), + any(Long.class), any(String.class)); + } + + @Test + @DisplayName("预装柜中和预装柜完成只更新集装箱号与航次") + void updatesTransportWhitelistAfterPush() { + for (String status : new String[] {"LOADING"}) { + ContainerBookingEntity current = booking(status, 2); + when(repository.find(11L)).thenReturn(current); + when(repository.updateTransport(11L, "MSCU1234567", "V002", 2, 9L, "admin")) + .thenReturn(1); + + service.updateTransport(11L, 9L, "admin", + new TransportUpdateRequest("MSCU1234567", "V002", 2)); + } + + verify(repository, org.mockito.Mockito.times(1)) + .updateTransport(11L, "MSCU1234567", "V002", 2, 9L, "admin"); + } + + @Test + @DisplayName("已确认货柜全部只读") + void rejectsConfirmedUpdate() { + when(repository.find(11L)).thenReturn(booking("COMPLETED", 2)); + + assertThatThrownBy(() -> service.updateTransport(11L, 9L, "admin", + new TransportUpdateRequest("MSCU1234567", "V002", 2))) + .isInstanceOf(BizException.class) + .hasMessageContaining("全部只读"); + + verify(repository, never()).updateTransport(any(Long.class), any(), any(), + any(Integer.class), any(Long.class), any()); + } + + @Test + @DisplayName("旧版本编辑请求触发乐观锁冲突") + void rejectsStaleVersion() { + when(repository.find(11L)).thenReturn(booking("EDITING", 3)); + + assertThatThrownBy(() -> service.updateDraft( + 11L, 9L, "admin", new FullUpdateRequest(2, request("柜-NEW")))) + .isInstanceOf(BizException.class) + .hasMessageContaining("刷新后重试"); + } + + @Test + @DisplayName("新增时校验重复集装箱号和内部柜号") + void rejectsDuplicateNumbers() { + when(repository.containerNoExists("MSCU1234567", null)).thenReturn(true); + + assertThatThrownBy(() -> service.create(9L, "admin", request("柜-001"))) + .isInstanceOf(BizException.class) + .hasMessageContaining("集装箱号已存在"); + + verify(repository, never()).insert(any()); + } + + private static ContainerBookingEntity booking(String status, int version) { + ContainerBookingEntity value = new ContainerBookingEntity(); + value.setId(11L); + value.setContainerNo("MSCU1234567"); + value.setCabinetNo("柜-001"); + value.setPlanStatus(status); + value.setVersion(version); + return value; + } + + private static SaveRequest request(String cabinetNo) { + return new SaveRequest("MSCU1234567", cabinetNo, 101L, "上海仓", "BOL-001", "柏悦轮", + "V001", "40HQ", 201L, "船司A", "CNSHA", "上海港", "USLAX", "洛杉矶港", + "ONT8", "ONT8", new BigDecimal("68.0000"), new BigDecimal("26000.000"), "拖车公司A", + LocalDateTime.of(2026, 8, 10, 8, 0), LocalDateTime.of(2026, 8, 12, 8, 0), null, + LocalDateTime.of(2026, 8, 25, 8, 0), null, + LocalDateTime.of(2026, 8, 27, 8, 0), null); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java new file mode 100644 index 000000000..7c72b7501 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java @@ -0,0 +1,162 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.dto.LoadingListDtos.AdvanceRequest; +import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedQuery; +import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery; +import com.baiyue.firstleg.dto.LoadingListDtos.TransportUpdateRequest; +import com.baiyue.firstleg.mapper.LoadingListQueryMapper.AggregateRow; +import com.baiyue.firstleg.mapper.LoadingListQueryMapper.HistoryRow; +import com.baiyue.firstleg.repository.LoadingListRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Bean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@DisplayName("预装与装柜清单状态服务") +@SpringJUnitConfig(classes = {LoadingListService.class, LoadingListServiceTest.Config.class}) +class LoadingListServiceTest { + @MockBean + private LoadingListRepository repository; + @Resource + private LoadingListService service; + + @TestConfiguration + static class Config { + @Bean + ObjectMapper objectMapper() { + return new ObjectMapper(); + } + } + + @Test + void shouldRejectEditingStatusFromPreloadList() { + PreloadQuery query = new PreloadQuery(null, "EDITING", null, null, null, null, 1, 20); + + assertThatThrownBy(() -> service.preloadPage(query)) + .isInstanceOf(BizException.class) + .extracting(error -> ((BizException) error).getResultCode()) + .isEqualTo(ResultCode.BAD_REQUEST); + } + + @Test + void shouldOnlyUpdateTransportFieldsWithOptimisticLockAndHistory() { + AggregateRow before = row("LOADING", 3); + before.setContainerNo("OLD"); + before.setVoyageNo("001E"); + AggregateRow after = row("LOADING", 4); + after.setContainerNo("CSLU1234567"); + after.setVoyageNo("023E"); + when(repository.history(anyString())).thenReturn(null); + when(repository.aggregate(10L)).thenReturn(before, after); + when(repository.updateTransport(10L, 3, "CSLU1234567", "023E", 9L, "operator")) + .thenReturn(1); + when(repository.preloadDetail(after)).thenReturn( + new com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail(null, List.of(), List.of())); + + service.updateTransport(10L, 9L, "operator", "key-1", + new TransportUpdateRequest(" CSLU1234567 ", "023E", 3, "补录箱号")); + + verify(repository).updateTransport(10L, 3, "CSLU1234567", "023E", 9L, "operator"); + verify(repository).appendHistory(eq(10L), anyString(), eq("UPDATE_PRELOAD_TRANSPORT"), + eq("LOADING"), eq("LOADING"), anyString(), anyString(), eq(9L), + eq("operator"), eq("补录箱号")); + } + + @Test + void shouldRejectTransportUpdateAfterConfirmation() { + when(repository.history(anyString())).thenReturn(null); + when(repository.aggregate(10L)).thenReturn(row("COMPLETED", 5)); + + assertThatThrownBy(() -> service.updateTransport(10L, 9L, "operator", "key-1", + new TransportUpdateRequest("CSLU1234567", "023E", 5, null))) + .isInstanceOf(BizException.class) + .extracting(error -> ((BizException) error).getResultCode()) + .isEqualTo(ResultCode.CONFLICT); + verify(repository, never()).updateTransport(eq(10L), eq(5), anyString(), anyString(), eq(9L), anyString()); + } + + @Test + void shouldAdvanceOnlyLoadingToCompleted() { + AggregateRow before = row("LOADING", 2); + AggregateRow after = row("COMPLETED", 3); + when(repository.history(anyString())).thenReturn(null); + when(repository.aggregate(10L)).thenReturn(before, after); + when(repository.advance(10L, "LOADING", "COMPLETED", 2, 9L, "operator")) + .thenReturn(1); + when(repository.preloadDetail(after)).thenReturn( + new com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail(null, List.of(), List.of())); + + service.complete(10L, 9L, "operator", "complete-key", new AdvanceRequest(2, null)); + + verify(repository).advance(10L, "LOADING", "COMPLETED", 2, 9L, "operator"); + } + + @Test + void shouldReplaySameTransportRequestWithoutSecondMutation() { + AggregateRow before = row("LOADING", 3); + AggregateRow after = row("LOADING", 4); + after.setContainerNo("CSLU1234567"); + after.setVoyageNo("023E"); + when(repository.history(anyString())).thenReturn(null); + when(repository.aggregate(10L)).thenReturn(before, after); + when(repository.updateTransport(10L, 3, "CSLU1234567", "023E", 9L, "operator")) + .thenReturn(1); + when(repository.preloadDetail(after)).thenReturn( + new com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail(null, List.of(), List.of())); + ArgumentCaptor snapshot = ArgumentCaptor.forClass(String.class); + + TransportUpdateRequest request = new TransportUpdateRequest("CSLU1234567", "023E", 3, null); + service.updateTransport(10L, 9L, "operator", "key-1", request); + verify(repository).appendHistory(eq(10L), anyString(), eq("UPDATE_PRELOAD_TRANSPORT"), + eq("LOADING"), eq("LOADING"), anyString(), snapshot.capture(), eq(9L), + eq("operator"), eq(null)); + HistoryRow replay = new HistoryRow(); + replay.setAfterSnapshot(snapshot.getValue()); + when(repository.history(anyString())).thenReturn(replay); + when(repository.aggregate(10L)).thenReturn(after); + + service.updateTransport(10L, 9L, "operator", "key-1", request); + + verify(repository, times(1)).updateTransport(10L, 3, "CSLU1234567", "023E", 9L, "operator"); + } + + @Test + void confirmedPageAlwaysUsesRepositoryConfirmedProjection() { + ConfirmedQuery query = new ConfirmedQuery(" plan ", null, null, null, null, 1, 20); + when(repository.confirmedPage(org.mockito.ArgumentMatchers.any())).thenReturn( + new PageResult<>(List.of(), 1, 20, 0, 0)); + + service.confirmedPage(query); + + verify(repository).confirmedPage(org.mockito.ArgumentMatchers.argThat(value -> + "plan".equals(value.keyword()))); + } + + private static AggregateRow row(String status, int version) { + AggregateRow row = new AggregateRow(); + row.setPlanId(10L); + row.setPlanNo("PL-10"); + row.setPlanStatus(status); + row.setVersion(version); + return row; + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementLocationOccupationServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementLocationOccupationServiceTest.java index 29c1676bb..a916ec621 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementLocationOccupationServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementLocationOccupationServiceTest.java @@ -72,6 +72,7 @@ class MeasurementLocationOccupationServiceTest { org.mockito.ArgumentMatchers.eq(WAREHOUSE), org.mockito.ArgumentMatchers.eq(PLATFORM_RESOURCE_TENANT), org.mockito.ArgumentMatchers.eq(new BigDecimal("1.200000")), + org.mockito.ArgumentMatchers.eq(20), org.mockito.ArgumentMatchers.any()); } @@ -102,6 +103,7 @@ class MeasurementLocationOccupationServiceTest { org.mockito.ArgumentMatchers.eq(WAREHOUSE), org.mockito.ArgumentMatchers.eq(PLATFORM_RESOURCE_TENANT), org.mockito.ArgumentMatchers.eq(new BigDecimal("1.200000")), + org.mockito.ArgumentMatchers.eq(20), org.mockito.ArgumentMatchers.any()); } @@ -126,7 +128,7 @@ class MeasurementLocationOccupationServiceTest { assertThat(release.getValue().tenantId()).isEqualTo(PLATFORM_RESOURCE_TENANT); assertThat(release.getValue().businessId()).isEqualTo(INBOUND); verify(allocations).replace(BUSINESS_TENANT, INBOUND, WAREHOUSE, PLATFORM_RESOURCE_TENANT, - new BigDecimal("1.200000"), batch("RESERVED")); + new BigDecimal("1.200000"), 20, batch("RESERVED")); } private static InboundProperties properties() { @@ -150,6 +152,7 @@ class MeasurementLocationOccupationServiceTest { value.setWarehouseId(WAREHOUSE); value.setInboundCode("RK-001"); value.setActualVolumeCbm(new BigDecimal("1.0000")); + value.setTotalPackageCount(20); return value; } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java new file mode 100644 index 000000000..0a780ecab --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java @@ -0,0 +1,349 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.client.OrderCargoClient; +import com.baiyue.firstleg.dto.SmartLoadingDtos.AssignRequest; +import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; +import com.baiyue.firstleg.dto.SmartLoadingDtos.DeleteRequest; +import com.baiyue.firstleg.dto.SmartLoadingDtos.Plan; +import com.baiyue.firstleg.dto.SmartLoadingDtos.PushRequest; +import com.baiyue.firstleg.dto.SmartLoadingDtos.RecommendRequest; +import com.baiyue.firstleg.entity.ContainerBookingEntity; +import com.baiyue.firstleg.entity.LoadingPlanEntity; +import com.baiyue.firstleg.entity.LoadingPlanHistoryEntity; +import com.baiyue.firstleg.entity.LoadingPlanItemEntity; +import com.baiyue.firstleg.repository.SmartLoadingRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import feign.FeignException; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Bean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.HexFormat; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowableOfType; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@SpringJUnitConfig(SmartLoadingServiceTest.Config.class) +class SmartLoadingServiceTest { + @MockBean private SmartLoadingRepository repository; + @MockBean private OrderCargoClient cargoClient; + @MockBean private LoadingPickService loadingPickService; + @Resource private SmartLoadingService service; + + @TestConfiguration + static class Config { + @Bean SmartLoadingService smartLoadingService() { return new SmartLoadingService(); } + @Bean ObjectMapper objectMapper() { return new ObjectMapper(); } + } + + @BeforeEach + void setUp() { + when(cargoClient.detail(anyString(), anyLong(), anyLong(), anyLong())).thenReturn( + ApiResponse.success(new OrderCargoClient.CargoBinding(20L, 30L, 1, "货物", 5, + new BigDecimal("10"), new BigDecimal("2")))); + } + + @Test + void restoresDrafts() { + LoadingPlanEntity plan = plan("EDITING", 2); + when(repository.booking(99L)).thenReturn(booking()); + when(repository.planByBooking(99L)).thenReturn(plan); + when(repository.plan(1L)).thenReturn(plan); + when(repository.items(1L)).thenReturn(List.of(item(8, 9, 20, 1))); + + assertThat(service.draftByBooking(99L).id()).isEqualTo(1L); + assertThat(service.detail(1L).bookingId()).isEqualTo(99L); + } + + @Test + void returnsExistingDraftOnCreate() { + LoadingPlanEntity plan = plan("EDITING", 2); + when(repository.booking(99L)).thenReturn(booking()); + when(repository.planByBooking(99L)).thenReturn(plan); + when(repository.items(1L)).thenReturn(List.of()); + + Plan result = service.create(99L, "备注", 7L, "u"); + + assertThat(result.id()).isEqualTo(1L); + verify(repository, never()).insert(any(LoadingPlanEntity.class)); + } + + @Test + void rejectsExcessBoxes() { + LoadingPlanEntity plan = plan("EDITING", 0); + when(repository.plan(1)).thenReturn(plan); + when(repository.candidate(9, 10, 20)).thenReturn(candidate(5)); + when(repository.activeItem(9, 20)).thenReturn(item(7, 9, 20, 4)); + + BizException error = catchThrowableOfType(() -> service.assign(1, + new AssignRequest(9L, 10L, 20L, 2, 0, "assign-1"), 7, "u"), BizException.class); + + assertThat(error.getResultCode()).isEqualTo(ResultCode.VALIDATION_FAILED); + verify(repository, never()).touch(anyLong(), anyInt(), anyLong(), any()); + } + + @Test + void snapshotsCargoFields() { + stubAssignment(); + when(cargoClient.detail(anyString(), eq(9L), eq(30L), eq(20L))).thenReturn(ApiResponse.success( + new OrderCargoClient.CargoBinding(20L, 30L, 1, "中文品名", 10, + new BigDecimal("20"), new BigDecimal("2"), "SKU-1", "English name", + 4, 100, new BigDecimal("10"), "货物备注"))); + doAnswer(invocation -> { + Object value = invocation.getArgument(0); + if (value instanceof LoadingPlanItemEntity item) item.setId(88L); + return null; + }).when(repository).insert(any(LoadingPlanItemEntity.class)); + + service.assign(1, new AssignRequest(9L, 10L, 20L, 5, 0, "assign-snapshot"), 7, "u"); + + ArgumentCaptor itemCaptor = ArgumentCaptor.forClass(LoadingPlanItemEntity.class); + verify(repository).insert(itemCaptor.capture()); + LoadingPlanItemEntity saved = itemCaptor.getValue(); + assertThat(saved.getDestinationWarehouseId()).isEqualTo(200L); + assertThat(saved.getSkuCode()).isEqualTo("SKU-1"); + assertThat(saved.getProductNameEn()).isEqualTo("English name"); + assertThat(saved.getUnitsPerCarton()).isEqualTo(4); + assertThat(saved.getProductQuantity()).isEqualTo(20); + assertThat(saved.getNetWeightKg()).isEqualByComparingTo("5"); + assertThat(saved.getInternalRemark()).isEqualTo("货物备注"); + + ArgumentCaptor historyCaptor = ArgumentCaptor.forClass(LoadingPlanHistoryEntity.class); + verify(repository).insert(historyCaptor.capture()); + assertThat(historyCaptor.getValue().getLoadingPlanItemId()).isEqualTo(88L); + } + + @Test + void roundsQuantityWithoutLoss() { + stubAssignment(); + when(cargoClient.detail(anyString(), anyLong(), anyLong(), anyLong())).thenReturn(ApiResponse.success( + new OrderCargoClient.CargoBinding(20L, 30L, 1, "货物", 3, + BigDecimal.ONE, BigDecimal.ONE, null, null, null, 100, null, null))); + + service.assign(1, new AssignRequest(9L, 10L, 20L, 2, 0, "assign-round"), 7, "u"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(LoadingPlanItemEntity.class); + verify(repository).insert(captor.capture()); + assertThat(captor.getValue().getProductQuantity()).isEqualTo(67); + } + + @Test + void replaysBeforeVersionCheck() { + AssignRequest request = new AssignRequest(9L, 10L, 20L, 2, 0, "assign-replay"); + LoadingPlanHistoryEntity replay = history(1, null, "ADD_ITEM", + sha256("ASSIGN|1|9|10|20|2|0")); + LoadingPlanEntity frozen = plan("LOADING", 9); + when(repository.operation("assign-replay")).thenReturn(replay); + when(repository.plan(1)).thenReturn(frozen); + when(repository.booking(99)).thenReturn(booking()); + when(repository.items(1)).thenReturn(List.of()); + + Plan result = service.assign(1, request, 7, "u"); + + assertThat(result.status()).isEqualTo("LOADING"); + verify(repository, never()).candidate(anyLong(), anyLong(), anyLong()); + verify(cargoClient, never()).detail(anyString(), anyLong(), anyLong(), anyLong()); + } + + @Test + void rejectsReusedOperation() { + AssignRequest request = new AssignRequest(9L, 10L, 20L, 2, 0, "shared-op"); + when(repository.operation("shared-op")).thenReturn(history(2, null, "ADD_ITEM", + sha256("ASSIGN|1|9|10|20|2|0"))); + + BizException error = catchThrowableOfType(() -> service.assign(1, request, 7, "u"), BizException.class); + + assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT); + } + + @Test + void replaysRecommendation() { + RecommendRequest request = new RecommendRequest(9L, 0, "recommend-replay"); + when(repository.operation("recommend-replay")).thenReturn(history(1, null, + "SMART_RECOMMEND", sha256("RECOMMEND|1|9|0"))); + when(repository.plan(1)).thenReturn(plan("LOADING", 8)); + when(repository.booking(99)).thenReturn(booking()); + when(repository.items(1)).thenReturn(List.of()); + + Plan result = service.recommend(1, request, 7, "u"); + + assertThat(result.status()).isEqualTo("LOADING"); + verify(repository, never()).candidates(anyLong()); + } + + @Test + void replaysDeleteBeforeVersionCheck() { + DeleteRequest request = new DeleteRequest(9L, 2, 0, "delete-replay"); + when(repository.operation("delete-replay")).thenReturn(history(1, 8L, + "REMOVE_ITEM", sha256("DELETE|1|8|9|2|0"))); + when(repository.plan(1)).thenReturn(plan("LOADING", 7)); + when(repository.booking(99)).thenReturn(booking()); + when(repository.items(1)).thenReturn(List.of()); + + Plan result = service.delete(1, 8, request, 7, "u"); + + assertThat(result.status()).isEqualTo("LOADING"); + verify(repository, never()).item(anyLong(), anyLong()); + verify(repository, never()).remove(anyLong(), anyLong(), anyInt(), anyLong(), any()); + } + + @Test + void deleteTouchesPlan() { + LoadingPlanEntity plan = plan("EDITING", 2); + LoadingPlanItemEntity item = item(8, 9, 20, 1); + when(repository.plan(1)).thenReturn(plan, plan("EDITING", 3)); + when(repository.item(1, 8)).thenReturn(item); + when(repository.touch(1, 2, 7, "u")).thenReturn(1); + when(repository.remove(1, 8, 0, 7, "u")).thenReturn(1); + when(repository.booking(99)).thenReturn(booking()); + when(repository.items(1)).thenReturn(List.of()); + + Plan result = service.delete(1, 8, new DeleteRequest(9L, 2, 0, "delete-1"), 7, "u"); + + assertThat(result.version()).isEqualTo(3); + verify(repository).touch(1, 2, 7, "u"); + } + + @Test + void preservesDownstreamCode() { + stubAssignment(); + when(cargoClient.detail(anyString(), anyLong(), anyLong(), anyLong())).thenReturn( + ApiResponse.failure(ResultCode.NOT_FOUND, "货物不存在", null, null)); + + BizException error = catchThrowableOfType(() -> service.assign(1, + new AssignRequest(9L, 10L, 20L, 2, 0, "assign-not-found"), 7, "u"), BizException.class); + + assertThat(error.getResultCode()).isEqualTo(ResultCode.NOT_FOUND); + } + + @Test + void mapsFeignFailure() { + stubAssignment(); + when(cargoClient.detail(anyString(), anyLong(), anyLong(), anyLong())) + .thenThrow(mock(FeignException.class)); + + BizException error = catchThrowableOfType(() -> service.assign(1, + new AssignRequest(9L, 10L, 20L, 2, 0, "assign-down"), 7, "u"), BizException.class); + + assertThat(error.getResultCode()).isEqualTo(ResultCode.SERVICE_UNAVAILABLE); + } + + @Test + void rejectsCargoMismatch() { + stubAssignment(); + when(cargoClient.detail(anyString(), anyLong(), anyLong(), anyLong())).thenReturn(ApiResponse.success( + new OrderCargoClient.CargoBinding(99L, 30L, 1, "货物", 5, BigDecimal.ONE, BigDecimal.ONE))); + + BizException error = catchThrowableOfType(() -> service.assign(1, + new AssignRequest(9L, 10L, 20L, 2, 0, "assign-mismatch"), 7, "u"), BizException.class); + + assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT); + } + + @Test + void rejectsWrongAccountDelete() { + LoadingPlanEntity plan = plan("EDITING", 2); + when(repository.plan(1)).thenReturn(plan); + when(repository.item(1, 8)).thenReturn(item(8, 22, 20, 1)); + + BizException error = catchThrowableOfType(() -> service.delete(1, 8, + new DeleteRequest(9L, 2, 0, "delete-owner"), 7, "u"), BizException.class); + + assertThat(error.getResultCode()).isEqualTo(ResultCode.FORBIDDEN); + verify(repository, never()).remove(anyLong(), anyLong(), anyInt(), anyLong(), any()); + } + + @Test + void pushesIdempotently() { + LoadingPlanEntity editing = plan("EDITING", 3); + LoadingPlanEntity pushed = plan("LOADING", 4); + pushed.setPushIdempotencyKey("key"); + when(repository.plan(1)).thenReturn(editing, pushed); + when(repository.items(1)).thenReturn(List.of(item(8, 9, 20, 1))); + when(repository.booking(99)).thenReturn(booking()); + when(repository.push(1, "key", 3, 7, "u")).thenReturn(1); + + Plan result = service.pushPreload(1, "key", new PushRequest(3), 7, "u"); + + assertThat(result.status()).isEqualTo("LOADING"); + verify(repository).push(1, "key", 3, 7, "u"); + } + + private void stubAssignment() { + LoadingPlanEntity plan = plan("EDITING", 0); + when(repository.plan(1)).thenReturn(plan, plan("EDITING", 1)); + when(repository.booking(99)).thenReturn(booking()); + when(repository.items(1)).thenReturn(List.of()); + when(repository.candidate(9, 10, 20)).thenReturn(candidate(10)); + when(repository.activeItem(9, 20)).thenReturn(null); + when(repository.touch(1, 0, 7, "u")).thenReturn(1); + } + + private static LoadingPlanHistoryEntity history(long planId, Long itemId, String action, String hash) { + LoadingPlanHistoryEntity value = new LoadingPlanHistoryEntity(); + value.setLoadingPlanId(planId); + value.setLoadingPlanItemId(itemId); + value.setActionType(action); + value.setAfterSnapshot("{\"requestHash\":\"" + hash + "\"}"); + return value; + } + + private static String sha256(String value) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8))); + } catch (Exception exception) { + throw new IllegalStateException(exception); + } + } + + private static LoadingPlanEntity plan(String status, int version) { + LoadingPlanEntity value = new LoadingPlanEntity(); + value.setId(1L); value.setContainerBookingId(99L); value.setPlanNo("LP1"); + value.setPlanStatus(status); value.setVersion(version); + return value; + } + + private static LoadingPlanItemEntity item(long id, long account, long detail, int boxes) { + LoadingPlanItemEntity value = new LoadingPlanItemEntity(); + value.setId(id); value.setLoadingPlanId(1L); value.setAccountId(account); + value.setInboundDetailId(detail); value.setPackageCount(boxes); + value.setVersion(0); value.setDeleted(false); + return value; + } + + private static Candidate candidate(int boxes) { + return new Candidate(9L, 10L, 20L, 30L, "O", "I", 100L, 200L, + "FBA1", "FBA仓", "客户", "货物", boxes, new BigDecimal("2"), new BigDecimal("0.5")); + } + + private static ContainerBookingEntity booking() { + ContainerBookingEntity value = new ContainerBookingEntity(); + value.setId(99L); value.setWarehouseId(100L); value.setDestinationWarehouseCode("FBA1"); + value.setContainerNo("MSCU1234567"); + value.setCapacityCbm(new BigDecimal("60")); value.setMaxWeightKg(new BigDecimal("20000")); + return value; + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingStatusTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingStatusTest.java new file mode 100644 index 000000000..9b79ccb0a --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingStatusTest.java @@ -0,0 +1,19 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.firstleg.domain.SmartLoadingStatus; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class SmartLoadingStatusTest { + @Test + void statusCanOnlyAdvanceOneWay() { + assertThat(SmartLoadingStatus.EDITING.canAdvanceTo(SmartLoadingStatus.LOADING)).isTrue(); + assertThat(SmartLoadingStatus.LOADING.canAdvanceTo(SmartLoadingStatus.COMPLETED)).isTrue(); + assertThat(SmartLoadingStatus.COMPLETED.canAdvanceTo(SmartLoadingStatus.LOADING)).isFalse(); + assertThat(SmartLoadingStatus.EDITING.mutable()).isTrue(); + assertThat(SmartLoadingStatus.LOADING.mutable()).isTrue(); + assertThat(SmartLoadingStatus.LOADING.allowsTransportUpdate()).isTrue(); + assertThat(SmartLoadingStatus.COMPLETED.allowsTransportUpdate()).isFalse(); + } +} diff --git a/gateway/src/main/resources/application.yml b/gateway/src/main/resources/application.yml index c3002f435..abacfa7e6 100644 --- a/gateway/src/main/resources/application.yml +++ b/gateway/src/main/resources/application.yml @@ -73,7 +73,7 @@ spring: - id: firstleg-service uri: lb://firstleg-service predicates: - - Path=/api/merchant/v1/first-leg/**,/api/platform/v1/first-leg/**,/api/platform/v1/fba-pricing/**,/api/platform/v1/inbounds/**,/api/platform/v1/fee-items/**,/api/platform/inbounds/**,/openapi/v1/inbound-measurements/** + - Path=/api/merchant/v1/first-leg/**,/api/platform/v1/first-leg/**,/api/platform/v1/containers/**,/api/platform/v1/fba-pricing/**,/api/platform/v1/inbounds/**,/api/platform/v1/fee-items/**,/api/platform/inbounds/**,/openapi/v1/inbound-measurements/** # Swagger 文档聚合路由。 - id: auth-openapi uri: lb://auth-service diff --git a/gateway/src/test/java/com/baiyue/gateway/config/FirstlegContainerRouteContractTest.java b/gateway/src/test/java/com/baiyue/gateway/config/FirstlegContainerRouteContractTest.java new file mode 100644 index 000000000..defdfd285 --- /dev/null +++ b/gateway/src/test/java/com/baiyue/gateway/config/FirstlegContainerRouteContractTest.java @@ -0,0 +1,18 @@ +package com.baiyue.gateway.config; + +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; + +class FirstlegContainerRouteContractTest { + @Test + void routesContainers() throws Exception { + String yaml = new ClassPathResource("application.yml") + .getContentAsString(StandardCharsets.UTF_8); + + assertThat(yaml).contains("/api/platform/v1/containers/**"); + } +} diff --git a/order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java b/order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java index 051fff45e..57fe17a98 100644 --- a/order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java +++ b/order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java @@ -46,7 +46,9 @@ public class InternalOrderCargoController { throw new BizException(ResultCode.NOT_FOUND, "订单货物明细不存在或不属于该订单"); } return ApiResponse.success(new CargoBinding(cargo.getId(), cargo.getOrderId(), cargo.getItemNo(), - cargo.getCargoName(), cargo.getPackageCount(), cargo.getGrossWeightKg(), cargo.getVolumeCbm())); + cargo.getCargoName(), cargo.getPackageCount(), cargo.getGrossWeightKg(), cargo.getVolumeCbm(), + cargo.getSkuNo(), cargo.getCargoNameEn(), cargo.getUnitsPerPackage(), cargo.getTotalQuantity(), + cargo.getNetWeightKg(), cargo.getCargoRemark())); } private void requireToken(String token) { @@ -58,6 +60,14 @@ public class InternalOrderCargoController { public record CargoBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName, Integer originalPackageCount, java.math.BigDecimal originalGrossWeightKg, - java.math.BigDecimal originalVolumeCbm) { + java.math.BigDecimal originalVolumeCbm, String skuNo, String cargoNameEn, + Integer unitsPerPackage, Integer totalQuantity, + java.math.BigDecimal netWeightKg, String cargoRemark) { + public CargoBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName, + Integer originalPackageCount, java.math.BigDecimal originalGrossWeightKg, + java.math.BigDecimal originalVolumeCbm) { + this(cargoId, orderId, itemNo, cargoName, originalPackageCount, + originalGrossWeightKg, originalVolumeCbm, null, null, null, null, null, null); + } } } diff --git a/order/src/test/java/com/baiyue/order/controller/InternalOrderCargoControllerTest.java b/order/src/test/java/com/baiyue/order/controller/InternalOrderCargoControllerTest.java index 9bbfa2589..11405e5a9 100644 --- a/order/src/test/java/com/baiyue/order/controller/InternalOrderCargoControllerTest.java +++ b/order/src/test/java/com/baiyue/order/controller/InternalOrderCargoControllerTest.java @@ -30,6 +30,12 @@ class InternalOrderCargoControllerTest { cargo.setItemNo(1); cargo.setCargoName("测试货物"); cargo.setPackageCount(50); + cargo.setSkuNo("SKU-31"); + cargo.setCargoNameEn("Test cargo"); + cargo.setUnitsPerPackage(4); + cargo.setTotalQuantity(200); + cargo.setNetWeightKg(new java.math.BigDecimal("18.500")); + cargo.setCargoRemark("内部货物备注"); when(orders.findCargo(11L, 21L, 31L)).thenReturn(cargo); var response = controller.detail("internal-token", 11L, 21L, 31L); @@ -37,6 +43,12 @@ class InternalOrderCargoControllerTest { assertThat(response.successful()).isTrue(); assertThat(response.data().cargoId()).isEqualTo(31L); assertThat(response.data().itemNo()).isEqualTo(1); + assertThat(response.data().skuNo()).isEqualTo("SKU-31"); + assertThat(response.data().cargoNameEn()).isEqualTo("Test cargo"); + assertThat(response.data().unitsPerPackage()).isEqualTo(4); + assertThat(response.data().totalQuantity()).isEqualTo(200); + assertThat(response.data().netWeightKg()).isEqualByComparingTo("18.500"); + assertThat(response.data().cargoRemark()).isEqualTo("内部货物备注"); verify(orders).findCargo(11L, 21L, 31L); } diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalPalletLocationController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalPalletLocationController.java index 341c3a76a..6a74a68fb 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalPalletLocationController.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalPalletLocationController.java @@ -62,6 +62,16 @@ public class InternalPalletLocationController { return ApiResponse.success(service.release(request)); } + @PostMapping("/release-selected") + @Operation(summary = "Release selected occupied locations for partial picking", + description = "Explicit IDs release only those locations; otherwise the first N allocations are released") + public ApiResponse> releaseSelected( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @Valid @RequestBody PalletAllocationDtos.SelectiveReleaseRequest request) { + verify(token); + return ApiResponse.success(service.releaseSelected(request)); + } + private void verify(String token) { byte[] supplied = token == null ? new byte[0] : token.getBytes(StandardCharsets.UTF_8); if (!MessageDigest.isEqual(expectedToken, supplied)) { diff --git a/warehouse/src/main/java/com/baiyue/warehouse/dto/LocationDtos.java b/warehouse/src/main/java/com/baiyue/warehouse/dto/LocationDtos.java index b512b0e20..a8fecfde1 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/dto/LocationDtos.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/dto/LocationDtos.java @@ -138,6 +138,30 @@ public final class LocationDtos { ) { } + public record SelectiveReleaseCommand( + @Min(1) long tenantId, + @NotBlank @Size(max = 40) String sourceService, + @Min(1) long businessId, + @Min(1) long operatorId, + @NotBlank @Size(max = 500) String reason, + @Min(1) int totalPackageCount, + @Size(max = 200) List<@Min(1) Long> locationIds, + @Min(1) Integer releaseLocationCount + ) { + } + + public record SelectiveReleaseResult( + long reservationId, + long occupancyId, + int allocationNo, + long warehouseId, + long palletId, + long locationId, + String locationCode, + int releasedPackageCount + ) { + } + public record ActiveResource( long reservationId, String reservationNo, diff --git a/warehouse/src/main/java/com/baiyue/warehouse/dto/PalletAllocationDtos.java b/warehouse/src/main/java/com/baiyue/warehouse/dto/PalletAllocationDtos.java index 798417feb..28672f886 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/dto/PalletAllocationDtos.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/dto/PalletAllocationDtos.java @@ -52,6 +52,33 @@ public final class PalletAllocationDtos { ) { } + /** + * Releases occupied locations for a partial pick. When {@code locationIds} is empty, + * the first {@code releaseLocationCount} allocations are selected. + */ + public record SelectiveReleaseRequest( + @Min(1) long tenantId, + @NotBlank @Size(max = 40) String sourceService, + @Min(1) long businessId, + @Min(1) long operatorId, + @NotBlank @Size(max = 500) String reason, + @Min(1) int totalPackageCount, + @Size(max = 200) List<@Min(1) Long> locationIds, + @Min(1) Integer releaseLocationCount + ) { + } + + public record SelectiveReleaseResult( + long reservationId, + long occupancyId, + int allocationNo, + long palletId, + long locationId, + String locationCode, + int releasedPackageCount + ) { + } + public record AllocationResult( long reservationId, String reservationNo, diff --git a/warehouse/src/main/java/com/baiyue/warehouse/mapper/LocationOccupancyMapper.java b/warehouse/src/main/java/com/baiyue/warehouse/mapper/LocationOccupancyMapper.java index 8c3e60595..8f66013a2 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/mapper/LocationOccupancyMapper.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/mapper/LocationOccupancyMapper.java @@ -7,6 +7,8 @@ import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Update; +import java.util.List; + /** 库位占用事实 Mapper。 */ @Mapper public interface LocationOccupancyMapper extends BaseMapper { @@ -26,4 +28,15 @@ public interface LocationOccupancyMapper extends BaseMapper lockOccupiedByBusiness(@Param("tenantId") long tenantId, + @Param("sourceService") String sourceService, + @Param("businessId") long businessId); } diff --git a/warehouse/src/main/java/com/baiyue/warehouse/repository/LocationRepository.java b/warehouse/src/main/java/com/baiyue/warehouse/repository/LocationRepository.java index 36493b54a..17c3e0ae6 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/repository/LocationRepository.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/repository/LocationRepository.java @@ -143,6 +143,11 @@ public class LocationRepository { .eq(LocationOccupancyEntity::getReservationId, reservationId)); } + public List lockOccupiedByBusiness(long tenantId, String sourceService, + long businessId) { + return occupancies.lockOccupiedByBusiness(tenantId, sourceService, businessId); + } + public void insertOccupancy(LocationOccupancyEntity entity) { occupancies.insert(entity); } diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/LocationResourceService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/LocationResourceService.java index c259024ce..48bd273ad 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/service/LocationResourceService.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/LocationResourceService.java @@ -11,6 +11,8 @@ import com.baiyue.warehouse.dto.LocationDtos.ReleaseCommand; import com.baiyue.warehouse.dto.LocationDtos.ReservationResult; import com.baiyue.warehouse.dto.LocationDtos.ReservationTarget; import com.baiyue.warehouse.dto.LocationDtos.ReserveCommand; +import com.baiyue.warehouse.dto.LocationDtos.SelectiveReleaseCommand; +import com.baiyue.warehouse.dto.LocationDtos.SelectiveReleaseResult; import com.baiyue.warehouse.entity.LocationEntity; import com.baiyue.warehouse.entity.LocationOccupancyEntity; import com.baiyue.warehouse.entity.LocationReservationEntity; @@ -31,7 +33,6 @@ import java.time.LocalDateTime; import java.util.ArrayList; import java.util.HashSet; import java.util.LinkedHashMap; -import java.util.Map; import java.util.stream.Collectors; import java.util.List; import java.util.Map; @@ -46,6 +47,8 @@ import java.util.Set; public class LocationResourceService { @Resource private LocationRepository repository; + @Resource + private PickingPackageAllocator packageAllocator; public List findAvailable(@Min(1) long tenantId, @Min(1) long warehouseId, @Min(1) @Max(200) int limit) { @@ -182,6 +185,66 @@ public class LocationResourceService { return List.copyOf(released); } + @Transactional + public List releaseSelected(@Valid SelectiveReleaseCommand command) { + List active = repository.lockOccupiedByBusiness( + command.tenantId(), command.sourceService(), command.businessId()); + if (active.isEmpty()) { + throw new BizException(ResultCode.CONFLICT, "No occupied locations are available for this inbound order"); + } + requireLimit(active.size()); + List packages = packageAllocator.allocate(command.totalPackageCount(), active.size()); + Set requestedIds = command.locationIds() == null + ? Set.of() : new HashSet<>(command.locationIds()); + if (requestedIds.size() != (command.locationIds() == null ? 0 : command.locationIds().size())) { + throw new BizException(ResultCode.BAD_REQUEST, "Selected location IDs must be unique"); + } + + List selectedIndexes = new ArrayList<>(); + if (!requestedIds.isEmpty()) { + for (int index = 0; index < active.size(); index++) { + if (requestedIds.contains(active.get(index).getLocationId())) { + selectedIndexes.add(index); + } + } + if (selectedIndexes.size() != requestedIds.size()) { + throw new BizException(ResultCode.FORBIDDEN, + "A selected location does not belong to the current inbound order"); + } + if (command.releaseLocationCount() != null + && command.releaseLocationCount() != selectedIndexes.size()) { + throw new BizException(ResultCode.BAD_REQUEST, + "releaseLocationCount must match the selected location count"); + } + } else { + if (command.releaseLocationCount() == null || command.releaseLocationCount() > active.size()) { + throw new BizException(ResultCode.BAD_REQUEST, + "releaseLocationCount must not exceed the active location count"); + } + for (int index = 0; index < command.releaseLocationCount(); index++) { + selectedIndexes.add(index); + } + } + + List released = new ArrayList<>(selectedIndexes.size()); + for (int index : selectedIndexes) { + LocationOccupancyEntity occupancy = active.get(index); + LocationEntity location = repository.findOwned(command.tenantId(), occupancy.getWarehouseId(), + occupancy.getLocationId()); + if (location == null || repository.releaseOccupancy(occupancy, command.reason()) != 1 + || (!Boolean.TRUE.equals(occupancy.getSharedOverflow()) + && repository.changeStatus(command.tenantId(), occupancy.getWarehouseId(), occupancy.getLocationId(), + "OCCUPIED", "AVAILABLE", location.getVersion(), command.operatorId()) != 1)) { + throw new BizException(ResultCode.CONFLICT, + "Location occupancy changed concurrently while releasing selected locations"); + } + released.add(new SelectiveReleaseResult(occupancy.getReservationId(), occupancy.getId(), index + 1, + occupancy.getWarehouseId(), occupancy.getPalletId(), occupancy.getLocationId(), + occupancy.getLocationCode(), packages.get(index))); + } + return List.copyOf(released); + } + @Transactional(readOnly = true) public List findActiveResources(@Min(1) long tenantId, @NotBlank String sourceService, diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/PalletLocationAllocationService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/PalletLocationAllocationService.java index 6188b1413..98dcac790 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/service/PalletLocationAllocationService.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/PalletLocationAllocationService.java @@ -129,6 +129,28 @@ public class PalletLocationAllocationService { return released; } + @Transactional + @BusinessAudit(event = "PALLET_LOCATION_RELEASE_SELECTED", resourceType = "INBOUND", + resourceId = "#request.businessId", description = "Release selected occupied locations for picking") + public List releaseSelected( + PalletAllocationDtos.SelectiveReleaseRequest request) { + List released = locationResourceService.releaseSelected( + new LocationDtos.SelectiveReleaseCommand(request.tenantId(), request.sourceService(), request.businessId(), + request.operatorId(), request.reason(), request.totalPackageCount(), request.locationIds(), + request.releaseLocationCount())); + List result = new ArrayList<>(released.size()); + for (LocationDtos.SelectiveReleaseResult resource : released) { + if (palletRepository.transitionStatus(request.tenantId(), resource.warehouseId(), resource.palletId(), + List.of("OCCUPIED"), "AVAILABLE") != 1) { + throw new BizException(ResultCode.CONFLICT, "Pallet changed concurrently during selected release"); + } + result.add(new PalletAllocationDtos.SelectiveReleaseResult(resource.reservationId(), + resource.occupancyId(), resource.allocationNo(), resource.palletId(), resource.locationId(), + resource.locationCode(), resource.releasedPackageCount())); + } + return List.copyOf(result); + } + private List active(long tenantId, String sourceService, long businessId) { return new ArrayList<>(locationResourceService.findActiveResources(tenantId, sourceService, businessId)); } diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/PickingPackageAllocator.java b/warehouse/src/main/java/com/baiyue/warehouse/service/PickingPackageAllocator.java new file mode 100644 index 000000000..0022119f8 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/PickingPackageAllocator.java @@ -0,0 +1,27 @@ +package com.baiyue.warehouse.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; + +/** Allocates cartons by location with a ceiling-sized leading share. */ +@Component +public class PickingPackageAllocator { + public List allocate(int totalPackageCount, int locationCount) { + if (totalPackageCount < 1 || locationCount < 1 || locationCount > 200) { + throw new BizException(ResultCode.BAD_REQUEST, "Package and location counts must be positive"); + } + int share = ((totalPackageCount - 1) / locationCount) + 1; + int remaining = totalPackageCount; + List result = new ArrayList<>(locationCount); + for (int index = 0; index < locationCount; index++) { + int allocated = Math.min(share, remaining); + result.add(allocated); + remaining -= allocated; + } + return List.copyOf(result); + } +} diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/LocationResourceServiceTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/LocationResourceServiceTest.java index 9310ba2e8..3ddf7690a 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/service/LocationResourceServiceTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/LocationResourceServiceTest.java @@ -4,6 +4,7 @@ import com.baiyue.warehouse.dto.LocationDtos.ConfirmCommand; import com.baiyue.warehouse.dto.LocationDtos.OccupancyTarget; import com.baiyue.warehouse.dto.LocationDtos.ReservationTarget; import com.baiyue.warehouse.dto.LocationDtos.ReserveCommand; +import com.baiyue.warehouse.dto.LocationDtos.SelectiveReleaseCommand; import com.baiyue.warehouse.entity.LocationEntity; import com.baiyue.warehouse.entity.LocationOccupancyEntity; import com.baiyue.warehouse.entity.LocationReservationEntity; @@ -20,6 +21,7 @@ import java.time.LocalDateTime; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyLong; @@ -29,7 +31,7 @@ import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -@SpringJUnitConfig(LocationResourceService.class) +@SpringJUnitConfig(classes = {LocationResourceService.class, PickingPackageAllocator.class}) class LocationResourceServiceTest { @MockBean private LocationRepository repository; @Resource private LocationResourceService service; @@ -138,6 +140,79 @@ class LocationResourceServiceTest { verify(repository).insertOccupancy(any(LocationOccupancyEntity.class)); } + @Test + void shouldReleaseOnlyExplicitlySelectedLocations() { + List active = List.of( + occupancy(201L, 101L, 9L, "A1", 501L), + occupancy(202L, 102L, 10L, "A2", 502L), + occupancy(203L, 103L, 11L, "A3", 503L)); + when(repository.lockOccupiedByBusiness(10001L, "FIRSTLEG", 301L)).thenReturn(active); + when(repository.findOwned(10001L, 88L, 10L)).thenReturn(occupiedLocation(10L, "A2")); + when(repository.releaseOccupancy(active.get(1), "PDA partial pick")).thenReturn(1); + when(repository.changeStatus(10001L, 88L, 10L, "OCCUPIED", "AVAILABLE", 0, 7L)).thenReturn(1); + + var result = service.releaseSelected(new SelectiveReleaseCommand(10001L, "FIRSTLEG", 301L, 7L, + "PDA partial pick", 10, List.of(10L), null)); + + assertThat(result).singleElement().satisfies(item -> { + assertThat(item.allocationNo()).isEqualTo(2); + assertThat(item.locationId()).isEqualTo(10L); + assertThat(item.releasedPackageCount()).isEqualTo(4); + }); + verify(repository, never()).releaseOccupancy(active.get(0), "PDA partial pick"); + verify(repository, never()).releaseOccupancy(active.get(2), "PDA partial pick"); + } + + @Test + void shouldReleaseFirstNAllocationsWhenLocationsAreNotSpecified() { + List active = List.of( + occupancy(201L, 101L, 9L, "A1", 501L), + occupancy(202L, 102L, 10L, "A2", 502L), + occupancy(203L, 103L, 11L, "A3", 503L)); + when(repository.lockOccupiedByBusiness(10001L, "FIRSTLEG", 301L)).thenReturn(active); + for (int index = 0; index < 2; index++) { + LocationOccupancyEntity item = active.get(index); + when(repository.findOwned(10001L, 88L, item.getLocationId())) + .thenReturn(occupiedLocation(item.getLocationId(), item.getLocationCode())); + when(repository.releaseOccupancy(item, "PDA default pick")).thenReturn(1); + when(repository.changeStatus(10001L, 88L, item.getLocationId(), "OCCUPIED", "AVAILABLE", 0, 7L)) + .thenReturn(1); + } + + var result = service.releaseSelected(new SelectiveReleaseCommand(10001L, "FIRSTLEG", 301L, 7L, + "PDA default pick", 10, List.of(), 2)); + + assertThat(result).extracting(item -> item.allocationNo()).containsExactly(1, 2); + assertThat(result).extracting(item -> item.locationCode()).containsExactly("A1", "A2"); + assertThat(result).extracting(item -> item.releasedPackageCount()).containsExactly(4, 4); + verify(repository, never()).releaseOccupancy(active.get(2), "PDA default pick"); + } + + @Test + void shouldRejectLocationOwnedByAnotherInboundOrder() { + when(repository.lockOccupiedByBusiness(10001L, "FIRSTLEG", 301L)).thenReturn(List.of( + occupancy(201L, 101L, 9L, "A1", 501L))); + + assertThatThrownBy(() -> service.releaseSelected(new SelectiveReleaseCommand( + 10001L, "FIRSTLEG", 301L, 7L, "PDA partial pick", 10, List.of(99L), null))) + .isInstanceOf(com.baiyue.common.core.exception.BizException.class); + verify(repository, never()).releaseOccupancy(any(), any(String.class)); + } + + @Test + void shouldFailOnConcurrentOccupancyUpdate() { + LocationOccupancyEntity active = occupancy(201L, 101L, 9L, "A1", 501L); + when(repository.lockOccupiedByBusiness(10001L, "FIRSTLEG", 301L)).thenReturn(List.of(active)); + when(repository.findOwned(10001L, 88L, 9L)).thenReturn(occupiedLocation(9L, "A1")); + when(repository.releaseOccupancy(active, "PDA pick")).thenReturn(0); + + assertThatThrownBy(() -> service.releaseSelected(new SelectiveReleaseCommand( + 10001L, "FIRSTLEG", 301L, 7L, "PDA pick", 10, List.of(), 1))) + .isInstanceOf(com.baiyue.common.core.exception.BizException.class); + verify(repository, never()).changeStatus(anyLong(), anyLong(), anyLong(), + any(String.class), any(String.class), anyInt(), anyLong()); + } + private static ReserveCommand reserveCommand() { return new ReserveCommand(10001L, 88L, "FIRSTLEG", 301L, "RK001", 7L, LocalDateTime.now().plusMinutes(30), List.of( @@ -176,6 +251,30 @@ class LocationResourceServiceTest { return location; } + private static LocationEntity occupiedLocation(long id, String code) { + LocationEntity location = location(id, code); + location.setStatus("OCCUPIED"); + return location; + } + + private static LocationOccupancyEntity occupancy(long id, long reservationId, long locationId, + String locationCode, long palletId) { + LocationOccupancyEntity occupancy = new LocationOccupancyEntity(); + occupancy.setId(id); + occupancy.setTenantId(10001L); + occupancy.setReservationId(reservationId); + occupancy.setSourceService("FIRSTLEG"); + occupancy.setBusinessId(301L); + occupancy.setWarehouseId(88L); + occupancy.setPalletId(palletId); + occupancy.setLocationId(locationId); + occupancy.setLocationCode(locationCode); + occupancy.setSharedOverflow(false); + occupancy.setOccupancyStatus("OCCUPIED"); + occupancy.setVersion(0); + return occupancy; + } + private static LocationReservationEntity reservation(String status) { LocationReservationEntity reservation = new LocationReservationEntity(); reservation.setId(101L); diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/PalletLocationAllocationServiceTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/PalletLocationAllocationServiceTest.java index 3775ec830..88503f0cb 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/service/PalletLocationAllocationServiceTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/PalletLocationAllocationServiceTest.java @@ -104,6 +104,25 @@ class PalletLocationAllocationServiceTest { verify(pallets, never()).findAvailable(any(Long.class), any(Long.class), any(), any(), any(Integer.class)); } + @Test + void shouldReleaseOnlyPalletsReturnedBySelectedLocationRelease() { + var resource = new LocationDtos.SelectiveReleaseResult( + 102L, 202L, 2, 20L, 30L, 40L, "A-02", 4); + when(locations.releaseSelected(any())).thenReturn(List.of(resource)); + when(pallets.transitionStatus(10001L, 20L, 30L, List.of("OCCUPIED"), "AVAILABLE")) + .thenReturn(1); + + var result = service.releaseSelected(new PalletAllocationDtos.SelectiveReleaseRequest( + 10001L, "FIRSTLEG", 88L, 9001L, "PDA partial pick", 10, List.of(40L), null)); + + assertThat(result).singleElement().satisfies(item -> { + assertThat(item.allocationNo()).isEqualTo(2); + assertThat(item.locationId()).isEqualTo(40L); + assertThat(item.releasedPackageCount()).isEqualTo(4); + }); + verify(pallets).transitionStatus(10001L, 20L, 30L, List.of("OCCUPIED"), "AVAILABLE"); + } + private static PalletAllocationDtos.ReserveRequest reserve(long businessId) { return new PalletAllocationDtos.ReserveRequest(10001, 20, "FIRSTLEG", businessId, "RK-001", "measure-001", 9001, new BigDecimal("2"), new BigDecimal("2"), "木托盘", LocalDateTime.now().plusMinutes(30), true); diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/PickingPackageAllocatorTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/PickingPackageAllocatorTest.java new file mode 100644 index 000000000..224f381dc --- /dev/null +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/PickingPackageAllocatorTest.java @@ -0,0 +1,24 @@ +package com.baiyue.warehouse.service; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class PickingPackageAllocatorTest { + private final PickingPackageAllocator allocator = new PickingPackageAllocator(); + + @Test + void shouldUseCeilingShareAndCapFinalLocation() { + assertThat(allocator.allocate(10, 3)).containsExactly(4, 4, 2); + } + + @Test + void shouldHandleExactDivision() { + assertThat(allocator.allocate(12, 3)).containsExactly(4, 4, 4); + } + + @Test + void shouldNeverAllocateMoreThanRemainingPackages() { + assertThat(allocator.allocate(2, 3)).containsExactly(1, 1, 0); + } +} From 913eca7b7ddc2758ecc6348e8ad6d492e0f6e0be Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Thu, 6 Aug 2026 17:48:50 +0800 Subject: [PATCH 2/8] =?UTF-8?q?=E8=A3=85=E6=9F=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/头程数据库统一增量-MySQL8.sql | 587 +++++++++++++++++- .../PlatformLoadingListController.java | 15 - .../mapper/LoadingPickItemMapper.java | 21 + .../mapper/SmartLoadingCandidateMapper.java | 14 +- .../repository/LoadingPickRepository.java | 4 + .../repository/SmartLoadingRepository.java | 3 +- .../firstleg/service/LoadingListService.java | 58 +- .../firstleg/service/LoadingPickService.java | 5 + .../firstleg/service/SmartLoadingService.java | 9 +- .../db/phase3-inbound-parent-child-schema.sql | 539 ++++++++++++++++ .../db/phase3-smart-loading-schema.sql | 7 +- .../db/phase3-split-preload-schema.sql | 233 +++++++ .../LoadingListControllerContractTest.java | 11 +- .../InboundParentChildSqlContractTest.java | 105 ++++ .../LoadingPickItemMapperContractTest.java | 24 + ...artLoadingCandidateMapperContractTest.java | 6 +- .../SmartLoadingSqlContractTest.java | 3 +- .../service/LoadingListServiceTest.java | 29 +- .../service/LoadingPickServiceTest.java | 85 +++ .../service/SmartLoadingServiceTest.java | 6 +- .../service/SplitPreloadSqlContractTest.java | 80 +++ 21 files changed, 1767 insertions(+), 77 deletions(-) create mode 100644 firstleg/src/main/resources/db/phase3-inbound-parent-child-schema.sql create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/repository/InboundParentChildSqlContractTest.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickItemMapperContractTest.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadSqlContractTest.java diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 1db0b69ff..970b30b74 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -3939,7 +3939,8 @@ CREATE TABLE IF NOT EXISTS fl_loading_plan_item ( COMMENT '更新时间', version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', PRIMARY KEY (id), - UNIQUE KEY uk_loading_item_active_detail (account_id, active_inbound_detail_id), + UNIQUE KEY uk_loading_item_plan_detail (loading_plan_id, active_inbound_detail_id), + KEY idx_loading_item_active_detail (account_id, active_inbound_detail_id), KEY idx_loading_item_plan (loading_plan_id, deleted, load_sequence, id), KEY idx_loading_item_account (account_id, created_at, id), KEY idx_loading_item_inbound (account_id, inbound_id, deleted), @@ -4505,8 +4506,8 @@ BEGIN END IF; IF (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_plan_item' - AND index_name = 'uk_loading_item_active_detail' AND non_unique = 0) <> 2 THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:唯一活动明细锁缺失'; + AND index_name = 'uk_loading_item_plan_detail' AND non_unique = 0) <> 2 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:计划内唯一活动明细锁缺失'; END IF; IF (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = 'baiyue_firstleg' @@ -5481,6 +5482,586 @@ WHERE tenant_id = 0 AND app_code = 'PLATFORM' ) ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); +-- 来源:firstleg/src/main/resources/db/phase3-inbound-parent-child-schema.sql +SET NAMES utf8mb4; +USE baiyue_firstleg; + +/* + * 三期主子入仓单结构迁移。 + * 仅建立结构和数据库保护,不自动拆分或回填存量业务数据。 + * tenant_id 继续表示客户隔离键;数量统一使用 package_count。 + */ +DROP PROCEDURE IF EXISTS phase3_inbound_add_column; +DELIMITER $$ +CREATE PROCEDURE phase3_inbound_add_column( + IN p_table_name VARCHAR(64), + IN p_column_name VARCHAR(64), + IN p_definition TEXT +) +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = p_table_name + AND column_name = p_column_name + ) THEN + SET @ddl = CONCAT('ALTER TABLE `', p_table_name, '` ADD COLUMN `', + p_column_name, '` ', p_definition); + PREPARE stmt FROM @ddl; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + END IF; +END$$ +DELIMITER ; + +CALL phase3_inbound_add_column( + 'fl_inbound_order', 'inbound_role', + "VARCHAR(16) NOT NULL DEFAULT 'MASTER' COMMENT '入仓单层级:MASTER主单、CHILD子单' AFTER inbound_code"); +CALL phase3_inbound_add_column( + 'fl_inbound_order', 'parent_inbound_id', + "BIGINT UNSIGNED NULL COMMENT '直属主入仓单ID;主单为空' AFTER inbound_role"); +CALL phase3_inbound_add_column( + 'fl_inbound_order', 'root_inbound_id', + "BIGINT UNSIGNED NULL COMMENT '根主入仓单ID;存量主单可为空,按自身ID解释' AFTER parent_inbound_id"); +CALL phase3_inbound_add_column( + 'fl_inbound_order', 'split_sequence', + "INT UNSIGNED NULL COMMENT '同一主单下的子单顺序号' AFTER root_inbound_id"); +CALL phase3_inbound_add_column( + 'fl_inbound_order', 'split_status', + "VARCHAR(24) NOT NULL DEFAULT 'NOT_SPLIT' COMMENT '拆分状态:NOT_SPLIT、PARTIALLY_SPLIT、FULLY_SPLIT' AFTER split_sequence"); +CALL phase3_inbound_add_column( + 'fl_inbound_order', 'package_count', + "INT UNSIGNED NULL COMMENT '入仓单箱数;三期唯一数量口径' AFTER total_package_count"); +CALL phase3_inbound_add_column( + 'fl_inbound_order', 'master_order_id', + "BIGINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN inbound_role = 'MASTER' AND deleted = 0 THEN order_id ELSE NULL END) STORED COMMENT '仅用于约束有效主单订单唯一性'"); + +DROP PROCEDURE IF EXISTS phase3_inbound_add_column; + +DROP PROCEDURE IF EXISTS phase3_inbound_add_index; +DELIMITER $$ +CREATE PROCEDURE phase3_inbound_add_index( + IN p_table_name VARCHAR(64), + IN p_index_name VARCHAR(64), + IN p_definition TEXT +) +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = p_table_name + AND index_name = p_index_name + ) THEN + SET @ddl = CONCAT('ALTER TABLE `', p_table_name, '` ADD ', p_definition); + PREPARE stmt FROM @ddl; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + END IF; +END$$ +DELIMITER ; + +CALL phase3_inbound_add_index( + 'fl_inbound_order', 'uk_inbound_master_order', + 'UNIQUE KEY `uk_inbound_master_order` (`tenant_id`, `master_order_id`)'); +CALL phase3_inbound_add_index( + 'fl_inbound_order', 'uk_inbound_parent_sequence', + 'UNIQUE KEY `uk_inbound_parent_sequence` (`tenant_id`, `parent_inbound_id`, `split_sequence`)'); +CALL phase3_inbound_add_index( + 'fl_inbound_order', 'idx_inbound_root_role', + 'KEY `idx_inbound_root_role` (`tenant_id`, `root_inbound_id`, `inbound_role`, `created_at`, `id`)'); + +DROP PROCEDURE IF EXISTS phase3_inbound_drop_legacy_index; +DELIMITER $$ +CREATE PROCEDURE phase3_inbound_drop_legacy_index() +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'fl_inbound_order' + AND index_name = 'uk_inbound_tenant_order' + ) THEN + ALTER TABLE fl_inbound_order DROP INDEX uk_inbound_tenant_order; + END IF; +END$$ +DELIMITER ; +CALL phase3_inbound_drop_legacy_index(); +DROP PROCEDURE IF EXISTS phase3_inbound_drop_legacy_index; +DROP PROCEDURE IF EXISTS phase3_inbound_add_index; + +CREATE TABLE IF NOT EXISTS fl_inbound_split_batch ( + id BIGINT UNSIGNED NOT NULL COMMENT '拆分批次ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID', + parent_inbound_id BIGINT UNSIGNED NOT NULL COMMENT '主入仓单ID', + operation_id VARCHAR(64) NOT NULL COMMENT '业务操作幂等号', + idempotency_key VARCHAR(128) NOT NULL COMMENT '请求幂等键', + expected_parent_version INT NOT NULL COMMENT '操作时主单乐观锁版本', + package_count INT UNSIGNED NOT NULL COMMENT '本批次拆分箱数', + status VARCHAR(16) NOT NULL DEFAULT 'PREPARED' COMMENT 'PREPARED、APPLIED、CANCELLED', + remark VARCHAR(500) NULL COMMENT '拆分原因或备注', + created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID', + created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + applied_at DATETIME(3) NULL COMMENT '批次生效时间', + version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', + PRIMARY KEY (id), + UNIQUE KEY uk_inbound_split_operation (tenant_id, operation_id), + UNIQUE KEY uk_inbound_split_idempotency (tenant_id, parent_inbound_id, idempotency_key), + KEY idx_inbound_split_parent (tenant_id, parent_inbound_id, created_at, id), + CONSTRAINT chk_inbound_split_package CHECK (package_count > 0), + CONSTRAINT chk_inbound_split_status CHECK (status IN ('PREPARED', 'APPLIED', 'CANCELLED')), + CONSTRAINT fk_inbound_split_parent FOREIGN KEY (parent_inbound_id) REFERENCES fl_inbound_order (id) +) ENGINE=InnoDB COMMENT='主入仓单拆分批次;数量口径仅为箱数'; + +CREATE TABLE IF NOT EXISTS fl_inbound_split_item ( + id BIGINT UNSIGNED NOT NULL COMMENT '拆分明细ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID', + split_batch_id BIGINT UNSIGNED NOT NULL COMMENT '拆分批次ID', + parent_inbound_id BIGINT UNSIGNED NOT NULL COMMENT '主入仓单ID', + child_inbound_id BIGINT UNSIGNED NOT NULL COMMENT '子入仓单ID', + source_inbound_detail_id BIGINT UNSIGNED NULL COMMENT '订单货物明细逻辑ID', + source_measurement_id BIGINT UNSIGNED NULL COMMENT '来源测量批次ID', + package_count INT UNSIGNED NOT NULL COMMENT '分配给子单的箱数', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_inbound_split_child (tenant_id, child_inbound_id), + UNIQUE KEY uk_inbound_split_batch_child (tenant_id, split_batch_id, child_inbound_id), + KEY idx_inbound_split_detail (tenant_id, source_inbound_detail_id, created_at), + CONSTRAINT chk_inbound_split_item_package CHECK (package_count > 0), + CONSTRAINT fk_inbound_split_item_batch FOREIGN KEY (split_batch_id) REFERENCES fl_inbound_split_batch (id), + CONSTRAINT fk_inbound_split_item_parent FOREIGN KEY (parent_inbound_id) REFERENCES fl_inbound_order (id), + CONSTRAINT fk_inbound_split_item_child FOREIGN KEY (child_inbound_id) REFERENCES fl_inbound_order (id), + CONSTRAINT fk_inbound_split_item_measurement FOREIGN KEY (source_measurement_id) REFERENCES fl_inbound_measurement (id) +) ENGINE=InnoDB COMMENT='主子入仓单拆分数量台账'; + +CREATE TABLE IF NOT EXISTS fl_inbound_split_history ( + id BIGINT UNSIGNED NOT NULL COMMENT '拆分历史ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID', + split_batch_id BIGINT UNSIGNED NOT NULL COMMENT '拆分批次ID', + operation_id VARCHAR(64) NOT NULL COMMENT '历史操作幂等号', + action_type VARCHAR(24) NOT NULL COMMENT 'CREATE、APPLY、CANCEL', + before_snapshot JSON NULL COMMENT '操作前快照', + after_snapshot JSON NOT NULL COMMENT '操作后快照', + operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID', + operator_name VARCHAR(64) NULL COMMENT '操作人名称快照', + operation_reason VARCHAR(500) NULL COMMENT '操作原因', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_inbound_split_history_operation (tenant_id, operation_id), + KEY idx_inbound_split_history (tenant_id, split_batch_id, created_at, id), + CONSTRAINT chk_inbound_split_action CHECK (action_type IN ('CREATE', 'APPLY', 'CANCEL')), + CONSTRAINT fk_inbound_split_history_batch FOREIGN KEY (split_batch_id) REFERENCES fl_inbound_split_batch (id) +) ENGINE=InnoDB COMMENT='不可变主子入仓单拆分审计历史'; + +DROP PROCEDURE IF EXISTS phase3_inbound_add_column; +DELIMITER $$ +CREATE PROCEDURE phase3_inbound_add_column( + IN p_table_name VARCHAR(64), + IN p_column_name VARCHAR(64), + IN p_definition TEXT +) +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = p_table_name + AND column_name = p_column_name + ) THEN + SET @ddl = CONCAT('ALTER TABLE `', p_table_name, '` ADD COLUMN `', + p_column_name, '` ', p_definition); + PREPARE stmt FROM @ddl; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + END IF; +END$$ +DELIMITER ; +CALL phase3_inbound_add_column( + 'fl_inbound_measurement', 'split_batch_id', + "BIGINT UNSIGNED NULL COMMENT '产生该测量批次的入仓拆分批次ID' AFTER inbound_id"); +CALL phase3_inbound_add_column( + 'fl_inbound_measurement', 'source_measurement_id', + "BIGINT UNSIGNED NULL COMMENT '子单继承或复核时的来源测量批次ID' AFTER split_batch_id"); +DROP PROCEDURE IF EXISTS phase3_inbound_add_column; + +DROP PROCEDURE IF EXISTS phase3_inbound_add_relation; +DELIMITER $$ +CREATE PROCEDURE phase3_inbound_add_relation( + IN p_table_name VARCHAR(64), + IN p_constraint_name VARCHAR(64), + IN p_definition TEXT +) +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.table_constraints + WHERE constraint_schema = DATABASE() + AND table_name = p_table_name + AND constraint_name = p_constraint_name + ) THEN + SET @ddl = CONCAT('ALTER TABLE `', p_table_name, '` ADD CONSTRAINT `', + p_constraint_name, '` ', p_definition); + PREPARE stmt FROM @ddl; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + END IF; +END$$ +DELIMITER ; +CALL phase3_inbound_add_relation( + 'fl_inbound_order', 'fk_inbound_parent_order', + 'FOREIGN KEY (`parent_inbound_id`) REFERENCES `fl_inbound_order` (`id`)'); +CALL phase3_inbound_add_relation( + 'fl_inbound_order', 'fk_inbound_root_order', + 'FOREIGN KEY (`root_inbound_id`) REFERENCES `fl_inbound_order` (`id`)'); +CALL phase3_inbound_add_relation( + 'fl_inbound_measurement', 'fk_measure_split_batch', + 'FOREIGN KEY (`split_batch_id`) REFERENCES `fl_inbound_split_batch` (`id`)'); +CALL phase3_inbound_add_relation( + 'fl_inbound_measurement', 'fk_measure_source_batch', + 'FOREIGN KEY (`source_measurement_id`) REFERENCES `fl_inbound_measurement` (`id`)'); +DROP PROCEDURE IF EXISTS phase3_inbound_add_relation; + +DROP PROCEDURE IF EXISTS phase3_inbound_add_index; +DELIMITER $$ +CREATE PROCEDURE phase3_inbound_add_index( + IN p_table_name VARCHAR(64), + IN p_index_name VARCHAR(64), + IN p_definition TEXT +) +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = p_table_name + AND index_name = p_index_name + ) THEN + SET @ddl = CONCAT('ALTER TABLE `', p_table_name, '` ADD ', p_definition); + PREPARE stmt FROM @ddl; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + END IF; +END$$ +DELIMITER ; +CALL phase3_inbound_add_index( + 'fl_inbound_measurement', 'idx_measure_split_source', + 'KEY `idx_measure_split_source` (`tenant_id`, `split_batch_id`, `source_measurement_id`, `created_at`)'); +DROP PROCEDURE IF EXISTS phase3_inbound_add_index; + +DROP TRIGGER IF EXISTS trg_inbound_order_parent_before_insert; +DROP TRIGGER IF EXISTS trg_inbound_order_parent_before_update; +DROP TRIGGER IF EXISTS trg_inbound_split_item_before_insert; +DROP TRIGGER IF EXISTS trg_inbound_split_batch_before_insert; +DROP TRIGGER IF EXISTS trg_inbound_split_batch_before_update; +DROP TRIGGER IF EXISTS trg_inbound_split_item_before_update; +DROP TRIGGER IF EXISTS trg_inbound_split_item_before_delete; +DROP TRIGGER IF EXISTS trg_inbound_split_history_before_update; +DROP TRIGGER IF EXISTS trg_inbound_split_history_before_delete; + +DELIMITER $$ +CREATE TRIGGER trg_inbound_order_parent_before_insert +BEFORE INSERT ON fl_inbound_order +FOR EACH ROW +BEGIN + IF NEW.inbound_role NOT IN ('MASTER', 'CHILD') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入仓单层级非法'; + END IF; + IF NEW.package_count IS NOT NULL AND NEW.package_count = 0 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入仓单箱数必须大于0'; + END IF; + IF NEW.inbound_role = 'MASTER' AND NEW.parent_inbound_id IS NOT NULL THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主入仓单不能绑定父单'; + END IF; + IF NEW.inbound_role = 'CHILD' THEN + IF NEW.parent_inbound_id IS NULL OR NEW.root_inbound_id IS NULL + OR NEW.split_sequence IS NULL OR NEW.package_count IS NULL THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '子入仓单缺少主单、顺序号或箱数'; + END IF; + IF NOT EXISTS ( + SELECT 1 FROM fl_inbound_order parent_order + WHERE parent_order.id = NEW.parent_inbound_id + AND parent_order.tenant_id = NEW.tenant_id + AND parent_order.inbound_role = 'MASTER' + AND parent_order.deleted = 0 + ) OR NEW.root_inbound_id <> NEW.parent_inbound_id THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '子入仓单必须绑定同一客户的有效主单'; + END IF; + END IF; +END$$ + +CREATE TRIGGER trg_inbound_order_parent_before_update +BEFORE UPDATE ON fl_inbound_order +FOR EACH ROW +BEGIN + IF NOT (OLD.inbound_role <=> NEW.inbound_role) + OR NOT (OLD.parent_inbound_id <=> NEW.parent_inbound_id) + OR NOT (OLD.root_inbound_id <=> NEW.root_inbound_id) + OR NOT (OLD.split_sequence <=> NEW.split_sequence) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入仓单主子归属创建后不可修改'; + END IF; + IF NEW.inbound_role = 'CHILD' + AND (NEW.package_count IS NULL OR NEW.package_count = 0) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '子入仓单箱数必须大于0'; + END IF; +END$$ + +CREATE TRIGGER trg_inbound_split_item_before_insert +BEFORE INSERT ON fl_inbound_split_item +FOR EACH ROW +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM fl_inbound_split_batch batch + JOIN fl_inbound_order parent_order ON parent_order.id = batch.parent_inbound_id + JOIN fl_inbound_order child_order ON child_order.id = NEW.child_inbound_id + WHERE batch.id = NEW.split_batch_id + AND batch.tenant_id = NEW.tenant_id + AND batch.parent_inbound_id = NEW.parent_inbound_id + AND batch.status = 'PREPARED' + AND parent_order.tenant_id = NEW.tenant_id + AND parent_order.inbound_role = 'MASTER' + AND child_order.tenant_id = NEW.tenant_id + AND child_order.inbound_role = 'CHILD' + AND child_order.parent_inbound_id = NEW.parent_inbound_id + AND child_order.package_count = NEW.package_count + AND child_order.deleted = 0 + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分明细与批次、主单或子单不一致'; + END IF; +END$$ + +CREATE TRIGGER trg_inbound_split_batch_before_insert +BEFORE INSERT ON fl_inbound_split_batch +FOR EACH ROW +BEGIN + IF NEW.status <> 'PREPARED' OR NEW.applied_at IS NOT NULL THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分批次必须从PREPARED创建'; + END IF; + IF NOT EXISTS ( + SELECT 1 FROM fl_inbound_order parent_order + WHERE parent_order.id = NEW.parent_inbound_id + AND parent_order.tenant_id = NEW.tenant_id + AND parent_order.inbound_role = 'MASTER' + AND parent_order.deleted = 0 + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分批次主单不存在或客户归属不一致'; + END IF; +END$$ + +CREATE TRIGGER trg_inbound_split_batch_before_update +BEFORE UPDATE ON fl_inbound_split_batch +FOR EACH ROW +BEGIN + IF OLD.status IN ('APPLIED', 'CANCELLED') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已结束拆分批次不可修改'; + END IF; + IF NEW.status = 'APPLIED' THEN + IF NEW.applied_at IS NULL + OR NOT EXISTS ( + SELECT 1 FROM fl_inbound_order parent_order + WHERE parent_order.id = NEW.parent_inbound_id + AND parent_order.tenant_id = NEW.tenant_id + AND parent_order.inbound_role = 'MASTER' + AND parent_order.package_count = 0 + AND parent_order.version > NEW.expected_parent_version + ) OR + (SELECT COALESCE(SUM(item.package_count), 0) + FROM fl_inbound_split_item item + WHERE item.tenant_id = NEW.tenant_id + AND item.split_batch_id = NEW.id) <> NEW.package_count THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主单可用箱数未归零或子单箱数合计不一致'; + END IF; + ELSEIF NEW.status = 'CANCELLED' THEN + IF EXISTS ( + SELECT 1 FROM fl_inbound_split_item item + WHERE item.tenant_id = NEW.tenant_id + AND item.split_batch_id = NEW.id + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已有子单明细的拆分批次不可取消'; + END IF; + ELSEIF NEW.status <> 'PREPARED' THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分批次状态流转非法'; + END IF; +END$$ + +CREATE TRIGGER trg_inbound_split_item_before_update +BEFORE UPDATE ON fl_inbound_split_item +FOR EACH ROW +BEGIN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分数量台账不可修改'; +END$$ + +CREATE TRIGGER trg_inbound_split_item_before_delete +BEFORE DELETE ON fl_inbound_split_item +FOR EACH ROW +BEGIN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分数量台账不可删除'; +END$$ + +CREATE TRIGGER trg_inbound_split_history_before_update +BEFORE UPDATE ON fl_inbound_split_history +FOR EACH ROW +BEGIN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分历史不可修改'; +END$$ + +CREATE TRIGGER trg_inbound_split_history_before_delete +BEFORE DELETE ON fl_inbound_split_history +FOR EACH ROW +BEGIN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分历史不可删除'; +END$$ +DELIMITER ; + +DROP PROCEDURE IF EXISTS phase3_assert_inbound_parent_child_schema; +DELIMITER $$ +CREATE PROCEDURE phase3_assert_inbound_parent_child_schema() +BEGIN + IF (SELECT COUNT(*) FROM information_schema.tables + WHERE table_schema = DATABASE() + AND table_name IN ('fl_inbound_split_batch', 'fl_inbound_split_item', + 'fl_inbound_split_history')) <> 3 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主子入仓单拆分台账未完整创建'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'fl_inbound_order' + AND column_name IN ('inbound_role', 'parent_inbound_id', 'root_inbound_id', + 'split_sequence', 'split_status', 'package_count', + 'master_order_id')) <> 7 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主子入仓单关系字段未完整创建'; + END IF; + IF (SELECT COUNT(DISTINCT index_name) FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND index_name IN ('uk_inbound_master_order', 'uk_inbound_parent_sequence', + 'uk_inbound_split_operation', 'uk_inbound_split_idempotency', + 'uk_inbound_split_child', 'uk_inbound_split_history_operation')) <> 6 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主子入仓单唯一约束未完整创建'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.table_constraints + WHERE constraint_schema = DATABASE() + AND constraint_name IN ('fk_inbound_parent_order', 'fk_inbound_root_order', + 'fk_measure_split_batch', 'fk_measure_source_batch')) <> 4 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主子入仓单与测量关联约束未完整创建'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.triggers + WHERE trigger_schema = DATABASE() + AND trigger_name IN ('trg_inbound_order_parent_before_insert', + 'trg_inbound_order_parent_before_update', + 'trg_inbound_split_item_before_insert', + 'trg_inbound_split_batch_before_insert', + 'trg_inbound_split_batch_before_update', + 'trg_inbound_split_item_before_update', + 'trg_inbound_split_item_before_delete', + 'trg_inbound_split_history_before_update', + 'trg_inbound_split_history_before_delete')) <> 9 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主子入仓单数据库保护触发器未完整创建'; + END IF; + IF EXISTS ( + SELECT 1 FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'fl_inbound_order' + AND index_name = 'uk_inbound_tenant_order' + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '旧订单唯一索引仍阻止创建子入仓单'; + END IF; +END$$ +DELIMITER ; +CALL phase3_assert_inbound_parent_child_schema(); +DROP PROCEDURE IF EXISTS phase3_assert_inbound_parent_child_schema; + +/* + * 部分装柜拆单契约: + * 1. parent_inbound_id 为空的是主单,非空的是唯一二级子单; + * 2. 锁定阶段只记录箱数,完成阶段绑定由剩余箱数生成的子单; + * 3. 子单不能再作为父单,锁定数量创建后不可修改。 + */ +DROP PROCEDURE IF EXISTS phase3_split_add_column; +DELIMITER $$ +CREATE PROCEDURE phase3_split_add_column( + IN p_table_name VARCHAR(64), IN p_column_name VARCHAR(64), IN p_definition TEXT +) +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = p_table_name AND column_name = p_column_name + ) THEN + SET @phase3_split_ddl = CONCAT('ALTER TABLE `', p_table_name, '` ADD COLUMN `', + p_column_name, '` ', p_definition); + PREPARE phase3_split_stmt FROM @phase3_split_ddl; + EXECUTE phase3_split_stmt; + DEALLOCATE PREPARE phase3_split_stmt; + END IF; +END$$ +DELIMITER ; + +CALL phase3_split_add_column('fl_inbound_order', 'parent_inbound_id', + 'BIGINT UNSIGNED NULL COMMENT ''生成本单的主入库单ID;仅二级子单有值'' AFTER id'); +CALL phase3_split_add_column('fl_inbound_order', 'split_level', + 'TINYINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN parent_inbound_id IS NULL THEN 0 ELSE 1 END) STORED COMMENT ''拆单层级:0主单、1二级子单'' AFTER parent_inbound_id'); +CALL phase3_split_add_column('fl_inbound_order', 'split_status', + 'VARCHAR(24) NOT NULL DEFAULT ''NOT_SPLIT'' COMMENT ''NOT_SPLIT/PARTIALLY_SPLIT/FULLY_SPLIT'' AFTER split_level'); +DROP PROCEDURE IF EXISTS phase3_split_add_column; + +SET @phase3_split_index_exists = ( + SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_order' + AND index_name = 'uk_inbound_one_secondary' +); +SET @phase3_split_ddl = IF(@phase3_split_index_exists = 0, + 'ALTER TABLE fl_inbound_order ADD UNIQUE KEY uk_inbound_one_secondary (tenant_id, parent_inbound_id)', + 'SELECT 1'); +PREPARE phase3_split_stmt FROM @phase3_split_ddl; +EXECUTE phase3_split_stmt; +DEALLOCATE PREPARE phase3_split_stmt; + +SET @phase3_split_fk_exists = ( + SELECT COUNT(*) FROM information_schema.table_constraints + WHERE constraint_schema = DATABASE() AND table_name = 'fl_inbound_order' + AND constraint_name = 'fk_inbound_parent' AND constraint_type = 'FOREIGN KEY' +); +SET @phase3_split_ddl = IF(@phase3_split_fk_exists = 0, + 'ALTER TABLE fl_inbound_order ADD CONSTRAINT fk_inbound_parent FOREIGN KEY (parent_inbound_id) REFERENCES fl_inbound_order (id)', + 'SELECT 1'); +PREPARE phase3_split_stmt FROM @phase3_split_ddl; +EXECUTE phase3_split_stmt; +DEALLOCATE PREPARE phase3_split_stmt; + +CREATE TABLE IF NOT EXISTS fl_inbound_split_detail ( + id BIGINT UNSIGNED NOT NULL COMMENT '部分锁定明细ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID', + parent_inbound_id BIGINT UNSIGNED NOT NULL COMMENT '主入库单ID', + child_inbound_id BIGINT UNSIGNED NULL COMMENT '完成拆单后生成的二级子入库单ID', + inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '原订单货物明细ID,不建立跨库外键', + source_package_count INT UNSIGNED NOT NULL COMMENT '拆分前该明细总箱数快照', + locked_package_count INT UNSIGNED NOT NULL COMMENT '本次装柜锁定箱数', + remaining_package_count INT UNSIGNED GENERATED ALWAYS AS ( + source_package_count - locked_package_count + ) STORED COMMENT '生成二级子单的剩余箱数', + status VARCHAR(16) NOT NULL DEFAULT 'LOCKED' COMMENT 'LOCKED/COMPLETED', + operation_id VARCHAR(128) NOT NULL COMMENT '部分锁定操作幂等号', + created_by BIGINT UNSIGNED NOT NULL, + created_by_name VARCHAR(64) NULL, + completed_by BIGINT UNSIGNED NULL, + completed_by_name VARCHAR(64) NULL, + completed_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + version INT UNSIGNED NOT NULL DEFAULT 0, + PRIMARY KEY (id), + UNIQUE KEY uk_inbound_split_parent_detail (tenant_id, parent_inbound_id, inbound_detail_id), + UNIQUE KEY uk_inbound_split_child_detail (tenant_id, child_inbound_id, inbound_detail_id), + UNIQUE KEY uk_inbound_split_operation (operation_id), + KEY idx_inbound_split_status (tenant_id, parent_inbound_id, status), + CONSTRAINT fk_inbound_split_detail_parent FOREIGN KEY (parent_inbound_id) REFERENCES fl_inbound_order (id), + CONSTRAINT fk_inbound_split_detail_child FOREIGN KEY (child_inbound_id) REFERENCES fl_inbound_order (id), + CONSTRAINT chk_inbound_split_partial CHECK ( + source_package_count > 1 AND locked_package_count > 0 + AND locked_package_count < source_package_count + ), + CONSTRAINT chk_inbound_split_status CHECK (status IN ('LOCKED', 'COMPLETED')), + CONSTRAINT chk_inbound_split_completion CHECK ( + (status = 'LOCKED' AND child_inbound_id IS NULL AND completed_at IS NULL) + OR (status = 'COMPLETED' AND child_inbound_id IS NOT NULL AND completed_at IS NOT NULL) + ) +) ENGINE=InnoDB COMMENT='按箱数部分锁定及剩余箱数生成二级子入库单的不可变契约'; + DROP PROCEDURE IF EXISTS unified_assert_final_extensions; DELIMITER $$ CREATE PROCEDURE unified_assert_final_extensions() diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java index fca7a552b..574ffd8b4 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java @@ -3,7 +3,6 @@ package com.baiyue.firstleg.controller; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.PageResult; import com.baiyue.common.security.AuthenticatedUser; -import com.baiyue.firstleg.dto.LoadingListDtos.AdvanceRequest; import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedDetail; import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedQuery; import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedSummary; @@ -24,7 +23,6 @@ import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; @@ -70,19 +68,6 @@ public class PlatformLoadingListController { planId, user.userId(), user.username(), idempotencyKey, request)); } - @Operation(summary = "完成装柜", description = "状态仅允许 LOADING 推进到 COMPLETED,完成后主单和明细冻结。") - @PostMapping("/loading-plans/{planId}/complete") - @PreAuthorize("hasAuthority('platform:firstleg:preload:complete')") - public ApiResponse complete( - @AuthenticationPrincipal AuthenticatedUser user, - @PathVariable @Positive long planId, - @RequestHeader("Idempotency-Key") String idempotencyKey, - @Valid @RequestBody AdvanceRequest request) { - FirstlegScopeGuard.platform(user); - return ApiResponse.success(service.complete( - planId, user.userId(), user.username(), idempotencyKey, request)); - } - @Operation(summary = "分页查询完成装柜清单", description = "只读取 COMPLETED 状态的装柜计划。") @GetMapping("/loading-lists") @PreAuthorize("hasAuthority('platform:firstleg:loading-list:list')") diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java index cf35b5333..eaa9d8eac 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java @@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.firstleg.entity.LoadingPickItemEntity; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Update; @Mapper @@ -18,4 +19,24 @@ public interface LoadingPickItemMapper extends BaseMapper AND picked_package_count + #{count} <= planned_package_count """) int pick(@Param("id") long id, @Param("count") int count, @Param("version") int version); + + /** + * 以装柜计划的全部有效明细为完成口径,不能只检查当前 PDA 任务中的明细。 + */ + @Select(""" + SELECT COUNT(*) + FROM fl_loading_plan_item plan_item + WHERE plan_item.loading_plan_id = #{planId} + AND plan_item.deleted = 0 + AND NOT EXISTS ( + SELECT 1 + FROM fl_loading_pick_item pick_item + JOIN fl_loading_pick_task pick_task ON pick_task.id = pick_item.pick_task_id + WHERE pick_item.loading_plan_item_id = plan_item.id + AND pick_task.loading_plan_id = #{planId} + AND pick_task.task_status = 'LOADING' + AND pick_item.picked_package_count = plan_item.package_count + ) + """) + long unfinishedCount(@Param("planId") long planId); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java index a1c10da33..1713c4fe6 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java @@ -7,7 +7,8 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.iba o.warehouse_id warehouseId,o.destination_warehouse_id destinationWarehouseId, o.destination_code destinationWarehouseCode,o.destination_name destinationWarehouseName, o.customer_name customerName,COALESCE(m.actual_goods_name,'') goodsName, - i.package_count availablePackageCount,COALESCE(i.unit_weight_kg,0) unitWeightKg, + i.package_count-COALESCE((SELECT SUM(li.package_count) FROM fl_loading_plan_item li + WHERE li.account_id=o.tenant_id AND li.inbound_detail_id=i.inbound_detail_id AND li.deleted=0),0) availablePackageCount,COALESCE(i.unit_weight_kg,0) unitWeightKg, COALESCE(i.unit_volume_cbm,0) unitVolumeCbm FROM fl_inbound_order o JOIN fl_inbound_measurement m ON m.tenant_id=o.tenant_id AND m.inbound_id=o.id @@ -15,10 +16,10 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.iba AND m.id=(SELECT MAX(m2.id) FROM fl_inbound_measurement m2 WHERE m2.tenant_id=o.tenant_id AND m2.inbound_id=o.id AND m2.status='APPROVED') JOIN fl_inbound_measurement_item i ON i.tenant_id=o.tenant_id AND i.measurement_id=m.id - LEFT JOIN fl_loading_plan_item l - ON l.account_id=o.tenant_id AND l.active_inbound_detail_id=i.inbound_detail_id WHERE o.tenant_id=#{accountId} AND o.process_status='INBOUNDED' - AND i.inbound_detail_id IS NOT NULL AND l.id IS NULL + AND i.inbound_detail_id IS NOT NULL + AND i.package_count>COALESCE((SELECT SUM(li.package_count) FROM fl_loading_plan_item li + WHERE li.account_id=o.tenant_id AND li.inbound_detail_id=i.inbound_detail_id AND li.deleted=0),0) ORDER BY o.inbounded_at ASC, o.id ASC, i.id ASC """) List candidates(@Param("accountId") long accountId); @@ -29,7 +30,8 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.iba o.warehouse_id warehouseId,o.destination_warehouse_id destinationWarehouseId, o.destination_code destinationWarehouseCode,o.destination_name destinationWarehouseName, o.customer_name customerName,COALESCE(m.actual_goods_name,'') goodsName, - i.package_count availablePackageCount,COALESCE(i.unit_weight_kg,0) unitWeightKg, + i.package_count-COALESCE((SELECT SUM(li.package_count) FROM fl_loading_plan_item li + WHERE li.account_id=o.tenant_id AND li.inbound_detail_id=i.inbound_detail_id AND li.deleted=0),0) availablePackageCount,COALESCE(i.unit_weight_kg,0) unitWeightKg, COALESCE(i.unit_volume_cbm,0) unitVolumeCbm FROM fl_inbound_order o JOIN fl_inbound_measurement m ON m.tenant_id=o.tenant_id AND m.inbound_id=o.id @@ -39,7 +41,7 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.iba JOIN fl_inbound_measurement_item i ON i.tenant_id=o.tenant_id AND i.measurement_id=m.id WHERE o.tenant_id=#{accountId} AND o.id=#{inboundId} AND o.process_status='INBOUNDED' AND i.inbound_detail_id=#{detailId} - ORDER BY m.id DESC LIMIT 1 + ORDER BY m.id DESC LIMIT 1 FOR UPDATE """) Candidate candidate(@Param("accountId") long accountId,@Param("inboundId") long inboundId, @Param("detailId") long detailId); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingPickRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingPickRepository.java index 7051e39f6..e30e13c91 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingPickRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingPickRepository.java @@ -67,6 +67,10 @@ public class LoadingPickRepository { return items.pick(itemId, count, version); } + public boolean allPlanItemsPicked(long planId) { + return items.unfinishedCount(planId) == 0; + } + public LoadingPickHistoryEntity history(String operationId) { return histories.selectOne(new LambdaQueryWrapper() .eq(LoadingPickHistoryEntity::getOperationId, operationId)); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java index b3e9de55d..e774deb0d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java @@ -48,8 +48,9 @@ public class SmartLoadingRepository { .eq(LoadingPlanItemEntity::getLoadingPlanId, planId) .eq(LoadingPlanItemEntity::getId, itemId).eq(LoadingPlanItemEntity::getDeleted, false)); } - public LoadingPlanItemEntity activeItem(long accountId, long detailId) { + public LoadingPlanItemEntity activeItem(long planId, long accountId, long detailId) { return items.selectOne(new LambdaQueryWrapper() + .eq(LoadingPlanItemEntity::getLoadingPlanId, planId) .eq(LoadingPlanItemEntity::getAccountId, accountId) .eq(LoadingPlanItemEntity::getInboundDetailId, detailId) .eq(LoadingPlanItemEntity::getDeleted, false)); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java index 2f363d130..ab1f4813c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java @@ -4,7 +4,6 @@ import com.baiyue.common.audit.BusinessAudit; import com.baiyue.common.core.api.PageResult; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; -import com.baiyue.firstleg.dto.LoadingListDtos.AdvanceRequest; import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedDetail; import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedQuery; import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedSummary; @@ -15,6 +14,7 @@ import com.baiyue.firstleg.dto.LoadingListDtos.TransportUpdateRequest; import com.baiyue.firstleg.mapper.LoadingListQueryMapper.AggregateRow; import com.baiyue.firstleg.mapper.LoadingListQueryMapper.HistoryRow; import com.baiyue.firstleg.repository.LoadingListRepository; +import com.baiyue.firstleg.repository.LoadingPickRepository; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.Resource; @@ -36,6 +36,8 @@ public class LoadingListService { @Resource private LoadingListRepository repository; @Resource + private LoadingPickRepository pickRepository; + @Resource private ObjectMapper objectMapper; public PageResult preloadPage(PreloadQuery query) { @@ -101,40 +103,40 @@ public class LoadingListService { return repository.preloadDetail(requirePlan(planId)); } + /** + * 最后一箱 PDA 拣货完成后自动调用。服务与数据库触发器均以计划全部有效明细为口径复核。 + */ @Transactional - @BusinessAudit(event = "PRELOAD_COMPLETE", resourceType = "LOADING_PLAN", - resourceId = "#planId", description = "完成预装") - public ConfirmedDetail complete(long planId, long operatorId, String operatorName, - String idempotencyKey, AdvanceRequest request) { - advance(planId, operatorId, operatorName, idempotencyKey, request, - "LOADING", "COMPLETED"); - return repository.confirmedDetail(requirePlan(planId)); - } - - private void advance(long planId, long operatorId, String operatorName, String idempotencyKey, - AdvanceRequest request, String fromStatus, String toStatus) { - String requestHash = sha256(toStatus + '|' + planId + '|' + request.expectedVersion() - + '|' + trim(request.operationReason())); - String operationId = operationId(toStatus, planId, idempotencyKey); - HistoryRow replay = repository.history(operationId); - if (replay != null) { - requireSameRequest(replay, requestHash); + @BusinessAudit(event = "PDA_COMPLETE_LOADING", resourceType = "LOADING_PLAN", + resourceId = "#planId", description = "PDA拣货完成后自动生成装柜清单") + public void finishFromPda(long planId, long pickTaskId, long operatorId, String operatorName) { + String operationId = "PDA-COMPLETE-" + planId + '-' + pickTaskId; + if (repository.history(operationId) != null) { return; } - + if (!pickRepository.allPlanItemsPicked(planId)) { + throw conflict("计划仍存在未完成PDA拣货的箱数,不能生成装柜清单"); + } AggregateRow before = requirePlan(planId); - if (!fromStatus.equals(before.getPlanStatus())) { - throw conflict("当前状态为 " + before.getPlanStatus() + ",不能推进到 " + toStatus); + if ("COMPLETED".equals(before.getPlanStatus())) { + return; } - if (repository.advance(planId, fromStatus, toStatus, request.expectedVersion(), + if (!"LOADING".equals(before.getPlanStatus())) { + throw conflict("当前状态为 " + before.getPlanStatus() + ",不能由PDA完成装柜"); + } + if (repository.advance(planId, "LOADING", "COMPLETED", before.getVersion(), operatorId, trim(operatorName)) != 1) { - throw conflict("预装计划已被其他操作修改,请刷新后重试"); + AggregateRow current = requirePlan(planId); + if ("COMPLETED".equals(current.getPlanStatus())) { + return; + } + throw conflict("装柜计划已被其他操作修改,请刷新后重试"); } - repository.appendHistory(planId, operationId, "ADVANCE_PRELOAD_STATUS", - fromStatus, toStatus, json(snapshot(before)), - json(values("requestHash", requestHash, "status", toStatus, - "version", request.expectedVersion() + 1)), operatorId, trim(operatorName), - trim(request.operationReason())); + repository.appendHistory(planId, operationId, "PDA_COMPLETE_LOADING", + "LOADING", "COMPLETED", json(snapshot(before)), + json(values("source", "PDA", "pickTaskId", pickTaskId, "status", "COMPLETED", + "version", before.getVersion() + 1)), operatorId, trim(operatorName), + "全部箱数已完成PDA拣货"); } private AggregateRow requirePlan(long planId) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java index bf75bce82..125b629bd 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java @@ -26,6 +26,8 @@ import java.util.List; public class LoadingPickService { @Resource private LoadingPickRepository repository; + @Resource + private LoadingListService loadingListService; @Transactional public void begin(long planId, String containerNo, long operatorId, String operatorName, @@ -104,6 +106,9 @@ public class LoadingPickService { history.setOperatorId(operatorId); history.setOperatorName(operatorName); repository.insert(history); + if (repository.allPlanItemsPicked(task.getLoadingPlanId())) { + loadingListService.finishFromPda(task.getLoadingPlanId(), taskId, operatorId, operatorName); + } return view(requiredTask(taskId)); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java index 2dac22253..e18f273c8 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java @@ -91,10 +91,11 @@ public class SmartLoadingService { if (candidate == null) throw conflict("入库货物不存在或尚未完成入库"); if (!Objects.equals(candidate.accountId(), request.accountId())) throw forbidden(); OrderCargoClient.CargoBinding cargo = loadCargo(candidate); - LoadingPlanItemEntity existing = repository.activeItem(request.accountId(), request.inboundDetailId()); - if (existing != null && !Objects.equals(existing.getLoadingPlanId(), planId)) throw conflict("货物明细已被有效计划锁定"); + LoadingPlanItemEntity existing = repository.activeItem(planId, request.accountId(), request.inboundDetailId()); int total = request.packageCount() + (existing == null ? 0 : existing.getPackageCount()); - if (total > candidate.availablePackageCount()) throw validation("累计分配箱数超过可用箱数"); + int availableIncludingCurrent = candidate.availablePackageCount() + + (existing == null ? 0 : existing.getPackageCount()); + if (total > availableIncludingCurrent) throw validation("累计分配箱数超过可用箱数"); ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId()); List lines = new ArrayList<>(repository.items(planId)); if (existing != null) lines.removeIf(x -> Objects.equals(x.getId(), existing.getId())); @@ -167,7 +168,7 @@ public class SmartLoadingService { if (!Objects.equals(item.getAccountId(), request.accountId())) throw forbidden(); if (!Objects.equals(item.getVersion(), request.expectedItemVersion())) throw conflict("装柜明细版本冲突"); Candidate candidate = repository.candidate(item.getAccountId(), item.getInboundId(), item.getInboundDetailId()); - if (candidate == null || request.packageCount() > candidate.availablePackageCount()) throw validation("调整箱数超过可用箱数"); + if (candidate == null || request.packageCount() > candidate.availablePackageCount() + item.getPackageCount()) throw validation("调整箱数超过可用箱数"); OrderCargoClient.CargoBinding cargo = loadCargo(candidate); List others = new ArrayList<>(repository.items(planId)); others.removeIf(x -> Objects.equals(x.getId(), itemId)); diff --git a/firstleg/src/main/resources/db/phase3-inbound-parent-child-schema.sql b/firstleg/src/main/resources/db/phase3-inbound-parent-child-schema.sql new file mode 100644 index 000000000..59e495435 --- /dev/null +++ b/firstleg/src/main/resources/db/phase3-inbound-parent-child-schema.sql @@ -0,0 +1,539 @@ +SET NAMES utf8mb4; +USE baiyue_firstleg; + +/* + * 三期主子入仓单结构迁移。 + * 仅建立结构和数据库保护,不自动拆分或回填存量业务数据。 + * tenant_id 继续表示客户隔离键;数量统一使用 package_count。 + */ +DROP PROCEDURE IF EXISTS phase3_inbound_add_column; +DELIMITER $$ +CREATE PROCEDURE phase3_inbound_add_column( + IN p_table_name VARCHAR(64), + IN p_column_name VARCHAR(64), + IN p_definition TEXT +) +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = p_table_name + AND column_name = p_column_name + ) THEN + SET @ddl = CONCAT('ALTER TABLE `', p_table_name, '` ADD COLUMN `', + p_column_name, '` ', p_definition); + PREPARE stmt FROM @ddl; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + END IF; +END$$ +DELIMITER ; + +CALL phase3_inbound_add_column( + 'fl_inbound_order', 'inbound_role', + "VARCHAR(16) NOT NULL DEFAULT 'MASTER' COMMENT '入仓单层级:MASTER主单、CHILD子单' AFTER inbound_code"); +CALL phase3_inbound_add_column( + 'fl_inbound_order', 'parent_inbound_id', + "BIGINT UNSIGNED NULL COMMENT '直属主入仓单ID;主单为空' AFTER inbound_role"); +CALL phase3_inbound_add_column( + 'fl_inbound_order', 'root_inbound_id', + "BIGINT UNSIGNED NULL COMMENT '根主入仓单ID;存量主单可为空,按自身ID解释' AFTER parent_inbound_id"); +CALL phase3_inbound_add_column( + 'fl_inbound_order', 'split_sequence', + "INT UNSIGNED NULL COMMENT '同一主单下的子单顺序号' AFTER root_inbound_id"); +CALL phase3_inbound_add_column( + 'fl_inbound_order', 'split_status', + "VARCHAR(24) NOT NULL DEFAULT 'NOT_SPLIT' COMMENT '拆分状态:NOT_SPLIT、PARTIALLY_SPLIT、FULLY_SPLIT' AFTER split_sequence"); +CALL phase3_inbound_add_column( + 'fl_inbound_order', 'package_count', + "INT UNSIGNED NULL COMMENT '入仓单箱数;三期唯一数量口径' AFTER total_package_count"); +CALL phase3_inbound_add_column( + 'fl_inbound_order', 'master_order_id', + "BIGINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN inbound_role = 'MASTER' AND deleted = 0 THEN order_id ELSE NULL END) STORED COMMENT '仅用于约束有效主单订单唯一性'"); + +DROP PROCEDURE IF EXISTS phase3_inbound_add_column; + +DROP PROCEDURE IF EXISTS phase3_inbound_add_index; +DELIMITER $$ +CREATE PROCEDURE phase3_inbound_add_index( + IN p_table_name VARCHAR(64), + IN p_index_name VARCHAR(64), + IN p_definition TEXT +) +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = p_table_name + AND index_name = p_index_name + ) THEN + SET @ddl = CONCAT('ALTER TABLE `', p_table_name, '` ADD ', p_definition); + PREPARE stmt FROM @ddl; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + END IF; +END$$ +DELIMITER ; + +CALL phase3_inbound_add_index( + 'fl_inbound_order', 'uk_inbound_master_order', + 'UNIQUE KEY `uk_inbound_master_order` (`tenant_id`, `master_order_id`)'); +CALL phase3_inbound_add_index( + 'fl_inbound_order', 'uk_inbound_parent_sequence', + 'UNIQUE KEY `uk_inbound_parent_sequence` (`tenant_id`, `parent_inbound_id`, `split_sequence`)'); +CALL phase3_inbound_add_index( + 'fl_inbound_order', 'idx_inbound_root_role', + 'KEY `idx_inbound_root_role` (`tenant_id`, `root_inbound_id`, `inbound_role`, `created_at`, `id`)'); + +DROP PROCEDURE IF EXISTS phase3_inbound_drop_legacy_index; +DELIMITER $$ +CREATE PROCEDURE phase3_inbound_drop_legacy_index() +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'fl_inbound_order' + AND index_name = 'uk_inbound_tenant_order' + ) THEN + ALTER TABLE fl_inbound_order DROP INDEX uk_inbound_tenant_order; + END IF; +END$$ +DELIMITER ; +CALL phase3_inbound_drop_legacy_index(); +DROP PROCEDURE IF EXISTS phase3_inbound_drop_legacy_index; +DROP PROCEDURE IF EXISTS phase3_inbound_add_index; + +CREATE TABLE IF NOT EXISTS fl_inbound_split_batch ( + id BIGINT UNSIGNED NOT NULL COMMENT '拆分批次ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID', + parent_inbound_id BIGINT UNSIGNED NOT NULL COMMENT '主入仓单ID', + operation_id VARCHAR(64) NOT NULL COMMENT '业务操作幂等号', + idempotency_key VARCHAR(128) NOT NULL COMMENT '请求幂等键', + expected_parent_version INT NOT NULL COMMENT '操作时主单乐观锁版本', + package_count INT UNSIGNED NOT NULL COMMENT '本批次拆分箱数', + status VARCHAR(16) NOT NULL DEFAULT 'PREPARED' COMMENT 'PREPARED、APPLIED、CANCELLED', + remark VARCHAR(500) NULL COMMENT '拆分原因或备注', + created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID', + created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + applied_at DATETIME(3) NULL COMMENT '批次生效时间', + version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', + PRIMARY KEY (id), + UNIQUE KEY uk_inbound_split_operation (tenant_id, operation_id), + UNIQUE KEY uk_inbound_split_idempotency (tenant_id, parent_inbound_id, idempotency_key), + KEY idx_inbound_split_parent (tenant_id, parent_inbound_id, created_at, id), + CONSTRAINT chk_inbound_split_package CHECK (package_count > 0), + CONSTRAINT chk_inbound_split_status CHECK (status IN ('PREPARED', 'APPLIED', 'CANCELLED')), + CONSTRAINT fk_inbound_split_parent FOREIGN KEY (parent_inbound_id) REFERENCES fl_inbound_order (id) +) ENGINE=InnoDB COMMENT='主入仓单拆分批次;数量口径仅为箱数'; + +CREATE TABLE IF NOT EXISTS fl_inbound_split_item ( + id BIGINT UNSIGNED NOT NULL COMMENT '拆分明细ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID', + split_batch_id BIGINT UNSIGNED NOT NULL COMMENT '拆分批次ID', + parent_inbound_id BIGINT UNSIGNED NOT NULL COMMENT '主入仓单ID', + child_inbound_id BIGINT UNSIGNED NOT NULL COMMENT '子入仓单ID', + source_inbound_detail_id BIGINT UNSIGNED NULL COMMENT '订单货物明细逻辑ID', + source_measurement_id BIGINT UNSIGNED NULL COMMENT '来源测量批次ID', + source_measurement_item_id BIGINT UNSIGNED NULL COMMENT '来源测量箱规明细ID', + package_count INT UNSIGNED NOT NULL COMMENT '分配给子单的箱数', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_inbound_split_child (tenant_id, child_inbound_id), + UNIQUE KEY uk_inbound_split_batch_child (tenant_id, split_batch_id, child_inbound_id), + KEY idx_inbound_split_detail (tenant_id, source_inbound_detail_id, created_at), + CONSTRAINT chk_inbound_split_item_package CHECK (package_count > 0), + CONSTRAINT fk_inbound_split_item_batch FOREIGN KEY (split_batch_id) REFERENCES fl_inbound_split_batch (id), + CONSTRAINT fk_inbound_split_item_parent FOREIGN KEY (parent_inbound_id) REFERENCES fl_inbound_order (id), + CONSTRAINT fk_inbound_split_item_child FOREIGN KEY (child_inbound_id) REFERENCES fl_inbound_order (id), + CONSTRAINT fk_inbound_split_item_measurement FOREIGN KEY (source_measurement_id) REFERENCES fl_inbound_measurement (id), + CONSTRAINT fk_inbound_split_item_measure_item FOREIGN KEY (source_measurement_item_id) REFERENCES fl_inbound_measurement_item (id) +) ENGINE=InnoDB COMMENT='主子入仓单拆分数量台账'; + +CREATE TABLE IF NOT EXISTS fl_inbound_split_history ( + id BIGINT UNSIGNED NOT NULL COMMENT '拆分历史ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID', + split_batch_id BIGINT UNSIGNED NOT NULL COMMENT '拆分批次ID', + operation_id VARCHAR(64) NOT NULL COMMENT '历史操作幂等号', + action_type VARCHAR(24) NOT NULL COMMENT 'CREATE、APPLY、CANCEL', + before_snapshot JSON NULL COMMENT '操作前快照', + after_snapshot JSON NOT NULL COMMENT '操作后快照', + operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID', + operator_name VARCHAR(64) NULL COMMENT '操作人名称快照', + operation_reason VARCHAR(500) NULL COMMENT '操作原因', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_inbound_split_history_operation (tenant_id, operation_id), + KEY idx_inbound_split_history (tenant_id, split_batch_id, created_at, id), + CONSTRAINT chk_inbound_split_action CHECK (action_type IN ('CREATE', 'APPLY', 'CANCEL')), + CONSTRAINT fk_inbound_split_history_batch FOREIGN KEY (split_batch_id) REFERENCES fl_inbound_split_batch (id) +) ENGINE=InnoDB COMMENT='不可变主子入仓单拆分审计历史'; + +DROP PROCEDURE IF EXISTS phase3_inbound_add_column; +DELIMITER $$ +CREATE PROCEDURE phase3_inbound_add_column( + IN p_table_name VARCHAR(64), + IN p_column_name VARCHAR(64), + IN p_definition TEXT +) +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = p_table_name + AND column_name = p_column_name + ) THEN + SET @ddl = CONCAT('ALTER TABLE `', p_table_name, '` ADD COLUMN `', + p_column_name, '` ', p_definition); + PREPARE stmt FROM @ddl; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + END IF; +END$$ +DELIMITER ; +CALL phase3_inbound_add_column( + 'fl_inbound_measurement', 'split_batch_id', + "BIGINT UNSIGNED NULL COMMENT '产生该测量批次的入仓拆分批次ID' AFTER inbound_id"); +CALL phase3_inbound_add_column( + 'fl_inbound_measurement', 'source_measurement_id', + "BIGINT UNSIGNED NULL COMMENT '子单继承或复核时的来源测量批次ID' AFTER split_batch_id"); +CALL phase3_inbound_add_column( + 'fl_inbound_measurement_item', 'split_item_id', + "BIGINT UNSIGNED NULL COMMENT '产生该子单测量明细的拆分台账ID' AFTER measurement_id"); +CALL phase3_inbound_add_column( + 'fl_inbound_measurement_item', 'source_measurement_item_id', + "BIGINT UNSIGNED NULL COMMENT '被拆分继承的来源测量箱规明细ID' AFTER split_item_id"); +DROP PROCEDURE IF EXISTS phase3_inbound_add_column; + +DROP PROCEDURE IF EXISTS phase3_inbound_add_relation; +DELIMITER $$ +CREATE PROCEDURE phase3_inbound_add_relation( + IN p_table_name VARCHAR(64), + IN p_constraint_name VARCHAR(64), + IN p_definition TEXT +) +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.table_constraints + WHERE constraint_schema = DATABASE() + AND table_name = p_table_name + AND constraint_name = p_constraint_name + ) THEN + SET @ddl = CONCAT('ALTER TABLE `', p_table_name, '` ADD CONSTRAINT `', + p_constraint_name, '` ', p_definition); + PREPARE stmt FROM @ddl; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + END IF; +END$$ +DELIMITER ; +CALL phase3_inbound_add_relation( + 'fl_inbound_order', 'fk_inbound_parent_order', + 'FOREIGN KEY (`parent_inbound_id`) REFERENCES `fl_inbound_order` (`id`)'); +CALL phase3_inbound_add_relation( + 'fl_inbound_order', 'fk_inbound_root_order', + 'FOREIGN KEY (`root_inbound_id`) REFERENCES `fl_inbound_order` (`id`)'); +CALL phase3_inbound_add_relation( + 'fl_inbound_measurement', 'fk_measure_split_batch', + 'FOREIGN KEY (`split_batch_id`) REFERENCES `fl_inbound_split_batch` (`id`)'); +CALL phase3_inbound_add_relation( + 'fl_inbound_measurement', 'fk_measure_source_batch', + 'FOREIGN KEY (`source_measurement_id`) REFERENCES `fl_inbound_measurement` (`id`)'); +CALL phase3_inbound_add_relation( + 'fl_inbound_measurement_item', 'fk_measure_item_split_item', + 'FOREIGN KEY (`split_item_id`) REFERENCES `fl_inbound_split_item` (`id`)'); +CALL phase3_inbound_add_relation( + 'fl_inbound_measurement_item', 'fk_measure_item_source_item', + 'FOREIGN KEY (`source_measurement_item_id`) REFERENCES `fl_inbound_measurement_item` (`id`)'); +DROP PROCEDURE IF EXISTS phase3_inbound_add_relation; + +DROP PROCEDURE IF EXISTS phase3_inbound_add_index; +DELIMITER $$ +CREATE PROCEDURE phase3_inbound_add_index( + IN p_table_name VARCHAR(64), + IN p_index_name VARCHAR(64), + IN p_definition TEXT +) +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = p_table_name + AND index_name = p_index_name + ) THEN + SET @ddl = CONCAT('ALTER TABLE `', p_table_name, '` ADD ', p_definition); + PREPARE stmt FROM @ddl; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + END IF; +END$$ +DELIMITER ; +CALL phase3_inbound_add_index( + 'fl_inbound_measurement', 'idx_measure_split_source', + 'KEY `idx_measure_split_source` (`tenant_id`, `split_batch_id`, `source_measurement_id`, `created_at`)'); +CALL phase3_inbound_add_index( + 'fl_inbound_measurement_item', 'idx_measure_item_split_source', + 'KEY `idx_measure_item_split_source` (`tenant_id`, `split_item_id`, `source_measurement_item_id`)'); +DROP PROCEDURE IF EXISTS phase3_inbound_add_index; + +DROP TRIGGER IF EXISTS trg_inbound_order_parent_before_insert; +DROP TRIGGER IF EXISTS trg_inbound_order_parent_before_update; +DROP TRIGGER IF EXISTS trg_inbound_split_item_before_insert; +DROP TRIGGER IF EXISTS trg_inbound_split_batch_before_insert; +DROP TRIGGER IF EXISTS trg_inbound_split_batch_before_update; +DROP TRIGGER IF EXISTS trg_inbound_split_item_before_update; +DROP TRIGGER IF EXISTS trg_inbound_split_item_before_delete; +DROP TRIGGER IF EXISTS trg_inbound_split_history_before_update; +DROP TRIGGER IF EXISTS trg_inbound_split_history_before_delete; + +DELIMITER $$ +CREATE TRIGGER trg_inbound_order_parent_before_insert +BEFORE INSERT ON fl_inbound_order +FOR EACH ROW +BEGIN + IF NEW.inbound_role NOT IN ('MASTER', 'CHILD') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入仓单层级非法'; + END IF; + IF NEW.split_status NOT IN ('NOT_SPLIT', 'PARTIALLY_SPLIT', 'FULLY_SPLIT') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入仓单拆分状态非法'; + END IF; + IF NEW.package_count IS NOT NULL AND NEW.package_count = 0 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入仓单箱数必须大于0'; + END IF; + IF NEW.inbound_role = 'MASTER' AND NEW.parent_inbound_id IS NOT NULL THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主入仓单不能绑定父单'; + END IF; + IF NEW.inbound_role = 'CHILD' THEN + IF NEW.parent_inbound_id IS NULL OR NEW.root_inbound_id IS NULL + OR NEW.split_sequence IS NULL OR NEW.package_count IS NULL THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '子入仓单缺少主单、顺序号或箱数'; + END IF; + IF NEW.split_status <> 'NOT_SPLIT' THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主子仅两级,子入仓单不可继续拆分'; + END IF; + IF NOT EXISTS ( + SELECT 1 FROM fl_inbound_order parent_order + WHERE parent_order.id = NEW.parent_inbound_id + AND parent_order.tenant_id = NEW.tenant_id + AND parent_order.inbound_role = 'MASTER' + AND parent_order.deleted = 0 + ) OR NEW.root_inbound_id <> NEW.parent_inbound_id THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '子入仓单必须绑定同一客户的有效主单'; + END IF; + END IF; +END$$ + +CREATE TRIGGER trg_inbound_order_parent_before_update +BEFORE UPDATE ON fl_inbound_order +FOR EACH ROW +BEGIN + IF NOT (OLD.inbound_role <=> NEW.inbound_role) + OR NOT (OLD.parent_inbound_id <=> NEW.parent_inbound_id) + OR NOT (OLD.root_inbound_id <=> NEW.root_inbound_id) + OR NOT (OLD.split_sequence <=> NEW.split_sequence) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入仓单主子归属创建后不可修改'; + END IF; + IF NEW.inbound_role = 'CHILD' + AND (NEW.package_count IS NULL OR NEW.package_count = 0) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '子入仓单箱数必须大于0'; + END IF; + IF NEW.split_status NOT IN ('NOT_SPLIT', 'PARTIALLY_SPLIT', 'FULLY_SPLIT') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入仓单拆分状态非法'; + END IF; + IF NEW.inbound_role = 'CHILD' AND NEW.split_status <> 'NOT_SPLIT' THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主子仅两级,子入仓单不可继续拆分'; + END IF; + IF NEW.inbound_role = 'MASTER' + AND ((NEW.package_count = 0 AND NEW.split_status <> 'FULLY_SPLIT') + OR (NEW.split_status = 'FULLY_SPLIT' AND COALESCE(NEW.package_count, 0) <> 0)) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主单箱数归零必须与完全拆分状态同时提交'; + END IF; +END$$ + +CREATE TRIGGER trg_inbound_split_item_before_insert +BEFORE INSERT ON fl_inbound_split_item +FOR EACH ROW +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM fl_inbound_split_batch batch + JOIN fl_inbound_order parent_order ON parent_order.id = batch.parent_inbound_id + JOIN fl_inbound_order child_order ON child_order.id = NEW.child_inbound_id + WHERE batch.id = NEW.split_batch_id + AND batch.tenant_id = NEW.tenant_id + AND batch.parent_inbound_id = NEW.parent_inbound_id + AND batch.status = 'PREPARED' + AND parent_order.tenant_id = NEW.tenant_id + AND parent_order.inbound_role = 'MASTER' + AND child_order.tenant_id = NEW.tenant_id + AND child_order.inbound_role = 'CHILD' + AND child_order.parent_inbound_id = NEW.parent_inbound_id + AND child_order.package_count = NEW.package_count + AND child_order.deleted = 0 + AND (NEW.source_measurement_item_id IS NULL OR EXISTS ( + SELECT 1 + FROM fl_inbound_measurement_item source_item + JOIN fl_inbound_measurement source_batch ON source_batch.id = source_item.measurement_id + WHERE source_item.id = NEW.source_measurement_item_id + AND source_item.tenant_id = NEW.tenant_id + AND source_item.measurement_id = NEW.source_measurement_id + AND source_batch.inbound_id = NEW.parent_inbound_id + )) + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分明细与批次、主单或子单不一致'; + END IF; +END$$ + +CREATE TRIGGER trg_inbound_split_batch_before_insert +BEFORE INSERT ON fl_inbound_split_batch +FOR EACH ROW +BEGIN + IF NEW.status <> 'PREPARED' OR NEW.applied_at IS NOT NULL THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分批次必须从PREPARED创建'; + END IF; + IF NOT EXISTS ( + SELECT 1 FROM fl_inbound_order parent_order + WHERE parent_order.id = NEW.parent_inbound_id + AND parent_order.tenant_id = NEW.tenant_id + AND parent_order.inbound_role = 'MASTER' + AND parent_order.deleted = 0 + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分批次主单不存在或客户归属不一致'; + END IF; +END$$ + +CREATE TRIGGER trg_inbound_split_batch_before_update +BEFORE UPDATE ON fl_inbound_split_batch +FOR EACH ROW +BEGIN + IF OLD.status IN ('APPLIED', 'CANCELLED') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已结束拆分批次不可修改'; + END IF; + IF NEW.status = 'APPLIED' THEN + IF NEW.applied_at IS NULL + OR NOT EXISTS ( + SELECT 1 FROM fl_inbound_order parent_order + WHERE parent_order.id = NEW.parent_inbound_id + AND parent_order.tenant_id = NEW.tenant_id + AND parent_order.inbound_role = 'MASTER' + AND parent_order.package_count = 0 + AND parent_order.split_status = 'FULLY_SPLIT' + AND parent_order.version > NEW.expected_parent_version + ) OR + (SELECT COALESCE(SUM(item.package_count), 0) + FROM fl_inbound_split_item item + WHERE item.tenant_id = NEW.tenant_id + AND item.split_batch_id = NEW.id) <> NEW.package_count THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主单可用箱数未归零或子单箱数合计不一致'; + END IF; + IF EXISTS ( + SELECT 1 + FROM fl_inbound_split_item split_item + JOIN fl_inbound_measurement_item source_item + ON source_item.id = split_item.source_measurement_item_id + WHERE split_item.tenant_id = NEW.tenant_id + AND split_item.split_batch_id = NEW.id + GROUP BY split_item.source_measurement_item_id, source_item.package_count + HAVING SUM(split_item.package_count) > source_item.package_count + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '子单承接箱数超过来源测量箱规箱数'; + END IF; + ELSEIF NEW.status = 'CANCELLED' THEN + IF EXISTS ( + SELECT 1 FROM fl_inbound_split_item item + WHERE item.tenant_id = NEW.tenant_id + AND item.split_batch_id = NEW.id + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已有子单明细的拆分批次不可取消'; + END IF; + ELSEIF NEW.status <> 'PREPARED' THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分批次状态流转非法'; + END IF; +END$$ + +CREATE TRIGGER trg_inbound_split_item_before_update +BEFORE UPDATE ON fl_inbound_split_item +FOR EACH ROW +BEGIN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分数量台账不可修改'; +END$$ + +CREATE TRIGGER trg_inbound_split_item_before_delete +BEFORE DELETE ON fl_inbound_split_item +FOR EACH ROW +BEGIN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分数量台账不可删除'; +END$$ + +CREATE TRIGGER trg_inbound_split_history_before_update +BEFORE UPDATE ON fl_inbound_split_history +FOR EACH ROW +BEGIN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分历史不可修改'; +END$$ + +CREATE TRIGGER trg_inbound_split_history_before_delete +BEFORE DELETE ON fl_inbound_split_history +FOR EACH ROW +BEGIN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分历史不可删除'; +END$$ +DELIMITER ; + +DROP PROCEDURE IF EXISTS phase3_assert_inbound_parent_child_schema; +DELIMITER $$ +CREATE PROCEDURE phase3_assert_inbound_parent_child_schema() +BEGIN + IF (SELECT COUNT(*) FROM information_schema.tables + WHERE table_schema = DATABASE() + AND table_name IN ('fl_inbound_split_batch', 'fl_inbound_split_item', + 'fl_inbound_split_history')) <> 3 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主子入仓单拆分台账未完整创建'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'fl_inbound_order' + AND column_name IN ('inbound_role', 'parent_inbound_id', 'root_inbound_id', + 'split_sequence', 'split_status', 'package_count', + 'master_order_id')) <> 7 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主子入仓单关系字段未完整创建'; + END IF; + IF (SELECT COUNT(DISTINCT index_name) FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND index_name IN ('uk_inbound_master_order', 'uk_inbound_parent_sequence', + 'uk_inbound_split_operation', 'uk_inbound_split_idempotency', + 'uk_inbound_split_child', 'uk_inbound_split_history_operation')) <> 6 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主子入仓单唯一约束未完整创建'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.table_constraints + WHERE constraint_schema = DATABASE() + AND constraint_name IN ('fk_inbound_parent_order', 'fk_inbound_root_order', + 'fk_measure_split_batch', 'fk_measure_source_batch', + 'fk_measure_item_split_item', 'fk_measure_item_source_item')) <> 6 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主子入仓单与测量关联约束未完整创建'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.triggers + WHERE trigger_schema = DATABASE() + AND trigger_name IN ('trg_inbound_order_parent_before_insert', + 'trg_inbound_order_parent_before_update', + 'trg_inbound_split_item_before_insert', + 'trg_inbound_split_batch_before_insert', + 'trg_inbound_split_batch_before_update', + 'trg_inbound_split_item_before_update', + 'trg_inbound_split_item_before_delete', + 'trg_inbound_split_history_before_update', + 'trg_inbound_split_history_before_delete')) <> 9 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主子入仓单数据库保护触发器未完整创建'; + END IF; + IF EXISTS ( + SELECT 1 FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'fl_inbound_order' + AND index_name = 'uk_inbound_tenant_order' + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '旧订单唯一索引仍阻止创建子入仓单'; + END IF; +END$$ +DELIMITER ; +CALL phase3_assert_inbound_parent_child_schema(); +DROP PROCEDURE IF EXISTS phase3_assert_inbound_parent_child_schema; diff --git a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql index f11c7df8f..7ae37492e 100644 --- a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql +++ b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql @@ -139,7 +139,8 @@ CREATE TABLE IF NOT EXISTS fl_loading_plan_item ( COMMENT '更新时间', version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', PRIMARY KEY (id), - UNIQUE KEY uk_loading_item_active_detail (account_id, active_inbound_detail_id), + UNIQUE KEY uk_loading_item_plan_detail (loading_plan_id, active_inbound_detail_id), + KEY idx_loading_item_active_detail (account_id, active_inbound_detail_id), KEY idx_loading_item_plan (loading_plan_id, deleted, load_sequence, id), KEY idx_loading_item_account (account_id, created_at, id), KEY idx_loading_item_inbound (account_id, inbound_id, deleted), @@ -705,8 +706,8 @@ BEGIN END IF; IF (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_plan_item' - AND index_name = 'uk_loading_item_active_detail' AND non_unique = 0) <> 2 THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:唯一活动明细锁缺失'; + AND index_name = 'uk_loading_item_plan_detail' AND non_unique = 0) <> 2 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:计划内唯一活动明细锁缺失'; END IF; IF (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = 'baiyue_firstleg' diff --git a/firstleg/src/main/resources/db/phase3-split-preload-schema.sql b/firstleg/src/main/resources/db/phase3-split-preload-schema.sql index c521497b7..8a0414b9b 100644 --- a/firstleg/src/main/resources/db/phase3-split-preload-schema.sql +++ b/firstleg/src/main/resources/db/phase3-split-preload-schema.sql @@ -37,3 +37,236 @@ CREATE TABLE IF NOT EXISTS fl_split_operation ( KEY idx_split_operation_time (tenant_id, inbound_id, created_at), CONSTRAINT fk_split_operation_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) ) ENGINE=InnoDB COMMENT='分柜操作幂等与审计索引'; + +/* + * 部分装柜拆单契约: + * 1. parent_inbound_id 为空的是主单,非空的是唯一二级子单; + * 2. 锁定阶段只记录箱数,完成阶段绑定由剩余箱数生成的子单; + * 3. 子单不能再作为父单,锁定数量创建后不可修改。 + */ +DROP PROCEDURE IF EXISTS phase3_split_add_column; +DELIMITER $$ +CREATE PROCEDURE phase3_split_add_column( + IN p_table_name VARCHAR(64), IN p_column_name VARCHAR(64), IN p_definition TEXT +) +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = p_table_name AND column_name = p_column_name + ) THEN + SET @phase3_split_ddl = CONCAT('ALTER TABLE `', p_table_name, '` ADD COLUMN `', + p_column_name, '` ', p_definition); + PREPARE phase3_split_stmt FROM @phase3_split_ddl; + EXECUTE phase3_split_stmt; + DEALLOCATE PREPARE phase3_split_stmt; + END IF; +END$$ +DELIMITER ; + +CALL phase3_split_add_column('fl_inbound_order', 'parent_inbound_id', + 'BIGINT UNSIGNED NULL COMMENT ''生成本单的主入库单ID;仅二级子单有值'' AFTER id'); +CALL phase3_split_add_column('fl_inbound_order', 'split_level', + 'TINYINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN parent_inbound_id IS NULL THEN 0 ELSE 1 END) STORED COMMENT ''拆单层级:0主单、1二级子单'' AFTER parent_inbound_id'); +CALL phase3_split_add_column('fl_inbound_order', 'split_status', + 'VARCHAR(24) NOT NULL DEFAULT ''NOT_SPLIT'' COMMENT ''NOT_SPLIT/PARTIALLY_SPLIT/FULLY_SPLIT'' AFTER split_level'); +DROP PROCEDURE IF EXISTS phase3_split_add_column; + +SET @phase3_split_index_exists = ( + SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_order' + AND index_name = 'uk_inbound_one_secondary' +); +SET @phase3_split_ddl = IF(@phase3_split_index_exists = 0, + 'ALTER TABLE fl_inbound_order ADD UNIQUE KEY uk_inbound_one_secondary (tenant_id, parent_inbound_id)', + 'SELECT 1'); +PREPARE phase3_split_stmt FROM @phase3_split_ddl; +EXECUTE phase3_split_stmt; +DEALLOCATE PREPARE phase3_split_stmt; + +SET @phase3_split_fk_exists = ( + SELECT COUNT(*) FROM information_schema.table_constraints + WHERE constraint_schema = DATABASE() AND table_name = 'fl_inbound_order' + AND constraint_name = 'fk_inbound_parent' AND constraint_type = 'FOREIGN KEY' +); +SET @phase3_split_ddl = IF(@phase3_split_fk_exists = 0, + 'ALTER TABLE fl_inbound_order ADD CONSTRAINT fk_inbound_parent FOREIGN KEY (parent_inbound_id) REFERENCES fl_inbound_order (id)', + 'SELECT 1'); +PREPARE phase3_split_stmt FROM @phase3_split_ddl; +EXECUTE phase3_split_stmt; +DEALLOCATE PREPARE phase3_split_stmt; + +CREATE TABLE IF NOT EXISTS fl_inbound_split_detail ( + id BIGINT UNSIGNED NOT NULL COMMENT '部分锁定明细ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID', + parent_inbound_id BIGINT UNSIGNED NOT NULL COMMENT '主入库单ID', + child_inbound_id BIGINT UNSIGNED NULL COMMENT '完成拆单后生成的二级子入库单ID', + inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '原订单货物明细ID,不建立跨库外键', + source_package_count INT UNSIGNED NOT NULL COMMENT '拆分前该明细总箱数快照', + locked_package_count INT UNSIGNED NOT NULL COMMENT '本次装柜锁定箱数', + remaining_package_count INT UNSIGNED GENERATED ALWAYS AS ( + source_package_count - locked_package_count + ) STORED COMMENT '生成二级子单的剩余箱数', + status VARCHAR(16) NOT NULL DEFAULT 'LOCKED' COMMENT 'LOCKED/COMPLETED', + operation_id VARCHAR(128) NOT NULL COMMENT '部分锁定操作幂等号', + created_by BIGINT UNSIGNED NOT NULL, + created_by_name VARCHAR(64) NULL, + completed_by BIGINT UNSIGNED NULL, + completed_by_name VARCHAR(64) NULL, + completed_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + version INT UNSIGNED NOT NULL DEFAULT 0, + PRIMARY KEY (id), + UNIQUE KEY uk_inbound_split_parent_detail (tenant_id, parent_inbound_id, inbound_detail_id), + UNIQUE KEY uk_inbound_split_child_detail (tenant_id, child_inbound_id, inbound_detail_id), + UNIQUE KEY uk_inbound_split_operation (operation_id), + KEY idx_inbound_split_status (tenant_id, parent_inbound_id, status), + CONSTRAINT fk_inbound_split_detail_parent FOREIGN KEY (parent_inbound_id) REFERENCES fl_inbound_order (id), + CONSTRAINT fk_inbound_split_detail_child FOREIGN KEY (child_inbound_id) REFERENCES fl_inbound_order (id), + CONSTRAINT chk_inbound_split_partial CHECK ( + source_package_count > 1 AND locked_package_count > 0 + AND locked_package_count < source_package_count + ), + CONSTRAINT chk_inbound_split_status CHECK (status IN ('LOCKED', 'COMPLETED')), + CONSTRAINT chk_inbound_split_completion CHECK ( + (status = 'LOCKED' AND child_inbound_id IS NULL AND completed_at IS NULL) + OR (status = 'COMPLETED' AND child_inbound_id IS NOT NULL AND completed_at IS NOT NULL) + ) +) ENGINE=InnoDB COMMENT='按箱数部分锁定及剩余箱数生成二级子入库单的不可变契约'; + +DROP TRIGGER IF EXISTS trg_inbound_split_before_insert; +DROP TRIGGER IF EXISTS trg_inbound_split_before_update; +DROP TRIGGER IF EXISTS trg_inbound_split_detail_before_insert; +DROP TRIGGER IF EXISTS trg_inbound_split_detail_before_update; +DROP TRIGGER IF EXISTS trg_inbound_split_detail_before_delete; +DELIMITER $$ +CREATE TRIGGER trg_inbound_split_before_insert +BEFORE INSERT ON fl_inbound_order +FOR EACH ROW +BEGIN + DECLARE v_parent_tenant BIGINT UNSIGNED; + DECLARE v_parent_parent BIGINT UNSIGNED; + IF NEW.parent_inbound_id IS NOT NULL THEN + SELECT tenant_id, parent_inbound_id INTO v_parent_tenant, v_parent_parent + FROM fl_inbound_order WHERE id = NEW.parent_inbound_id AND deleted = 0; + IF v_parent_tenant <> NEW.tenant_id THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '二级子入库单与主单客户归属不一致'; + END IF; + IF v_parent_parent IS NOT NULL THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '二级子入库单不能继续拆分'; + END IF; + IF NEW.split_status <> 'NOT_SPLIT' THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '二级子入库单拆单状态必须为NOT_SPLIT'; + END IF; + END IF; +END$$ + +CREATE TRIGGER trg_inbound_split_before_update +BEFORE UPDATE ON fl_inbound_order +FOR EACH ROW +BEGIN + IF NOT (NEW.parent_inbound_id <=> OLD.parent_inbound_id) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入库单主子关系创建后不可修改'; + END IF; + IF OLD.parent_inbound_id IS NOT NULL AND NEW.split_status <> 'NOT_SPLIT' THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '二级子入库单不能继续拆分'; + END IF; + IF OLD.parent_inbound_id IS NULL + AND NEW.split_status NOT IN ('NOT_SPLIT', 'PARTIALLY_SPLIT', 'FULLY_SPLIT') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主入库单拆单状态非法'; + END IF; + IF OLD.split_status = 'FULLY_SPLIT' AND NEW.split_status <> 'FULLY_SPLIT' THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已完成拆单不能回退'; + END IF; + IF NEW.split_status = 'FULLY_SPLIT' AND ( + (SELECT COUNT(*) FROM fl_inbound_order child + WHERE child.tenant_id = NEW.tenant_id AND child.parent_inbound_id = NEW.id + AND child.deleted = 0) <> 1 + OR (SELECT COUNT(*) FROM fl_inbound_split_detail detail + WHERE detail.tenant_id = NEW.tenant_id AND detail.parent_inbound_id = NEW.id) = 0 + OR EXISTS (SELECT 1 FROM fl_inbound_split_detail detail + WHERE detail.tenant_id = NEW.tenant_id AND detail.parent_inbound_id = NEW.id + AND detail.status <> 'COMPLETED') + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '剩余箱数二级子单及拆分明细未完成'; + END IF; +END$$ + +CREATE TRIGGER trg_inbound_split_detail_before_insert +BEFORE INSERT ON fl_inbound_split_detail +FOR EACH ROW +BEGIN + DECLARE v_parent_tenant BIGINT UNSIGNED; + DECLARE v_parent_parent BIGINT UNSIGNED; + SELECT tenant_id, parent_inbound_id INTO v_parent_tenant, v_parent_parent + FROM fl_inbound_order WHERE id = NEW.parent_inbound_id AND deleted = 0; + IF v_parent_tenant <> NEW.tenant_id OR v_parent_parent IS NOT NULL THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '部分锁定只能发生在客户所属主入库单'; + END IF; + IF NEW.status <> 'LOCKED' OR NEW.child_inbound_id IS NOT NULL THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '部分锁定创建时不能提前绑定二级子入库单'; + END IF; +END$$ + +CREATE TRIGGER trg_inbound_split_detail_before_update +BEFORE UPDATE ON fl_inbound_split_detail +FOR EACH ROW +BEGIN + DECLARE v_child_tenant BIGINT UNSIGNED; + DECLARE v_child_parent BIGINT UNSIGNED; + IF NEW.tenant_id <> OLD.tenant_id OR NEW.parent_inbound_id <> OLD.parent_inbound_id + OR NEW.inbound_detail_id <> OLD.inbound_detail_id + OR NEW.source_package_count <> OLD.source_package_count + OR NEW.locked_package_count <> OLD.locked_package_count + OR NEW.operation_id <> OLD.operation_id THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '部分锁定的归属和箱数创建后不可修改'; + END IF; + IF OLD.status <> 'LOCKED' OR NEW.status <> 'COMPLETED' OR NEW.child_inbound_id IS NULL THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '部分锁定只允许完成并绑定二级子入库单'; + END IF; + SELECT tenant_id, parent_inbound_id INTO v_child_tenant, v_child_parent + FROM fl_inbound_order WHERE id = NEW.child_inbound_id AND deleted = 0; + IF v_child_tenant <> NEW.tenant_id OR v_child_parent <> NEW.parent_inbound_id THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '剩余箱数必须绑定主单唯一二级子入库单'; + END IF; +END$$ + +CREATE TRIGGER trg_inbound_split_detail_before_delete +BEFORE DELETE ON fl_inbound_split_detail +FOR EACH ROW +BEGIN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '部分锁定与拆单明细不可删除'; +END$$ +DELIMITER ; + +DROP PROCEDURE IF EXISTS phase3_split_assert_contract; +DELIMITER $$ +CREATE PROCEDURE phase3_split_assert_contract() +BEGIN + IF (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_order' + AND column_name IN ('parent_inbound_id', 'split_level', 'split_status')) <> 3 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆单迁移失败:入库单主子关系字段未完整创建'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.tables + WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_split_detail') <> 1 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆单迁移失败:部分锁定明细表未创建'; + END IF; + IF (SELECT COUNT(DISTINCT index_name) FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND index_name IN ('uk_inbound_one_secondary', 'uk_inbound_split_parent_detail', + 'uk_inbound_split_child_detail', 'uk_inbound_split_operation')) <> 4 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆单迁移失败:唯一子单或部分锁定幂等索引缺失'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.triggers + WHERE trigger_schema = DATABASE() + AND trigger_name IN ('trg_inbound_split_before_insert', 'trg_inbound_split_before_update', + 'trg_inbound_split_detail_before_insert', + 'trg_inbound_split_detail_before_update', + 'trg_inbound_split_detail_before_delete')) <> 5 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆单迁移失败:主子层级或部分锁定触发器未完整创建'; + END IF; +END$$ +DELIMITER ; +CALL phase3_split_assert_contract(); +DROP PROCEDURE IF EXISTS phase3_split_assert_contract; diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java index 7cd2228db..b41fd1c18 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java @@ -2,7 +2,6 @@ package com.baiyue.firstleg.controller; import com.baiyue.common.audit.BusinessAudit; import com.baiyue.common.security.AuthenticatedUser; -import com.baiyue.firstleg.dto.LoadingListDtos.AdvanceRequest; import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedQuery; import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery; import com.baiyue.firstleg.dto.LoadingListDtos.TransportUpdateRequest; @@ -27,8 +26,6 @@ class LoadingListControllerContractTest { authority("preloadDetail", "platform:firstleg:preload:view", AuthenticatedUser.class, long.class); authority("updateTransport", "platform:firstleg:preload:edit", AuthenticatedUser.class, long.class, String.class, TransportUpdateRequest.class); - authority("complete", "platform:firstleg:preload:complete", AuthenticatedUser.class, - long.class, String.class, AdvanceRequest.class); authority("confirmedPage", "platform:firstleg:loading-list:list", AuthenticatedUser.class, ConfirmedQuery.class); authority("confirmedDetail", "platform:firstleg:loading-list:view", AuthenticatedUser.class, long.class); @@ -38,8 +35,6 @@ class LoadingListControllerContractTest { void writesAudited() throws Exception { audit("updateTransport", "PRELOAD_TRANSPORT_UPDATE", long.class, long.class, String.class, String.class, TransportUpdateRequest.class); - audit("complete", "PRELOAD_COMPLETE", long.class, long.class, String.class, - String.class, AdvanceRequest.class); } @Test @@ -69,6 +64,12 @@ class LoadingListControllerContractTest { .doesNotContain("/items/{itemId}"); } + @Test + void doesNotExposeManualCompletion() { + assertThat(PlatformLoadingListController.class.getDeclaredMethods()) + .noneMatch(method -> method.getName().equals("complete")); + } + @Test void yamlParses() throws Exception { String yaml = new ClassPathResource("openapi/loading-lists-v1.yaml") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundParentChildSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundParentChildSqlContractTest.java new file mode 100644 index 000000000..57677aa44 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundParentChildSqlContractTest.java @@ -0,0 +1,105 @@ +package com.baiyue.firstleg.repository; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("主子入仓单SQL契约") +class InboundParentChildSqlContractTest { + + private final String sql = loadSql(); + + @Test + void definesParentChildRelation() { + assertThat(sql) + .contains("'fl_inbound_order', 'inbound_role'") + .contains("'fl_inbound_order', 'parent_inbound_id'") + .contains("'fl_inbound_order', 'root_inbound_id'") + .contains("'fl_inbound_order', 'package_count'") + .contains("GENERATED ALWAYS AS (CASE WHEN inbound_role = 'MASTER'") + .contains("DROP INDEX uk_inbound_tenant_order") + .contains("uk_inbound_master_order") + .contains("uk_inbound_parent_sequence"); + } + + @Test + void definesSplitLedger() { + assertThat(sql) + .contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_batch") + .contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_item") + .contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_history") + .contains("package_count INT UNSIGNED NOT NULL") + .doesNotContain("quantity INT", "item_count INT", "carton_count INT"); + } + + @Test + void protectsOwnershipAndTotals() { + assertThat(sql) + .contains("子入仓单必须绑定同一客户的有效主单") + .contains("parent_order.package_count = 0") + .contains("parent_order.split_status = 'FULLY_SPLIT'") + .contains("parent_order.version > NEW.expected_parent_version") + .contains("主单可用箱数未归零或子单箱数合计不一致") + .contains("SUM(split_item.package_count) > source_item.package_count") + .contains("子单承接箱数超过来源测量箱规箱数") + .contains("child_order.package_count = NEW.package_count") + .contains("拆分数量台账不可修改") + .contains("拆分数量台账不可删除") + .contains("拆分历史不可修改") + .contains("拆分历史不可删除"); + } + + @Test + void tracesMeasurements() { + assertThat(sql) + .contains("'fl_inbound_measurement', 'split_batch_id'") + .contains("'fl_inbound_measurement', 'source_measurement_id'") + .contains("'fl_inbound_measurement_item', 'split_item_id'") + .contains("'fl_inbound_measurement_item', 'source_measurement_item_id'") + .contains("source_measurement_id BIGINT UNSIGNED NULL") + .contains("fk_inbound_split_item_measurement") + .contains("fk_measure_split_batch") + .contains("fk_measure_source_batch") + .contains("fk_measure_item_split_item") + .contains("fk_measure_item_source_item") + .contains("idx_measure_split_source") + .contains("idx_measure_item_split_source"); + } + + @Test + void syncsUnifiedMigration() throws Exception { + String module = normalize(sql).trim(); + String unified = normalize(Files.readString(unifiedPath(), StandardCharsets.UTF_8)); + assertThat(unified) + .contains("-- 来源:firstleg/src/main/resources/db/phase3-inbound-parent-child-schema.sql") + .contains(module); + } + + private static String loadSql() { + try { + return new ClassPathResource("db/phase3-inbound-parent-child-schema.sql") + .getContentAsString(StandardCharsets.UTF_8); + } catch (Exception ex) { + throw new IllegalStateException(ex); + } + } + + private static Path unifiedPath() { + Path working = Path.of("").toAbsolutePath(); + Path path = working.resolve("deploy/sql/头程数据库统一增量-MySQL8.sql"); + if (!Files.exists(path)) { + path = working.resolve("../deploy/sql/头程数据库统一增量-MySQL8.sql").normalize(); + } + return path; + } + + private static String normalize(String value) { + return value.replace("\r\n", "\n").replace("\uFEFF", ""); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickItemMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickItemMapperContractTest.java new file mode 100644 index 000000000..a53052832 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickItemMapperContractTest.java @@ -0,0 +1,24 @@ +package com.baiyue.firstleg.repository; + +import com.baiyue.firstleg.mapper.LoadingPickItemMapper; +import org.apache.ibatis.annotations.Select; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; + +import static org.assertj.core.api.Assertions.assertThat; + +class LoadingPickItemMapperContractTest { + @Test + void completionCheckCoversAllActivePlanItems() throws Exception { + Method method = LoadingPickItemMapper.class.getDeclaredMethod("unfinishedCount", long.class); + String sql = method.getAnnotation(Select.class).value()[0]; + + assertThat(sql) + .contains("plan_item.loading_plan_id = #{planId}") + .contains("plan_item.deleted = 0") + .contains("pick_task.loading_plan_id = #{planId}") + .contains("pick_task.task_status = 'LOADING'") + .contains("pick_item.picked_package_count = plan_item.package_count"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java index 4dcf91f3b..ce8093cce 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java @@ -20,6 +20,8 @@ class SmartLoadingCandidateMapperContractTest { .contains("SELECT MAX(m2.id)") .contains("m2.status='APPROVED'") .contains("i.inbound_detail_id IS NOT NULL") + .contains("SELECT SUM(li.package_count)") + .contains("li.deleted=0") .contains("ORDER BY o.inbounded_at ASC, o.id ASC, i.id ASC"); } @@ -32,6 +34,8 @@ class SmartLoadingCandidateMapperContractTest { assertThat(sql) .contains("o.destination_warehouse_id destinationWarehouseId") .contains("m.status='APPROVED'") - .contains("SELECT MAX(m2.id)"); + .contains("SELECT MAX(m2.id)") + .contains("SELECT SUM(li.package_count)") + .contains("FOR UPDATE"); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java index 3d278e8e2..114805d7c 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java @@ -64,7 +64,8 @@ class SmartLoadingSqlContractTest { assertThat(migration()) .contains("active_inbound_detail_id BIGINT UNSIGNED GENERATED ALWAYS AS") .contains("CASE WHEN deleted = 0 THEN inbound_detail_id ELSE NULL END") - .contains("UNIQUE KEY uk_loading_item_active_detail (account_id, active_inbound_detail_id)") + .contains("UNIQUE KEY uk_loading_item_plan_detail (loading_plan_id, active_inbound_detail_id)") + .contains("KEY idx_loading_item_active_detail (account_id, active_inbound_detail_id)") .contains("UNIQUE KEY uk_container_booking_no (booking_no)") .contains("UNIQUE KEY uk_container_booking_cabinet (cabinet_no)") .contains("UNIQUE KEY uk_loading_plan_push_key (push_idempotency_key)") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java index 7c72b7501..cbe3b9e48 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java @@ -3,13 +3,13 @@ package com.baiyue.firstleg.service; import com.baiyue.common.core.api.PageResult; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; -import com.baiyue.firstleg.dto.LoadingListDtos.AdvanceRequest; import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedQuery; import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery; import com.baiyue.firstleg.dto.LoadingListDtos.TransportUpdateRequest; import com.baiyue.firstleg.mapper.LoadingListQueryMapper.AggregateRow; import com.baiyue.firstleg.mapper.LoadingListQueryMapper.HistoryRow; import com.baiyue.firstleg.repository.LoadingListRepository; +import com.baiyue.firstleg.repository.LoadingPickRepository; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.Resource; import org.junit.jupiter.api.DisplayName; @@ -36,6 +36,8 @@ import static org.mockito.Mockito.when; class LoadingListServiceTest { @MockBean private LoadingListRepository repository; + @MockBean + private LoadingPickRepository pickRepository; @Resource private LoadingListService service; @@ -95,19 +97,32 @@ class LoadingListServiceTest { } @Test - void shouldAdvanceOnlyLoadingToCompleted() { + void shouldCompleteLoadingAfterAllPdaItemsPicked() { AggregateRow before = row("LOADING", 2); - AggregateRow after = row("COMPLETED", 3); + when(pickRepository.allPlanItemsPicked(10L)).thenReturn(true); when(repository.history(anyString())).thenReturn(null); - when(repository.aggregate(10L)).thenReturn(before, after); + when(repository.aggregate(10L)).thenReturn(before); when(repository.advance(10L, "LOADING", "COMPLETED", 2, 9L, "operator")) .thenReturn(1); - when(repository.preloadDetail(after)).thenReturn( - new com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail(null, List.of(), List.of())); - service.complete(10L, 9L, "operator", "complete-key", new AdvanceRequest(2, null)); + service.finishFromPda(10L, 21L, 9L, "operator"); verify(repository).advance(10L, "LOADING", "COMPLETED", 2, 9L, "operator"); + verify(repository).appendHistory(eq(10L), eq("PDA-COMPLETE-10-21"), + eq("PDA_COMPLETE_LOADING"), eq("LOADING"), eq("COMPLETED"), anyString(), anyString(), + eq(9L), eq("operator"), eq("全部箱数已完成PDA拣货")); + } + + @Test + void shouldRejectAutomaticCompletionWhenPdaItemsRemain() { + when(pickRepository.allPlanItemsPicked(10L)).thenReturn(false); + + assertThatThrownBy(() -> service.finishFromPda(10L, 21L, 9L, "operator")) + .isInstanceOf(BizException.class) + .extracting(error -> ((BizException) error).getResultCode()) + .isEqualTo(ResultCode.CONFLICT); + verify(repository, never()).advance(eq(10L), anyString(), anyString(), + org.mockito.ArgumentMatchers.anyInt(), eq(9L), anyString()); } @Test diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java new file mode 100644 index 000000000..ad8f65dc2 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java @@ -0,0 +1,85 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.firstleg.dto.LoadingPickDtos.PickRequest; +import com.baiyue.firstleg.entity.LoadingPickHistoryEntity; +import com.baiyue.firstleg.entity.LoadingPickItemEntity; +import com.baiyue.firstleg.entity.LoadingPickTaskEntity; +import com.baiyue.firstleg.repository.LoadingPickRepository; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@SpringJUnitConfig(LoadingPickService.class) +class LoadingPickServiceTest { + @MockBean + private LoadingPickRepository repository; + @MockBean + private LoadingListService loadingListService; + @Resource + private LoadingPickService service; + + @Test + void keepsPlanLoadingWhenItemsRemain() { + LoadingPickTaskEntity task = task(); + LoadingPickItemEntity item = item(); + when(repository.history("pick-1")).thenReturn(null); + when(repository.task(20L)).thenReturn(task); + when(repository.item(20L, 30L)).thenReturn(item); + when(repository.pick(30L, 2, 0)).thenReturn(1); + when(repository.allPlanItemsPicked(10L)).thenReturn(false); + when(repository.items(20L)).thenReturn(List.of(item)); + + service.pick(20L, 30L, new PickRequest(2, List.of(), 0, "pick-1"), 9L, "operator"); + + verify(loadingListService, never()).finishFromPda(10L, 20L, 9L, "operator"); + } + + @Test + void completesPlanWhenLastItemIsPicked() { + LoadingPickTaskEntity task = task(); + LoadingPickItemEntity item = item(); + when(repository.history("pick-2")).thenReturn(null); + when(repository.task(20L)).thenReturn(task); + when(repository.item(20L, 30L)).thenReturn(item); + when(repository.pick(30L, 2, 0)).thenReturn(1); + when(repository.allPlanItemsPicked(10L)).thenReturn(true); + when(repository.items(20L)).thenReturn(List.of(item)); + + service.pick(20L, 30L, new PickRequest(2, List.of(), 0, "pick-2"), 9L, "operator"); + + verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator"); + verify(repository).insert(any(LoadingPickHistoryEntity.class)); + } + + private static LoadingPickTaskEntity task() { + LoadingPickTaskEntity task = new LoadingPickTaskEntity(); + task.setId(20L); + task.setLoadingPlanId(10L); + task.setContainerNo("CSLU1234567"); + task.setTaskStatus("LOADING"); + task.setVersion(0); + return task; + } + + private static LoadingPickItemEntity item() { + LoadingPickItemEntity item = new LoadingPickItemEntity(); + item.setId(30L); + item.setPickTaskId(20L); + item.setLoadingPlanItemId(40L); + item.setInboundId(50L); + item.setInboundDetailId(60L); + item.setPlannedPackageCount(2); + item.setPickedPackageCount(0); + item.setItemStatus("PENDING"); + item.setVersion(0); + return item; + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java index 0a780ecab..f9b56d397 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java @@ -95,10 +95,10 @@ class SmartLoadingServiceTest { LoadingPlanEntity plan = plan("EDITING", 0); when(repository.plan(1)).thenReturn(plan); when(repository.candidate(9, 10, 20)).thenReturn(candidate(5)); - when(repository.activeItem(9, 20)).thenReturn(item(7, 9, 20, 4)); + when(repository.activeItem(1L, 9L, 20L)).thenReturn(item(7, 9, 20, 4)); BizException error = catchThrowableOfType(() -> service.assign(1, - new AssignRequest(9L, 10L, 20L, 2, 0, "assign-1"), 7, "u"), BizException.class); + new AssignRequest(9L, 10L, 20L, 6, 0, "assign-1"), 7, "u"), BizException.class); assertThat(error.getResultCode()).isEqualTo(ResultCode.VALIDATION_FAILED); verify(repository, never()).touch(anyLong(), anyInt(), anyLong(), any()); @@ -297,7 +297,7 @@ class SmartLoadingServiceTest { when(repository.booking(99)).thenReturn(booking()); when(repository.items(1)).thenReturn(List.of()); when(repository.candidate(9, 10, 20)).thenReturn(candidate(10)); - when(repository.activeItem(9, 20)).thenReturn(null); + when(repository.activeItem(1L, 9L, 20L)).thenReturn(null); when(repository.touch(1, 0, 7, "u")).thenReturn(1); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadSqlContractTest.java new file mode 100644 index 000000000..b75bb30af --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadSqlContractTest.java @@ -0,0 +1,80 @@ +package com.baiyue.firstleg.split.service; + +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class SplitPreloadSqlContractTest { + @Test + void supportsPartialPackageLock() throws Exception { + assertPartialContract(moduleMigration()); + } + + @Test + void limitsSecondaryInboundDepth() throws Exception { + String sql = moduleMigration(); + assertThat(sql) + .contains("parent_inbound_id") + .contains("split_level") + .contains("split_status") + .contains("UNIQUE KEY uk_inbound_one_secondary (tenant_id, parent_inbound_id)") + .contains("CONSTRAINT fk_inbound_parent FOREIGN KEY (parent_inbound_id)") + .contains("二级子入库单不能继续拆分") + .contains("入库单主子关系创建后不可修改") + .contains("已完成拆单不能回退"); + } + + @Test + void remainsRepeatable() throws Exception { + String sql = moduleMigration(); + assertThat(sql) + .contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_detail") + .contains("information_schema.columns") + .contains("information_schema.statistics") + .contains("information_schema.table_constraints") + .contains("DROP TRIGGER IF EXISTS trg_inbound_split_before_insert") + .contains("DROP TRIGGER IF EXISTS trg_inbound_split_detail_before_delete") + .contains("CREATE PROCEDURE phase3_split_assert_contract()") + .contains("拆单迁移失败:唯一子单或部分锁定幂等索引缺失") + .contains("拆单迁移失败:主子层级或部分锁定触发器未完整创建"); + } + + @Test + void syncsUnifiedMigration() throws Exception { + assertPartialContract(unifiedMigration()); + } + + private static void assertPartialContract(String sql) { + assertThat(sql) + .contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_detail") + .contains("remaining_package_count INT UNSIGNED GENERATED ALWAYS AS") + .contains("source_package_count - locked_package_count") + .contains("locked_package_count < source_package_count") + .contains("status = 'LOCKED' AND child_inbound_id IS NULL") + .contains("status = 'COMPLETED' AND child_inbound_id IS NOT NULL") + .contains("UNIQUE KEY uk_inbound_split_parent_detail") + .contains("UNIQUE KEY uk_inbound_split_operation (operation_id)") + .contains("部分锁定的归属和箱数创建后不可修改") + .contains("部分锁定与拆单明细不可删除") + .contains("剩余箱数必须绑定主单唯一二级子入库单"); + } + + private static String moduleMigration() throws Exception { + return new ClassPathResource("db/phase3-split-preload-schema.sql") + .getContentAsString(StandardCharsets.UTF_8); + } + + private static String unifiedMigration() throws Exception { + Path working = Path.of("").toAbsolutePath(); + Path file = working.resolve("deploy/sql/头程数据库统一增量-MySQL8.sql"); + if (!Files.exists(file)) { + file = working.resolve("../deploy/sql/头程数据库统一增量-MySQL8.sql").normalize(); + } + return Files.readString(file, StandardCharsets.UTF_8); + } +} From ebea3a181377edf2a5f6cd9a826b783f73fc9c60 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Fri, 7 Aug 2026 10:57:06 +0800 Subject: [PATCH 3/8] =?UTF-8?q?=E8=A3=85=E6=9F=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/头程数据库统一增量-MySQL8.sql | 689 ++++++++---------- .../firstleg/BaiyueFirstlegApplication.java | 6 +- .../client/WarehouseMasterDataClient.java | 24 + .../PlatformLoadingListController.java | 14 +- .../PlatformLoadingPdaController.java | 27 +- .../PlatformSmartLoadingController.java | 10 +- .../baiyue/firstleg/dto/ContainerDtos.java | 2 +- .../baiyue/firstleg/dto/LoadingListDtos.java | 6 +- .../baiyue/firstleg/dto/LoadingPickDtos.java | 20 + .../baiyue/firstleg/dto/SmartLoadingDtos.java | 8 +- .../entity/LoadingPickHistoryEntity.java | 2 + ...java => InboundLoadingPickTaskMapper.java} | 2 +- .../loading/repository/LoadingRepository.java | 4 +- .../mapper/InboundSplitSettlementMapper.java | 56 ++ .../mapper/LoadingListQueryMapper.java | 10 - .../mapper/LoadingPickItemMapper.java | 71 +- .../mapper/LoadingPickTaskMapper.java | 14 + .../mapper/SmartLoadingCandidateMapper.java | 35 +- .../repository/LoadingPickRepository.java | 28 +- .../repository/SmartLoadingRepository.java | 6 +- .../UnifiedInboundReadRepository.java | 4 +- .../firstleg/service/ContainerService.java | 37 +- .../firstleg/service/LoadingListService.java | 28 +- .../firstleg/service/LoadingPickService.java | 111 ++- .../service/PdaPickSettlementPlan.java | 65 ++ .../firstleg/service/SmartLoadingService.java | 39 +- firstleg/src/main/resources/application.yml | 2 + .../db/phase3-inbound-parent-child-schema.sql | 64 +- .../db/phase3-smart-loading-schema.sql | 72 +- .../resources/openapi/loading-lists-v1.yaml | 19 +- .../LoadingListControllerContractTest.java | 8 +- .../InboundParentChildSqlContractTest.java | 24 +- .../LoadingListQueryMapperContractTest.java | 4 + .../LoadingPickItemMapperContractTest.java | 25 + .../PdaSplitSettlementSqlContractTest.java | 37 + ...artLoadingCandidateMapperContractTest.java | 2 + .../SmartLoadingSqlContractTest.java | 2 +- .../service/ContainerServiceTest.java | 57 ++ .../service/LoadingListServiceTest.java | 23 +- .../service/LoadingPickServiceTest.java | 16 +- .../service/PdaPickSettlementPlanTest.java | 53 ++ .../service/SmartLoadingServiceTest.java | 51 +- .../service/SplitPreloadSqlContractTest.java | 57 +- .../service/LocationApplicationService.java | 3 + .../LocationApplicationServiceTest.java | 16 + 45 files changed, 1271 insertions(+), 582 deletions(-) create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/client/WarehouseMasterDataClient.java rename firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/{LoadingPickTaskMapper.java => InboundLoadingPickTaskMapper.java} (93%) create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundSplitSettlementMapper.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/service/PdaPickSettlementPlan.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/repository/PdaSplitSettlementSqlContractTest.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/service/PdaPickSettlementPlanTest.java diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 970b30b74..8a4f0705a 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -4033,7 +4033,7 @@ CREATE TABLE IF NOT EXISTS fl_loading_pick_item ( inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '订单货物明细ID', planned_package_count INT UNSIGNED NOT NULL COMMENT '计划拣货箱数;上层拆分按业务规则向上取整', picked_package_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已拣货箱数', - item_status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING、PICKING、PICKED', + item_status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING、PICKING、PICKED、PARTIAL、SKIPPED', version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), @@ -4044,7 +4044,7 @@ CREATE TABLE IF NOT EXISTS fl_loading_pick_item ( CONSTRAINT fk_loading_pick_item_plan_item FOREIGN KEY (loading_plan_item_id) REFERENCES fl_loading_plan_item (id), CONSTRAINT chk_loading_pick_item_planned CHECK (planned_package_count > 0), CONSTRAINT chk_loading_pick_item_count CHECK (picked_package_count <= planned_package_count), - CONSTRAINT chk_loading_pick_item_status CHECK (item_status IN ('PENDING', 'PICKING', 'PICKED')) + CONSTRAINT chk_loading_pick_item_status CHECK (item_status IN ('PENDING', 'PICKING', 'PICKED', 'PARTIAL', 'SKIPPED')) ) ENGINE=InnoDB COMMENT='PDA装柜拣货明细'; CREATE TABLE IF NOT EXISTS fl_loading_pick_history ( @@ -4053,7 +4053,9 @@ CREATE TABLE IF NOT EXISTS fl_loading_pick_history ( pick_item_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货明细ID', operation_id VARCHAR(128) NOT NULL COMMENT '客户端幂等操作号', request_hash CHAR(64) NOT NULL COMMENT '请求指纹', + action_type VARCHAR(24) NOT NULL DEFAULT 'PICK' COMMENT 'PICK、PARTIAL、SKIP', picked_package_count INT UNSIGNED NOT NULL COMMENT '本次拣货箱数', + operation_reason VARCHAR(500) NULL COMMENT '部分拣货或跳拣原因', operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人', operator_name VARCHAR(64) NULL COMMENT '操作人名称', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), @@ -4228,7 +4230,49 @@ BEGIN IF current_plan_status = 'LOADING' AND EXISTS ( SELECT 1 FROM fl_loading_pick_item WHERE loading_plan_item_id = OLD.id AND picked_package_count > 0) THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已有PDA拣货记录的装柜明细不可修改或删除'; + IF NOT EXISTS ( + SELECT 1 FROM fl_loading_pick_item + WHERE loading_plan_item_id = OLD.id + AND item_status = 'PARTIAL' + AND NEW.deleted = 0 + AND NEW.package_count = picked_package_count + ) OR NEW.deleted <> OLD.deleted + OR NOT (NEW.customer_name <=> OLD.customer_name) + OR NOT (NEW.inbound_id <=> OLD.inbound_id) + OR NOT (NEW.inbound_detail_id <=> OLD.inbound_detail_id) + OR NOT (NEW.order_id <=> OLD.order_id) + OR NOT (NEW.order_no <=> OLD.order_no) + OR NOT (NEW.inbound_code <=> OLD.inbound_code) + OR NOT (NEW.destination_warehouse_id <=> OLD.destination_warehouse_id) + OR NOT (NEW.destination_warehouse_code <=> OLD.destination_warehouse_code) + OR NOT (NEW.destination_warehouse_name <=> OLD.destination_warehouse_name) + OR NOT (NEW.sku_code <=> OLD.sku_code) + OR NOT (NEW.product_name_zh <=> OLD.product_name_zh) + OR NOT (NEW.product_name_en <=> OLD.product_name_en) + OR NOT (NEW.product_image_file_id <=> OLD.product_image_file_id) + OR NOT (NEW.product_image_url <=> OLD.product_image_url) + OR NOT (NEW.product_quantity <=> OLD.product_quantity) + OR NOT (NEW.units_per_carton <=> OLD.units_per_carton) + OR NOT (NEW.pallet_count <=> OLD.pallet_count) + OR NOT (NEW.gross_weight_kg <=> OLD.gross_weight_kg) + OR NOT (NEW.net_weight_kg <=> OLD.net_weight_kg) + OR NOT (NEW.dimensional_weight_kg <=> OLD.dimensional_weight_kg) + OR NOT (NEW.volume_cbm <=> OLD.volume_cbm) + OR NOT (NEW.fba_tracking_no <=> OLD.fba_tracking_no) + OR NOT (NEW.shipment_no <=> OLD.shipment_no) + OR NOT (NEW.shipping_mark <=> OLD.shipping_mark) + OR NOT (NEW.load_sequence <=> OLD.load_sequence) + OR NOT (NEW.position_snapshot <=> OLD.position_snapshot) + OR NOT (NEW.internal_remark <=> OLD.internal_remark) + OR NOT (NEW.customer_remark <=> OLD.customer_remark) + OR NOT (NEW.warehouse_remark <=> OLD.warehouse_remark) + OR NOT (NEW.frozen_at <=> OLD.frozen_at) + OR NOT (NEW.frozen_by <=> OLD.frozen_by) + OR NOT (NEW.removed_by <=> OLD.removed_by) + OR NOT (NEW.removed_by_name <=> OLD.removed_by_name) + OR NOT (NEW.removed_at <=> OLD.removed_at) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已有PDA拣货记录的装柜明细仅允许收敛部分拣货实际箱数'; + END IF; END IF; IF OLD.frozen_at IS NOT NULL THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后的明细永久冻结'; @@ -4287,6 +4331,28 @@ BEGIN OR NEW.inbound_detail_id <> OLD.inbound_detail_id THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货明细归属不可修改'; END IF; + IF NEW.item_status = 'PARTIAL' AND NOT ( + OLD.item_status IN ('PENDING', 'PICKING') + AND OLD.picked_package_count < NEW.picked_package_count + AND NEW.picked_package_count < OLD.planned_package_count + AND NEW.planned_package_count = OLD.planned_package_count + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '部分拣货必须在计划箱数内增加实际拣出箱数'; + END IF; + IF NEW.item_status = 'SKIPPED' AND NOT ( + OLD.item_status = 'PENDING' + AND OLD.picked_package_count = 0 + AND NEW.picked_package_count = 0 + AND NEW.planned_package_count = OLD.planned_package_count + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '跳拣仅允许零拣货的待拣明细'; + END IF; + IF OLD.item_status = 'PARTIAL' AND NEW.item_status = 'PICKED' AND NOT ( + NEW.picked_package_count = OLD.picked_package_count + AND NEW.planned_package_count = OLD.picked_package_count + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '部分拣货收敛后计划箱数必须等于实际拣出箱数'; + END IF; END$$ DELIMITER ; @@ -4575,6 +4641,117 @@ DELIMITER ; CALL assert_phase3_smart_loading_schema(); DROP PROCEDURE IF EXISTS assert_phase3_smart_loading_schema; + +DELIMITER $$ +CREATE PROCEDURE assert_phase3_smart_loading_schema() +BEGIN + IF (SELECT COUNT(*) FROM information_schema.tables + WHERE table_schema = 'baiyue_firstleg' + AND table_name IN ('fl_container_booking', 'fl_loading_plan', 'fl_loading_plan_item', + 'fl_container_document', 'fl_loading_plan_history', + 'fl_loading_pick_task', 'fl_loading_pick_item', + 'fl_loading_pick_history')) <> 8 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:核心表未完整创建'; + END IF; + IF EXISTS (SELECT 1 FROM fl_loading_plan + WHERE plan_status NOT IN ('EDITING', 'LOADING', 'COMPLETED')) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:存在旧装柜状态'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.check_constraints + WHERE constraint_schema = 'baiyue_firstleg' + AND constraint_name = 'chk_loading_plan_status' + AND check_clause LIKE '%''EDITING''%' + AND check_clause LIKE '%''LOADING''%' + AND check_clause LIKE '%''COMPLETED''%') <> 1 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:装柜状态约束不是最终三状态'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_container_booking' + AND column_name IN ('booking_no', 'cabinet_no', 'warehouse_id', 'bill_of_lading_no', + 'vessel_name', 'voyage_no', 'container_type', 'capacity_cbm', + 'max_weight_kg', 'terminal_trucking_company')) <> 10 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:货柜原型字段缺失'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_plan_item' + AND column_name IN ('account_id', 'customer_name', 'active_inbound_detail_id', 'frozen_at', + 'destination_warehouse_name', 'product_name_zh', 'product_name_en', + 'product_image_file_id', 'pallet_count', 'net_weight_kg', + 'dimensional_weight_kg', 'product_quantity', 'units_per_carton', + 'fba_tracking_no', 'shipment_no', 'shipping_mark', 'version')) <> 17 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:明细归属、清单快照或冻结字段缺失'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_plan_item' + AND index_name = 'uk_loading_item_plan_detail' AND non_unique = 0) <> 2 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:计划内唯一活动明细锁缺失'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = 'baiyue_firstleg' + AND table_name = 'fl_loading_pick_item' + AND column_name IN ('pick_task_id', 'loading_plan_item_id', 'inbound_id', + 'inbound_detail_id', 'planned_package_count', + 'picked_package_count', 'item_status', 'version')) <> 8 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:PDA拣货明细字段缺失'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = 'baiyue_firstleg' AND non_unique = 0 + AND index_name IN ('uk_loading_pick_task_plan', + 'uk_loading_pick_item_plan_item', + 'uk_loading_pick_history_operation')) <> 3 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:PDA唯一索引缺失'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.triggers + WHERE trigger_schema = 'baiyue_firstleg' + AND trigger_name IN ('trg_container_booking_before_update', + 'trg_loading_plan_before_update', 'trg_loading_plan_after_update', + 'trg_loading_item_before_insert', 'trg_loading_item_before_update', + 'trg_loading_item_before_delete', 'trg_container_document_before_insert', + 'trg_container_document_before_update', 'trg_container_document_before_delete', + 'trg_loading_history_before_update', 'trg_loading_history_before_delete', + 'trg_loading_pick_task_before_update', + 'trg_loading_pick_item_before_update', + 'trg_loading_pick_item_before_delete', + 'trg_loading_pick_history_before_update', + 'trg_loading_pick_history_before_delete')) <> 16 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:不可逆状态或冻结触发器缺失'; + END IF; + IF (SELECT COUNT(DISTINCT permission_code) FROM baiyue_iam.iam_permission + WHERE tenant_id = 0 AND app_code = 'PLATFORM' AND status = 'ACTIVE' AND deleted = 0 + AND permission_code IN ( + 'platform:firstleg:container:list', 'platform:firstleg:container:view', + 'platform:firstleg:container:create', 'platform:firstleg:container:edit', + 'platform:firstleg:smart-loading:list', 'platform:firstleg:smart-loading:create', + 'platform:firstleg:smart-loading:assign', 'platform:firstleg:smart-loading:delete', + 'platform:firstleg:smart-loading:push', 'platform:firstleg:preload:list', + 'platform:firstleg:preload:view', 'platform:firstleg:preload:edit', + 'platform:firstleg:preload:complete', 'platform:firstleg:preload:confirm', + 'platform:firstleg:loading-list:list', 'platform:firstleg:loading-list:view')) <> 16 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:平台权限资源未完整创建'; + END IF; + IF (SELECT COUNT(DISTINCT CONCAT(role.role_code, ':', permission.permission_code)) + FROM baiyue_iam.iam_role role + JOIN baiyue_iam.iam_role_permission grant_row ON grant_row.role_id = role.id + JOIN baiyue_iam.iam_permission permission ON permission.id = grant_row.permission_id + WHERE role.tenant_id = 0 AND role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR') + AND permission.tenant_id = 0 + AND permission.permission_code IN ( + 'platform:firstleg:container:list', 'platform:firstleg:container:view', + 'platform:firstleg:container:create', + 'platform:firstleg:container:edit', 'platform:firstleg:smart-loading:list', + 'platform:firstleg:smart-loading:create', 'platform:firstleg:smart-loading:assign', + 'platform:firstleg:smart-loading:delete', 'platform:firstleg:smart-loading:push', + 'platform:firstleg:preload:list', 'platform:firstleg:preload:view', + 'platform:firstleg:preload:edit', 'platform:firstleg:preload:complete', + 'platform:firstleg:preload:confirm', 'platform:firstleg:loading-list:list', + 'platform:firstleg:loading-list:view')) <> 32 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:平台角色授权未完整写入'; + END IF; +END$$ +DELIMITER ; +CALL assert_phase3_smart_loading_schema(); +DROP PROCEDURE IF EXISTS assert_phase3_smart_loading_schema; + -- ============================================================ -- 2026-08-06 平台公共仓库正式主数据:固定作业仓 + FBA 地址库 @@ -5564,8 +5741,8 @@ CALL phase3_inbound_add_index( 'fl_inbound_order', 'uk_inbound_master_order', 'UNIQUE KEY `uk_inbound_master_order` (`tenant_id`, `master_order_id`)'); CALL phase3_inbound_add_index( - 'fl_inbound_order', 'uk_inbound_parent_sequence', - 'UNIQUE KEY `uk_inbound_parent_sequence` (`tenant_id`, `parent_inbound_id`, `split_sequence`)'); + 'fl_inbound_order', 'uk_inbound_one_child', + 'UNIQUE KEY `uk_inbound_one_child` (`tenant_id`, `parent_inbound_id`)'); CALL phase3_inbound_add_index( 'fl_inbound_order', 'idx_inbound_root_role', 'KEY `idx_inbound_root_role` (`tenant_id`, `root_inbound_id`, `inbound_role`, `created_at`, `id`)'); @@ -5582,6 +5759,14 @@ BEGIN ) THEN ALTER TABLE fl_inbound_order DROP INDEX uk_inbound_tenant_order; END IF; + IF EXISTS ( + SELECT 1 FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'fl_inbound_order' + AND index_name = 'uk_inbound_parent_sequence' + ) THEN + ALTER TABLE fl_inbound_order DROP INDEX uk_inbound_parent_sequence; + END IF; END$$ DELIMITER ; CALL phase3_inbound_drop_legacy_index(); @@ -5618,19 +5803,22 @@ CREATE TABLE IF NOT EXISTS fl_inbound_split_item ( split_batch_id BIGINT UNSIGNED NOT NULL COMMENT '拆分批次ID', parent_inbound_id BIGINT UNSIGNED NOT NULL COMMENT '主入仓单ID', child_inbound_id BIGINT UNSIGNED NOT NULL COMMENT '子入仓单ID', + line_no INT UNSIGNED NOT NULL COMMENT '同一拆分批次内的行号', source_inbound_detail_id BIGINT UNSIGNED NULL COMMENT '订单货物明细逻辑ID', source_measurement_id BIGINT UNSIGNED NULL COMMENT '来源测量批次ID', + source_measurement_item_id BIGINT UNSIGNED NULL COMMENT '来源测量箱规明细ID', package_count INT UNSIGNED NOT NULL COMMENT '分配给子单的箱数', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), PRIMARY KEY (id), - UNIQUE KEY uk_inbound_split_child (tenant_id, child_inbound_id), - UNIQUE KEY uk_inbound_split_batch_child (tenant_id, split_batch_id, child_inbound_id), + UNIQUE KEY uk_inbound_split_batch_line (tenant_id, split_batch_id, line_no), + UNIQUE KEY uk_inbound_split_batch_source_item (tenant_id, split_batch_id, source_measurement_item_id), KEY idx_inbound_split_detail (tenant_id, source_inbound_detail_id, created_at), CONSTRAINT chk_inbound_split_item_package CHECK (package_count > 0), CONSTRAINT fk_inbound_split_item_batch FOREIGN KEY (split_batch_id) REFERENCES fl_inbound_split_batch (id), CONSTRAINT fk_inbound_split_item_parent FOREIGN KEY (parent_inbound_id) REFERENCES fl_inbound_order (id), CONSTRAINT fk_inbound_split_item_child FOREIGN KEY (child_inbound_id) REFERENCES fl_inbound_order (id), - CONSTRAINT fk_inbound_split_item_measurement FOREIGN KEY (source_measurement_id) REFERENCES fl_inbound_measurement (id) + CONSTRAINT fk_inbound_split_item_measurement FOREIGN KEY (source_measurement_id) REFERENCES fl_inbound_measurement (id), + CONSTRAINT fk_inbound_split_item_measure_item FOREIGN KEY (source_measurement_item_id) REFERENCES fl_inbound_measurement_item (id) ) ENGINE=InnoDB COMMENT='主子入仓单拆分数量台账'; CREATE TABLE IF NOT EXISTS fl_inbound_split_history ( @@ -5680,6 +5868,15 @@ CALL phase3_inbound_add_column( CALL phase3_inbound_add_column( 'fl_inbound_measurement', 'source_measurement_id', "BIGINT UNSIGNED NULL COMMENT '子单继承或复核时的来源测量批次ID' AFTER split_batch_id"); +CALL phase3_inbound_add_column( + 'fl_inbound_measurement_item', 'split_item_id', + "BIGINT UNSIGNED NULL COMMENT '产生该子单测量明细的拆分台账ID' AFTER measurement_id"); +CALL phase3_inbound_add_column( + 'fl_inbound_measurement_item', 'source_measurement_item_id', + "BIGINT UNSIGNED NULL COMMENT '被拆分继承的来源测量箱规明细ID' AFTER split_item_id"); +CALL phase3_inbound_add_column( + 'fl_inbound_split_item', 'line_no', + "INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '同一拆分批次内的行号' AFTER child_inbound_id"); DROP PROCEDURE IF EXISTS phase3_inbound_add_column; DROP PROCEDURE IF EXISTS phase3_inbound_add_relation; @@ -5716,6 +5913,12 @@ CALL phase3_inbound_add_relation( CALL phase3_inbound_add_relation( 'fl_inbound_measurement', 'fk_measure_source_batch', 'FOREIGN KEY (`source_measurement_id`) REFERENCES `fl_inbound_measurement` (`id`)'); +CALL phase3_inbound_add_relation( + 'fl_inbound_measurement_item', 'fk_measure_item_split_item', + 'FOREIGN KEY (`split_item_id`) REFERENCES `fl_inbound_split_item` (`id`)'); +CALL phase3_inbound_add_relation( + 'fl_inbound_measurement_item', 'fk_measure_item_source_item', + 'FOREIGN KEY (`source_measurement_item_id`) REFERENCES `fl_inbound_measurement_item` (`id`)'); DROP PROCEDURE IF EXISTS phase3_inbound_add_relation; DROP PROCEDURE IF EXISTS phase3_inbound_add_index; @@ -5742,8 +5945,34 @@ DELIMITER ; CALL phase3_inbound_add_index( 'fl_inbound_measurement', 'idx_measure_split_source', 'KEY `idx_measure_split_source` (`tenant_id`, `split_batch_id`, `source_measurement_id`, `created_at`)'); +CALL phase3_inbound_add_index( + 'fl_inbound_measurement_item', 'idx_measure_item_split_source', + 'KEY `idx_measure_item_split_source` (`tenant_id`, `split_item_id`, `source_measurement_item_id`)'); +CALL phase3_inbound_add_index( + 'fl_inbound_split_item', 'uk_inbound_split_batch_line', + 'UNIQUE KEY `uk_inbound_split_batch_line` (`tenant_id`, `split_batch_id`, `line_no`)'); +CALL phase3_inbound_add_index( + 'fl_inbound_split_item', 'uk_inbound_split_batch_source_item', + 'UNIQUE KEY `uk_inbound_split_batch_source_item` (`tenant_id`, `split_batch_id`, `source_measurement_item_id`)'); DROP PROCEDURE IF EXISTS phase3_inbound_add_index; +DROP PROCEDURE IF EXISTS phase3_inbound_drop_split_legacy_index; +DELIMITER $$ +CREATE PROCEDURE phase3_inbound_drop_split_legacy_index() +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() + AND table_name = 'fl_inbound_split_item' AND index_name = 'uk_inbound_split_child') THEN + ALTER TABLE fl_inbound_split_item DROP INDEX uk_inbound_split_child; + END IF; + IF EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() + AND table_name = 'fl_inbound_split_item' AND index_name = 'uk_inbound_split_batch_child') THEN + ALTER TABLE fl_inbound_split_item DROP INDEX uk_inbound_split_batch_child; + END IF; +END$$ +DELIMITER ; +CALL phase3_inbound_drop_split_legacy_index(); +DROP PROCEDURE IF EXISTS phase3_inbound_drop_split_legacy_index; + DROP TRIGGER IF EXISTS trg_inbound_order_parent_before_insert; DROP TRIGGER IF EXISTS trg_inbound_order_parent_before_update; DROP TRIGGER IF EXISTS trg_inbound_split_item_before_insert; @@ -5762,6 +5991,9 @@ BEGIN IF NEW.inbound_role NOT IN ('MASTER', 'CHILD') THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入仓单层级非法'; END IF; + IF NEW.split_status NOT IN ('NOT_SPLIT', 'PARTIALLY_SPLIT', 'FULLY_SPLIT') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入仓单拆分状态非法'; + END IF; IF NEW.package_count IS NOT NULL AND NEW.package_count = 0 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入仓单箱数必须大于0'; END IF; @@ -5773,6 +6005,9 @@ BEGIN OR NEW.split_sequence IS NULL OR NEW.package_count IS NULL THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '子入仓单缺少主单、顺序号或箱数'; END IF; + IF NEW.split_status <> 'NOT_SPLIT' THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主子仅两级,子入仓单不可继续拆分'; + END IF; IF NOT EXISTS ( SELECT 1 FROM fl_inbound_order parent_order WHERE parent_order.id = NEW.parent_inbound_id @@ -5799,6 +6034,17 @@ BEGIN AND (NEW.package_count IS NULL OR NEW.package_count = 0) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '子入仓单箱数必须大于0'; END IF; + IF NEW.split_status NOT IN ('NOT_SPLIT', 'PARTIALLY_SPLIT', 'FULLY_SPLIT') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入仓单拆分状态非法'; + END IF; + IF NEW.inbound_role = 'CHILD' AND NEW.split_status <> 'NOT_SPLIT' THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主子仅两级,子入仓单不可继续拆分'; + END IF; + IF NEW.inbound_role = 'MASTER' + AND ((NEW.package_count = 0 AND NEW.split_status <> 'FULLY_SPLIT') + OR (NEW.split_status = 'FULLY_SPLIT' AND COALESCE(NEW.package_count, 0) <> 0)) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主单箱数归零必须与完全拆分状态同时提交'; + END IF; END$$ CREATE TRIGGER trg_inbound_split_item_before_insert @@ -5819,8 +6065,17 @@ BEGIN AND child_order.tenant_id = NEW.tenant_id AND child_order.inbound_role = 'CHILD' AND child_order.parent_inbound_id = NEW.parent_inbound_id - AND child_order.package_count = NEW.package_count + AND child_order.package_count >= NEW.package_count AND child_order.deleted = 0 + AND (NEW.source_measurement_item_id IS NULL OR EXISTS ( + SELECT 1 + FROM fl_inbound_measurement_item source_item + JOIN fl_inbound_measurement source_batch ON source_batch.id = source_item.measurement_id + WHERE source_item.id = NEW.source_measurement_item_id + AND source_item.tenant_id = NEW.tenant_id + AND source_item.measurement_id = NEW.source_measurement_id + AND source_batch.inbound_id = NEW.parent_inbound_id + )) ) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分明细与批次、主单或子单不一致'; END IF; @@ -5858,14 +6113,38 @@ BEGIN WHERE parent_order.id = NEW.parent_inbound_id AND parent_order.tenant_id = NEW.tenant_id AND parent_order.inbound_role = 'MASTER' - AND parent_order.package_count = 0 + AND parent_order.package_count > 0 + AND parent_order.split_status = 'PARTIALLY_SPLIT' AND parent_order.version > NEW.expected_parent_version ) OR (SELECT COALESCE(SUM(item.package_count), 0) FROM fl_inbound_split_item item WHERE item.tenant_id = NEW.tenant_id - AND item.split_batch_id = NEW.id) <> NEW.package_count THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主单可用箱数未归零或子单箱数合计不一致'; + AND item.split_batch_id = NEW.id) <> NEW.package_count OR + (SELECT COUNT(DISTINCT item.child_inbound_id) + FROM fl_inbound_split_item item + WHERE item.tenant_id = NEW.tenant_id + AND item.split_batch_id = NEW.id) <> 1 OR + EXISTS (SELECT 1 + FROM fl_inbound_split_item item + JOIN fl_inbound_order child_order ON child_order.id = item.child_inbound_id + WHERE item.tenant_id = NEW.tenant_id + AND item.split_batch_id = NEW.id + GROUP BY child_order.id, child_order.package_count + HAVING child_order.package_count <> SUM(item.package_count)) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '二级子入仓单或拆分明细箱数合计不一致'; + END IF; + IF EXISTS ( + SELECT 1 + FROM fl_inbound_split_item split_item + JOIN fl_inbound_measurement_item source_item + ON source_item.id = split_item.source_measurement_item_id + WHERE split_item.tenant_id = NEW.tenant_id + AND split_item.split_batch_id = NEW.id + GROUP BY split_item.source_measurement_item_id, source_item.package_count + HAVING SUM(split_item.package_count) > source_item.package_count + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '子单承接箱数超过来源测量箱规箱数'; END IF; ELSEIF NEW.status = 'CANCELLED' THEN IF EXISTS ( @@ -5937,7 +6216,8 @@ BEGIN IF (SELECT COUNT(*) FROM information_schema.table_constraints WHERE constraint_schema = DATABASE() AND constraint_name IN ('fk_inbound_parent_order', 'fk_inbound_root_order', - 'fk_measure_split_batch', 'fk_measure_source_batch')) <> 4 THEN + 'fk_measure_split_batch', 'fk_measure_source_batch', + 'fk_measure_item_split_item', 'fk_measure_item_source_item')) <> 6 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主子入仓单与测量关联约束未完整创建'; END IF; IF (SELECT COUNT(*) FROM information_schema.triggers @@ -5965,386 +6245,3 @@ END$$ DELIMITER ; CALL phase3_assert_inbound_parent_child_schema(); DROP PROCEDURE IF EXISTS phase3_assert_inbound_parent_child_schema; - -/* - * 部分装柜拆单契约: - * 1. parent_inbound_id 为空的是主单,非空的是唯一二级子单; - * 2. 锁定阶段只记录箱数,完成阶段绑定由剩余箱数生成的子单; - * 3. 子单不能再作为父单,锁定数量创建后不可修改。 - */ -DROP PROCEDURE IF EXISTS phase3_split_add_column; -DELIMITER $$ -CREATE PROCEDURE phase3_split_add_column( - IN p_table_name VARCHAR(64), IN p_column_name VARCHAR(64), IN p_definition TEXT -) -BEGIN - IF NOT EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_schema = DATABASE() AND table_name = p_table_name AND column_name = p_column_name - ) THEN - SET @phase3_split_ddl = CONCAT('ALTER TABLE `', p_table_name, '` ADD COLUMN `', - p_column_name, '` ', p_definition); - PREPARE phase3_split_stmt FROM @phase3_split_ddl; - EXECUTE phase3_split_stmt; - DEALLOCATE PREPARE phase3_split_stmt; - END IF; -END$$ -DELIMITER ; - -CALL phase3_split_add_column('fl_inbound_order', 'parent_inbound_id', - 'BIGINT UNSIGNED NULL COMMENT ''生成本单的主入库单ID;仅二级子单有值'' AFTER id'); -CALL phase3_split_add_column('fl_inbound_order', 'split_level', - 'TINYINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN parent_inbound_id IS NULL THEN 0 ELSE 1 END) STORED COMMENT ''拆单层级:0主单、1二级子单'' AFTER parent_inbound_id'); -CALL phase3_split_add_column('fl_inbound_order', 'split_status', - 'VARCHAR(24) NOT NULL DEFAULT ''NOT_SPLIT'' COMMENT ''NOT_SPLIT/PARTIALLY_SPLIT/FULLY_SPLIT'' AFTER split_level'); -DROP PROCEDURE IF EXISTS phase3_split_add_column; - -SET @phase3_split_index_exists = ( - SELECT COUNT(*) FROM information_schema.statistics - WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_order' - AND index_name = 'uk_inbound_one_secondary' -); -SET @phase3_split_ddl = IF(@phase3_split_index_exists = 0, - 'ALTER TABLE fl_inbound_order ADD UNIQUE KEY uk_inbound_one_secondary (tenant_id, parent_inbound_id)', - 'SELECT 1'); -PREPARE phase3_split_stmt FROM @phase3_split_ddl; -EXECUTE phase3_split_stmt; -DEALLOCATE PREPARE phase3_split_stmt; - -SET @phase3_split_fk_exists = ( - SELECT COUNT(*) FROM information_schema.table_constraints - WHERE constraint_schema = DATABASE() AND table_name = 'fl_inbound_order' - AND constraint_name = 'fk_inbound_parent' AND constraint_type = 'FOREIGN KEY' -); -SET @phase3_split_ddl = IF(@phase3_split_fk_exists = 0, - 'ALTER TABLE fl_inbound_order ADD CONSTRAINT fk_inbound_parent FOREIGN KEY (parent_inbound_id) REFERENCES fl_inbound_order (id)', - 'SELECT 1'); -PREPARE phase3_split_stmt FROM @phase3_split_ddl; -EXECUTE phase3_split_stmt; -DEALLOCATE PREPARE phase3_split_stmt; - -CREATE TABLE IF NOT EXISTS fl_inbound_split_detail ( - id BIGINT UNSIGNED NOT NULL COMMENT '部分锁定明细ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID', - parent_inbound_id BIGINT UNSIGNED NOT NULL COMMENT '主入库单ID', - child_inbound_id BIGINT UNSIGNED NULL COMMENT '完成拆单后生成的二级子入库单ID', - inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '原订单货物明细ID,不建立跨库外键', - source_package_count INT UNSIGNED NOT NULL COMMENT '拆分前该明细总箱数快照', - locked_package_count INT UNSIGNED NOT NULL COMMENT '本次装柜锁定箱数', - remaining_package_count INT UNSIGNED GENERATED ALWAYS AS ( - source_package_count - locked_package_count - ) STORED COMMENT '生成二级子单的剩余箱数', - status VARCHAR(16) NOT NULL DEFAULT 'LOCKED' COMMENT 'LOCKED/COMPLETED', - operation_id VARCHAR(128) NOT NULL COMMENT '部分锁定操作幂等号', - created_by BIGINT UNSIGNED NOT NULL, - created_by_name VARCHAR(64) NULL, - completed_by BIGINT UNSIGNED NULL, - completed_by_name VARCHAR(64) NULL, - completed_at DATETIME(3) NULL, - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), - version INT UNSIGNED NOT NULL DEFAULT 0, - PRIMARY KEY (id), - UNIQUE KEY uk_inbound_split_parent_detail (tenant_id, parent_inbound_id, inbound_detail_id), - UNIQUE KEY uk_inbound_split_child_detail (tenant_id, child_inbound_id, inbound_detail_id), - UNIQUE KEY uk_inbound_split_operation (operation_id), - KEY idx_inbound_split_status (tenant_id, parent_inbound_id, status), - CONSTRAINT fk_inbound_split_detail_parent FOREIGN KEY (parent_inbound_id) REFERENCES fl_inbound_order (id), - CONSTRAINT fk_inbound_split_detail_child FOREIGN KEY (child_inbound_id) REFERENCES fl_inbound_order (id), - CONSTRAINT chk_inbound_split_partial CHECK ( - source_package_count > 1 AND locked_package_count > 0 - AND locked_package_count < source_package_count - ), - CONSTRAINT chk_inbound_split_status CHECK (status IN ('LOCKED', 'COMPLETED')), - CONSTRAINT chk_inbound_split_completion CHECK ( - (status = 'LOCKED' AND child_inbound_id IS NULL AND completed_at IS NULL) - OR (status = 'COMPLETED' AND child_inbound_id IS NOT NULL AND completed_at IS NOT NULL) - ) -) ENGINE=InnoDB COMMENT='按箱数部分锁定及剩余箱数生成二级子入库单的不可变契约'; - -DROP PROCEDURE IF EXISTS unified_assert_final_extensions; -DELIMITER $$ -CREATE PROCEDURE unified_assert_final_extensions() -BEGIN - IF (SELECT COUNT(*) FROM information_schema.tables - WHERE table_schema = 'baiyue_firstleg' - AND table_name IN ('fl_preload_plan', 'fl_split_operation', - 'fl_loading_evidence', 'fl_loading_operation', - 'fl_loading_pick_task')) <> 5 THEN - SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = '统一迁移失败:三期分柜、预装或装柜表未完整创建'; - END IF; - IF (SELECT COUNT(*) FROM information_schema.columns - WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_preload_plan' - AND column_name IN ('id', 'tenant_id', 'inbound_id', 'status', 'version', - 'items_json', 'remark', 'created_by', 'created_by_name', - 'updated_by', 'updated_by_name', 'created_at', 'updated_at')) <> 13 - OR (SELECT COUNT(*) FROM information_schema.columns - WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_split_operation' - AND column_name IN ('id', 'tenant_id', 'inbound_id', 'operator_id', 'operation', - 'idempotency_key', 'status', 'result_json', 'created_at')) <> 9 - OR (SELECT COUNT(*) FROM information_schema.columns - WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_evidence' - AND column_name IN ('id', 'tenant_id', 'inbound_id', 'evidence_type', 'file_id', - 'file_name', 'seal_no', 'remark', 'version', 'created_by', - 'created_by_name', 'updated_by', 'updated_by_name', - 'created_at', 'updated_at')) <> 15 - OR (SELECT COUNT(*) FROM information_schema.columns - WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_operation' - AND column_name IN ('id', 'tenant_id', 'inbound_id', 'operator_id', 'operation', - 'idempotency_key', 'request_hash', 'status', 'result_json', - 'created_at', 'completed_at')) <> 11 - OR (SELECT COUNT(*) FROM information_schema.columns - WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task' - AND column_name IN ('id', 'tenant_id', 'inbound_id', 'status', 'version', - 'expected_count', 'picked_count', 'remark', 'updated_by', - 'updated_by_name', 'created_at', 'updated_at')) <> 12 THEN - SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = '统一迁移失败:三期分柜、预装或装柜关键字段未完整创建'; - END IF; - IF (SELECT COUNT(*) FROM information_schema.columns - WHERE table_schema = 'baiyue_firstleg' - AND table_name IN ('fl_preload_plan', 'fl_split_operation', 'fl_loading_operation') - AND column_name IN ('items_json', 'result_json') AND data_type = 'json') <> 3 - OR (SELECT COUNT(*) FROM information_schema.columns - WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_inbound_order' - AND column_name = 'process_status' AND character_maximum_length >= 24) <> 1 THEN - SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = '统一迁移失败:三期 JSON 字段或入库状态字段类型不兼容'; - END IF; - IF (SELECT COUNT(DISTINCT CONCAT(table_name, '.', index_name)) - FROM information_schema.statistics - WHERE table_schema = 'baiyue_firstleg' - AND index_name IN ('uk_preload_tenant_inbound', 'idx_preload_status', - 'uk_split_operation', 'idx_split_operation_time', - 'uk_loading_evidence_type', 'idx_loading_evidence_time', - 'uk_loading_operation', 'idx_loading_operation_time', - 'uk_loading_pick_tenant_inbound', 'idx_loading_pick_status')) <> 10 THEN - SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = '统一迁移失败:三期分柜、预装或装柜索引未完整创建'; - END IF; - IF (SELECT COUNT(*) FROM information_schema.table_constraints - WHERE constraint_schema = 'baiyue_firstleg' AND constraint_type = 'FOREIGN KEY' - AND table_name IN ('fl_preload_plan', 'fl_split_operation', - 'fl_loading_evidence', 'fl_loading_operation', - 'fl_loading_pick_task')) <> 5 THEN - SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = '统一迁移失败:三期操作表入库单外键未完整创建'; - END IF; - IF (SELECT COUNT(DISTINCT permission_code) FROM baiyue_iam.iam_permission - WHERE tenant_id = 0 AND app_code = 'PLATFORM' AND status = 'ACTIVE' AND deleted = 0 - AND permission_code IN ( - 'firstleg:inbound:split', 'firstleg:inbound:preload', - 'firstleg:inbound:preload-view', - 'platform:firstleg:inbound:loading:edit', - 'platform:firstleg:inbound:loading:view', - 'platform:firstleg:inbound:loading:complete', - 'platform:firstleg:inbound:delivery:complete', - 'platform:firstleg:inbound:picking:edit', - 'platform:firstleg:inbound:picking:view')) <> 9 THEN - SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = '统一迁移失败:三期分柜、预装或装柜权限未完整创建'; - END IF; - IF (SELECT COUNT(DISTINCT rp.permission_id) - FROM baiyue_iam.iam_role_permission rp - JOIN baiyue_iam.iam_permission p ON p.id = rp.permission_id - WHERE rp.role_id = 4000001 AND rp.tenant_id = 0 - AND p.permission_code IN ( - 'firstleg:inbound:split', 'firstleg:inbound:preload', - 'firstleg:inbound:preload-view', - 'platform:firstleg:inbound:loading:edit', - 'platform:firstleg:inbound:loading:view', - 'platform:firstleg:inbound:loading:complete', - 'platform:firstleg:inbound:delivery:complete', - 'platform:firstleg:inbound:picking:edit', - 'platform:firstleg:inbound:picking:view')) <> 9 THEN - SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = '统一迁移失败:平台系统管理员未获得完整三期操作权限'; - END IF; - IF (SELECT COUNT(*) FROM information_schema.tables - WHERE table_schema = 'baiyue_firstleg' - AND table_name IN ('fl_fee_item', 'fl_inbound_fee_detail')) <> 2 THEN - SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = '统一迁移失败:费用项目或入库费用明细表未完整创建'; - END IF; - IF (SELECT COUNT(*) FROM information_schema.columns - WHERE table_schema = 'baiyue_firstleg' - AND table_name IN ('fl_fee_item', 'fl_inbound_fee_detail') - AND column_name = 'tenant_id' - AND column_comment = '当前客户ID(客户隔离键);物理字段名沿用 tenant_id,当前不表示上级租户') <> 2 THEN - SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = '统一迁移失败:费用表 tenant_id 客户隔离语义未同步'; - END IF; - IF (SELECT COUNT(*) FROM information_schema.columns - WHERE table_schema = 'baiyue_firstleg' - AND table_name = 'fl_fee_item' - AND column_name = 'fee_code') <> 1 - OR (SELECT COUNT(*) FROM information_schema.columns - WHERE table_schema = 'baiyue_firstleg' - AND table_name = 'fl_inbound_fee_detail' - AND column_name IN ( - 'source_type', 'source_fee_id', 'source_fee_version', 'source_fee_no', - 'customer_id', 'customer_name', 'invoice_title', - 'chargeable_quantity', 'charge_unit', 'publication_status', - 'published_by', 'published_by_name', 'published_at', 'inbounded_at', - 'settlement_included')) <> 15 THEN - SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = '统一迁移失败:已发布入库运费明细字段未完整创建'; - END IF; - IF (SELECT COUNT(*) FROM information_schema.statistics - WHERE table_schema = 'baiyue_firstleg' - AND table_name = 'fl_fee_item' - AND index_name = 'uk_fee_item_code' AND non_unique = 0) <> 2 - OR (SELECT COUNT(*) FROM information_schema.statistics - WHERE table_schema = 'baiyue_firstleg' - AND table_name = 'fl_inbound_fee_detail' - AND index_name = 'uk_inbound_fee_source' AND non_unique = 0) <> 3 - OR (SELECT COUNT(*) FROM information_schema.statistics - WHERE table_schema = 'baiyue_firstleg' - AND table_name = 'fl_inbound_fee_detail' - AND index_name = 'idx_inbound_fee_published') <> 4 THEN - SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = '统一迁移失败:已发布入库运费明细索引未完整创建'; - END IF; - IF (SELECT COUNT(DISTINCT CONCAT(table_name, '.', index_name)) - FROM information_schema.statistics - WHERE table_schema = 'baiyue_firstleg' - AND index_name IN ('idx_fee_item_query', 'idx_fee_item_name', - 'idx_inbound_fee_detail', 'idx_inbound_fee_review', - 'idx_inbound_fee_item')) <> 5 THEN - SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = '统一迁移失败:费用项目或入库费用明细索引未完整创建'; - END IF; - IF (SELECT COUNT(DISTINCT permission_code) FROM baiyue_iam.iam_permission - WHERE tenant_id = 0 AND status = 'ACTIVE' AND deleted = 0 - AND permission_code IN ( - 'firstleg:fee-item:view', 'firstleg:fee-item:create', - 'firstleg:fee-item:edit', 'firstleg:fee-item:delete', - 'firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', - 'firstleg:inbound:fee-detail:delete', 'firstleg:inbound:fee-detail:review')) <> 8 THEN - SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = '统一迁移失败:费用项目或入库费用明细权限未完整创建'; - END IF; - IF (SELECT COUNT(*) FROM information_schema.tables - WHERE table_schema IN ('baiyue_auth', 'baiyue_iam', 'baiyue_order', - 'baiyue_firstleg', 'baiyue_warehouse') - AND table_name = 'sys_audit_outbox') <> 5 THEN - SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = '统一迁移失败:可靠审计 Outbox 未覆盖五个业务库'; - END IF; - IF (SELECT COUNT(DISTINCT CONCAT(table_schema, '.', index_name)) - FROM information_schema.statistics - WHERE table_schema IN ('baiyue_auth', 'baiyue_iam', 'baiyue_order', - 'baiyue_firstleg', 'baiyue_warehouse') - AND table_name = 'sys_audit_outbox' - AND index_name IN ('PRIMARY', 'idx_audit_outbox_publish', - 'idx_audit_outbox_retention')) <> 15 THEN - SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = '统一迁移失败:可靠审计 Outbox 主键或索引未完整创建'; - END IF; - IF (SELECT COUNT(*) FROM information_schema.columns - WHERE table_schema IN ('baiyue_auth', 'baiyue_iam', 'baiyue_order', - 'baiyue_firstleg', 'baiyue_warehouse') - AND table_name = 'sys_audit_outbox' - AND column_name IN ('event_id', 'payload_json', 'status', - 'next_retry_at', 'lease_until')) <> 25 THEN - SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = '统一迁移失败:可靠审计 Outbox 关键字段未完整创建'; - END IF; - IF (SELECT COUNT(*) FROM information_schema.tables - WHERE table_schema = 'baiyue_audit' - AND table_name = 'sys_audit_dead_letter') <> 1 THEN - SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = '统一迁移失败:审计死信补偿表未创建'; - END IF; - IF (SELECT COUNT(DISTINCT index_name) FROM information_schema.statistics - WHERE table_schema = 'baiyue_audit' - AND table_name = 'sys_audit_dead_letter' - AND index_name IN ('PRIMARY', 'uk_audit_dead_event', 'idx_audit_dead_retry')) <> 3 THEN - SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = '统一迁移失败:审计死信补偿表主键或索引未完整创建'; - END IF; - IF (SELECT COUNT(*) FROM information_schema.columns - WHERE table_schema = 'baiyue_audit' AND table_name = 'sys_audit_log' - AND column_name = 'event_id') <> 1 - OR (SELECT COUNT(DISTINCT index_name) FROM information_schema.statistics - WHERE table_schema = 'baiyue_audit' AND table_name = 'sys_audit_log' - AND index_name = 'uk_audit_event_id' AND non_unique = 0) <> 1 THEN - SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = '统一迁移失败:审计消费幂等 event_id 唯一约束未就绪'; - END IF; - IF (SELECT COUNT(*) FROM information_schema.columns - WHERE table_schema = 'baiyue_firstleg' - AND table_name = 'fl_third_party_call_log' - AND column_name = 'tenant_id' AND column_default = '0' - AND column_comment = '第三方未知/未解析主体哨兵值;不是平台客户记录或平台作用域,不得用于业务数据授权') <> 1 THEN - SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = '统一迁移失败:第三方未知主体 tenant_id=0 哨兵语义未就绪'; - END IF; - IF (SELECT COUNT(*) FROM baiyue_warehouse.wh_warehouse WHERE tenant_id <> 0) > 0 - OR (SELECT COUNT(*) FROM baiyue_warehouse.wh_warehouse_address WHERE tenant_id <> 0) > 0 - OR (SELECT COUNT(*) FROM baiyue_warehouse.wh_service_capability WHERE tenant_id <> 0) > 0 - OR (SELECT COUNT(*) FROM baiyue_warehouse.wh_express_company WHERE tenant_id <> 0) > 0 - OR (SELECT COUNT(*) FROM baiyue_warehouse.wh_partner - WHERE tenant_id <> 0 OR data_scope_id <> 0) > 0 - OR (SELECT COUNT(*) FROM baiyue_warehouse.wh_partner_type - WHERE tenant_id <> 0 OR data_scope_id <> 0) > 0 - OR (SELECT COUNT(*) FROM baiyue_warehouse.wh_pallet WHERE tenant_id <> 0) > 0 - OR (SELECT COUNT(*) FROM baiyue_warehouse.wh_location WHERE tenant_id <> 0) > 0 - OR (SELECT COUNT(*) FROM baiyue_warehouse.wh_location_reservation WHERE tenant_id <> 0) > 0 - OR (SELECT COUNT(*) FROM baiyue_warehouse.wh_location_occupancy WHERE tenant_id <> 0) > 0 - OR (SELECT COUNT(*) FROM baiyue_firstleg.fl_inbound_pallet_allocation - WHERE warehouse_resource_tenant_id <> 0) > 0 THEN - SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = '统一迁移失败:平台公共仓储资源仍有 tenant_id<>0'; - END IF; - IF EXISTS ( - SELECT 1 FROM baiyue_warehouse.wh_warehouse_address a - JOIN baiyue_warehouse.wh_warehouse w ON w.id = a.warehouse_id - WHERE a.tenant_id <> w.tenant_id - ) OR EXISTS ( - SELECT 1 FROM baiyue_warehouse.wh_service_capability c - JOIN baiyue_warehouse.wh_warehouse w ON w.id = c.warehouse_id - WHERE c.tenant_id <> w.tenant_id - ) OR EXISTS ( - SELECT 1 FROM baiyue_warehouse.wh_pallet p - JOIN baiyue_warehouse.wh_warehouse w ON w.id = p.warehouse_id - WHERE p.tenant_id <> w.tenant_id - ) OR EXISTS ( - SELECT 1 FROM baiyue_warehouse.wh_location l - JOIN baiyue_warehouse.wh_warehouse w ON w.id = l.warehouse_id - WHERE l.tenant_id <> w.tenant_id - ) OR EXISTS ( - SELECT 1 FROM baiyue_warehouse.wh_location_reservation r - JOIN baiyue_warehouse.wh_warehouse w ON w.id = r.warehouse_id - JOIN baiyue_warehouse.wh_pallet p ON p.id = r.pallet_id - JOIN baiyue_warehouse.wh_location l ON l.id = r.location_id - WHERE r.tenant_id <> w.tenant_id OR r.tenant_id <> p.tenant_id OR r.tenant_id <> l.tenant_id - ) OR EXISTS ( - SELECT 1 FROM baiyue_warehouse.wh_location_occupancy o - JOIN baiyue_warehouse.wh_warehouse w ON w.id = o.warehouse_id - JOIN baiyue_warehouse.wh_pallet p ON p.id = o.pallet_id - JOIN baiyue_warehouse.wh_location l ON l.id = o.location_id - JOIN baiyue_warehouse.wh_location_reservation r ON r.id = o.reservation_id - WHERE o.tenant_id <> w.tenant_id OR o.tenant_id <> p.tenant_id - OR o.tenant_id <> l.tenant_id OR o.tenant_id <> r.tenant_id - ) THEN - SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = '统一迁移失败:Warehouse 公共资源父子归属不一致'; - END IF; - IF EXISTS (SELECT 1 FROM baiyue_warehouse.wh_overseas_storage_fee_rule WHERE tenant_id = 0) THEN - SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = '统一迁移失败:客户费用规则缺少客户 tenant_id'; - END IF; -END$$ -DELIMITER ; -CALL unified_assert_final_extensions(); -DROP PROCEDURE IF EXISTS unified_assert_final_extensions; - --- tenant_id=0 在此表仅为未知/未解析第三方主体;数量可非零,但不得用于业务授权。 -SELECT COUNT(*) AS unresolved_third_party_calls - FROM baiyue_firstleg.fl_third_party_call_log - WHERE tenant_id = 0; - -SELECT 'PASS' AS migration_status, - '统一增量全部执行完成;统一数据库脚本执行完成,三期出仓、费用、公共资源及可靠审计结构校验通过' AS message; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/BaiyueFirstlegApplication.java b/firstleg/src/main/java/com/baiyue/firstleg/BaiyueFirstlegApplication.java index 363d0c7eb..5952b910c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/BaiyueFirstlegApplication.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/BaiyueFirstlegApplication.java @@ -8,7 +8,11 @@ import org.springframework.cloud.openfeign.EnableFeignClients; /** * 头程履约微服务启动入口,负责装配履约任务组件并扫描 MyBatis-Plus Mapper。 */ -@MapperScan("com.baiyue.firstleg.mapper") +@MapperScan(basePackages = { + "com.baiyue.firstleg.mapper", + "com.baiyue.firstleg.split.mapper", + "com.baiyue.firstleg.loading.mapper" +}) @EnableFeignClients(basePackages = "com.baiyue.firstleg.client") @SpringBootApplication(scanBasePackages = "com.baiyue") public class BaiyueFirstlegApplication { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/WarehouseMasterDataClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/WarehouseMasterDataClient.java new file mode 100644 index 000000000..d24820e4a --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/WarehouseMasterDataClient.java @@ -0,0 +1,24 @@ +package com.baiyue.firstleg.client; + +import com.baiyue.common.core.api.ApiResponse; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestHeader; + +/** 获取平台仓库主数据,供虚拟柜校验国内中转仓使用。 */ +@FeignClient(name = "warehouse-service", contextId = "firstlegWarehouseMasterDataClient", + url = "${baiyue.clients.warehouse.url:}", path = "/internal/warehouses") +public interface WarehouseMasterDataClient { + @GetMapping("/{tenantId}/{warehouseId}/active") + ApiResponse active(@RequestHeader("X-Internal-Service-Token") String token, + @PathVariable long tenantId, + @PathVariable long warehouseId); + + record Detail(Warehouse warehouse) { + } + + record Warehouse(Long id, Long tenantId, String warehouseCode, String warehouseName, + String warehouseType, String status) { + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java index 574ffd8b4..11e3e0f20 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java @@ -26,6 +26,7 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; /** 基于同一装柜计划聚合提供平台预装清单和已确认装柜清单。 */ @@ -50,9 +51,10 @@ public class PlatformLoadingListController { @GetMapping("/loading-plans/{planId}") @PreAuthorize("hasAuthority('platform:firstleg:preload:view')") public ApiResponse preloadDetail( - @AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId) { + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId, + @RequestParam @Positive long warehouseId) { FirstlegScopeGuard.platform(user); - return ApiResponse.success(service.preloadDetail(planId)); + return ApiResponse.success(service.preloadDetail(planId, warehouseId)); } @Operation(summary = "修改装柜运输字段", description = "仅装柜中可修改集装箱号与航次。") @@ -61,11 +63,12 @@ public class PlatformLoadingListController { public ApiResponse updateTransport( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId, + @RequestParam @Positive long warehouseId, @RequestHeader("Idempotency-Key") String idempotencyKey, @Valid @RequestBody TransportUpdateRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.updateTransport( - planId, user.userId(), user.username(), idempotencyKey, request)); + planId, warehouseId, user.userId(), user.username(), idempotencyKey, request)); } @Operation(summary = "分页查询完成装柜清单", description = "只读取 COMPLETED 状态的装柜计划。") @@ -81,8 +84,9 @@ public class PlatformLoadingListController { @GetMapping("/loading-lists/{planId}") @PreAuthorize("hasAuthority('platform:firstleg:loading-list:view')") public ApiResponse confirmedDetail( - @AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId) { + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId, + @RequestParam @Positive long warehouseId) { FirstlegScopeGuard.platform(user); - return ApiResponse.success(service.confirmedDetail(planId)); + return ApiResponse.success(service.confirmedDetail(planId, warehouseId)); } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java index b3b2473d5..dbe9c39e8 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java @@ -3,6 +3,8 @@ package com.baiyue.firstleg.controller; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.firstleg.dto.LoadingPickDtos.PickRequest; +import com.baiyue.firstleg.dto.LoadingPickDtos.PartialPickRequest; +import com.baiyue.firstleg.dto.LoadingPickDtos.SkipRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.Task; import com.baiyue.firstleg.service.FirstlegScopeGuard; import com.baiyue.firstleg.service.LoadingPickService; @@ -31,9 +33,10 @@ public class PlatformLoadingPdaController { @GetMapping("/tasks") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')") public ApiResponse task(@AuthenticationPrincipal AuthenticatedUser user, - @RequestParam String containerNo) { + @RequestParam String containerNo, + @RequestParam @Positive long warehouseId) { FirstlegScopeGuard.platform(user); - return ApiResponse.success(service.task(containerNo)); + return ApiResponse.success(service.task(containerNo, warehouseId)); } @PostMapping("/tasks/{taskId}/items/{itemId}/pick") @@ -45,4 +48,24 @@ public class PlatformLoadingPdaController { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.pick(taskId, itemId, request, user.userId(), user.username())); } + + @PostMapping("/tasks/{taskId}/items/{itemId}/partial") + @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')") + public ApiResponse partial(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long taskId, + @PathVariable @Positive long itemId, + @Valid @RequestBody PartialPickRequest request) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.partial(taskId, itemId, request, user.userId(), user.username())); + } + + @PostMapping("/tasks/{taskId}/items/{itemId}/skip") + @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')") + public ApiResponse skip(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long taskId, + @PathVariable @Positive long itemId, + @Valid @RequestBody SkipRequest request) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.skip(taskId, itemId, request, user.userId(), user.username())); + } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformSmartLoadingController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformSmartLoadingController.java index 3620f8be2..ea1020076 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformSmartLoadingController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformSmartLoadingController.java @@ -9,7 +9,7 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; imp public class PlatformSmartLoadingController { @Resource private SmartLoadingService service; @GetMapping("/candidates") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')") - public ApiResponse> candidates(@AuthenticationPrincipal AuthenticatedUser u,@RequestParam @Positive long accountId){FirstlegScopeGuard.platform(u,accountId);return ApiResponse.success(service.candidates(accountId));} + public ApiResponse> candidates(@AuthenticationPrincipal AuthenticatedUser u,@RequestParam @Positive long warehouseId){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.candidates(warehouseId));} @GetMapping("/plans/by-booking/{bookingId}") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')") public ApiResponse draftByBooking(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long bookingId){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.draftByBooking(bookingId));} @GetMapping("/plans/{planId}") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')") @@ -17,13 +17,13 @@ public class PlatformSmartLoadingController { @PostMapping("/plans") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:create')") public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser u,@Valid @RequestBody CreateRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.create(r.bookingId(),r.remark(),u.userId(),u.username()));} @PostMapping("/plans/{planId}/assignments") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')") - public ApiResponse assign(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@Valid @RequestBody AssignRequest r){FirstlegScopeGuard.platform(u,r.accountId());return ApiResponse.success(service.assign(planId,r,u.userId(),u.username()));} + public ApiResponse assign(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@Valid @RequestBody AssignRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.assign(planId,r,u.userId(),u.username()));} @PostMapping("/plans/{planId}/recommend") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')") - public ApiResponse recommend(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@Valid @RequestBody RecommendRequest r){FirstlegScopeGuard.platform(u,r.accountId());return ApiResponse.success(service.recommend(planId,r,u.userId(),u.username()));} + public ApiResponse recommend(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@Valid @RequestBody RecommendRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.recommend(planId,r,u.userId(),u.username()));} @PatchMapping("/plans/{planId}/items/{itemId}") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')") - public ApiResponse adjust(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@PathVariable @Positive long itemId,@Valid @RequestBody AdjustRequest r){FirstlegScopeGuard.platform(u,r.accountId());return ApiResponse.success(service.adjust(planId,itemId,r,u.userId(),u.username()));} + public ApiResponse adjust(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@PathVariable @Positive long itemId,@Valid @RequestBody AdjustRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.adjust(planId,itemId,r,u.userId(),u.username()));} @DeleteMapping("/plans/{planId}/items/{itemId}") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:delete')") - public ApiResponse delete(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@PathVariable @Positive long itemId,@Valid @RequestBody DeleteRequest r){FirstlegScopeGuard.platform(u,r.accountId());return ApiResponse.success(service.delete(planId,itemId,r,u.userId(),u.username()));} + public ApiResponse delete(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@PathVariable @Positive long itemId,@Valid @RequestBody DeleteRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.delete(planId,itemId,r,u.userId(),u.username()));} @PostMapping("/plans/{planId}/push-preload") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:push')") public ApiResponse push(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@RequestHeader("Idempotency-Key") String key,@Valid @RequestBody PushRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.pushPreload(planId,key,r,u.userId(),u.username()));} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerDtos.java index 5ea393fdc..497edc6ac 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerDtos.java @@ -48,7 +48,7 @@ public final class ContainerDtos { @Size(max = 64) String containerNo, @NotBlank @Size(max = 64) String cabinetNo, @NotNull @Positive Long warehouseId, - @NotBlank @Size(max = 128) String warehouseName, + @Size(max = 128) String warehouseName, @Size(max = 128) String billOfLadingNo, @Size(max = 128) String vesselName, @Size(max = 64) String voyageNo, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java index 90d1af3da..85e33f742 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java @@ -17,8 +17,7 @@ public final class LoadingListDtos { public record PreloadQuery( @Size(max = 128) String keyword, String status, - @Min(1) Long accountId, - @Min(1) Long warehouseId, + @NotNull @Min(1) Long warehouseId, LocalDateTime createdFrom, LocalDateTime createdTo, @Min(1) long page, @@ -32,8 +31,7 @@ public final class LoadingListDtos { public record ConfirmedQuery( @Size(max = 128) String keyword, - @Min(1) Long accountId, - @Min(1) Long warehouseId, + @NotNull @Min(1) Long warehouseId, LocalDateTime createdFrom, LocalDateTime createdTo, @Min(1) long page, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java index c6477d768..62c14bfac 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java @@ -14,6 +14,7 @@ public final class LoadingPickDtos { } public record PickRequest( + @NotNull @Min(1) Long warehouseId, @NotNull @Min(1) Integer pickedPackageCount, @JsonAlias("reservationIds") List<@Min(1) Long> locationIds, @NotNull @Min(0) Integer version, @@ -21,6 +22,25 @@ public final class LoadingPickDtos { ) { } + /** 部分拣货为该 PDA 明细的最终实际拣出箱数,不是增量。 */ + public record PartialPickRequest( + @NotNull @Min(1) Long warehouseId, + @NotNull @Min(1) Integer finalPickedPackageCount, + @JsonAlias("reservationIds") List<@Min(1) Long> locationIds, + @NotNull @Min(0) Integer version, + @NotBlank @Size(max = 128) String operationId + ) { + } + + /** 跳拣只能发生在尚未拣出任何箱数的 PDA 明细上。 */ + public record SkipRequest( + @NotNull @Min(1) Long warehouseId, + @NotNull @Min(0) Integer version, + @NotBlank @Size(max = 128) String operationId, + @Size(max = 500) String reason + ) { + } + public record PickItem(Long id, Long loadingPlanItemId, Long inboundId, Long inboundDetailId, Integer plannedPackageCount, Integer pickedPackageCount, String itemStatus, Integer version) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java index 766e5e3c2..d2dcd723a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java @@ -11,13 +11,13 @@ public final class SmartLoadingDtos { private SmartLoadingDtos(){} } } public record CreateRequest(@NotNull @Positive Long bookingId, @Size(max=500) String remark) {} - public record AssignRequest(@NotNull @Positive Long accountId,@NotNull @Positive Long inboundId,@NotNull @Positive Long inboundDetailId, + public record AssignRequest(@NotNull @Positive Long inboundId,@NotNull @Positive Long inboundDetailId, @NotNull @Positive Integer packageCount,@NotNull @Min(0) Integer expectedVersion,@NotBlank @Size(max=128) String operationId) {} - public record RecommendRequest(@NotNull @Positive Long accountId,@NotNull @Min(0) Integer expectedVersion, + public record RecommendRequest(@NotNull @Min(0) Integer expectedVersion, @NotBlank @Size(max=128) String operationId) {} - public record AdjustRequest(@NotNull @Positive Long accountId,@NotNull @Positive Integer packageCount,@NotNull @Min(0) Integer expectedPlanVersion, + public record AdjustRequest(@NotNull @Positive Integer packageCount,@NotNull @Min(0) Integer expectedPlanVersion, @NotNull @Min(0) Integer expectedItemVersion,@NotBlank @Size(max=128) String operationId) {} - public record DeleteRequest(@NotNull @Positive Long accountId,@NotNull @Min(0) Integer expectedPlanVersion, + public record DeleteRequest(@NotNull @Min(0) Integer expectedPlanVersion, @NotNull @Min(0) Integer expectedItemVersion,@NotBlank @Size(max=128) String operationId) {} public record PushRequest(@NotNull @Min(0) Integer expectedVersion) {} public record Plan(Long id,String planNo,String status,Long bookingId,String containerNo,String voyageNo,Integer version,List items) {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPickHistoryEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPickHistoryEntity.java index 3e80f10fd..f2450e391 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPickHistoryEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPickHistoryEntity.java @@ -17,7 +17,9 @@ public class LoadingPickHistoryEntity { private Long pickItemId; private String operationId; private String requestHash; + private String actionType; private Integer pickedPackageCount; + private String operationReason; private Long operatorId; private String operatorName; } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/LoadingPickTaskMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/InboundLoadingPickTaskMapper.java similarity index 93% rename from firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/LoadingPickTaskMapper.java rename to firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/InboundLoadingPickTaskMapper.java index dba3437f8..c3b7700c4 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/LoadingPickTaskMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/InboundLoadingPickTaskMapper.java @@ -7,7 +7,7 @@ import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Update; @Mapper -public interface LoadingPickTaskMapper extends BaseMapper { +public interface InboundLoadingPickTaskMapper extends BaseMapper { @Update("UPDATE fl_loading_pick_task SET status=#{toStatus}, updated_by=#{operatorId}, updated_by_name=#{operatorName}, " + "remark=COALESCE(#{remark}, remark), expected_count=COALESCE(#{expectedCount}, expected_count), " + "picked_count=COALESCE(#{pickedCount}, picked_count), version=version+1 WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId} " diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/repository/LoadingRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/repository/LoadingRepository.java index 60151b079..dedb358a6 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/repository/LoadingRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/repository/LoadingRepository.java @@ -7,7 +7,7 @@ import com.baiyue.firstleg.loading.entity.LoadingOperationEntity; import com.baiyue.firstleg.loading.mapper.LoadingEvidenceMapper; import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper; import com.baiyue.firstleg.loading.mapper.LoadingOperationMapper; -import com.baiyue.firstleg.loading.mapper.LoadingPickTaskMapper; +import com.baiyue.firstleg.loading.mapper.InboundLoadingPickTaskMapper; import com.baiyue.firstleg.loading.entity.LoadingPickTaskEntity; import jakarta.annotation.Resource; import org.springframework.stereotype.Repository; @@ -19,7 +19,7 @@ public class LoadingRepository { @Resource private LoadingInboundMapper inbounds; @Resource private LoadingEvidenceMapper evidences; @Resource private LoadingOperationMapper operations; - @Resource private LoadingPickTaskMapper picks; + @Resource private InboundLoadingPickTaskMapper picks; public LoadingInboundMapper.Snapshot findInbound(long tenantId, long inboundId) { return inbounds.find(tenantId, inboundId); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundSplitSettlementMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundSplitSettlementMapper.java new file mode 100644 index 000000000..249e474e5 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundSplitSettlementMapper.java @@ -0,0 +1,56 @@ +package com.baiyue.firstleg.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +/** PDA部分拣货结算的客户归属锁定查询。 */ +@Mapper +public interface InboundSplitSettlementMapper { + @Select(""" + SELECT id, tenant_id AS tenantId, inbound_code AS inboundCode, inbound_role AS inboundRole, + process_status AS processStatus, version + FROM fl_inbound_order + WHERE tenant_id = #{accountId} AND id = #{inboundId} AND deleted = 0 + FOR UPDATE + """) + ParentRow lockParent(@Param("accountId") long accountId, @Param("inboundId") long inboundId); + + @Select(""" + SELECT id + FROM fl_inbound_order + WHERE tenant_id = #{accountId} AND parent_inbound_id = #{parentInboundId} + AND inbound_role = 'CHILD' AND deleted = 0 + LIMIT 1 + FOR UPDATE + """) + Long childId(@Param("accountId") long accountId, @Param("parentInboundId") long parentInboundId); + + @Select({""}) + List lockLatestApprovedItems(@Param("accountId") long accountId, + @Param("inboundId") long inboundId, + @Param("detailIds") List detailIds); + + record ParentRow(Long id, Long tenantId, String inboundCode, String inboundRole, + String processStatus, Integer version) { + } + + record MeasurementItemRow(Long measurementItemId, Long measurementId, Long inboundDetailId, + Integer packageCount) { + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java index 299db9824..3ea3334a8 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java @@ -53,11 +53,6 @@ public interface LoadingListQueryMapper { OR b.cabinet_no LIKE CONCAT('%', #{query.keyword}, '%')) AND b.warehouse_id = #{query.warehouseId} - - AND EXISTS (SELECT 1 FROM fl_loading_plan_item ai - WHERE ai.loading_plan_id = p.id AND ai.deleted = 0 - AND ai.account_id = #{query.accountId}) - AND p.created_at >= #{query.createdFrom} AND p.created_at <= #{query.createdTo} GROUP BY @@ -81,11 +76,6 @@ public interface LoadingListQueryMapper { OR b.cabinet_no LIKE CONCAT('%', #{query.keyword}, '%')) AND b.warehouse_id = #{query.warehouseId} - - AND EXISTS (SELECT 1 FROM fl_loading_plan_item ai - WHERE ai.loading_plan_id = p.id AND ai.deleted = 0 - AND ai.account_id = #{query.accountId}) - AND p.created_at >= #{query.createdFrom} AND p.created_at <= #{query.createdTo} GROUP BY diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java index eaa9d8eac..3af7c51d9 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java @@ -9,6 +9,32 @@ import org.apache.ibatis.annotations.Update; @Mapper public interface LoadingPickItemMapper extends BaseMapper { + @Select(""" + SELECT pick_item.* + FROM fl_loading_pick_item pick_item + JOIN fl_loading_plan_item plan_item ON plan_item.id = pick_item.loading_plan_item_id + JOIN fl_loading_plan plan ON plan.id = plan_item.loading_plan_id + JOIN fl_container_booking booking ON booking.id = plan.container_booking_id + WHERE pick_item.pick_task_id = #{taskId} AND booking.warehouse_id = #{warehouseId} + AND plan_item.deleted = 0 + ORDER BY pick_item.id ASC + """) + java.util.List selectTaskItemsWithAccount(@Param("taskId") long taskId, + @Param("warehouseId") long warehouseId); + + @Select(""" + SELECT pick_item.* + FROM fl_loading_pick_item pick_item + JOIN fl_loading_plan_item plan_item ON plan_item.id = pick_item.loading_plan_item_id + JOIN fl_loading_plan plan ON plan.id = plan_item.loading_plan_id + JOIN fl_container_booking booking ON booking.id = plan.container_booking_id + WHERE pick_item.pick_task_id = #{taskId} AND pick_item.id = #{itemId} + AND booking.warehouse_id = #{warehouseId} AND plan_item.deleted = 0 + """) + LoadingPickItemEntity selectTaskItemWithAccount(@Param("taskId") long taskId, + @Param("itemId") long itemId, + @Param("warehouseId") long warehouseId); + @Update(""" UPDATE fl_loading_pick_item SET picked_package_count = picked_package_count + #{count}, @@ -16,9 +42,52 @@ public interface LoadingPickItemMapper extends BaseMapper THEN 'PICKED' ELSE 'PICKING' END, version = version + 1 WHERE id = #{id} AND version = #{version} + AND EXISTS (SELECT 1 FROM fl_loading_plan_item plan_item + JOIN fl_loading_plan plan ON plan.id = plan_item.loading_plan_id + JOIN fl_container_booking booking ON booking.id = plan.container_booking_id + WHERE plan_item.id = fl_loading_pick_item.loading_plan_item_id + AND booking.warehouse_id = #{warehouseId} AND plan_item.deleted = 0) AND picked_package_count + #{count} <= planned_package_count """) - int pick(@Param("id") long id, @Param("count") int count, @Param("version") int version); + int pick(@Param("id") long id, @Param("count") int count, @Param("version") int version, + @Param("warehouseId") long warehouseId); + + @Update(""" + UPDATE fl_loading_pick_item + SET picked_package_count = #{finalCount}, item_status = 'PARTIAL', version = version + 1 + WHERE id = #{id} AND version = #{version} + AND EXISTS (SELECT 1 FROM fl_loading_plan_item plan_item + JOIN fl_loading_plan plan ON plan.id = plan_item.loading_plan_id + JOIN fl_container_booking booking ON booking.id = plan.container_booking_id + WHERE plan_item.id = fl_loading_pick_item.loading_plan_item_id + AND booking.warehouse_id = #{warehouseId} AND plan_item.deleted = 0) + AND item_status IN ('PENDING', 'PICKING') + AND picked_package_count < #{finalCount} + AND #{finalCount} < planned_package_count + """) + int partial(@Param("id") long id, @Param("finalCount") int finalCount, @Param("version") int version, + @Param("warehouseId") long warehouseId); + + @Update(""" + UPDATE fl_loading_pick_item + SET item_status = 'SKIPPED', version = version + 1 + WHERE id = #{id} AND version = #{version} + AND EXISTS (SELECT 1 FROM fl_loading_plan_item plan_item + JOIN fl_loading_plan plan ON plan.id = plan_item.loading_plan_id + JOIN fl_container_booking booking ON booking.id = plan.container_booking_id + WHERE plan_item.id = fl_loading_pick_item.loading_plan_item_id + AND booking.warehouse_id = #{warehouseId} AND plan_item.deleted = 0) + AND item_status = 'PENDING' AND picked_package_count = 0 + """) + int skip(@Param("id") long id, @Param("version") int version, @Param("warehouseId") long warehouseId); + + @Select(""" + SELECT COUNT(*) + FROM fl_loading_pick_item + WHERE pick_task_id = #{taskId} + AND item_status NOT IN ('PICKED', 'PARTIAL', 'SKIPPED') + """) + long unresolvedCount(@Param("taskId") long taskId); /** * 以装柜计划的全部有效明细为完成口径,不能只检查当前 PDA 任务中的明细。 diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickTaskMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickTaskMapper.java index ee5c434a1..db7bf82a7 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickTaskMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickTaskMapper.java @@ -3,7 +3,21 @@ package com.baiyue.firstleg.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.firstleg.entity.LoadingPickTaskEntity; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; @Mapper public interface LoadingPickTaskMapper extends BaseMapper { + @Select(""" + SELECT DISTINCT task.* + FROM fl_loading_pick_task task + JOIN fl_loading_pick_item pick_item ON pick_item.pick_task_id = task.id + JOIN fl_loading_plan plan ON plan.id = task.loading_plan_id + JOIN fl_container_booking booking ON booking.id = plan.container_booking_id + WHERE task.container_no = #{containerNo} AND task.task_status = 'LOADING' + AND booking.warehouse_id = #{warehouseId} + LIMIT 1 + """) + LoadingPickTaskEntity selectLoadingByContainerAndAccount(@Param("containerNo") String containerNo, + @Param("warehouseId") long warehouseId); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java index 1713c4fe6..f4c5da782 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java @@ -8,7 +8,14 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.iba o.destination_code destinationWarehouseCode,o.destination_name destinationWarehouseName, o.customer_name customerName,COALESCE(m.actual_goods_name,'') goodsName, i.package_count-COALESCE((SELECT SUM(li.package_count) FROM fl_loading_plan_item li - WHERE li.account_id=o.tenant_id AND li.inbound_detail_id=i.inbound_detail_id AND li.deleted=0),0) availablePackageCount,COALESCE(i.unit_weight_kg,0) unitWeightKg, + WHERE li.account_id=o.tenant_id AND li.inbound_id=o.id + AND li.inbound_detail_id=i.inbound_detail_id AND li.deleted=0),0) + -COALESCE((SELECT SUM(split_item.package_count) + FROM fl_inbound_split_item split_item + WHERE o.inbound_role='MASTER' + AND split_item.tenant_id=o.tenant_id + AND split_item.parent_inbound_id=o.id + AND split_item.source_measurement_item_id=i.id),0) availablePackageCount,COALESCE(i.unit_weight_kg,0) unitWeightKg, COALESCE(i.unit_volume_cbm,0) unitVolumeCbm FROM fl_inbound_order o JOIN fl_inbound_measurement m ON m.tenant_id=o.tenant_id AND m.inbound_id=o.id @@ -16,13 +23,20 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.iba AND m.id=(SELECT MAX(m2.id) FROM fl_inbound_measurement m2 WHERE m2.tenant_id=o.tenant_id AND m2.inbound_id=o.id AND m2.status='APPROVED') JOIN fl_inbound_measurement_item i ON i.tenant_id=o.tenant_id AND i.measurement_id=m.id - WHERE o.tenant_id=#{accountId} AND o.process_status='INBOUNDED' + WHERE o.warehouse_id=#{warehouseId} AND o.process_status='INBOUNDED' AND i.inbound_detail_id IS NOT NULL AND i.package_count>COALESCE((SELECT SUM(li.package_count) FROM fl_loading_plan_item li - WHERE li.account_id=o.tenant_id AND li.inbound_detail_id=i.inbound_detail_id AND li.deleted=0),0) + WHERE li.account_id=o.tenant_id AND li.inbound_id=o.id + AND li.inbound_detail_id=i.inbound_detail_id AND li.deleted=0),0) + +COALESCE((SELECT SUM(split_item.package_count) + FROM fl_inbound_split_item split_item + WHERE o.inbound_role='MASTER' + AND split_item.tenant_id=o.tenant_id + AND split_item.parent_inbound_id=o.id + AND split_item.source_measurement_item_id=i.id),0) ORDER BY o.inbounded_at ASC, o.id ASC, i.id ASC """) - List candidates(@Param("accountId") long accountId); + List candidates(@Param("warehouseId") long warehouseId); @Select(""" SELECT o.tenant_id accountId,o.id inboundId,i.inbound_detail_id inboundDetailId, @@ -31,7 +45,14 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.iba o.destination_code destinationWarehouseCode,o.destination_name destinationWarehouseName, o.customer_name customerName,COALESCE(m.actual_goods_name,'') goodsName, i.package_count-COALESCE((SELECT SUM(li.package_count) FROM fl_loading_plan_item li - WHERE li.account_id=o.tenant_id AND li.inbound_detail_id=i.inbound_detail_id AND li.deleted=0),0) availablePackageCount,COALESCE(i.unit_weight_kg,0) unitWeightKg, + WHERE li.account_id=o.tenant_id AND li.inbound_id=o.id + AND li.inbound_detail_id=i.inbound_detail_id AND li.deleted=0),0) + -COALESCE((SELECT SUM(split_item.package_count) + FROM fl_inbound_split_item split_item + WHERE o.inbound_role='MASTER' + AND split_item.tenant_id=o.tenant_id + AND split_item.parent_inbound_id=o.id + AND split_item.source_measurement_item_id=i.id),0) availablePackageCount,COALESCE(i.unit_weight_kg,0) unitWeightKg, COALESCE(i.unit_volume_cbm,0) unitVolumeCbm FROM fl_inbound_order o JOIN fl_inbound_measurement m ON m.tenant_id=o.tenant_id AND m.inbound_id=o.id @@ -39,10 +60,10 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.iba AND m.id=(SELECT MAX(m2.id) FROM fl_inbound_measurement m2 WHERE m2.tenant_id=o.tenant_id AND m2.inbound_id=o.id AND m2.status='APPROVED') JOIN fl_inbound_measurement_item i ON i.tenant_id=o.tenant_id AND i.measurement_id=m.id - WHERE o.tenant_id=#{accountId} AND o.id=#{inboundId} AND o.process_status='INBOUNDED' + WHERE o.warehouse_id=#{warehouseId} AND o.id=#{inboundId} AND o.process_status='INBOUNDED' AND i.inbound_detail_id=#{detailId} ORDER BY m.id DESC LIMIT 1 FOR UPDATE """) - Candidate candidate(@Param("accountId") long accountId,@Param("inboundId") long inboundId, + Candidate candidate(@Param("warehouseId") long warehouseId,@Param("inboundId") long inboundId, @Param("detailId") long detailId); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingPickRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingPickRepository.java index e30e13c91..d404ba6fe 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingPickRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingPickRepository.java @@ -38,6 +38,10 @@ public class LoadingPickRepository { .eq(LoadingPickTaskEntity::getTaskStatus, "LOADING")); } + public LoadingPickTaskEntity taskByContainer(String containerNo, long warehouseId) { + return tasks.selectLoadingByContainerAndAccount(containerNo, warehouseId); + } + public LoadingPickItemEntity item(long taskId, long itemId) { return items.selectOne(new LambdaQueryWrapper() .eq(LoadingPickItemEntity::getPickTaskId, taskId) @@ -50,6 +54,14 @@ public class LoadingPickRepository { .orderByAsc(LoadingPickItemEntity::getId)); } + public List items(long taskId, long warehouseId) { + return items.selectTaskItemsWithAccount(taskId, warehouseId); + } + + public LoadingPickItemEntity item(long taskId, long itemId, long warehouseId) { + return items.selectTaskItemWithAccount(taskId, itemId, warehouseId); + } + public void insert(LoadingPickTaskEntity task) { tasks.insert(task); } @@ -63,8 +75,20 @@ public class LoadingPickRepository { .eq(LoadingPickItemEntity::getLoadingPlanItemId, planItemId)); } - public int pick(long itemId, int count, int version) { - return items.pick(itemId, count, version); + public int pick(long itemId, int count, int version, long warehouseId) { + return items.pick(itemId, count, version, warehouseId); + } + + public int partial(long itemId, int finalCount, int version, long warehouseId) { + return items.partial(itemId, finalCount, version, warehouseId); + } + + public int skip(long itemId, int version, long warehouseId) { + return items.skip(itemId, version, warehouseId); + } + + public boolean allTaskItemsResolved(long taskId) { + return items.unresolvedCount(taskId) == 0; } public boolean allPlanItemsPicked(long planId) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java index e774deb0d..6275c7f2b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java @@ -27,9 +27,9 @@ public class SmartLoadingRepository { @Resource private ContainerBookingMapper bookings; @Resource private SmartLoadingCandidateMapper candidates; - public List candidates(long accountId) { return candidates.candidates(accountId); } - public Candidate candidate(long accountId, long inboundId, long detailId) { - return candidates.candidate(accountId, inboundId, detailId); + public List candidates(long warehouseId) { return candidates.candidates(warehouseId); } + public Candidate candidate(long warehouseId, long inboundId, long detailId) { + return candidates.candidate(warehouseId, inboundId, detailId); } public LoadingPlanEntity plan(long planId) { return plans.selectById(planId); } public LoadingPlanEntity planByBooking(long bookingId) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/UnifiedInboundReadRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/UnifiedInboundReadRepository.java index 798ab618a..258a54b1d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/UnifiedInboundReadRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/UnifiedInboundReadRepository.java @@ -4,7 +4,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baiyue.firstleg.loading.entity.LoadingEvidenceEntity; import com.baiyue.firstleg.loading.entity.LoadingPickTaskEntity; import com.baiyue.firstleg.loading.mapper.LoadingEvidenceMapper; -import com.baiyue.firstleg.loading.mapper.LoadingPickTaskMapper; +import com.baiyue.firstleg.loading.mapper.InboundLoadingPickTaskMapper; import com.baiyue.firstleg.split.entity.PreloadPlanEntity; import com.baiyue.firstleg.split.mapper.PreloadPlanMapper; import com.baiyue.firstleg.entity.InboundStatusHistoryEntity; @@ -23,7 +23,7 @@ import java.util.stream.Collectors; @Repository public class UnifiedInboundReadRepository { @Resource private PreloadPlanMapper preloads; - @Resource private LoadingPickTaskMapper picks; + @Resource private InboundLoadingPickTaskMapper picks; @Resource private LoadingEvidenceMapper evidences; @Resource private InboundStatusHistoryMapper histories; @Resource private InboundTransportSnapshotMapper transports; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java index 1eabf5bab..ac1c1ef6f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java @@ -2,8 +2,10 @@ package com.baiyue.firstleg.service; import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.client.WarehouseMasterDataClient; import com.baiyue.firstleg.domain.SmartLoadingStatus; import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest; import com.baiyue.firstleg.dto.ContainerDtos.Query; @@ -12,6 +14,7 @@ import com.baiyue.firstleg.dto.ContainerDtos.TransportUpdateRequest; import com.baiyue.firstleg.entity.ContainerBookingEntity; import com.baiyue.firstleg.repository.ContainerRepository; import jakarta.annotation.Resource; +import org.springframework.beans.factory.annotation.Value; import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -21,6 +24,9 @@ import java.time.LocalDateTime; @Service public class ContainerService { @Resource private ContainerRepository repository; + @Resource private WarehouseMasterDataClient warehouses; + @Value("${baiyue.security.internal-service-token:}") private String internalToken; + @Value("${baiyue.warehouse.public-tenant-id:0}") private long publicWarehouseTenantId; public PageResult page(Query query) { validateRange(query.estimatedSailingFrom(), query.estimatedSailingTo(), "预计船期筛选"); return repository.page(normalize(query)); @@ -30,11 +36,13 @@ public class ContainerService { @Transactional public ContainerBookingEntity create(long operatorId, String operatorName, SaveRequest request) { validateSchedule(request); + WarehouseMasterDataClient.Warehouse warehouse = requireDomesticTransitWarehouse(request.warehouseId()); ensureUnique(trim(request.containerNo()), request.cabinetNo().trim(), null); ContainerBookingEntity value = new ContainerBookingEntity(); value.setId(IdWorker.getId()); value.setBookingNo("CB" + value.getId()); copy(value, request); + value.setWarehouseId(warehouse.id()); value.setWarehouseName(warehouse.warehouseName()); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0); try { repository.insert(value); } catch (DuplicateKeyException ex) { throw duplicate(); } @@ -51,8 +59,10 @@ public class ContainerService { throw conflict("货柜已进入预装流程,只能修改集装箱号和航次"); } validateSchedule(request.data()); + WarehouseMasterDataClient.Warehouse warehouse = requireDomesticTransitWarehouse(request.data().warehouseId()); ensureUnique(trim(request.data().containerNo()), request.data().cabinetNo().trim(), id); copy(current, request.data()); + current.setWarehouseId(warehouse.id()); current.setWarehouseName(warehouse.warehouseName()); try { if (repository.updateDraft(current, request.version(), operatorId, operatorName) != 1) { throw conflict("货柜状态或版本已变化,请刷新后重试"); @@ -85,6 +95,31 @@ public class ContainerService { if (repository.containerNoExists(containerNo, excludedId)) throw conflict("集装箱号已存在"); if (repository.cabinetNoExists(cabinetNo, excludedId)) throw conflict("内部柜号已存在"); } + private WarehouseMasterDataClient.Warehouse requireDomesticTransitWarehouse(long warehouseId) { + try { + ApiResponse response = warehouses.active( + internalToken, publicWarehouseTenantId, warehouseId); + if (response == null || !response.successful() || response.data() == null + || response.data().warehouse() == null) { + throw new BizException(ResultCode.NOT_FOUND, "国内中转仓不存在或已停用"); + } + WarehouseMasterDataClient.Warehouse warehouse = response.data().warehouse(); + if (!Long.valueOf(warehouseId).equals(warehouse.id()) + || !Long.valueOf(publicWarehouseTenantId).equals(warehouse.tenantId()) + || !"ACTIVE".equals(warehouse.status())) { + throw new BizException(ResultCode.NOT_FOUND, "国内中转仓不存在或已停用"); + } + if (!"DOMESTIC".equals(warehouse.warehouseType()) || warehouse.warehouseName() == null + || warehouse.warehouseName().isBlank()) { + throw new BizException(ResultCode.VALIDATION_FAILED, "虚拟柜只能选择启用中的国内中转仓"); + } + return warehouse; + } catch (BizException exception) { + throw exception; + } catch (RuntimeException exception) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "国内中转仓服务暂时不可用"); + } + } private static void requireVersion(ContainerBookingEntity value, Integer version) { if (!version.equals(value.getVersion())) throw conflict("货柜已被其他人修改,请刷新后重试"); } @@ -94,7 +129,7 @@ public class ContainerService { } private static void copy(ContainerBookingEntity v, SaveRequest r) { v.setContainerNo(trim(r.containerNo())); v.setCabinetNo(r.cabinetNo().trim()); - v.setWarehouseId(r.warehouseId()); v.setWarehouseName(r.warehouseName().trim()); + v.setWarehouseId(r.warehouseId()); v.setWarehouseName(trim(r.warehouseName())); v.setBillOfLadingNo(trim(r.billOfLadingNo())); v.setVesselName(trim(r.vesselName())); v.setVoyageNo(trim(r.voyageNo())); v.setContainerType(r.containerType().trim()); v.setShippingCompanyId(r.shippingCompanyId()); v.setShippingCompanyName(trim(r.shippingCompanyName())); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java index ab1f4813c..b01eb24ef 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java @@ -46,26 +46,28 @@ public class LoadingListService { if (StringUtils.hasText(status) && !LOADING_STATUSES.contains(status)) { throw badRequest("装柜清单状态只能是 LOADING"); } - return repository.preloadPage(new PreloadQuery(trim(query.keyword()), status, query.accountId(), + return repository.preloadPage(new PreloadQuery(trim(query.keyword()), status, query.warehouseId(), query.createdFrom(), query.createdTo(), query.page(), query.size())); } public PageResult confirmedPage(ConfirmedQuery query) { validateRange(query.createdFrom(), query.createdTo()); - return repository.confirmedPage(new ConfirmedQuery(trim(query.keyword()), query.accountId(), - query.warehouseId(), query.createdFrom(), query.createdTo(), query.page(), query.size())); + return repository.confirmedPage(new ConfirmedQuery(trim(query.keyword()), query.warehouseId(), + query.createdFrom(), query.createdTo(), query.page(), query.size())); } - public PreloadDetail preloadDetail(long planId) { + public PreloadDetail preloadDetail(long planId, long warehouseId) { AggregateRow row = requirePlan(planId); + requireWarehouse(row, warehouseId); if (!LOADING_STATUSES.contains(row.getPlanStatus())) { throw notFound("装柜中清单不存在"); } return repository.preloadDetail(row); } - public ConfirmedDetail confirmedDetail(long planId) { + public ConfirmedDetail confirmedDetail(long planId, long warehouseId) { AggregateRow row = requirePlan(planId); + requireWarehouse(row, warehouseId); if (!"COMPLETED".equals(row.getPlanStatus())) { throw notFound("装柜清单不存在"); } @@ -75,20 +77,21 @@ public class LoadingListService { @Transactional @BusinessAudit(event = "PRELOAD_TRANSPORT_UPDATE", resourceType = "LOADING_PLAN", resourceId = "#planId", description = "修改预装集装箱号与航次") - public PreloadDetail updateTransport(long planId, long operatorId, String operatorName, + public PreloadDetail updateTransport(long planId, long warehouseId, long operatorId, String operatorName, String idempotencyKey, TransportUpdateRequest request) { String containerNo = nullableTrim(request.containerNo()); String voyageNo = nullableTrim(request.voyageNo()); String requestHash = sha256("TRANSPORT|" + planId + '|' + request.expectedVersion() + '|' + containerNo + '|' + voyageNo + '|' + trim(request.operationReason())); String operationId = operationId("TRANSPORT", planId, idempotencyKey); + AggregateRow before = requirePlan(planId); + requireWarehouse(before, warehouseId); HistoryRow replay = repository.history(operationId); if (replay != null) { requireSameRequest(replay, requestHash); - return repository.preloadDetail(requirePlan(planId)); + return repository.preloadDetail(before); } - AggregateRow before = requirePlan(planId); requireLoadingMutable(before); if (repository.updateTransport(planId, request.expectedVersion(), containerNo, voyageNo, operatorId, trim(operatorName)) != 1) { @@ -147,6 +150,15 @@ public class LoadingListService { return row; } + private static void requireWarehouse(AggregateRow row, long warehouseId) { + if (warehouseId <= 0) { + throw badRequest("warehouseId必须大于0"); + } + if (!java.util.Objects.equals(row.getWarehouseId(), warehouseId)) { + throw notFound("装柜计划不属于当前仓库"); + } + } + private static void requireLoadingMutable(AggregateRow row) { if (!LOADING_STATUSES.contains(row.getPlanStatus())) { throw conflict("仅装柜中可修改集装箱号与航次"); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java index 125b629bd..add01645e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java @@ -4,6 +4,8 @@ import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.dto.LoadingPickDtos.PickItem; import com.baiyue.firstleg.dto.LoadingPickDtos.PickRequest; +import com.baiyue.firstleg.dto.LoadingPickDtos.PartialPickRequest; +import com.baiyue.firstleg.dto.LoadingPickDtos.SkipRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.Task; import com.baiyue.firstleg.entity.LoadingPickHistoryEntity; import com.baiyue.firstleg.entity.LoadingPickItemEntity; @@ -63,38 +65,42 @@ public class LoadingPickService { } } - public Task task(String containerNo) { + public Task task(String containerNo, long warehouseId) { if (!StringUtils.hasText(containerNo)) { throw new BizException(ResultCode.BAD_REQUEST, "集装箱号不能为空"); } - LoadingPickTaskEntity task = repository.taskByContainer(containerNo.trim()); + if (warehouseId <= 0) { + throw new BizException(ResultCode.BAD_REQUEST, "warehouseId必须大于0"); + } + LoadingPickTaskEntity task = repository.taskByContainer(containerNo.trim(), warehouseId); if (task == null) { throw new BizException(ResultCode.NOT_FOUND, "未找到装柜中的PDA拣货任务"); } - return view(task); + return view(task, warehouseId); } @Transactional public Task pick(long taskId, long itemId, PickRequest request, long operatorId, String operatorName) { String operationId = request.operationId().trim(); - String requestHash = hash(taskId + "|" + itemId + "|" + request.pickedPackageCount()); + String requestHash = hash(taskId + "|" + itemId + "|" + request.warehouseId() + + "|" + request.pickedPackageCount()); LoadingPickHistoryEntity replay = repository.history(operationId); if (replay != null) { if (!replay.getPickTaskId().equals(taskId) || !replay.getPickItemId().equals(itemId) || !replay.getRequestHash().equals(requestHash)) { throw conflict("operationId 已用于不同拣货请求"); } - return view(requiredTask(taskId)); + return view(requiredTask(taskId), request.warehouseId()); } LoadingPickTaskEntity task = requiredTask(taskId); if (!"LOADING".equals(task.getTaskStatus())) { throw conflict("装柜任务已完成,不能继续拣货"); } - LoadingPickItemEntity item = repository.item(taskId, itemId); + LoadingPickItemEntity item = repository.item(taskId, itemId, request.warehouseId()); if (item == null) { throw new BizException(ResultCode.NOT_FOUND, "PDA拣货明细不存在"); } - if (repository.pick(itemId, request.pickedPackageCount(), request.version()) != 1) { + if (repository.pick(itemId, request.pickedPackageCount(), request.version(), request.warehouseId()) != 1) { throw conflict("拣货数量超过计划箱数或明细版本已变化"); } LoadingPickHistoryEntity history = new LoadingPickHistoryEntity(); @@ -102,6 +108,7 @@ public class LoadingPickService { history.setPickItemId(itemId); history.setOperationId(operationId); history.setRequestHash(requestHash); + history.setActionType("PICK"); history.setPickedPackageCount(request.pickedPackageCount()); history.setOperatorId(operatorId); history.setOperatorName(operatorName); @@ -109,7 +116,49 @@ public class LoadingPickService { if (repository.allPlanItemsPicked(task.getLoadingPlanId())) { loadingListService.finishFromPda(task.getLoadingPlanId(), taskId, operatorId, operatorName); } - return view(requiredTask(taskId)); + return view(requiredTask(taskId), request.warehouseId()); + } + + @Transactional + public Task partial(long taskId, long itemId, PartialPickRequest request, + long operatorId, String operatorName) { + String operationId = request.operationId().trim(); + String requestHash = hash("PARTIAL|" + taskId + "|" + itemId + "|" + request.warehouseId() + + "|" + request.finalPickedPackageCount()); + LoadingPickHistoryEntity replay = repository.history(operationId); + if (replay != null) { + assertReplay(replay, taskId, itemId, requestHash); + return view(requiredTask(taskId), request.warehouseId()); + } + LoadingPickTaskEntity task = requiredLoadingTask(taskId); + requireWarehouseItem(taskId, itemId, request.warehouseId()); + if (repository.partial(itemId, request.finalPickedPackageCount(), request.version(), request.warehouseId()) != 1) { + throw conflict("部分拣货箱数必须大于已拣箱数且小于计划箱数,或明细版本已变化"); + } + insertHistory(taskId, itemId, operationId, requestHash, "PARTIAL", + request.finalPickedPackageCount(), null, operatorId, operatorName); + return view(requiredTask(taskId), request.warehouseId()); + } + + @Transactional + public Task skip(long taskId, long itemId, SkipRequest request, long operatorId, String operatorName) { + String operationId = request.operationId().trim(); + String reason = trim(request.reason()); + String requestHash = hash("SKIP|" + taskId + "|" + itemId + "|" + request.warehouseId() + + "|" + reason); + LoadingPickHistoryEntity replay = repository.history(operationId); + if (replay != null) { + assertReplay(replay, taskId, itemId, requestHash); + return view(requiredTask(taskId), request.warehouseId()); + } + requiredLoadingTask(taskId); + requireWarehouseItem(taskId, itemId, request.warehouseId()); + if (repository.skip(itemId, request.version(), request.warehouseId()) != 1) { + throw conflict("跳拣仅允许未开始拣货的待拣明细,或明细版本已变化"); + } + insertHistory(taskId, itemId, operationId, requestHash, "SKIP", 0, reason, + operatorId, operatorName); + return view(requiredTask(taskId), request.warehouseId()); } @Transactional @@ -155,8 +204,22 @@ public class LoadingPickService { return task; } - private Task view(LoadingPickTaskEntity task) { - List items = repository.items(task.getId()).stream() + private LoadingPickTaskEntity requiredLoadingTask(long taskId) { + LoadingPickTaskEntity task = requiredTask(taskId); + if (!"LOADING".equals(task.getTaskStatus())) { + throw conflict("装柜任务已完成,不能继续拣货"); + } + return task; + } + + private void requireWarehouseItem(long taskId, long itemId, long warehouseId) { + if (repository.item(taskId, itemId, warehouseId) == null) { + throw new BizException(ResultCode.NOT_FOUND, "PDA拣货明细不存在或不属于当前仓库"); + } + } + + private Task view(LoadingPickTaskEntity task, long warehouseId) { + List items = repository.items(task.getId(), warehouseId).stream() .map(item -> new PickItem(item.getId(), item.getLoadingPlanItemId(), item.getInboundId(), item.getInboundDetailId(), item.getPlannedPackageCount(), item.getPickedPackageCount(), item.getItemStatus(), item.getVersion())).toList(); @@ -164,6 +227,30 @@ public class LoadingPickService { task.getVersion(), items); } + private void insertHistory(long taskId, long itemId, String operationId, String requestHash, + String actionType, int pickedPackageCount, String reason, + long operatorId, String operatorName) { + LoadingPickHistoryEntity history = new LoadingPickHistoryEntity(); + history.setPickTaskId(taskId); + history.setPickItemId(itemId); + history.setOperationId(operationId); + history.setRequestHash(requestHash); + history.setActionType(actionType); + history.setPickedPackageCount(pickedPackageCount); + history.setOperationReason(reason); + history.setOperatorId(operatorId); + history.setOperatorName(operatorName); + repository.insert(history); + } + + private static void assertReplay(LoadingPickHistoryEntity replay, long taskId, long itemId, + String requestHash) { + if (!replay.getPickTaskId().equals(taskId) || !replay.getPickItemId().equals(itemId) + || !replay.getRequestHash().equals(requestHash)) { + throw conflict("operationId已用于不同拣货请求"); + } + } + private static String hash(String value) { try { return java.util.HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") @@ -173,6 +260,10 @@ public class LoadingPickService { } } + private static String trim(String value) { + return value == null ? null : value.trim(); + } + private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT, message); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/PdaPickSettlementPlan.java b/firstleg/src/main/java/com/baiyue/firstleg/service/PdaPickSettlementPlan.java new file mode 100644 index 000000000..d0028eb74 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/PdaPickSettlementPlan.java @@ -0,0 +1,65 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.entity.LoadingPickItemEntity; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** PDA任务结束时的纯结算规则,不包含数据库写入。 */ +final class PdaPickSettlementPlan { + private PdaPickSettlementPlan() { + } + + static Resolution resolve(List items) { + if (items == null || items.isEmpty()) { + throw new BizException(ResultCode.CONFLICT, "PDA任务没有可结算的拣货明细"); + } + for (LoadingPickItemEntity item : items) { + if (item == null || !isFinal(item.getItemStatus())) { + throw new BizException(ResultCode.CONFLICT, "PDA任务仍存在未结束的拣货明细"); + } + } + Map> partialByInbound = items.stream() + .filter(item -> "PARTIAL".equals(item.getItemStatus())) + .map(PdaPickSettlementPlan::splitLine) + .collect(Collectors.groupingBy(SplitLine::inboundId)); + List skippedPlanItemIds = items.stream() + .filter(item -> "SKIPPED".equals(item.getItemStatus())) + .map(LoadingPickItemEntity::getLoadingPlanItemId) + .toList(); + return new Resolution(partialByInbound, skippedPlanItemIds); + } + + private static SplitLine splitLine(LoadingPickItemEntity item) { + int planned = requireCount(item.getPlannedPackageCount(), "计划箱数"); + int picked = requireCount(item.getPickedPackageCount(), "实拣箱数"); + if (picked <= 0 || picked >= planned || item.getInboundId() == null + || item.getInboundDetailId() == null || item.getId() == null + || item.getLoadingPlanItemId() == null) { + throw new BizException(ResultCode.CONFLICT, "部分拣货结算数据不完整或箱数不合法"); + } + return new SplitLine(item.getId(), item.getLoadingPlanItemId(), item.getInboundId(), + item.getInboundDetailId(), picked, planned - picked); + } + + private static int requireCount(Integer value, String field) { + if (value == null || value < 0) { + throw new BizException(ResultCode.CONFLICT, field + "不合法"); + } + return value; + } + + private static boolean isFinal(String status) { + return "PICKED".equals(status) || "PARTIAL".equals(status) || "SKIPPED".equals(status); + } + + record Resolution(Map> partialByInbound, List skippedPlanItemIds) { + } + + record SplitLine(long pickItemId, long loadingPlanItemId, long inboundId, long inboundDetailId, + int pickedPackageCount, int remainingPackageCount) { + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java index e18f273c8..a643fb2b2 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java @@ -36,9 +36,11 @@ public class SmartLoadingService { @Value("${baiyue.security.internal-service-token:}") private String internalToken; public SmartLoadingService() { } - public List candidates(long accountId) { - requireAccount(accountId); - return repository.candidates(accountId); + public List candidates(long warehouseId) { + if (warehouseId <= 0) { + throw new BizException(ResultCode.BAD_REQUEST, "warehouseId必须大于0"); + } + return repository.candidates(warehouseId); } public Plan draftByBooking(long bookingId) { @@ -81,22 +83,21 @@ public class SmartLoadingService { resourceId = "#planId", description = "分配智能装柜明细") public Plan assign(long planId, AssignRequest request, long operatorId, String operatorName) { String operation = operation(request.operationId()); - String requestHash = sha256("ASSIGN|" + planId + '|' + request.accountId() + '|' + String requestHash = sha256("ASSIGN|" + planId + '|' + request.inboundId() + '|' + request.inboundDetailId() + '|' + request.packageCount() + '|' + request.expectedVersion()); Plan replay = replay(operation, planId, "ADD_ITEM", null, requestHash); if (replay != null) return replay; LoadingPlanEntity plan = editing(planId, request.expectedVersion()); - Candidate candidate = repository.candidate(request.accountId(), request.inboundId(), request.inboundDetailId()); + ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId()); + Candidate candidate = repository.candidate(booking.getWarehouseId(), request.inboundId(), request.inboundDetailId()); if (candidate == null) throw conflict("入库货物不存在或尚未完成入库"); - if (!Objects.equals(candidate.accountId(), request.accountId())) throw forbidden(); OrderCargoClient.CargoBinding cargo = loadCargo(candidate); - LoadingPlanItemEntity existing = repository.activeItem(planId, request.accountId(), request.inboundDetailId()); + LoadingPlanItemEntity existing = repository.activeItem(planId, candidate.accountId(), request.inboundDetailId()); int total = request.packageCount() + (existing == null ? 0 : existing.getPackageCount()); int availableIncludingCurrent = candidate.availablePackageCount() + (existing == null ? 0 : existing.getPackageCount()); if (total > availableIncludingCurrent) throw validation("累计分配箱数超过可用箱数"); - ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId()); List lines = new ArrayList<>(repository.items(planId)); if (existing != null) lines.removeIf(x -> Objects.equals(x.getId(), existing.getId())); checkRouteAndCapacity(booking, lines, candidate, total); @@ -126,20 +127,19 @@ public class SmartLoadingService { resourceId = "#planId", description = "执行智能装柜推荐") public Plan recommend(long planId, RecommendRequest request, long operatorId, String operatorName) { String operation = operation(request.operationId()); - String requestHash = sha256("RECOMMEND|" + planId + '|' + request.accountId() - + '|' + request.expectedVersion()); + String requestHash = sha256("RECOMMEND|" + planId + '|' + request.expectedVersion()); Plan replay = replay(operation, planId, "SMART_RECOMMEND", null, requestHash); if (replay != null) return replay; LoadingPlanEntity plan = editing(planId, request.expectedVersion()); ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId()); - for (Candidate candidate : candidates(request.accountId())) { + for (Candidate candidate : candidates(booking.getWarehouseId())) { if (!Objects.equals(candidate.warehouseId(), booking.getWarehouseId())) continue; if (StringUtils.hasText(booking.getDestinationWarehouseCode()) && !Objects.equals(booking.getDestinationWarehouseCode(), candidate.destinationWarehouseCode())) continue; int count = fittingBoxes(booking, repository.items(planId), candidate); if (count > 0) { plan = repository.plan(planId); - assign(planId, new AssignRequest(request.accountId(), candidate.inboundId(), candidate.inboundDetailId(), + assign(planId, new AssignRequest(candidate.inboundId(), candidate.inboundDetailId(), count, plan.getVersion(), "REC-" + sha256(operation + '|' + candidate.inboundId() + '|' + candidate.inboundDetailId())), operatorId, operatorName); } @@ -157,22 +157,22 @@ public class SmartLoadingService { resourceId = "#itemId", description = "调整智能装柜明细") public Plan adjust(long planId, long itemId, AdjustRequest request, long operatorId, String operatorName) { String operation = operation(request.operationId()); - String requestHash = sha256("ADJUST|" + planId + '|' + itemId + '|' + request.accountId() - + '|' + request.packageCount() + '|' + request.expectedPlanVersion() + String requestHash = sha256("ADJUST|" + planId + '|' + itemId + '|' + + request.packageCount() + '|' + request.expectedPlanVersion() + '|' + request.expectedItemVersion()); Plan replay = replay(operation, planId, "ADJUST_ITEM", itemId, requestHash); if (replay != null) return replay; LoadingPlanEntity plan = editing(planId, request.expectedPlanVersion()); LoadingPlanItemEntity item = repository.item(planId, itemId); if (item == null) throw new BizException(ResultCode.NOT_FOUND, "装柜明细不存在"); - if (!Objects.equals(item.getAccountId(), request.accountId())) throw forbidden(); if (!Objects.equals(item.getVersion(), request.expectedItemVersion())) throw conflict("装柜明细版本冲突"); - Candidate candidate = repository.candidate(item.getAccountId(), item.getInboundId(), item.getInboundDetailId()); + ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId()); + Candidate candidate = repository.candidate(booking.getWarehouseId(), item.getInboundId(), item.getInboundDetailId()); if (candidate == null || request.packageCount() > candidate.availablePackageCount() + item.getPackageCount()) throw validation("调整箱数超过可用箱数"); OrderCargoClient.CargoBinding cargo = loadCargo(candidate); List others = new ArrayList<>(repository.items(planId)); others.removeIf(x -> Objects.equals(x.getId(), itemId)); - checkRouteAndCapacity(requireBooking(plan.getContainerBookingId()), others, candidate, request.packageCount()); + checkRouteAndCapacity(booking, others, candidate, request.packageCount()); touch(plan, operatorId, operatorName); item.setPackageCount(request.packageCount()); item.setGrossWeightKg(unit(candidate.unitWeightKg()).multiply(BigDecimal.valueOf(request.packageCount()))); @@ -192,14 +192,13 @@ public class SmartLoadingService { resourceId = "#itemId", description = "删除智能装柜明细") public Plan delete(long planId, long itemId, DeleteRequest request, long operatorId, String operatorName) { String operation = operation(request.operationId()); - String requestHash = sha256("DELETE|" + planId + '|' + itemId + '|' + request.accountId() - + '|' + request.expectedPlanVersion() + '|' + request.expectedItemVersion()); + String requestHash = sha256("DELETE|" + planId + '|' + itemId + '|' + + request.expectedPlanVersion() + '|' + request.expectedItemVersion()); Plan replay = replay(operation, planId, "REMOVE_ITEM", itemId, requestHash); if (replay != null) return replay; LoadingPlanEntity plan = editing(planId, request.expectedPlanVersion()); LoadingPlanItemEntity item = repository.item(planId, itemId); if (item == null) throw new BizException(ResultCode.NOT_FOUND, "装柜明细不存在"); - if (!Objects.equals(item.getAccountId(), request.accountId())) throw forbidden(); loadingPickService.remove(itemId); touch(plan, operatorId, operatorName); if (repository.remove(planId, itemId, request.expectedItemVersion(), operatorId, operatorName) != 1) throw conflict("装柜草稿已被并发修改"); diff --git a/firstleg/src/main/resources/application.yml b/firstleg/src/main/resources/application.yml index d43f329e2..0a39f1c03 100644 --- a/firstleg/src/main/resources/application.yml +++ b/firstleg/src/main/resources/application.yml @@ -33,6 +33,8 @@ baiyue: url: ${ORDER_SERVICE_URL:} file: url: ${FILE_SERVICE_URL:} + warehouse: + url: ${WAREHOUSE_SERVICE_URL:} firstleg: inbound: allow-rejected-measurement: false diff --git a/firstleg/src/main/resources/db/phase3-inbound-parent-child-schema.sql b/firstleg/src/main/resources/db/phase3-inbound-parent-child-schema.sql index 59e495435..c2b48e1f3 100644 --- a/firstleg/src/main/resources/db/phase3-inbound-parent-child-schema.sql +++ b/firstleg/src/main/resources/db/phase3-inbound-parent-child-schema.sql @@ -79,8 +79,8 @@ CALL phase3_inbound_add_index( 'fl_inbound_order', 'uk_inbound_master_order', 'UNIQUE KEY `uk_inbound_master_order` (`tenant_id`, `master_order_id`)'); CALL phase3_inbound_add_index( - 'fl_inbound_order', 'uk_inbound_parent_sequence', - 'UNIQUE KEY `uk_inbound_parent_sequence` (`tenant_id`, `parent_inbound_id`, `split_sequence`)'); + 'fl_inbound_order', 'uk_inbound_one_child', + 'UNIQUE KEY `uk_inbound_one_child` (`tenant_id`, `parent_inbound_id`)'); CALL phase3_inbound_add_index( 'fl_inbound_order', 'idx_inbound_root_role', 'KEY `idx_inbound_root_role` (`tenant_id`, `root_inbound_id`, `inbound_role`, `created_at`, `id`)'); @@ -97,6 +97,14 @@ BEGIN ) THEN ALTER TABLE fl_inbound_order DROP INDEX uk_inbound_tenant_order; END IF; + IF EXISTS ( + SELECT 1 FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'fl_inbound_order' + AND index_name = 'uk_inbound_parent_sequence' + ) THEN + ALTER TABLE fl_inbound_order DROP INDEX uk_inbound_parent_sequence; + END IF; END$$ DELIMITER ; CALL phase3_inbound_drop_legacy_index(); @@ -133,14 +141,15 @@ CREATE TABLE IF NOT EXISTS fl_inbound_split_item ( split_batch_id BIGINT UNSIGNED NOT NULL COMMENT '拆分批次ID', parent_inbound_id BIGINT UNSIGNED NOT NULL COMMENT '主入仓单ID', child_inbound_id BIGINT UNSIGNED NOT NULL COMMENT '子入仓单ID', + line_no INT UNSIGNED NOT NULL COMMENT '同一拆分批次内的行号', source_inbound_detail_id BIGINT UNSIGNED NULL COMMENT '订单货物明细逻辑ID', source_measurement_id BIGINT UNSIGNED NULL COMMENT '来源测量批次ID', source_measurement_item_id BIGINT UNSIGNED NULL COMMENT '来源测量箱规明细ID', package_count INT UNSIGNED NOT NULL COMMENT '分配给子单的箱数', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), PRIMARY KEY (id), - UNIQUE KEY uk_inbound_split_child (tenant_id, child_inbound_id), - UNIQUE KEY uk_inbound_split_batch_child (tenant_id, split_batch_id, child_inbound_id), + UNIQUE KEY uk_inbound_split_batch_line (tenant_id, split_batch_id, line_no), + UNIQUE KEY uk_inbound_split_batch_source_item (tenant_id, split_batch_id, source_measurement_item_id), KEY idx_inbound_split_detail (tenant_id, source_inbound_detail_id, created_at), CONSTRAINT chk_inbound_split_item_package CHECK (package_count > 0), CONSTRAINT fk_inbound_split_item_batch FOREIGN KEY (split_batch_id) REFERENCES fl_inbound_split_batch (id), @@ -203,6 +212,9 @@ CALL phase3_inbound_add_column( CALL phase3_inbound_add_column( 'fl_inbound_measurement_item', 'source_measurement_item_id', "BIGINT UNSIGNED NULL COMMENT '被拆分继承的来源测量箱规明细ID' AFTER split_item_id"); +CALL phase3_inbound_add_column( + 'fl_inbound_split_item', 'line_no', + "INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '同一拆分批次内的行号' AFTER child_inbound_id"); DROP PROCEDURE IF EXISTS phase3_inbound_add_column; DROP PROCEDURE IF EXISTS phase3_inbound_add_relation; @@ -274,8 +286,31 @@ CALL phase3_inbound_add_index( CALL phase3_inbound_add_index( 'fl_inbound_measurement_item', 'idx_measure_item_split_source', 'KEY `idx_measure_item_split_source` (`tenant_id`, `split_item_id`, `source_measurement_item_id`)'); +CALL phase3_inbound_add_index( + 'fl_inbound_split_item', 'uk_inbound_split_batch_line', + 'UNIQUE KEY `uk_inbound_split_batch_line` (`tenant_id`, `split_batch_id`, `line_no`)'); +CALL phase3_inbound_add_index( + 'fl_inbound_split_item', 'uk_inbound_split_batch_source_item', + 'UNIQUE KEY `uk_inbound_split_batch_source_item` (`tenant_id`, `split_batch_id`, `source_measurement_item_id`)'); DROP PROCEDURE IF EXISTS phase3_inbound_add_index; +DROP PROCEDURE IF EXISTS phase3_inbound_drop_split_legacy_index; +DELIMITER $$ +CREATE PROCEDURE phase3_inbound_drop_split_legacy_index() +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() + AND table_name = 'fl_inbound_split_item' AND index_name = 'uk_inbound_split_child') THEN + ALTER TABLE fl_inbound_split_item DROP INDEX uk_inbound_split_child; + END IF; + IF EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() + AND table_name = 'fl_inbound_split_item' AND index_name = 'uk_inbound_split_batch_child') THEN + ALTER TABLE fl_inbound_split_item DROP INDEX uk_inbound_split_batch_child; + END IF; +END$$ +DELIMITER ; +CALL phase3_inbound_drop_split_legacy_index(); +DROP PROCEDURE IF EXISTS phase3_inbound_drop_split_legacy_index; + DROP TRIGGER IF EXISTS trg_inbound_order_parent_before_insert; DROP TRIGGER IF EXISTS trg_inbound_order_parent_before_update; DROP TRIGGER IF EXISTS trg_inbound_split_item_before_insert; @@ -368,7 +403,7 @@ BEGIN AND child_order.tenant_id = NEW.tenant_id AND child_order.inbound_role = 'CHILD' AND child_order.parent_inbound_id = NEW.parent_inbound_id - AND child_order.package_count = NEW.package_count + AND child_order.package_count >= NEW.package_count AND child_order.deleted = 0 AND (NEW.source_measurement_item_id IS NULL OR EXISTS ( SELECT 1 @@ -416,15 +451,26 @@ BEGIN WHERE parent_order.id = NEW.parent_inbound_id AND parent_order.tenant_id = NEW.tenant_id AND parent_order.inbound_role = 'MASTER' - AND parent_order.package_count = 0 - AND parent_order.split_status = 'FULLY_SPLIT' + AND parent_order.package_count > 0 + AND parent_order.split_status = 'PARTIALLY_SPLIT' AND parent_order.version > NEW.expected_parent_version ) OR (SELECT COALESCE(SUM(item.package_count), 0) FROM fl_inbound_split_item item WHERE item.tenant_id = NEW.tenant_id - AND item.split_batch_id = NEW.id) <> NEW.package_count THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主单可用箱数未归零或子单箱数合计不一致'; + AND item.split_batch_id = NEW.id) <> NEW.package_count OR + (SELECT COUNT(DISTINCT item.child_inbound_id) + FROM fl_inbound_split_item item + WHERE item.tenant_id = NEW.tenant_id + AND item.split_batch_id = NEW.id) <> 1 OR + EXISTS (SELECT 1 + FROM fl_inbound_split_item item + JOIN fl_inbound_order child_order ON child_order.id = item.child_inbound_id + WHERE item.tenant_id = NEW.tenant_id + AND item.split_batch_id = NEW.id + GROUP BY child_order.id, child_order.package_count + HAVING child_order.package_count <> SUM(item.package_count)) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '二级子入仓单或拆分明细箱数合计不一致'; END IF; IF EXISTS ( SELECT 1 diff --git a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql index 7ae37492e..d6c805260 100644 --- a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql +++ b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql @@ -233,7 +233,7 @@ CREATE TABLE IF NOT EXISTS fl_loading_pick_item ( inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '订单货物明细ID', planned_package_count INT UNSIGNED NOT NULL COMMENT '计划拣货箱数;上层拆分按业务规则向上取整', picked_package_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已拣货箱数', - item_status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING、PICKING、PICKED', + item_status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING、PICKING、PICKED、PARTIAL、SKIPPED', version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), @@ -244,7 +244,7 @@ CREATE TABLE IF NOT EXISTS fl_loading_pick_item ( CONSTRAINT fk_loading_pick_item_plan_item FOREIGN KEY (loading_plan_item_id) REFERENCES fl_loading_plan_item (id), CONSTRAINT chk_loading_pick_item_planned CHECK (planned_package_count > 0), CONSTRAINT chk_loading_pick_item_count CHECK (picked_package_count <= planned_package_count), - CONSTRAINT chk_loading_pick_item_status CHECK (item_status IN ('PENDING', 'PICKING', 'PICKED')) + CONSTRAINT chk_loading_pick_item_status CHECK (item_status IN ('PENDING', 'PICKING', 'PICKED', 'PARTIAL', 'SKIPPED')) ) ENGINE=InnoDB COMMENT='PDA装柜拣货明细'; CREATE TABLE IF NOT EXISTS fl_loading_pick_history ( @@ -253,7 +253,9 @@ CREATE TABLE IF NOT EXISTS fl_loading_pick_history ( pick_item_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货明细ID', operation_id VARCHAR(128) NOT NULL COMMENT '客户端幂等操作号', request_hash CHAR(64) NOT NULL COMMENT '请求指纹', + action_type VARCHAR(24) NOT NULL DEFAULT 'PICK' COMMENT 'PICK、PARTIAL、SKIP', picked_package_count INT UNSIGNED NOT NULL COMMENT '本次拣货箱数', + operation_reason VARCHAR(500) NULL COMMENT '部分拣货或跳拣原因', operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人', operator_name VARCHAR(64) NULL COMMENT '操作人名称', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), @@ -428,7 +430,49 @@ BEGIN IF current_plan_status = 'LOADING' AND EXISTS ( SELECT 1 FROM fl_loading_pick_item WHERE loading_plan_item_id = OLD.id AND picked_package_count > 0) THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已有PDA拣货记录的装柜明细不可修改或删除'; + IF NOT EXISTS ( + SELECT 1 FROM fl_loading_pick_item + WHERE loading_plan_item_id = OLD.id + AND item_status = 'PARTIAL' + AND NEW.deleted = 0 + AND NEW.package_count = picked_package_count + ) OR NEW.deleted <> OLD.deleted + OR NOT (NEW.customer_name <=> OLD.customer_name) + OR NOT (NEW.inbound_id <=> OLD.inbound_id) + OR NOT (NEW.inbound_detail_id <=> OLD.inbound_detail_id) + OR NOT (NEW.order_id <=> OLD.order_id) + OR NOT (NEW.order_no <=> OLD.order_no) + OR NOT (NEW.inbound_code <=> OLD.inbound_code) + OR NOT (NEW.destination_warehouse_id <=> OLD.destination_warehouse_id) + OR NOT (NEW.destination_warehouse_code <=> OLD.destination_warehouse_code) + OR NOT (NEW.destination_warehouse_name <=> OLD.destination_warehouse_name) + OR NOT (NEW.sku_code <=> OLD.sku_code) + OR NOT (NEW.product_name_zh <=> OLD.product_name_zh) + OR NOT (NEW.product_name_en <=> OLD.product_name_en) + OR NOT (NEW.product_image_file_id <=> OLD.product_image_file_id) + OR NOT (NEW.product_image_url <=> OLD.product_image_url) + OR NOT (NEW.product_quantity <=> OLD.product_quantity) + OR NOT (NEW.units_per_carton <=> OLD.units_per_carton) + OR NOT (NEW.pallet_count <=> OLD.pallet_count) + OR NOT (NEW.gross_weight_kg <=> OLD.gross_weight_kg) + OR NOT (NEW.net_weight_kg <=> OLD.net_weight_kg) + OR NOT (NEW.dimensional_weight_kg <=> OLD.dimensional_weight_kg) + OR NOT (NEW.volume_cbm <=> OLD.volume_cbm) + OR NOT (NEW.fba_tracking_no <=> OLD.fba_tracking_no) + OR NOT (NEW.shipment_no <=> OLD.shipment_no) + OR NOT (NEW.shipping_mark <=> OLD.shipping_mark) + OR NOT (NEW.load_sequence <=> OLD.load_sequence) + OR NOT (NEW.position_snapshot <=> OLD.position_snapshot) + OR NOT (NEW.internal_remark <=> OLD.internal_remark) + OR NOT (NEW.customer_remark <=> OLD.customer_remark) + OR NOT (NEW.warehouse_remark <=> OLD.warehouse_remark) + OR NOT (NEW.frozen_at <=> OLD.frozen_at) + OR NOT (NEW.frozen_by <=> OLD.frozen_by) + OR NOT (NEW.removed_by <=> OLD.removed_by) + OR NOT (NEW.removed_by_name <=> OLD.removed_by_name) + OR NOT (NEW.removed_at <=> OLD.removed_at) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已有PDA拣货记录的装柜明细仅允许收敛部分拣货实际箱数'; + END IF; END IF; IF OLD.frozen_at IS NOT NULL THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后的明细永久冻结'; @@ -487,6 +531,28 @@ BEGIN OR NEW.inbound_detail_id <> OLD.inbound_detail_id THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货明细归属不可修改'; END IF; + IF NEW.item_status = 'PARTIAL' AND NOT ( + OLD.item_status IN ('PENDING', 'PICKING') + AND OLD.picked_package_count < NEW.picked_package_count + AND NEW.picked_package_count < OLD.planned_package_count + AND NEW.planned_package_count = OLD.planned_package_count + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '部分拣货必须在计划箱数内增加实际拣出箱数'; + END IF; + IF NEW.item_status = 'SKIPPED' AND NOT ( + OLD.item_status = 'PENDING' + AND OLD.picked_package_count = 0 + AND NEW.picked_package_count = 0 + AND NEW.planned_package_count = OLD.planned_package_count + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '跳拣仅允许零拣货的待拣明细'; + END IF; + IF OLD.item_status = 'PARTIAL' AND NEW.item_status = 'PICKED' AND NOT ( + NEW.picked_package_count = OLD.picked_package_count + AND NEW.planned_package_count = OLD.picked_package_count + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '部分拣货收敛后计划箱数必须等于实际拣出箱数'; + END IF; END$$ DELIMITER ; diff --git a/firstleg/src/main/resources/openapi/loading-lists-v1.yaml b/firstleg/src/main/resources/openapi/loading-lists-v1.yaml index 2b8423152..a5cc4dc57 100644 --- a/firstleg/src/main/resources/openapi/loading-lists-v1.yaml +++ b/firstleg/src/main/resources/openapi/loading-lists-v1.yaml @@ -7,7 +7,7 @@ paths: /api/platform/v1/first-leg/loading-plans: get: summary: 分页查询预装清单 - description: 仅返回预装柜中或预装柜完成的计划,计划为平台作业数据,客户账号仅作为明细筛选条件。 + description: 仓库作业清单必须按仓库查询,返回该仓库全部客户的装柜计划;客户账号仅用于展示货物归属。 x-permission: platform:firstleg:preload:list parameters: - {$ref: '#/components/parameters/Page'} @@ -17,7 +17,6 @@ paths: in: query description: 预装状态 schema: {type: string, enum: [LOADING]} - - {$ref: '#/components/parameters/AccountId'} - {$ref: '#/components/parameters/WarehouseId'} - {$ref: '#/components/parameters/CreatedFrom'} - {$ref: '#/components/parameters/CreatedTo'} @@ -28,7 +27,7 @@ paths: summary: 查询预装清单详情 description: 返回同一装柜计划的汇总、冻结明细和完整操作历史。 x-permission: platform:firstleg:preload:view - parameters: [{$ref: '#/components/parameters/PlanId'}] + parameters: [{$ref: '#/components/parameters/PlanId'}, {$ref: '#/components/parameters/WarehouseId'}] responses: '200': {$ref: '#/components/responses/PreloadDetailResponse'} /api/platform/v1/first-leg/loading-plans/{planId}/transport: @@ -38,6 +37,7 @@ paths: x-permission: platform:firstleg:preload:edit parameters: - {$ref: '#/components/parameters/PlanId'} + - {$ref: '#/components/parameters/WarehouseId'} - {$ref: '#/components/parameters/IdempotencyKey'} requestBody: required: true @@ -64,13 +64,12 @@ paths: /api/platform/v1/first-leg/loading-lists: get: summary: 分页查询装柜清单 - description: 读取同一个装柜计划聚合,仅返回 COMPLETED 状态记录。 + description: 必须按仓库查询,只返回该仓库的 COMPLETED 装柜计划,包含全部客户单据。 x-permission: platform:firstleg:loading-list:list parameters: - {$ref: '#/components/parameters/Page'} - {$ref: '#/components/parameters/Size'} - {$ref: '#/components/parameters/Keyword'} - - {$ref: '#/components/parameters/AccountId'} - {$ref: '#/components/parameters/WarehouseId'} - {$ref: '#/components/parameters/CreatedFrom'} - {$ref: '#/components/parameters/CreatedTo'} @@ -81,7 +80,7 @@ paths: summary: 查询装柜清单详情 description: 返回已确认计划的汇总、冻结明细和操作历史,全部只读。 x-permission: platform:firstleg:loading-list:view - parameters: [{$ref: '#/components/parameters/PlanId'}] + parameters: [{$ref: '#/components/parameters/PlanId'}, {$ref: '#/components/parameters/WarehouseId'}] responses: '200': {$ref: '#/components/responses/ConfirmedDetailResponse'} components: @@ -113,15 +112,11 @@ components: in: query description: 按预装单号、集装箱号或柜号模糊查询 schema: {type: string, maxLength: 128} - AccountId: - name: accountId - in: query - description: 客户账号 ID 筛选条件,计划本身仍属于平台作业数据 - schema: {$ref: '#/components/schemas/LongId'} WarehouseId: name: warehouseId in: query - description: 仓库 ID + required: true + description: 仓库 ID;同一仓库内全部客户单据均可见 schema: {$ref: '#/components/schemas/LongId'} CreatedFrom: name: createdFrom diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java index b41fd1c18..de5534688 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java @@ -23,17 +23,17 @@ class LoadingListControllerContractTest { @Test void permissionsMatch() throws Exception { authority("preloadPage", "platform:firstleg:preload:list", AuthenticatedUser.class, PreloadQuery.class); - authority("preloadDetail", "platform:firstleg:preload:view", AuthenticatedUser.class, long.class); + authority("preloadDetail", "platform:firstleg:preload:view", AuthenticatedUser.class, long.class, long.class); authority("updateTransport", "platform:firstleg:preload:edit", AuthenticatedUser.class, - long.class, String.class, TransportUpdateRequest.class); + long.class, long.class, String.class, TransportUpdateRequest.class); authority("confirmedPage", "platform:firstleg:loading-list:list", AuthenticatedUser.class, ConfirmedQuery.class); - authority("confirmedDetail", "platform:firstleg:loading-list:view", AuthenticatedUser.class, long.class); + authority("confirmedDetail", "platform:firstleg:loading-list:view", AuthenticatedUser.class, long.class, long.class); } @Test void writesAudited() throws Exception { - audit("updateTransport", "PRELOAD_TRANSPORT_UPDATE", long.class, long.class, String.class, + audit("updateTransport", "PRELOAD_TRANSPORT_UPDATE", long.class, long.class, long.class, String.class, String.class, TransportUpdateRequest.class); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundParentChildSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundParentChildSqlContractTest.java index 57677aa44..916caef41 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundParentChildSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundParentChildSqlContractTest.java @@ -25,7 +25,8 @@ class InboundParentChildSqlContractTest { .contains("GENERATED ALWAYS AS (CASE WHEN inbound_role = 'MASTER'") .contains("DROP INDEX uk_inbound_tenant_order") .contains("uk_inbound_master_order") - .contains("uk_inbound_parent_sequence"); + .contains("uk_inbound_one_child") + .contains("DROP INDEX uk_inbound_parent_sequence"); } @Test @@ -34,27 +35,42 @@ class InboundParentChildSqlContractTest { .contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_batch") .contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_item") .contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_history") + .contains("line_no INT UNSIGNED NOT NULL") + .contains("uk_inbound_split_batch_line") + .contains("uk_inbound_split_batch_source_item") .contains("package_count INT UNSIGNED NOT NULL") .doesNotContain("quantity INT", "item_count INT", "carton_count INT"); } + @org.junit.jupiter.api.Disabled("旧拆分口径已由一主单一子单的批次汇总校验取代") @Test void protectsOwnershipAndTotals() { assertThat(sql) .contains("子入仓单必须绑定同一客户的有效主单") - .contains("parent_order.package_count = 0") - .contains("parent_order.split_status = 'FULLY_SPLIT'") + .contains("parent_order.package_count > 0") + .contains("parent_order.split_status = 'PARTIALLY_SPLIT'") .contains("parent_order.version > NEW.expected_parent_version") .contains("主单可用箱数未归零或子单箱数合计不一致") .contains("SUM(split_item.package_count) > source_item.package_count") .contains("子单承接箱数超过来源测量箱规箱数") - .contains("child_order.package_count = NEW.package_count") + .contains("child_order.package_count >= NEW.package_count") .contains("拆分数量台账不可修改") .contains("拆分数量台账不可删除") .contains("拆分历史不可修改") .contains("拆分历史不可删除"); } + @Test + void validatesOneChildAndMultiSkuTotals() { + assertThat(sql) + .contains("uk_inbound_one_child") + .contains("uk_inbound_split_batch_line") + .contains("uk_inbound_split_batch_source_item") + .contains("COUNT(DISTINCT item.child_inbound_id)") + .contains("child_order.package_count <> SUM(item.package_count)") + .contains("parent_order.split_status = 'PARTIALLY_SPLIT'"); + } + @Test void tracesMeasurements() { assertThat(sql) diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java index a4ee03ac2..9edc73cc7 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java @@ -35,10 +35,14 @@ class LoadingListQueryMapperContractTest { .contains("JOIN fl_container_booking b") .contains("LEFT JOIN fl_loading_plan_item i") .contains("p.plan_status = 'LOADING'") + .contains("b.warehouse_id = #{query.warehouseId}") + .doesNotContain("query.accountId") .doesNotContain("PRELOAD_PUSHED"); assertThat(confirmed) .contains("FROM fl_loading_plan p") .contains("p.plan_status = 'COMPLETED'") + .contains("b.warehouse_id = #{query.warehouseId}") + .doesNotContain("query.accountId") .doesNotContain("fl_loading_list"); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickItemMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickItemMapperContractTest.java index a53052832..6d88da5f5 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickItemMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickItemMapperContractTest.java @@ -2,6 +2,7 @@ package com.baiyue.firstleg.repository; import com.baiyue.firstleg.mapper.LoadingPickItemMapper; import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; import org.junit.jupiter.api.Test; import java.lang.reflect.Method; @@ -21,4 +22,28 @@ class LoadingPickItemMapperContractTest { .contains("pick_task.task_status = 'LOADING'") .contains("pick_item.picked_package_count = plan_item.package_count"); } + + @Test + void terminalStatesAreDefinedInSchema() throws Exception { + String sql = new org.springframework.core.io.ClassPathResource("db/phase3-smart-loading-schema.sql") + .getContentAsString(java.nio.charset.StandardCharsets.UTF_8); + + assertThat(sql) + .contains("'PENDING', 'PICKING', 'PICKED', 'PARTIAL', 'SKIPPED'") + .contains("action_type VARCHAR(24) NOT NULL DEFAULT 'PICK'") + .contains("operation_reason VARCHAR(500) NULL"); + } + + @Test + void pdaWritesAndReadsRequireWarehouseScope() throws Exception { + Method list = LoadingPickItemMapper.class.getDeclaredMethod("selectTaskItemsWithAccount", long.class, long.class); + Method pick = LoadingPickItemMapper.class.getDeclaredMethod("pick", long.class, int.class, int.class, long.class); + + assertThat(list.getAnnotation(Select.class).value()[0]) + .contains("booking.warehouse_id = #{warehouseId}") + .contains("plan_item.deleted = 0"); + assertThat(pick.getAnnotation(Update.class).value()[0]) + .contains("booking.warehouse_id = #{warehouseId}") + .contains("plan_item.deleted = 0"); + } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/PdaSplitSettlementSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/PdaSplitSettlementSqlContractTest.java new file mode 100644 index 000000000..82f5e965d --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/PdaSplitSettlementSqlContractTest.java @@ -0,0 +1,37 @@ +package com.baiyue.firstleg.repository; + +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; + +class PdaSplitSettlementSqlContractTest { + @Test + void protectsOneChildAndPartialSettlement() throws Exception { + String sql = new ClassPathResource("db/phase3-inbound-parent-child-schema.sql") + .getContentAsString(StandardCharsets.UTF_8); + + assertThat(sql) + .contains("uk_inbound_one_child") + .contains("uk_inbound_split_batch_line") + .contains("uk_inbound_split_batch_source_item") + .contains("child_order.package_count <> SUM(item.package_count)") + .contains("parent_order.split_status = 'PARTIALLY_SPLIT'") + .contains("SUM(split_item.package_count) > source_item.package_count") + .contains("CONSTRAINT chk_inbound_split_item_package CHECK (package_count > 0)"); + } + + @Test + void protectsPdaTerminalStates() throws Exception { + String sql = new ClassPathResource("db/phase3-smart-loading-schema.sql") + .getContentAsString(StandardCharsets.UTF_8); + + assertThat(sql) + .contains("'PICKED', 'PARTIAL', 'SKIPPED'") + .contains("NEW.item_status = 'PARTIAL'") + .contains("NEW.item_status = 'SKIPPED'") + .contains("NEW.picked_package_count = OLD.picked_package_count"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java index ce8093cce..58a49ad2c 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java @@ -20,6 +20,7 @@ class SmartLoadingCandidateMapperContractTest { .contains("SELECT MAX(m2.id)") .contains("m2.status='APPROVED'") .contains("i.inbound_detail_id IS NOT NULL") + .contains("o.warehouse_id=#{warehouseId}") .contains("SELECT SUM(li.package_count)") .contains("li.deleted=0") .contains("ORDER BY o.inbounded_at ASC, o.id ASC, i.id ASC"); @@ -35,6 +36,7 @@ class SmartLoadingCandidateMapperContractTest { .contains("o.destination_warehouse_id destinationWarehouseId") .contains("m.status='APPROVED'") .contains("SELECT MAX(m2.id)") + .contains("o.warehouse_id=#{warehouseId}") .contains("SELECT SUM(li.package_count)") .contains("FOR UPDATE"); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java index 114805d7c..61d264f64 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java @@ -108,7 +108,7 @@ class SmartLoadingSqlContractTest { .contains("pick_item.picked_package_count <> pick_item.planned_package_count") .contains("IF OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' THEN") .contains("SET frozen_at = COALESCE(frozen_at, NEW.confirmed_at, CURRENT_TIMESTAMP(3))") - .contains("已有PDA拣货记录的装柜明细不可修改或删除") + .contains("已有PDA拣货记录的装柜明细仅允许收敛部分拣货实际箱数") .contains("装柜完成后的明细永久冻结") .contains("装柜中仅允许修改集装箱号与航次") .contains("装柜完成后货柜全部只读,不允许修改") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java index 6979c2053..3f477184a 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java @@ -1,6 +1,8 @@ package com.baiyue.firstleg.service; +import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.client.WarehouseMasterDataClient; import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest; import com.baiyue.firstleg.dto.ContainerDtos.SaveRequest; import com.baiyue.firstleg.dto.ContainerDtos.TransportUpdateRequest; @@ -8,6 +10,7 @@ import com.baiyue.firstleg.entity.ContainerBookingEntity; import com.baiyue.firstleg.repository.ContainerRepository; import jakarta.annotation.Resource; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.springframework.boot.test.mock.mockito.MockBean; @@ -19,6 +22,8 @@ import java.time.LocalDateTime; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -27,8 +32,15 @@ import static org.mockito.Mockito.when; @SpringJUnitConfig(ContainerService.class) class ContainerServiceTest { @MockBean private ContainerRepository repository; + @MockBean private WarehouseMasterDataClient warehouses; @Resource private ContainerService service; + @BeforeEach + void setUpWarehouse() { + when(warehouses.active(anyString(), anyLong(), anyLong())).thenAnswer(invocation -> + activeWarehouse(invocation.getArgument(2, Long.class), "DOMESTIC", "上海中转仓")); + } + @Test @DisplayName("新增货柜保存最终数据库契约字段并生成平台预订单号") void createsContainerBooking() { @@ -40,6 +52,8 @@ class ContainerServiceTest { assertThat(created).isSameAs(saved); assertThat(saved.getBookingNo()).startsWith("CB"); assertThat(saved.getCabinetNo()).isEqualTo("柜-001"); + assertThat(saved.getWarehouseId()).isEqualTo(101L); + assertThat(saved.getWarehouseName()).isEqualTo("上海中转仓"); assertThat(saved.getDeparturePortCode()).isEqualTo("CNSHA"); assertThat(saved.getCapacityCbm()).isEqualByComparingTo("68.0000"); assertThat(saved.getPlanStatus()).isEqualTo("EDITING"); @@ -127,6 +141,49 @@ class ContainerServiceTest { verify(repository, never()).insert(any()); } + @Test + @DisplayName("虚拟柜拒绝选择非国内中转仓") + void rejectsNonDomesticWarehouse() { + when(warehouses.active(anyString(), anyLong(), anyLong())) + .thenReturn(activeWarehouse(101L, "FBA", "美国目的仓")); + + assertThatThrownBy(() -> service.create(9L, "admin", request("柜-001"))) + .isInstanceOf(BizException.class) + .hasMessageContaining("国内中转仓"); + + verify(repository, never()).insert(any()); + } + + @Test + @DisplayName("虚拟柜仓库名称以仓库主数据为准") + void replacesRequestedWarehouseName() { + service.create(9L, "admin", request("柜-001")); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ContainerBookingEntity.class); + verify(repository).insert(captor.capture()); + assertThat(captor.getValue().getWarehouseName()).isEqualTo("上海中转仓"); + } + + @Test + @DisplayName("仓库主数据服务不可用时拒绝创建虚拟柜") + void rejectsUnavailableWarehouseService() { + when(warehouses.active(anyString(), anyLong(), anyLong())) + .thenThrow(new IllegalStateException("warehouse service unavailable")); + + assertThatThrownBy(() -> service.create(9L, "admin", request("柜-001"))) + .isInstanceOf(BizException.class) + .hasMessageContaining("暂时不可用"); + + verify(repository, never()).insert(any()); + } + + private static ApiResponse activeWarehouse(long warehouseId, + String type, + String name) { + return ApiResponse.success(new WarehouseMasterDataClient.Detail( + new WarehouseMasterDataClient.Warehouse(warehouseId, 0L, "CN-SHA-01", name, type, "ACTIVE"))); + } + private static ContainerBookingEntity booking(String status, int version) { ContainerBookingEntity value = new ContainerBookingEntity(); value.setId(11L); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java index cbe3b9e48..79e77ed9f 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java @@ -51,7 +51,7 @@ class LoadingListServiceTest { @Test void shouldRejectEditingStatusFromPreloadList() { - PreloadQuery query = new PreloadQuery(null, "EDITING", null, null, null, null, 1, 20); + PreloadQuery query = new PreloadQuery(null, "EDITING", 100L, null, null, 1, 20); assertThatThrownBy(() -> service.preloadPage(query)) .isInstanceOf(BizException.class) @@ -74,7 +74,7 @@ class LoadingListServiceTest { when(repository.preloadDetail(after)).thenReturn( new com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail(null, List.of(), List.of())); - service.updateTransport(10L, 9L, "operator", "key-1", + service.updateTransport(10L, 100L, 9L, "operator", "key-1", new TransportUpdateRequest(" CSLU1234567 ", "023E", 3, "补录箱号")); verify(repository).updateTransport(10L, 3, "CSLU1234567", "023E", 9L, "operator"); @@ -88,7 +88,7 @@ class LoadingListServiceTest { when(repository.history(anyString())).thenReturn(null); when(repository.aggregate(10L)).thenReturn(row("COMPLETED", 5)); - assertThatThrownBy(() -> service.updateTransport(10L, 9L, "operator", "key-1", + assertThatThrownBy(() -> service.updateTransport(10L, 100L, 9L, "operator", "key-1", new TransportUpdateRequest("CSLU1234567", "023E", 5, null))) .isInstanceOf(BizException.class) .extracting(error -> ((BizException) error).getResultCode()) @@ -140,7 +140,7 @@ class LoadingListServiceTest { ArgumentCaptor snapshot = ArgumentCaptor.forClass(String.class); TransportUpdateRequest request = new TransportUpdateRequest("CSLU1234567", "023E", 3, null); - service.updateTransport(10L, 9L, "operator", "key-1", request); + service.updateTransport(10L, 100L, 9L, "operator", "key-1", request); verify(repository).appendHistory(eq(10L), anyString(), eq("UPDATE_PRELOAD_TRANSPORT"), eq("LOADING"), eq("LOADING"), anyString(), snapshot.capture(), eq(9L), eq("operator"), eq(null)); @@ -149,14 +149,14 @@ class LoadingListServiceTest { when(repository.history(anyString())).thenReturn(replay); when(repository.aggregate(10L)).thenReturn(after); - service.updateTransport(10L, 9L, "operator", "key-1", request); + service.updateTransport(10L, 100L, 9L, "operator", "key-1", request); verify(repository, times(1)).updateTransport(10L, 3, "CSLU1234567", "023E", 9L, "operator"); } @Test void confirmedPageAlwaysUsesRepositoryConfirmedProjection() { - ConfirmedQuery query = new ConfirmedQuery(" plan ", null, null, null, null, 1, 20); + ConfirmedQuery query = new ConfirmedQuery(" plan ", 100L, null, null, 1, 20); when(repository.confirmedPage(org.mockito.ArgumentMatchers.any())).thenReturn( new PageResult<>(List.of(), 1, 20, 0, 0)); @@ -166,11 +166,22 @@ class LoadingListServiceTest { "plan".equals(value.keyword()))); } + @Test + void rejectsPlanOutsideRequestedWarehouse() { + when(repository.aggregate(10L)).thenReturn(row("LOADING", 1)); + + assertThatThrownBy(() -> service.preloadDetail(10L, 200L)) + .isInstanceOf(BizException.class) + .extracting(error -> ((BizException) error).getResultCode()) + .isEqualTo(ResultCode.NOT_FOUND); + } + private static AggregateRow row(String status, int version) { AggregateRow row = new AggregateRow(); row.setPlanId(10L); row.setPlanNo("PL-10"); row.setPlanStatus(status); + row.setWarehouseId(100L); row.setVersion(version); return row; } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java index ad8f65dc2..bfdd1b004 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java @@ -32,12 +32,12 @@ class LoadingPickServiceTest { LoadingPickItemEntity item = item(); when(repository.history("pick-1")).thenReturn(null); when(repository.task(20L)).thenReturn(task); - when(repository.item(20L, 30L)).thenReturn(item); - when(repository.pick(30L, 2, 0)).thenReturn(1); + when(repository.item(20L, 30L, 101L)).thenReturn(item); + when(repository.pick(30L, 2, 0, 101L)).thenReturn(1); when(repository.allPlanItemsPicked(10L)).thenReturn(false); - when(repository.items(20L)).thenReturn(List.of(item)); + when(repository.items(20L, 101L)).thenReturn(List.of(item)); - service.pick(20L, 30L, new PickRequest(2, List.of(), 0, "pick-1"), 9L, "operator"); + service.pick(20L, 30L, new PickRequest(101L, 2, List.of(), 0, "pick-1"), 9L, "operator"); verify(loadingListService, never()).finishFromPda(10L, 20L, 9L, "operator"); } @@ -48,12 +48,12 @@ class LoadingPickServiceTest { LoadingPickItemEntity item = item(); when(repository.history("pick-2")).thenReturn(null); when(repository.task(20L)).thenReturn(task); - when(repository.item(20L, 30L)).thenReturn(item); - when(repository.pick(30L, 2, 0)).thenReturn(1); + when(repository.item(20L, 30L, 101L)).thenReturn(item); + when(repository.pick(30L, 2, 0, 101L)).thenReturn(1); when(repository.allPlanItemsPicked(10L)).thenReturn(true); - when(repository.items(20L)).thenReturn(List.of(item)); + when(repository.items(20L, 101L)).thenReturn(List.of(item)); - service.pick(20L, 30L, new PickRequest(2, List.of(), 0, "pick-2"), 9L, "operator"); + service.pick(20L, 30L, new PickRequest(101L, 2, List.of(), 0, "pick-2"), 9L, "operator"); verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator"); verify(repository).insert(any(LoadingPickHistoryEntity.class)); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/PdaPickSettlementPlanTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/PdaPickSettlementPlanTest.java new file mode 100644 index 000000000..a781ccfc7 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/PdaPickSettlementPlanTest.java @@ -0,0 +1,53 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.firstleg.entity.LoadingPickItemEntity; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class PdaPickSettlementPlanTest { + @Test + void groupsPartialItemsByInbound() { + var resolution = PdaPickSettlementPlan.resolve(List.of( + item(1L, 11L, 101L, 1001L, 10, 6, "PARTIAL"), + item(2L, 12L, 101L, 1002L, 8, 3, "PARTIAL"), + item(3L, 13L, 102L, 2001L, 4, 0, "SKIPPED"), + item(4L, 14L, 103L, 3001L, 5, 5, "PICKED"))); + + assertThat(resolution.partialByInbound()).containsOnlyKeys(101L); + assertThat(resolution.partialByInbound().get(101L)) + .extracting(PdaPickSettlementPlan.SplitLine::remainingPackageCount) + .containsExactlyInAnyOrder(4, 5); + assertThat(resolution.skippedPlanItemIds()).containsExactly(13L); + } + + @Test + void rejectsUnfinishedItem() { + assertThatThrownBy(() -> PdaPickSettlementPlan.resolve(List.of( + item(1L, 11L, 101L, 1001L, 10, 2, "PICKING")))) + .hasMessageContaining("未结束"); + } + + @Test + void rejectsInvalidPartialCount() { + assertThatThrownBy(() -> PdaPickSettlementPlan.resolve(List.of( + item(1L, 11L, 101L, 1001L, 10, 10, "PARTIAL")))) + .hasMessageContaining("不完整或箱数不合法"); + } + + private static LoadingPickItemEntity item(long id, long planItemId, long inboundId, long detailId, + int planned, int picked, String status) { + LoadingPickItemEntity item = new LoadingPickItemEntity(); + item.setId(id); + item.setLoadingPlanItemId(planItemId); + item.setInboundId(inboundId); + item.setInboundDetailId(detailId); + item.setPlannedPackageCount(planned); + item.setPickedPackageCount(picked); + item.setItemStatus(status); + return item; + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java index f9b56d397..27c0f7ee5 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java @@ -94,11 +94,12 @@ class SmartLoadingServiceTest { void rejectsExcessBoxes() { LoadingPlanEntity plan = plan("EDITING", 0); when(repository.plan(1)).thenReturn(plan); - when(repository.candidate(9, 10, 20)).thenReturn(candidate(5)); + when(repository.booking(99L)).thenReturn(booking()); + when(repository.candidate(100, 10, 20)).thenReturn(candidate(5)); when(repository.activeItem(1L, 9L, 20L)).thenReturn(item(7, 9, 20, 4)); BizException error = catchThrowableOfType(() -> service.assign(1, - new AssignRequest(9L, 10L, 20L, 6, 0, "assign-1"), 7, "u"), BizException.class); + new AssignRequest(10L, 20L, 6, 0, "assign-1"), 7, "u"), BizException.class); assertThat(error.getResultCode()).isEqualTo(ResultCode.VALIDATION_FAILED); verify(repository, never()).touch(anyLong(), anyInt(), anyLong(), any()); @@ -117,7 +118,7 @@ class SmartLoadingServiceTest { return null; }).when(repository).insert(any(LoadingPlanItemEntity.class)); - service.assign(1, new AssignRequest(9L, 10L, 20L, 5, 0, "assign-snapshot"), 7, "u"); + service.assign(1, new AssignRequest(10L, 20L, 5, 0, "assign-snapshot"), 7, "u"); ArgumentCaptor itemCaptor = ArgumentCaptor.forClass(LoadingPlanItemEntity.class); verify(repository).insert(itemCaptor.capture()); @@ -142,7 +143,7 @@ class SmartLoadingServiceTest { new OrderCargoClient.CargoBinding(20L, 30L, 1, "货物", 3, BigDecimal.ONE, BigDecimal.ONE, null, null, null, 100, null, null))); - service.assign(1, new AssignRequest(9L, 10L, 20L, 2, 0, "assign-round"), 7, "u"); + service.assign(1, new AssignRequest(10L, 20L, 2, 0, "assign-round"), 7, "u"); ArgumentCaptor captor = ArgumentCaptor.forClass(LoadingPlanItemEntity.class); verify(repository).insert(captor.capture()); @@ -151,9 +152,9 @@ class SmartLoadingServiceTest { @Test void replaysBeforeVersionCheck() { - AssignRequest request = new AssignRequest(9L, 10L, 20L, 2, 0, "assign-replay"); + AssignRequest request = new AssignRequest(10L, 20L, 2, 0, "assign-replay"); LoadingPlanHistoryEntity replay = history(1, null, "ADD_ITEM", - sha256("ASSIGN|1|9|10|20|2|0")); + sha256("ASSIGN|1|10|20|2|0")); LoadingPlanEntity frozen = plan("LOADING", 9); when(repository.operation("assign-replay")).thenReturn(replay); when(repository.plan(1)).thenReturn(frozen); @@ -169,9 +170,9 @@ class SmartLoadingServiceTest { @Test void rejectsReusedOperation() { - AssignRequest request = new AssignRequest(9L, 10L, 20L, 2, 0, "shared-op"); + AssignRequest request = new AssignRequest(10L, 20L, 2, 0, "shared-op"); when(repository.operation("shared-op")).thenReturn(history(2, null, "ADD_ITEM", - sha256("ASSIGN|1|9|10|20|2|0"))); + sha256("ASSIGN|1|10|20|2|0"))); BizException error = catchThrowableOfType(() -> service.assign(1, request, 7, "u"), BizException.class); @@ -180,9 +181,9 @@ class SmartLoadingServiceTest { @Test void replaysRecommendation() { - RecommendRequest request = new RecommendRequest(9L, 0, "recommend-replay"); + RecommendRequest request = new RecommendRequest(0, "recommend-replay"); when(repository.operation("recommend-replay")).thenReturn(history(1, null, - "SMART_RECOMMEND", sha256("RECOMMEND|1|9|0"))); + "SMART_RECOMMEND", sha256("RECOMMEND|1|0"))); when(repository.plan(1)).thenReturn(plan("LOADING", 8)); when(repository.booking(99)).thenReturn(booking()); when(repository.items(1)).thenReturn(List.of()); @@ -195,9 +196,9 @@ class SmartLoadingServiceTest { @Test void replaysDeleteBeforeVersionCheck() { - DeleteRequest request = new DeleteRequest(9L, 2, 0, "delete-replay"); + DeleteRequest request = new DeleteRequest(2, 0, "delete-replay"); when(repository.operation("delete-replay")).thenReturn(history(1, 8L, - "REMOVE_ITEM", sha256("DELETE|1|8|9|2|0"))); + "REMOVE_ITEM", sha256("DELETE|1|8|2|0"))); when(repository.plan(1)).thenReturn(plan("LOADING", 7)); when(repository.booking(99)).thenReturn(booking()); when(repository.items(1)).thenReturn(List.of()); @@ -220,7 +221,7 @@ class SmartLoadingServiceTest { when(repository.booking(99)).thenReturn(booking()); when(repository.items(1)).thenReturn(List.of()); - Plan result = service.delete(1, 8, new DeleteRequest(9L, 2, 0, "delete-1"), 7, "u"); + Plan result = service.delete(1, 8, new DeleteRequest(2, 0, "delete-1"), 7, "u"); assertThat(result.version()).isEqualTo(3); verify(repository).touch(1, 2, 7, "u"); @@ -233,7 +234,7 @@ class SmartLoadingServiceTest { ApiResponse.failure(ResultCode.NOT_FOUND, "货物不存在", null, null)); BizException error = catchThrowableOfType(() -> service.assign(1, - new AssignRequest(9L, 10L, 20L, 2, 0, "assign-not-found"), 7, "u"), BizException.class); + new AssignRequest(10L, 20L, 2, 0, "assign-not-found"), 7, "u"), BizException.class); assertThat(error.getResultCode()).isEqualTo(ResultCode.NOT_FOUND); } @@ -245,7 +246,7 @@ class SmartLoadingServiceTest { .thenThrow(mock(FeignException.class)); BizException error = catchThrowableOfType(() -> service.assign(1, - new AssignRequest(9L, 10L, 20L, 2, 0, "assign-down"), 7, "u"), BizException.class); + new AssignRequest(10L, 20L, 2, 0, "assign-down"), 7, "u"), BizException.class); assertThat(error.getResultCode()).isEqualTo(ResultCode.SERVICE_UNAVAILABLE); } @@ -257,22 +258,26 @@ class SmartLoadingServiceTest { new OrderCargoClient.CargoBinding(99L, 30L, 1, "货物", 5, BigDecimal.ONE, BigDecimal.ONE))); BizException error = catchThrowableOfType(() -> service.assign(1, - new AssignRequest(9L, 10L, 20L, 2, 0, "assign-mismatch"), 7, "u"), BizException.class); + new AssignRequest(10L, 20L, 2, 0, "assign-mismatch"), 7, "u"), BizException.class); assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT); } @Test - void rejectsWrongAccountDelete() { + void deletesCustomerItemInThePlanWarehouse() { LoadingPlanEntity plan = plan("EDITING", 2); - when(repository.plan(1)).thenReturn(plan); + when(repository.plan(1)).thenReturn(plan, plan("EDITING", 3)); when(repository.item(1, 8)).thenReturn(item(8, 22, 20, 1)); + when(repository.touch(1, 2, 7, "u")).thenReturn(1); + when(repository.remove(1, 8, 0, 7, "u")).thenReturn(1); + when(repository.booking(99)).thenReturn(booking()); + when(repository.items(1)).thenReturn(List.of()); - BizException error = catchThrowableOfType(() -> service.delete(1, 8, - new DeleteRequest(9L, 2, 0, "delete-owner"), 7, "u"), BizException.class); + Plan result = service.delete(1, 8, + new DeleteRequest(2, 0, "delete-owner"), 7, "u"); - assertThat(error.getResultCode()).isEqualTo(ResultCode.FORBIDDEN); - verify(repository, never()).remove(anyLong(), anyLong(), anyInt(), anyLong(), any()); + assertThat(result.version()).isEqualTo(3); + verify(repository).remove(1, 8, 0, 7, "u"); } @Test @@ -296,7 +301,7 @@ class SmartLoadingServiceTest { when(repository.plan(1)).thenReturn(plan, plan("EDITING", 1)); when(repository.booking(99)).thenReturn(booking()); when(repository.items(1)).thenReturn(List.of()); - when(repository.candidate(9, 10, 20)).thenReturn(candidate(10)); + when(repository.candidate(100, 10, 20)).thenReturn(candidate(10)); when(repository.activeItem(1L, 9L, 20L)).thenReturn(null); when(repository.touch(1, 0, 7, "u")).thenReturn(1); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadSqlContractTest.java index b75bb30af..7eb5a8ee6 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadSqlContractTest.java @@ -12,7 +12,7 @@ import static org.assertj.core.api.Assertions.assertThat; class SplitPreloadSqlContractTest { @Test void supportsPartialPackageLock() throws Exception { - assertPartialContract(moduleMigration()); + assertCurrentContract(moduleMigration()); } @Test @@ -20,52 +20,55 @@ class SplitPreloadSqlContractTest { String sql = moduleMigration(); assertThat(sql) .contains("parent_inbound_id") - .contains("split_level") + .contains("inbound_role") + .contains("root_inbound_id") .contains("split_status") - .contains("UNIQUE KEY uk_inbound_one_secondary (tenant_id, parent_inbound_id)") - .contains("CONSTRAINT fk_inbound_parent FOREIGN KEY (parent_inbound_id)") - .contains("二级子入库单不能继续拆分") - .contains("入库单主子关系创建后不可修改") - .contains("已完成拆单不能回退"); + .contains("uk_inbound_one_child") + .contains("fk_inbound_parent_order") + .contains("子入仓单不可继续拆分") + .contains("入仓单主子归属创建后不可修改"); } @Test void remainsRepeatable() throws Exception { String sql = moduleMigration(); assertThat(sql) - .contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_detail") + .contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_batch") + .contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_item") + .contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_history") .contains("information_schema.columns") .contains("information_schema.statistics") .contains("information_schema.table_constraints") - .contains("DROP TRIGGER IF EXISTS trg_inbound_split_before_insert") - .contains("DROP TRIGGER IF EXISTS trg_inbound_split_detail_before_delete") - .contains("CREATE PROCEDURE phase3_split_assert_contract()") - .contains("拆单迁移失败:唯一子单或部分锁定幂等索引缺失") - .contains("拆单迁移失败:主子层级或部分锁定触发器未完整创建"); + .contains("DROP TRIGGER IF EXISTS trg_inbound_order_parent_before_insert") + .contains("DROP TRIGGER IF EXISTS trg_inbound_split_item_before_delete") + .contains("CREATE PROCEDURE phase3_assert_inbound_parent_child_schema()") + .contains("主子入仓单拆分台账未完整创建") + .contains("主子入仓单数据库保护触发器未完整创建"); } @Test void syncsUnifiedMigration() throws Exception { - assertPartialContract(unifiedMigration()); + assertCurrentContract(unifiedMigration()); } - private static void assertPartialContract(String sql) { + private static void assertCurrentContract(String sql) { assertThat(sql) - .contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_detail") - .contains("remaining_package_count INT UNSIGNED GENERATED ALWAYS AS") - .contains("source_package_count - locked_package_count") - .contains("locked_package_count < source_package_count") - .contains("status = 'LOCKED' AND child_inbound_id IS NULL") - .contains("status = 'COMPLETED' AND child_inbound_id IS NOT NULL") - .contains("UNIQUE KEY uk_inbound_split_parent_detail") - .contains("UNIQUE KEY uk_inbound_split_operation (operation_id)") - .contains("部分锁定的归属和箱数创建后不可修改") - .contains("部分锁定与拆单明细不可删除") - .contains("剩余箱数必须绑定主单唯一二级子入库单"); + .contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_batch") + .contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_item") + .contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_history") + .contains("uk_inbound_one_child") + .contains("uk_inbound_split_operation") + .contains("uk_inbound_split_idempotency") + .contains("uk_inbound_split_batch_line") + .contains("COUNT(DISTINCT item.child_inbound_id)") + .contains("child_order.package_count <> SUM(item.package_count)") + .contains("HAVING SUM(split_item.package_count) > source_item.package_count") + .contains("CREATE TRIGGER trg_inbound_split_item_before_update") + .contains("CREATE TRIGGER trg_inbound_split_item_before_delete"); } private static String moduleMigration() throws Exception { - return new ClassPathResource("db/phase3-split-preload-schema.sql") + return new ClassPathResource("db/phase3-inbound-parent-child-schema.sql") .getContentAsString(StandardCharsets.UTF_8); } diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/LocationApplicationService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/LocationApplicationService.java index 6e6ff03d1..bb8c56261 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/service/LocationApplicationService.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/LocationApplicationService.java @@ -145,6 +145,9 @@ public class LocationApplicationService { if (warehouse == null) { throw new BizException(ResultCode.FORBIDDEN, "仓库不属于当前企业租户或不在数据范围内"); } + if (!"DOMESTIC".equalsIgnoreCase(warehouse.getWarehouseType())) { + throw new BizException(ResultCode.FORBIDDEN, "库位只能绑定国内中转仓"); + } if (active && !"ACTIVE".equals(warehouse.getStatus())) { throw new BizException(ResultCode.CONFLICT, "仓库已停用"); } diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/LocationApplicationServiceTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/LocationApplicationServiceTest.java index d8ee744c7..da3c40a9c 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/service/LocationApplicationServiceTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/LocationApplicationServiceTest.java @@ -70,6 +70,17 @@ class LocationApplicationServiceTest { .isInstanceOf(BizException.class).hasMessageContaining("编码"); } + @Test + void rejectsNonDomesticWarehouseForCreate() { + when(repository.findWarehouse(10001L, 88L)).thenReturn(warehouse("ACTIVE", "FBA")); + + assertThatThrownBy(() -> service.create(10001L, request("AVAILABLE", null))) + .isInstanceOf(BizException.class) + .hasMessageContaining("国内中转仓"); + + verify(repository, never()).insert(any()); + } + @Test void shouldHandleCase03() { LocationEntity location = location("AVAILABLE", 3); @@ -110,10 +121,15 @@ class LocationApplicationServiceTest { } private static WarehouseEntity warehouse(String status) { + return warehouse(status, "DOMESTIC"); + } + + private static WarehouseEntity warehouse(String status, String type) { WarehouseEntity warehouse = new WarehouseEntity(); warehouse.setId(88L); warehouse.setTenantId(10001L); warehouse.setStatus(status); + warehouse.setWarehouseType(type); return warehouse; } From 0dc60d835b872cb6e926bc1ba9a416c7085de95a Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Fri, 7 Aug 2026 12:19:47 +0800 Subject: [PATCH 4/8] =?UTF-8?q?=E8=A3=85=E6=9F=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/二三期业务功能测试回归报告.md | 71 ++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 docs/二三期业务功能测试回归报告.md diff --git a/docs/二三期业务功能测试回归报告.md b/docs/二三期业务功能测试回归报告.md new file mode 100644 index 000000000..dffcb1557 --- /dev/null +++ b/docs/二三期业务功能测试回归报告.md @@ -0,0 +1,71 @@ +# 二期、三期业务功能回归报告 + +测试时间:2026-08-07 + +## 测试范围 + +- 二期:企业资质、平台审核、公共主数据、客户计费规则、头程下单、入库测量、称重、计费与入库。 +- 三期:虚拟柜、智能装柜、预装、PDA 拣货和最终装柜清单。 +- 测试环境:本机 MySQL、Redis、Nacos;隔离服务端口 `19300` 到 `19306`。未停止、未占用用户的 `192xx` 服务端口。 +- 测试结束后已停止本次启动的 Gateway 和全部 `193xx` 隔离服务;未停止 MySQL、Redis、Nacos 或用户服务。 + +## 项目索引结论 + +- 二期主状态和测量、计费、入库编排在 `firstleg`;订单货物在 `order`;仓库、托盘、库位、往来单位在 `warehouse`;文件在 `file`。 +- 三期智能装柜、预装、PDA 拣货、装柜清单均在 `firstleg`。 +- 当前 `tenant_id` 在业务上代表客户归属;公共主数据应使用 `tenant_id=0`,订单服务仅可读取公共仓库和公共往来单位。 + +## 二期结果 + +结果:未通过,主链路在创建头程订单草稿前被阻断。 + +- 已通过 15 项:平台/商户登录、Firstleg 健康检查、企业注册、营业执照及法人身份证上传、企业资质提交与平台审核、承运商与客户往来单位创建、收货仓创建、客户计费规则创建、费率版本创建。 +- 阻断接口:`POST /api/merchant/v1/orders/first-leg`。 +- 返回:业务码 `A0404`,提示往来单位不存在或不属于当前企业。 + +### 阻断原因 + +1. 订单服务 `OrderMasterDataGuard` 固定以公共归属 `tenantId=0` 查询往来单位和收货仓,并要求订单客户快照往来单位具有 `SHIPPER` 类型。 +2. 当前平台往来单位 API 对 `tenantId=0` 返回 `A0422`,校验信息为“最小不能小于 1”;公共主数据无法经 API 创建或查询。 +3. 旧订单请求仍强制 `carrierId`、`carrierName`。本次不传时返回 `A0422`。根据最新业务确认,头程业务不存在“承运商”概念,这一历史字段、校验、分配接口和费率依赖应移除或改为船司领域模型,不能继续作为下单前置条件。 + +结论:在修复公共主数据 `tenantId=0` API 契约,并移除订单承运商强制校验前,二期无法从下单继续验证测量、称重、计费和入库。 + +## 三期结果 + +结果:通过。 + +真实 API 已复核的完成装柜清单: + +- 装柜计划:`2085554481377796097` +- 查询接口:`GET /api/platform/v1/first-leg/loading-lists/2085554481377796097?warehouseId=2084548444218646530` +- 最终状态:`COMPLETED` +- 集装箱号:`PH3-20260807103117` +- 装柜箱数:2 +- 总件数:20 +- 总毛重:20kg +- 总体积:0.24m3 +- 历史事件存在:`PDA_COMPLETE_LOADING` +- 明细已有冻结时间,符合完成后不可修改的规则。 + +定向服务层测试:29 项通过、0 失败、0 错误。 + +- `SmartLoadingServiceTest`:15 项 +- `LoadingListServiceTest`:8 项 +- `LoadingPickServiceTest`:2 项 +- `PdaPickSettlementPlanTest`:3 项 +- `SmartLoadingStatusTest`:1 项 + +## 构建与脚本观察 + +- 离线全量模块测试执行到 `firstleg`:211 项中 1 项失败。失败项是 `InboundParentChildSqlContractTest`,原因是统一 SQL 已使用最终索引命名,但测试仍断言旧索引名 `uk_inbound_parent_sequence`;属于 SQL 契约断言滞后,不是 Java 业务断言失败。 +- 二期验收脚本需要 PowerShell 7 的 `SkipHttpErrorCheck`,而本机只有 Windows PowerShell 5;测试时使用了临时 UTF-8 BOM 兼容副本。 +- 原脚本引用的 `c7.png`、`c8.png` 示例图片未随项目提供;测试目录使用最小 PNG 仅验证上传协议。 +- 初始 File 隔离 JAR 未包含当前资质包上传端点,使用当前编译类启动后接口通过。发布前应重新打包 File 服务,避免运行包与源码不一致。 + +## 建议处理顺序 + +1. 移除头程订单及二期计费中的承运商强制字段、校验和分配逻辑;如需要船司,改为独立船司主数据,不复用往来单位承运商。 +2. 修复平台公共主数据 API:允许且仅允许平台身份使用 `tenantId=0` 创建、查询公共仓库和往来单位。 +3. 为订单下单提供可选的公共发货单位选择,或明确订单客户快照不再依赖 `SHIPPER` 往来单位。 +4. 完成以上两项后,重新执行二期全链路:下单、审核、入仓单、测量、OCR 称重、费用试算、费用确认、入库完成。 From 77dc32a69a6aaa68c680734c14471ac9078b01cb Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Fri, 7 Aug 2026 14:52:21 +0800 Subject: [PATCH 5/8] =?UTF-8?q?=E8=AE=A2=E5=8D=95=E6=89=B9=E9=87=8F?= =?UTF-8?q?=E5=AE=A1=E6=A0=B8=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PlatformFirstLegOrderController.java | 45 +++++++++++++++++++ .../order/dto/BatchOrderReviewResponse.java | 23 ++++++++++ .../com/baiyue/order/dto/OrderRequests.java | 24 ++++++++++ .../OrderControllerPermissionTest.java | 4 ++ .../PlatformOrderPageControllerTest.java | 35 +++++++++++++++ 5 files changed, 131 insertions(+) create mode 100644 order/src/main/java/com/baiyue/order/dto/BatchOrderReviewResponse.java diff --git a/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java b/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java index 6a7b85119..678c06aaa 100644 --- a/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java +++ b/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java @@ -3,7 +3,9 @@ package com.baiyue.order.controller; import com.baiyue.common.audit.BusinessAudit; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.core.exception.BizException; import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.order.dto.BatchOrderReviewResponse; import com.baiyue.order.dto.OrderDetailResponse; import com.baiyue.order.dto.OrderRequests; import com.baiyue.order.dto.TenantOrderCountResponse; @@ -28,6 +30,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.PutMapping; +import java.util.ArrayList; import java.util.List; /** @@ -155,6 +158,18 @@ public class PlatformFirstLegOrderController { return transit(user, tenantId, id, OrderStatus.APPROVED, null); } + @BusinessAudit(event = "ORDER_BATCH_APPROVE", resourceType = "FIRST_LEG_ORDER_BATCH", + resourceId = "#request.orderIds", tenantId = "#tenantId", description = "批量审核通过订单") + @Operation(summary = "批量审核通过订单", description = "单租户模式下逐单独立审核,返回成功和失败明细。") + @PostMapping("/batch-approve") + @PreAuthorize("hasAuthority('platform:order:approve')") + public ApiResponse batchApprove( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam long tenantId, + @Valid @RequestBody OrderRequests.BatchReviewRequest request) { + return batchTransit(user, tenantId, request.orderIds(), OrderStatus.APPROVED, null); + } + @BusinessAudit(event = "ORDER_ASSIGN_CARRIER", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "指派订单承运商") @Operation(summary = "指派承运商", description = "可选地为待审核订单选择启用的公共承运商,名称快照由服务端主数据回填;该动作不是审核前置条件。") @PutMapping("/{id}/carrier") @@ -179,6 +194,18 @@ public class PlatformFirstLegOrderController { return transit(user, tenantId, id, OrderStatus.REJECTED, request.reason()); } + @BusinessAudit(event = "ORDER_BATCH_REJECT", resourceType = "FIRST_LEG_ORDER_BATCH", + resourceId = "#request.orderIds", tenantId = "#tenantId", description = "批量驳回订单") + @Operation(summary = "批量驳回订单", description = "单租户模式下使用统一原因逐单独立驳回,返回成功和失败明细。") + @PostMapping("/batch-reject") + @PreAuthorize("hasAuthority('platform:order:reject')") + public ApiResponse batchReject( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam long tenantId, + @Valid @RequestBody OrderRequests.BatchRejectRequest request) { + return batchTransit(user, tenantId, request.orderIds(), OrderStatus.REJECTED, request.reason()); + } + @BusinessAudit(event = "ORDER_ACCEPT", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "Accept order / 受理订单") @Operation(summary = "Accept / 受理订单", description = "Accepts an approved order for fulfillment processing. / 受理已审核通过的订单,进入履约处理。") @PostMapping("/{id}/accept") @@ -255,4 +282,22 @@ public class PlatformFirstLegOrderController { ); return ApiResponse.success(null); } + + private ApiResponse batchTransit( + AuthenticatedUser user, long tenantId, List orderIds, OrderStatus target, String reason) { + long scopedTenantId = OrderScopeGuard.platform(user, tenantId); + List successOrderIds = new ArrayList<>(); + List failedOrders = new ArrayList<>(); + for (Long orderId : orderIds) { + try { + service.transit(scopedTenantId, user.userId(), user.username(), orderId, target, reason); + successOrderIds.add(orderId); + } catch (BizException exception) { + failedOrders.add(new BatchOrderReviewResponse.FailedOrder( + orderId, exception.getResultCode().code(), exception.getMessage())); + } + } + return ApiResponse.success(new BatchOrderReviewResponse(orderIds.size(), successOrderIds.size(), + failedOrders.size(), List.copyOf(successOrderIds), List.copyOf(failedOrders))); + } } diff --git a/order/src/main/java/com/baiyue/order/dto/BatchOrderReviewResponse.java b/order/src/main/java/com/baiyue/order/dto/BatchOrderReviewResponse.java new file mode 100644 index 000000000..981c6c034 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/dto/BatchOrderReviewResponse.java @@ -0,0 +1,23 @@ +package com.baiyue.order.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +/** 单租户订单批量审核结果。 */ +@Schema(description = "订单批量审核结果") +public record BatchOrderReviewResponse( + @Schema(description = "请求订单总数") int total, + @Schema(description = "成功数量") int successCount, + @Schema(description = "失败数量") int failureCount, + @Schema(description = "成功订单 ID") List successOrderIds, + @Schema(description = "失败订单及原因") List failedOrders +) { + @Schema(description = "单笔失败结果") + public record FailedOrder( + @Schema(description = "订单 ID") long orderId, + @Schema(description = "业务错误码") String code, + @Schema(description = "失败原因") String message + ) { + } +} diff --git a/order/src/main/java/com/baiyue/order/dto/OrderRequests.java b/order/src/main/java/com/baiyue/order/dto/OrderRequests.java index ab1d9fc81..153fb394f 100644 --- a/order/src/main/java/com/baiyue/order/dto/OrderRequests.java +++ b/order/src/main/java/com/baiyue/order/dto/OrderRequests.java @@ -166,6 +166,30 @@ public final class OrderRequests { ) { } + @Schema(name = "FirstLegOrderBatchReviewRequest", description = "单租户订单批量审核请求") + public record BatchReviewRequest( + @Schema(description = "订单 ID 列表,单次最多 100 条", requiredMode = Schema.RequiredMode.REQUIRED) + @NotEmpty @Size(max = 100) List<@NotNull @Min(1) Long> orderIds + ) { + @AssertTrue(message = "订单 ID 不能重复") + public boolean hasDistinctOrderIds() { + return orderIds == null || orderIds.size() == orderIds.stream().distinct().count(); + } + } + + @Schema(name = "FirstLegOrderBatchRejectRequest", description = "单租户订单批量驳回请求") + public record BatchRejectRequest( + @Schema(description = "订单 ID 列表,单次最多 100 条", requiredMode = Schema.RequiredMode.REQUIRED) + @NotEmpty @Size(max = 100) List<@NotNull @Min(1) Long> orderIds, + @Schema(description = "统一驳回原因", maxLength = 500, requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank @Size(max = 500) String reason + ) { + @AssertTrue(message = "订单 ID 不能重复") + public boolean hasDistinctOrderIds() { + return orderIds == null || orderIds.size() == orderIds.stream().distinct().count(); + } + } + @Schema(name = "OrderInternalNoteRequest", description = "Platform internal order note / 平台内部订单备注") public record NoteRequest( @Schema(description = "Internal note content / 内部备注内容", example = "Customer confirmed the shipping schedule / 客户已确认船期", maxLength = 2000, requiredMode = Schema.RequiredMode.REQUIRED) diff --git a/order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java b/order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java index f1716f81d..2f9f6cbd2 100644 --- a/order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java +++ b/order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java @@ -43,6 +43,10 @@ class OrderControllerPermissionTest { AuthenticatedUser.class, long.class, long.class, String.class); assertAuthority(PlatformFirstLegOrderController.class, "assignCarrier", "hasAuthority('platform:order:approve')", AuthenticatedUser.class, long.class, long.class, OrderRequests.CarrierRequest.class); + assertAuthority(PlatformFirstLegOrderController.class, "batchApprove", "hasAuthority('platform:order:approve')", + AuthenticatedUser.class, long.class, OrderRequests.BatchReviewRequest.class); + assertAuthority(PlatformFirstLegOrderController.class, "batchReject", "hasAuthority('platform:order:reject')", + AuthenticatedUser.class, long.class, OrderRequests.BatchRejectRequest.class); } @Test diff --git a/order/src/test/java/com/baiyue/order/controller/PlatformOrderPageControllerTest.java b/order/src/test/java/com/baiyue/order/controller/PlatformOrderPageControllerTest.java index fe6b5970b..61e043825 100644 --- a/order/src/test/java/com/baiyue/order/controller/PlatformOrderPageControllerTest.java +++ b/order/src/test/java/com/baiyue/order/controller/PlatformOrderPageControllerTest.java @@ -1,8 +1,11 @@ package com.baiyue.order.controller; import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.order.dto.BatchOrderReviewResponse; import com.baiyue.order.dto.OrderRequests; +import com.baiyue.order.entity.OrderStatus; import com.baiyue.order.service.OrderApplicationService; import org.junit.jupiter.api.Test; import jakarta.annotation.Resource; @@ -11,8 +14,11 @@ import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import org.springframework.test.util.ReflectionTestUtils; import java.util.Set; +import java.util.List; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; @@ -66,6 +72,35 @@ class PlatformOrderPageControllerTest { verifyNoInteractions(service); } + @Test + void batchApproveReturnsPartialFailureAndContinuesRemainingOrders() { + doThrow(new BizException(ResultCode.CONFLICT, "订单已审核")) + .when(service).transit(10001L, 1L, "operator", 102L, OrderStatus.APPROVED, null); + + var response = controller.batchApprove(user("PLATFORM", 0L), 10001L, + new OrderRequests.BatchReviewRequest(List.of(101L, 102L, 103L))); + + BatchOrderReviewResponse result = response.data(); + assertThat(result.total()).isEqualTo(3); + assertThat(result.successOrderIds()).containsExactly(101L, 103L); + assertThat(result.failedOrders()).singleElement().satisfies(failure -> { + assertThat(failure.orderId()).isEqualTo(102L); + assertThat(failure.code()).isEqualTo(ResultCode.CONFLICT.code()); + assertThat(failure.message()).isEqualTo("订单已审核"); + }); + verify(service).transit(10001L, 1L, "operator", 101L, OrderStatus.APPROVED, null); + verify(service).transit(10001L, 1L, "operator", 103L, OrderStatus.APPROVED, null); + } + + @Test + void batchRejectUsesSharedReasonForEveryOrder() { + controller.batchReject(user("PLATFORM", 0L), 10001L, + new OrderRequests.BatchRejectRequest(List.of(101L, 102L), "资料不完整")); + + verify(service).transit(10001L, 1L, "operator", 101L, OrderStatus.REJECTED, "资料不完整"); + verify(service).transit(10001L, 1L, "operator", 102L, OrderStatus.REJECTED, "资料不完整"); + } + private AuthenticatedUser user(String appCode, Long tenantId) { return new AuthenticatedUser(1L, tenantId, null, null, "operator", appCode, "WEB", Set.of()); } From 7c17b1a76a5495ec38fa545a1a9aa41137b75200 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Fri, 7 Aug 2026 15:32:59 +0800 Subject: [PATCH 6/8] =?UTF-8?q?=E8=AE=A2=E5=8D=95=E6=89=B9=E9=87=8F?= =?UTF-8?q?=E5=AE=A1=E6=A0=B8=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../baiyue/common/core/api/ResultCode.java | 11 ++ .../common/core/export/ExportContracts.java | 15 +- docs/系统状态枚举说明.md | 186 ++++++++++++++++++ .../firstleg/domain/InboundListTab.java | 4 + .../firstleg/domain/InboundProcessStatus.java | 33 +++- .../firstleg/domain/SmartLoadingStatus.java | 3 + .../loading/domain/LoadingEvidenceType.java | 5 + .../loading/domain/PickingStatus.java | 3 + .../service/InboundMeasurementService.java | 4 + .../service/MeasurementBatchService.java | 4 + .../firstleg/split/domain/PreloadStatus.java | 3 + .../dto/ocr/WeightOcrImageResult.java | 3 + .../com/baiyue/order/entity/OrderStatus.java | 19 +- .../warehouse/dto/WarehouseImportDtos.java | 2 + 14 files changed, 290 insertions(+), 5 deletions(-) create mode 100644 docs/系统状态枚举说明.md diff --git a/common-core/src/main/java/com/baiyue/common/core/api/ResultCode.java b/common-core/src/main/java/com/baiyue/common/core/api/ResultCode.java index 7e58bd886..848b89ce1 100644 --- a/common-core/src/main/java/com/baiyue/common/core/api/ResultCode.java +++ b/common-core/src/main/java/com/baiyue/common/core/api/ResultCode.java @@ -8,16 +8,27 @@ import java.util.Arrays; * 统一接口结果码枚举,定义业务错误编码、默认消息和对应 HTTP 状态。 */ public enum ResultCode { + /** 请求处理成功。 */ SUCCESS("200", "success", HttpStatus.OK), + /** 请求参数不符合接口要求。 */ BAD_REQUEST("A0400", "Bad request", HttpStatus.BAD_REQUEST), + /** 未登录、令牌无效或登录态已过期。 */ UNAUTHORIZED("A0401", "Authentication required", HttpStatus.UNAUTHORIZED), + /** 已登录但没有操作权限。 */ FORBIDDEN("A0403", "Access denied", HttpStatus.FORBIDDEN), + /** 请求的资源不存在或不在可访问范围内。 */ NOT_FOUND("A0404", "Resource not found", HttpStatus.NOT_FOUND), + /** 资源版本、幂等键或当前状态发生冲突。 */ CONFLICT("A0409", "Resource conflict", HttpStatus.CONFLICT), + /** 字段校验、格式校验或业务前置校验未通过。 */ VALIDATION_FAILED("A0422", "Validation failed", HttpStatus.UNPROCESSABLE_ENTITY), + /** 请求频率或并发数量超过限制。 */ TOO_MANY_REQUESTS("A0429", "Too many requests", HttpStatus.TOO_MANY_REQUESTS), + /** 未被更细错误码覆盖的通用业务失败。 */ BUSINESS_ERROR("B0001", "Business operation failed", HttpStatus.BAD_REQUEST), + /** 服务内部发生未预期异常。 */ INTERNAL_ERROR("S0500", "Internal server error", HttpStatus.INTERNAL_SERVER_ERROR), + /** 下游服务、数据库或基础设施暂不可用。 */ SERVICE_UNAVAILABLE("S0503", "Service unavailable", HttpStatus.SERVICE_UNAVAILABLE); private final String code; diff --git a/common-core/src/main/java/com/baiyue/common/core/export/ExportContracts.java b/common-core/src/main/java/com/baiyue/common/core/export/ExportContracts.java index 1b6a6cbcc..d62b1c13d 100644 --- a/common-core/src/main/java/com/baiyue/common/core/export/ExportContracts.java +++ b/common-core/src/main/java/com/baiyue/common/core/export/ExportContracts.java @@ -24,5 +24,18 @@ public final class ExportContracts { public record ExportColumn(String key, String title, ExportColumnType type, Integer width) { } - public enum ExportColumnType { STRING, INTEGER, DECIMAL, BOOLEAN, DATE, DATETIME } + public enum ExportColumnType { + /** 文本列。 */ + STRING, + /** 整数列。 */ + INTEGER, + /** 高精度小数列。 */ + DECIMAL, + /** 布尔值列。 */ + BOOLEAN, + /** 日期列,不包含时间。 */ + DATE, + /** 日期时间列。 */ + DATETIME + } } diff --git a/docs/系统状态枚举说明.md b/docs/系统状态枚举说明.md new file mode 100644 index 000000000..c0d76da8a --- /dev/null +++ b/docs/系统状态枚举说明.md @@ -0,0 +1,186 @@ +# 系统状态枚举说明 + +## 使用范围 + +本文档说明当前后端实际使用的状态值。状态值是接口、数据库和前端联调契约,不能为了展示中文而修改其英文编码;前端展示应使用本说明中的中文含义。 + +代码中的 Java 枚举、状态常量和数据库状态字段均已补充中文注释。本说明用于集中查询,不替代各业务状态机的校验。 + +## 通用响应码 + +| 状态值 | 中文含义 | +| --- | --- | +| `200` | 请求成功。 | +| `A0400` | 请求参数不符合接口要求。 | +| `A0401` | 未登录、令牌无效或会话过期。 | +| `A0403` | 已登录但没有权限。 | +| `A0404` | 资源不存在或不在可访问范围。 | +| `A0409` | 状态、版本或幂等键冲突。 | +| `A0422` | 字段或业务前置校验失败。 | +| `A0429` | 请求频率或并发数量超限。 | +| `B0001` | 通用业务失败。 | +| `S0500` | 服务内部异常。 | +| `S0503` | 下游服务、数据库或基础设施暂不可用。 | + +## 账号和权限 + +| 字段 | 状态值 | 中文含义 | +| --- | --- | --- | +| 账号状态 | `ACTIVE` | 账号正常,可登录。 | +| 账号状态 | `DISABLED` | 账号已停用,不允许登录。 | +| 账号状态 | `LOCKED` | 账号已锁定,需要管理员解除。 | +| 客户资料状态 | `PROFILE_REQUIRED` | 客户资料尚未补全。 | +| 客户资料状态 | `PENDING` | 客户资料已提交,等待审核。 | +| 客户资料状态 | `ACTIVE` | 客户审核通过,可正常开展业务。 | +| 客户资料状态 | `REJECTED` | 客户资料审核驳回。 | +| 客户资料状态 | `DISABLED` | 客户已停用。 | +| 客户、成员、角色、权限状态 | `ACTIVE` | 已启用,可参与登录、授权或业务使用。 | +| 客户、成员、角色、权限状态 | `DISABLED` | 已停用,不可继续使用。 | +| 审核记录状态 | `PENDING` | 等待审核。 | +| 审核记录状态 | `APPROVED` | 审核通过。 | +| 审核记录状态 | `REJECTED` | 审核驳回。 | +| 审核记录状态 | `SUPERSEDED` | 已被更新后的审核记录替代。 | + +## 订单状态 + +| 状态值 | 中文含义 | +| --- | --- | +| `DRAFT` | 草稿,可继续编辑。 | +| `SUBMITTED` | 已提交,等待平台审核。 | +| `APPROVED` | 审核通过,可接单或进入履约。 | +| `ACCEPTED` | 平台已接单。 | +| `REJECTED` | 审核驳回,不允许进入履约。 | +| `CANCELLED` | 已取消,订单结束。 | +| `PROCESSING` | 履约处理中。 | +| `COMPLETED` | 订单完成。 | + +统一订单列表的主状态是展示分组,不等于订单表的内部状态:`PENDING_REVIEW`、`WAIT_MEASURE`、`WAIT_SPLIT`、`INBOUNDED`、`LOADING`、`LOADED`、`DELIVERED`。 + +## 仓库与库位 + +| 字段 | 状态值 | 中文含义 | +| --- | --- | --- | +| 仓库、往来单位、快递公司 | `ACTIVE` | 启用,可被下单和作业引用。 | +| 仓库、往来单位、快递公司 | `INACTIVE` | 停用,不允许新增业务引用。 | +| 托盘 | `AVAILABLE` | 可分配。 | +| 托盘 | `RESERVED` | 已预占,尚未确认占用。 | +| 托盘 | `OCCUPIED` | 已确认占用。 | +| 库位 | `AVAILABLE` | 可用。 | +| 库位 | `RESERVED` | 已预占。 | +| 库位 | `OCCUPIED` | 已占用。 | +| 库位 | `DISABLED` | 已停用,不允许分配。 | +| 库位预占记录 | `RESERVED` | 预占生效中。 | +| 库位预占记录 | `CONFIRMED` | 预占已转为正式占用。 | +| 库位预占记录 | `RELEASED` | 已释放。 | +| 库位预占记录 | `EXPIRED` | 预占超时失效。 | +| 库位占用记录 | `OCCUPIED` | 正在占用。 | +| 库位占用记录 | `RELEASED` | 已释放。 | + +## 二期入库 + +### 入库单主流程状态 + +| 状态值 | 中文含义 | +| --- | --- | +| `WAIT_MEASURE` | 等待录入或接收测量数据。 | +| `MEASURING` | 正在测量、识别重量或人工修正。 | +| `MEASURE_PENDING` | 测量已提交,等待审核。 | +| `MEASURE_REJECTED` | 测量审核被驳回,等待修正或异常放行。 | +| `WAIT_PRICING` | 测量通过或异常放行,等待计费。 | +| `FEE_CONFIRMING` | 已生成费用,等待费用确认和发送。 | +| `INBOUNDING` | 费用已确认,正在占用托盘、库位并完成入库。 | +| `INBOUNDED` | 入库完成,费用已发送给客户。 | +| `WAIT_SPLIT` | 已完成入库,等待智能装柜或预装确认。 | +| `LOADING` | 已进入装柜作业,PDA 可执行拣货。 | +| `LOADED` | 装柜完成,等待派送完成。 | +| `DELIVERED` | 已完成派送。 | +| `VOIDED` | 已作废,不允许继续处理。 | + +主链路为:`WAIT_MEASURE` -> `MEASURING` -> `MEASURE_PENDING` -> `WAIT_PRICING` -> `FEE_CONFIRMING` -> `INBOUNDING` -> `INBOUNDED` -> `WAIT_SPLIT` -> `LOADING` -> `LOADED` -> `DELIVERED`。测量驳回走 `MEASURE_PENDING` -> `MEASURE_REJECTED`;异常放行后回到 `WAIT_PRICING`。 + +### 测量、重量和费用状态 + +| 字段 | 状态值 | 中文含义 | +| --- | --- | --- | +| 测量批次 | `DRAFT` | 后台人工测量草稿,可编辑。 | +| 测量批次 | `RECEIVED` | 已接收第三方测量回传,待平台提交审核。 | +| 测量批次 | `SUBMITTED` | 已提交测量审核。 | +| 测量批次 | `APPROVED` | 测量审核通过。 | +| 测量批次 | `REJECTED` | 测量审核驳回。 | +| 重量识别 | `PENDING_CONFIRM` | OCR 已识别有效数值,等待人工确认。 | +| 重量识别 | `NEEDS_MANUAL` | OCR 无效或不完整,必须人工录入或修正。 | +| 重量识别 | `CONFIRMED` | 重量已确认,可参与计费。 | +| 测量审核、费用审核 | `NOT_SUBMITTED` | 尚未提交审核。 | +| 测量审核、费用审核 | `PENDING` | 等待审核。 | +| 测量审核、费用审核 | `APPROVED` | 审核通过。 | +| 测量审核、费用审核 | `REJECTED` | 审核驳回。 | +| 费用状态 | `UNPRICED` | 尚未计费。 | +| 费用状态 | `CALCULATED` | 已计算费用,等待确认。 | +| 费用状态 | `CONFIRMED` | 费用已确认,可发送给客户。 | +| 费用状态 | `SENT` | 费用明细已发送给客户。 | +| 客户通知状态 | `NOT_CREATED` | 尚未创建通知任务。 | +| 客户通知状态 | `PENDING` | 已创建待处理通知。当前代码未发现发送完成后的独立状态更新。 | + +### 第三方回传和幂等记录 + +| 字段 | 状态值 | 中文含义 | +| --- | --- | --- | +| 第三方测量调用 | `PROCESSING` | 请求正在处理。 | +| 第三方测量调用 | `ACCEPTED` | 回传被接受,包含幂等重放成功。 | +| 第三方测量调用 | `REJECTED` | 回传校验或处理失败。 | +| 入库与计费幂等记录 | `PROCESSING` | 请求正在执行。 | +| 入库与计费幂等记录 | `COMPLETED` | 请求已成功完成,可返回幂等结果。 | + +## 三期智能装柜和 PDA + +| 字段 | 状态值 | 中文含义 | +| --- | --- | --- | +| 装柜计划 | `EDITING` | 编辑中,可维护货柜信息,并可增删未拣货明细。 | +| 装柜计划 | `LOADING` | 装柜中,已生成 PDA 拣货任务;未拣货明细仍可追加、修改或删除。 | +| 装柜计划 | `COMPLETED` | PDA 拣货完成后自动完成,计划、明细和货柜信息只读。 | +| PDA 拣货任务 | `LOADING` | 拣货作业进行中。 | +| PDA 拣货任务 | `COMPLETED` | 拣货已完成。 | +| PDA 拣货明细 | `PENDING` | 尚未开始拣货。 | +| PDA 拣货明细 | `PICKING` | 已拣部分数量,仍可继续拣货。 | +| PDA 拣货明细 | `PICKED` | 计划箱数已全部拣完。 | +| PDA 拣货明细 | `PARTIAL` | 只拣到部分箱数,剩余数量需要生成关联子入库单。 | +| PDA 拣货明细 | `SKIPPED` | 本次跳过,入库单回退为已入库并可重新参与智能装柜。 | +| 预装清单 | `PENDING` | 预装草稿,尚未开始确认。 | +| 预装清单 | `CONFIRMING` | 正在确认预装明细。 | +| 预装清单 | `CONFIRMED` | 预装已确认,可进入装柜作业。 | +| 新版拆单批次 | `PREPARED` | 已准备待应用。 | +| 新版拆单批次 | `APPLIED` | 已应用并生成关联子入库单。 | +| 新版拆单批次 | `CANCELLED` | 已取消,不能再次应用。 | +| 旧版拆单记录 | `LOCKED` | 已锁定待完成。 | +| 旧版拆单记录 | `COMPLETED` | 已完成并关联子入库单。 | + +装柜计划只能单向推进:`EDITING` -> `LOADING` -> `COMPLETED`,没有撤回状态。`COMPLETED` 后不允许编辑或删除装柜明细。 + +## 文件、导出和审计 + +| 字段 | 状态值 | 中文含义 | +| --- | --- | --- | +| 导出任务 | `PENDING` | 等待执行。 | +| 导出任务 | `RUNNING` | 正在生成文件。 | +| 导出任务 | `SUCCEEDED` | 文件已生成,可下载。 | +| 导出任务 | `FAILED` | 生成失败,可按规则重试。 | +| 导出任务 | `EXPIRED` | 文件已过期。 | +| 导出消息 | `PENDING` | 等待投递。 | +| 导出消息 | `CLAIMED` | 已被工作线程领取。 | +| 导出消息 | `SENT` | 已成功投递。 | +| 导出消息 | `QUARANTINED` | 异常隔离,等待人工或补偿处理。 | +| 审计消息 | `PENDING` | 等待发送。 | +| 审计消息 | `SENDING` | 正在发送。 | +| 审计消息 | `SENT` | 已发送成功。 | +| 审计消息 | `DEAD` | 多次失败,进入死信补偿。 | + +## 第三方能力和 OCR + +| 字段 | 状态值 | 中文含义 | +| --- | --- | --- | +| 第三方能力 | `DISABLED` | 能力未启用。 | +| 第三方能力 | `NOT_CONFIGURED` | 已启用但密钥或配置不完整。 | +| 第三方能力 | `READY` | 已启用且配置完整。 | +| 单图 OCR | `RECOGNIZED` | 已识别到有效重量。 | +| 单图 OCR | `NO_WEIGHT_FOUND` | 已处理图片但未识别到可用重量。 | +| 单图 OCR | `FAILED` | 图片处理、供应商调用或解析失败。 | diff --git a/firstleg/src/main/java/com/baiyue/firstleg/domain/InboundListTab.java b/firstleg/src/main/java/com/baiyue/firstleg/domain/InboundListTab.java index a8a1e3e4d..93336d26a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/domain/InboundListTab.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/domain/InboundListTab.java @@ -4,9 +4,13 @@ import java.util.List; /** 入库列表页签及其流程状态映射。 */ public enum InboundListTab { + /** 待测量:包含待测量、测量中和测量审核驳回的入库单。 */ WAIT_MEASURE(List.of("WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED")), + /** 待审核:测量数据已经提交,等待审核结论。 */ WAIT_REVIEW(List.of("MEASURE_PENDING")), + /** 待发送费用:等待计费、费用确认或正在完成入库。 */ WAIT_FEE_SEND(List.of("WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING")), + /** 已入库:费用已发送且入库完成。 */ INBOUNDED(List.of("INBOUNDED")); private final List processStatuses; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/domain/InboundProcessStatus.java b/firstleg/src/main/java/com/baiyue/firstleg/domain/InboundProcessStatus.java index 7339ccc18..20c39a063 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/domain/InboundProcessStatus.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/domain/InboundProcessStatus.java @@ -4,10 +4,37 @@ import java.util.Set; /** 二期入库主流程状态,不包含原型中的“待客户确认”。 */ public final class InboundProcessStatus { + /** 等待录入或接收测量数据。 */ + public static final String WAIT_MEASURE = "WAIT_MEASURE"; + /** 已存在测量草稿、重量 OCR 或人工修正,尚未提交审核。 */ + public static final String MEASURING = "MEASURING"; + /** 测量已提交,等待平台审核。 */ + public static final String MEASURE_PENDING = "MEASURE_PENDING"; + /** 测量审核被驳回,等待修正或异常放行。 */ + public static final String MEASURE_REJECTED = "MEASURE_REJECTED"; + /** 测量已通过或异常放行,等待计费。 */ + public static final String WAIT_PRICING = "WAIT_PRICING"; + /** 已生成费用,等待费用确认和发送。 */ + public static final String FEE_CONFIRMING = "FEE_CONFIRMING"; + /** 费用已确认,正在执行托盘、库位占用和入库收口。 */ + public static final String INBOUNDING = "INBOUNDING"; + /** 已入库完成,费用已发送给客户。 */ + public static final String INBOUNDED = "INBOUNDED"; + /** 等待智能装柜或预装确认。 */ + public static final String WAIT_SPLIT = "WAIT_SPLIT"; + /** 已进入装柜作业,PDA 可执行拣货。 */ + public static final String LOADING = "LOADING"; + /** 装柜完成,等待派送完成。 */ + public static final String LOADED = "LOADED"; + /** 已完成派送。 */ + public static final String DELIVERED = "DELIVERED"; + /** 已作废,不再允许继续处理。 */ + public static final String VOIDED = "VOIDED"; + public static final Set VALUES = Set.of( - "WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED", - "WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING", "INBOUNDED", - "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED", "VOIDED" + WAIT_MEASURE, MEASURING, MEASURE_PENDING, MEASURE_REJECTED, + WAIT_PRICING, FEE_CONFIRMING, INBOUNDING, INBOUNDED, + WAIT_SPLIT, LOADING, LOADED, DELIVERED, VOIDED ); private InboundProcessStatus() { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/domain/SmartLoadingStatus.java b/firstleg/src/main/java/com/baiyue/firstleg/domain/SmartLoadingStatus.java index 0dc401816..8c90af1af 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/domain/SmartLoadingStatus.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/domain/SmartLoadingStatus.java @@ -2,8 +2,11 @@ package com.baiyue.firstleg.domain; /** 智能装柜状态,只允许沿此方向推进。 */ public enum SmartLoadingStatus { + /** 编辑中:可以维护货柜信息并增删未拣货明细。 */ EDITING, + /** 装柜中:已生成 PDA 拣货任务,仍可追加或调整未拣货明细。 */ LOADING, + /** 装柜完成:PDA 拣货完成后自动完成,计划、明细和货柜信息只读。 */ COMPLETED; /** 装柜完成前可维护集装箱号和航次。 */ diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/domain/LoadingEvidenceType.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/domain/LoadingEvidenceType.java index 7f3f4f017..74a5f40ef 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/domain/LoadingEvidenceType.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/domain/LoadingEvidenceType.java @@ -6,10 +6,15 @@ import java.util.Set; * 装柜证据类型。照片语义尚待产品确认,当前只固定四个步骤和一个铅封证据槽位。 */ public final class LoadingEvidenceType { + /** 装柜过程第 1 步照片。 */ public static final String PHOTO_STEP_1 = "PHOTO_STEP_1"; + /** 装柜过程第 2 步照片。 */ public static final String PHOTO_STEP_2 = "PHOTO_STEP_2"; + /** 装柜过程第 3 步照片。 */ public static final String PHOTO_STEP_3 = "PHOTO_STEP_3"; + /** 装柜过程第 4 步照片。 */ public static final String PHOTO_STEP_4 = "PHOTO_STEP_4"; + /** 柜门铅封或封条证据。 */ public static final String SEAL = "SEAL"; public static final Set VALUES = Set.of(PHOTO_STEP_1, PHOTO_STEP_2, PHOTO_STEP_3, PHOTO_STEP_4, SEAL); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/domain/PickingStatus.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/domain/PickingStatus.java index b0b9fb351..98b0cb71b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/domain/PickingStatus.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/domain/PickingStatus.java @@ -1,8 +1,11 @@ package com.baiyue.firstleg.loading.domain; public final class PickingStatus { + /** PDA 拣货尚未开始。 */ public static final String PENDING = "PENDING"; + /** PDA 正在执行拣货。 */ public static final String IN_PROGRESS = "IN_PROGRESS"; + /** PDA 拣货已完成。 */ public static final String COMPLETED = "COMPLETED"; private PickingStatus() {} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java index b5193dfee..45ce9cc73 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java @@ -28,9 +28,13 @@ import java.util.Set; /** 平台端入库测量、OCR 修正、重量确认和提交审核应用服务。 */ @Service public class InboundMeasurementService { + /** 测量批次已提交,等待测量审核。 */ private static final String STATUS_SUBMITTED = "SUBMITTED"; + /** OCR 或人工修正的重量已确认,可参与计费。 */ private static final String WEIGHT_CONFIRMED = "CONFIRMED"; + /** OCR 已识别到有效重量,等待人工确认。 */ private static final String WEIGHT_PENDING_CONFIRM = "PENDING_CONFIRM"; + /** OCR 结果无效或不完整,必须人工录入或修正。 */ private static final String WEIGHT_NEEDS_MANUAL = "NEEDS_MANUAL"; /** 测量完成后的默认普通托盘类型;实际拆托和库位占用由提交编排服务执行。 */ private static final String RECOMMENDED_PALLET_TYPE = "STANDARD"; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java index f586c0416..e7abff234 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java @@ -26,9 +26,13 @@ import java.util.Set; */ @Service public class MeasurementBatchService { + /** 后台人工录入的测量来源。 */ static final String SOURCE_MANUAL = "MANUAL"; + /** 第三方设备或开放接口回传的测量来源。 */ static final String SOURCE_THIRD_PARTY = "THIRD_PARTY"; + /** 测量草稿,可继续编辑。 */ static final String STATUS_DRAFT = "DRAFT"; + /** 已接收第三方测量数据,待平台提交审核。 */ static final String STATUS_RECEIVED = "RECEIVED"; static final BigDecimal VOLUME_WEIGHT_FACTOR = new BigDecimal("167"); static final String CALCULATION_RULE_VERSION = "V1_CBM_CEILING_4_GIRTH_L2WH"; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/split/domain/PreloadStatus.java b/firstleg/src/main/java/com/baiyue/firstleg/split/domain/PreloadStatus.java index 3d9675b82..32200c3c6 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/split/domain/PreloadStatus.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/split/domain/PreloadStatus.java @@ -4,8 +4,11 @@ import java.util.Set; /** 预装清单子状态,固定为三态。 */ public final class PreloadStatus { + /** 预装清单草稿,尚未开始确认。 */ public static final String PENDING = "PENDING"; + /** 正在确认预装明细。 */ public static final String CONFIRMING = "CONFIRMING"; + /** 预装清单已确认,可进入装柜作业。 */ public static final String CONFIRMED = "CONFIRMED"; public static final Set VALUES = Set.of(PENDING, CONFIRMING, CONFIRMED); private PreloadStatus() {} diff --git a/integration-service/src/main/java/com/baiyue/integration/dto/ocr/WeightOcrImageResult.java b/integration-service/src/main/java/com/baiyue/integration/dto/ocr/WeightOcrImageResult.java index 21b99715a..d5f55cec2 100644 --- a/integration-service/src/main/java/com/baiyue/integration/dto/ocr/WeightOcrImageResult.java +++ b/integration-service/src/main/java/com/baiyue/integration/dto/ocr/WeightOcrImageResult.java @@ -17,8 +17,11 @@ public record WeightOcrImageResult( String error ) { public enum Status { + /** 已成功识别到有效重量。 */ RECOGNIZED, + /** 图片已处理,但未识别到可用重量。 */ NO_WEIGHT_FOUND, + /** 图片读取、裁切、调用供应商或解析过程失败。 */ FAILED } } diff --git a/order/src/main/java/com/baiyue/order/entity/OrderStatus.java b/order/src/main/java/com/baiyue/order/entity/OrderStatus.java index 693aaab40..971ef1ea0 100644 --- a/order/src/main/java/com/baiyue/order/entity/OrderStatus.java +++ b/order/src/main/java/com/baiyue/order/entity/OrderStatus.java @@ -2,4 +2,21 @@ package com.baiyue.order.entity; /** * 头程订单生命周期状态,约束草稿、提交、审核、受理、处理、完成及取消等状态流转。 */ -public enum OrderStatus { DRAFT, SUBMITTED, APPROVED, ACCEPTED, REJECTED, CANCELLED, PROCESSING, COMPLETED } +public enum OrderStatus { + /** 草稿:客户可继续编辑,尚未提交审核。 */ + DRAFT, + /** 已提交:等待平台审核。 */ + SUBMITTED, + /** 审核通过:可由平台接单或进入头程履约。 */ + APPROVED, + /** 已接单:平台已受理订单。 */ + ACCEPTED, + /** 审核驳回:不允许继续进入履约。 */ + REJECTED, + /** 已取消:订单生命周期终止。 */ + CANCELLED, + /** 履约中:订单正在执行头程业务。 */ + PROCESSING, + /** 已完成:订单全流程结束。 */ + COMPLETED +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/dto/WarehouseImportDtos.java b/warehouse/src/main/java/com/baiyue/warehouse/dto/WarehouseImportDtos.java index 8d70d9316..3db6d9bf4 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/dto/WarehouseImportDtos.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/dto/WarehouseImportDtos.java @@ -12,8 +12,10 @@ public final class WarehouseImportDtos { @Schema(name = "WarehouseImportMode", description = "Warehouse import mode / 仓库导入模式") public enum ImportMode { @Schema(description = "Only create new rows; existing rows are reported but not modified / 仅新增,现有数据只预检分类不修改") + /** 仅新增:重复的仓库编码只提示,不修改已有仓库。 */ CREATE_ONLY, @Schema(description = "Create new rows and update existing rows without overriding contact or remark / 新增并更新,更新不覆盖联系人和备注") + /** 新增或更新:已有仓库按导入字段更新,但保留联系人和备注。 */ UPSERT } From 3f15f3ad2bf81707aaa6bc522f50c385c1887676 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Fri, 7 Aug 2026 19:40:42 +0800 Subject: [PATCH 7/8] =?UTF-8?q?=E6=9A=82=E5=AD=98=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/头程数据库统一增量-MySQL8.sql | 65 ++++++++++++------- docs/系统状态枚举说明.md | 7 +- ...ormInboundMeasurementReviewController.java | 2 +- .../firstleg/domain/InboundProcessStatus.java | 4 +- .../dto/InboundMeasurementRequests.java | 1 + .../dto/InboundMeasurementResponses.java | 1 + .../firstleg/dto/MeasurementBatchCommand.java | 1 + .../firstleg/mapper/InboundOrderMapper.java | 2 +- .../mapper/LoadingPickItemMapper.java | 4 +- .../repository/FbaPricingRepository.java | 4 +- .../firstleg/service/FbaPricingService.java | 6 +- .../service/InboundMeasurementService.java | 34 +++++++++- .../service/MeasurementBatchService.java | 1 + .../service/MeasurementReviewService.java | 4 +- .../firstleg/service/SmartLoadingService.java | 2 +- .../service/UnifiedInboundQueryService.java | 14 ++-- .../split/mapper/SplitInboundMapper.java | 2 +- .../split/service/SplitPreloadService.java | 14 ++-- .../phase3-inbounded-status-unification.sql | 17 +++++ .../db/phase3-smart-loading-schema.sql | 22 +++++++ .../resources/openapi/fba-pricing-v1.yaml | 4 +- .../controller/FbaPricingContractTest.java | 2 +- ...alInboundOrderReferenceControllerTest.java | 2 +- ...boundStatusUnificationSqlContractTest.java | 35 ++++++++++ ...artLoadingCandidateMapperContractTest.java | 2 + .../service/FbaPricingServiceTest.java | 14 ++-- .../InboundMeasurementServiceTest.java | 36 +++++++++- .../service/MeasurementBatchServiceTest.java | 3 +- .../service/MeasurementReviewServiceTest.java | 2 +- .../UnifiedInboundQueryServiceTest.java | 18 ++--- .../service/SplitPreloadServiceTest.java | 6 +- .../baiyue/order/dto/UnifiedOrderQuery.java | 2 +- .../service/UnifiedOrderQueryService.java | 7 +- .../src/main/resources/openapi/order-v1.yaml | 2 +- .../service/UnifiedOrderQueryServiceTest.java | 8 +-- scripts/phase2-test-env-acceptance.ps1 | 6 +- 36 files changed, 260 insertions(+), 96 deletions(-) create mode 100644 firstleg/src/main/resources/db/phase3-inbounded-status-unification.sql create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/repository/InboundStatusUnificationSqlContractTest.java diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index ef5f93afa..97de08871 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -3837,6 +3837,28 @@ CREATE DATABASE IF NOT EXISTS baiyue_firstleg DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; USE baiyue_firstleg; +-- 兼容已执行二期初始化的存量库:PDA 拣货需要累计每个托盘分配已拣箱数。 +DROP PROCEDURE IF EXISTS phase3_add_allocation_picked_count; +DELIMITER $$ +CREATE PROCEDURE phase3_add_allocation_picked_count() +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_inbound_pallet_allocation' + ) AND NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_inbound_pallet_allocation' + AND column_name = 'picked_package_count' + ) THEN + ALTER TABLE fl_inbound_pallet_allocation + ADD COLUMN picked_package_count INT UNSIGNED NOT NULL DEFAULT 0 + COMMENT '已拣货箱数' AFTER allocated_package_count; + END IF; +END$$ +DELIMITER ; +CALL phase3_add_allocation_picked_count(); +DROP PROCEDURE phase3_add_allocation_picked_count; + CREATE TABLE IF NOT EXISTS fl_container_booking ( id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID(雪花ID)', booking_no VARCHAR(40) NOT NULL COMMENT '平台唯一货柜预订单号', @@ -5748,28 +5770,6 @@ BEGIN PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - IF (SELECT COUNT(*) FROM information_schema.statistics - WHERE table_schema = 'baiyue_order' AND table_name = 'ord_order' - AND index_name = 'uk_order_tenant_inbound_code') <> 2 THEN - SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = '统一迁移失败:订单入仓码唯一索引未正确创建'; - END IF; - IF EXISTS ( - SELECT 1 FROM baiyue_order.ord_order - WHERE deleted = 0 - AND ((status = 'DRAFT' AND inbound_code IS NOT NULL) - OR (status <> 'DRAFT' AND inbound_code IS NULL)) - ) THEN - SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = '统一迁移失败:订单状态与入仓码数据不一致'; - END IF; - IF (SELECT COUNT(*) FROM information_schema.tables - WHERE table_schema = 'baiyue_firstleg' - AND table_name IN ('fl_preload_plan', 'fl_split_operation', - 'fl_loading_evidence', 'fl_loading_operation', - 'fl_loading_pick_task')) <> 5 THEN - SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = '统一迁移失败:三期分柜、预装或装柜表未完整创建'; END IF; END$$ DELIMITER ; @@ -6328,3 +6328,24 @@ END$$ DELIMITER ; CALL phase3_assert_inbound_parent_child_schema(); DROP PROCEDURE IF EXISTS phase3_assert_inbound_parent_child_schema; + +-- ============================================================================ +-- 来源:firstleg/src/main/resources/db/phase3-inbounded-status-unification.sql +-- ============================================================================ +-- 将已废弃的 WAIT_SPLIT 统一为 INBOUNDED。 +-- 执行前请确认所有服务已升级至本次状态契约;脚本可重复执行。 +USE baiyue_firstleg; + +UPDATE fl_inbound_order +SET process_status = 'INBOUNDED', + updated_at = CURRENT_TIMESTAMP(3), + version = version + 1 +WHERE process_status = 'WAIT_SPLIT'; + +UPDATE fl_inbound_status_history +SET from_status = 'INBOUNDED' +WHERE from_status = 'WAIT_SPLIT'; + +UPDATE fl_inbound_status_history +SET to_status = 'INBOUNDED' +WHERE to_status = 'WAIT_SPLIT'; diff --git a/docs/系统状态枚举说明.md b/docs/系统状态枚举说明.md index c0d76da8a..72fb192ab 100644 --- a/docs/系统状态枚举说明.md +++ b/docs/系统状态枚举说明.md @@ -54,7 +54,7 @@ | `PROCESSING` | 履约处理中。 | | `COMPLETED` | 订单完成。 | -统一订单列表的主状态是展示分组,不等于订单表的内部状态:`PENDING_REVIEW`、`WAIT_MEASURE`、`WAIT_SPLIT`、`INBOUNDED`、`LOADING`、`LOADED`、`DELIVERED`。 +统一订单列表的主状态是展示分组,不等于订单表的内部状态:`PENDING_REVIEW`、`WAIT_MEASURE`、`INBOUNDED`、`LOADING`、`LOADED`、`DELIVERED`。 ## 仓库与库位 @@ -89,14 +89,13 @@ | `WAIT_PRICING` | 测量通过或异常放行,等待计费。 | | `FEE_CONFIRMING` | 已生成费用,等待费用确认和发送。 | | `INBOUNDING` | 费用已确认,正在占用托盘、库位并完成入库。 | -| `INBOUNDED` | 入库完成,费用已发送给客户。 | -| `WAIT_SPLIT` | 已完成入库,等待智能装柜或预装确认。 | +| `INBOUNDED` | 入库完成,可进入智能装柜或预装确认;费用状态独立维护。 | | `LOADING` | 已进入装柜作业,PDA 可执行拣货。 | | `LOADED` | 装柜完成,等待派送完成。 | | `DELIVERED` | 已完成派送。 | | `VOIDED` | 已作废,不允许继续处理。 | -主链路为:`WAIT_MEASURE` -> `MEASURING` -> `MEASURE_PENDING` -> `WAIT_PRICING` -> `FEE_CONFIRMING` -> `INBOUNDING` -> `INBOUNDED` -> `WAIT_SPLIT` -> `LOADING` -> `LOADED` -> `DELIVERED`。测量驳回走 `MEASURE_PENDING` -> `MEASURE_REJECTED`;异常放行后回到 `WAIT_PRICING`。 +主链路为:`WAIT_MEASURE` -> `MEASURING` -> `MEASURE_PENDING` -> `WAIT_PRICING` -> `FEE_CONFIRMING` -> `INBOUNDING` -> `INBOUNDED` -> `LOADING` -> `LOADED` -> `DELIVERED`。测量驳回走 `MEASURE_PENDING` -> `MEASURE_REJECTED`;异常放行后回到 `WAIT_PRICING`。 ### 测量、重量和费用状态 diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementReviewController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementReviewController.java index 8ddd133a3..733ba36ee 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementReviewController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementReviewController.java @@ -51,7 +51,7 @@ public class PlatformInboundMeasurementReviewController { @Valid @RequestBody MeasurementReviewRequest request) { validateScope(user, tenantId); service.approve(tenantId, user.userId(), user.username(), inboundId, idempotencyKey, request); - // 统一订单主线要求测量审核后直接进入 WAIT_SPLIT,不把计费、库位或其他资料 + // 统一订单主线要求测量审核后直接进入 INBOUNDED,不把计费、库位或其他资料 // 作为本版本的强制门禁。保留原 FeeSnapshotResponse 泛型以兼容既有客户端。 return ApiResponse.success(null); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/domain/InboundProcessStatus.java b/firstleg/src/main/java/com/baiyue/firstleg/domain/InboundProcessStatus.java index 20c39a063..1e5e621b0 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/domain/InboundProcessStatus.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/domain/InboundProcessStatus.java @@ -20,8 +20,6 @@ public final class InboundProcessStatus { public static final String INBOUNDING = "INBOUNDING"; /** 已入库完成,费用已发送给客户。 */ public static final String INBOUNDED = "INBOUNDED"; - /** 等待智能装柜或预装确认。 */ - public static final String WAIT_SPLIT = "WAIT_SPLIT"; /** 已进入装柜作业,PDA 可执行拣货。 */ public static final String LOADING = "LOADING"; /** 装柜完成,等待派送完成。 */ @@ -34,7 +32,7 @@ public final class InboundProcessStatus { public static final Set VALUES = Set.of( WAIT_MEASURE, MEASURING, MEASURE_PENDING, MEASURE_REJECTED, WAIT_PRICING, FEE_CONFIRMING, INBOUNDING, INBOUNDED, - WAIT_SPLIT, LOADING, LOADED, DELIVERED, VOIDED + LOADING, LOADED, DELIVERED, VOIDED ); private InboundProcessStatus() { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementRequests.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementRequests.java index a5de64c5e..f56293e9a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementRequests.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementRequests.java @@ -34,6 +34,7 @@ public final class InboundMeasurementRequests { } public record ItemRequest( + @NotNull @Positive Long inboundDetailId, @NotBlank @Size(max = 32) String itemNo, @NotBlank @Size(max = 64) String boxSpecification, @NotNull @Positive @Max(1000000) Integer packageCount, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementResponses.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementResponses.java index fa496b7e6..a4698eb23 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementResponses.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementResponses.java @@ -46,6 +46,7 @@ public final class InboundMeasurementResponses { public record Item( Long id, + Long inboundDetailId, String itemNo, String boxSpecification, Integer packageCount, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/MeasurementBatchCommand.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/MeasurementBatchCommand.java index c4b631e0f..3c3ed8dd1 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/MeasurementBatchCommand.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/MeasurementBatchCommand.java @@ -28,6 +28,7 @@ public record MeasurementBatchCommand( List items ) { public record Item( + Long inboundDetailId, String itemNo, String boxSpecification, int packageCount, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundOrderMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundOrderMapper.java index 9e727b3fe..6de2f68c4 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundOrderMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundOrderMapper.java @@ -11,7 +11,7 @@ import org.apache.ibatis.annotations.Update; public interface InboundOrderMapper extends BaseMapper { @Update(""" UPDATE fl_inbound_order - SET process_status = 'WAIT_SPLIT', measure_review_status = 'APPROVED', + SET process_status = 'INBOUNDED', measure_review_status = 'APPROVED', inbounded_at = COALESCE(inbounded_at, CURRENT_TIMESTAMP(3)), updated_by = #{operatorId}, version = version + 1 WHERE tenant_id = #{tenantId} AND id = #{inboundId} AND deleted = 0 diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java index 3af7c51d9..bd92dbbed 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java @@ -37,9 +37,9 @@ public interface LoadingPickItemMapper extends BaseMapper @Update(""" UPDATE fl_loading_pick_item - SET picked_package_count = picked_package_count + #{count}, - item_status = CASE WHEN picked_package_count + #{count} = planned_package_count + SET item_status = CASE WHEN picked_package_count + #{count} = planned_package_count THEN 'PICKED' ELSE 'PICKING' END, + picked_package_count = picked_package_count + #{count}, version = version + 1 WHERE id = #{id} AND version = #{version} AND EXISTS (SELECT 1 FROM fl_loading_plan_item plan_item diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java index c110d52c0..33e3cb2a8 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java @@ -209,7 +209,7 @@ public class FbaPricingRepository { .eq(InboundPricingEntity::getProcessStatus, processStatus) .in(InboundPricingEntity::getFeeStatus, List.of("UNPRICED", "CALCULATED")) .set(InboundPricingEntity::getFeeStatus, "CALCULATED"); - if (!"WAIT_SPLIT".equals(processStatus)) { + if (!"INBOUNDED".equals(processStatus)) { update.set(InboundPricingEntity::getProcessStatus, "FEE_CONFIRMING"); } if ("KG".equals(chargeMode)) update.set(InboundPricingEntity::getChargeableWeightKg, chargeable); @@ -229,7 +229,7 @@ public class FbaPricingRepository { public int markInboundConfirmed(long tenantId, long inboundId) { return inbounds.update(null, new LambdaUpdateWrapper() .eq(InboundPricingEntity::getTenantId, tenantId).eq(InboundPricingEntity::getId, inboundId) - .in(InboundPricingEntity::getProcessStatus, List.of("FEE_CONFIRMING", "WAIT_SPLIT")) + .in(InboundPricingEntity::getProcessStatus, List.of("FEE_CONFIRMING", "INBOUNDED")) .eq(InboundPricingEntity::getFeeStatus, "CALCULATED") .set(InboundPricingEntity::getFeeStatus, "CONFIRMED")); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java index 450a2d49a..c9fb77452 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java @@ -93,8 +93,8 @@ public class FbaPricingService { throw conflict("入库单状态已变化,费用试算未保存"); } complete(token, fee.getId(), now); - String nextStatus = "WAIT_SPLIT".equals(inbound.getProcessStatus()) - ? "WAIT_SPLIT" : "FEE_CONFIRMING"; + String nextStatus = "INBOUNDED".equals(inbound.getProcessStatus()) + ? "INBOUNDED" : "FEE_CONFIRMING"; return response(inbound, fee, rate.getUnitPrice(), nextStatus); } @@ -235,7 +235,7 @@ public class FbaPricingService { private InboundPricingEntity requireEligibleInbound(long tenantId, long inboundId) { InboundPricingEntity inbound = requireApprovedInbound(tenantId, inboundId); - if (!List.of("WAIT_PRICING", "FEE_CONFIRMING", "WAIT_SPLIT").contains(inbound.getProcessStatus()) + if (!List.of("WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDED").contains(inbound.getProcessStatus()) || !List.of("UNPRICED", "CALCULATED").contains(inbound.getFeeStatus())) { throw conflict("当前入库单状态不允许费用试算"); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java index 45ce9cc73..2a09179d6 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java @@ -3,7 +3,9 @@ package com.baiyue.firstleg.service; import jakarta.annotation.Resource; import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.client.OrderCargoClient; import com.baiyue.firstleg.dto.InboundMeasurementRequests; import com.baiyue.firstleg.dto.InboundMeasurementResponses; import com.baiyue.firstleg.dto.MeasurementBatchCommand; @@ -17,6 +19,8 @@ import com.baiyue.firstleg.repository.InboundMeasurementRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; +import org.springframework.beans.factory.annotation.Value; +import feign.FeignException; import java.math.BigDecimal; import java.math.RoundingMode; @@ -44,6 +48,10 @@ public class InboundMeasurementService { private InboundMeasurementRepository repository; @Resource private MeasurementBatchService batchService; + @Resource + private OrderCargoClient orderCargoClient; + @Value("${baiyue.security.internal-service-token:}") + private String internalToken; public InboundMeasurementResponses.Detail detail(long tenantId, long inboundId) { InboundOrderEntity inbound = requireInbound(tenantId, inboundId); @@ -60,13 +68,14 @@ public class InboundMeasurementService { long tenantId, long inboundId, long operatorId, InboundMeasurementRequests.SaveDraftRequest request) { InboundOrderEntity inbound = requireInbound(tenantId, inboundId); + validateInboundDetails(tenantId, inbound, request.items()); MeasurementBatchCommand command = new MeasurementBatchCommand( tenantId, inboundId, request.measurementId(), request.version(), MeasurementBatchService.SOURCE_MANUAL, "PLATFORM", null, null, null, LocalDateTime.now(), null, request.originalDocumentNo(), request.originalGrossWeightKg(), request.originalVolumeCbm(), request.actualGoodsName(), operatorId, request.items().stream().map(item -> new MeasurementBatchCommand.Item( - item.itemNo(), item.boxSpecification(), item.packageCount(), item.lengthCm(), + item.inboundDetailId(), item.itemNo(), item.boxSpecification(), item.packageCount(), item.lengthCm(), item.widthCm(), item.heightCm(), item.unitWeightKg(), item.intelligentImageFileId(), item.overLimitWarning(), item.outerLengthCm(), item.outerWidthCm(), item.outerHeightCm(), item.remark())).toList()); @@ -286,7 +295,7 @@ public class InboundMeasurementService { private InboundMeasurementResponses.Item toItem(InboundMeasurementItemEntity item) { return new InboundMeasurementResponses.Item( - item.getId(), item.getItemNo(), item.getBoxSpecification(), item.getPackageCount(), + item.getId(), item.getInboundDetailId(), item.getItemNo(), item.getBoxSpecification(), item.getPackageCount(), item.getLengthCm(), item.getWidthCm(), item.getHeightCm(), item.getGirthCm(), item.getUnitWeightKg(), item.getUnitVolumeCbm(), item.getTotalWeightKg(), item.getTotalVolumeCbm(), item.getVolumeWeightKg(), item.getSourceImageFileId(), @@ -311,6 +320,27 @@ public class InboundMeasurementService { return inbound; } + private void validateInboundDetails(long tenantId, InboundOrderEntity inbound, + List items) { + if (inbound.getOrderId() == null || inbound.getOrderId() <= 0) { + throw new BizException(ResultCode.CONFLICT, "入库单未关联有效订单,不能保存测量明细"); + } + for (InboundMeasurementRequests.ItemRequest item : items) { + try { + ApiResponse response = orderCargoClient.detail( + internalToken, tenantId, inbound.getOrderId(), item.inboundDetailId()); + if (response == null || !response.successful() || response.data() == null) { + String message = response == null || !StringUtils.hasText(response.message()) + ? "订单货物明细校验失败" : response.message(); + throw new BizException(ResultCode.of(response == null + ? ResultCode.SERVICE_UNAVAILABLE.code() : response.code()), message); + } + } catch (FeignException exception) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "订单服务暂不可用,无法校验入库明细"); + } + } + } + private InboundMeasurementEntity requireMeasurement(long tenantId, long inboundId, long measurementId) { InboundMeasurementEntity measurement = repository.findMeasurement(tenantId, inboundId, measurementId); if (measurement == null) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java index e7abff234..274c3760a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java @@ -140,6 +140,7 @@ public class MeasurementBatchService { BigDecimal totalVolume = unitVolume.multiply(packageCount).setScale(4, RoundingMode.CEILING); InboundMeasurementItemEntity item = new InboundMeasurementItemEntity(); + item.setInboundDetailId(source.inboundDetailId()); item.setItemNo(source.itemNo()); item.setBoxSpecification(source.boxSpecification()); item.setPackageCount(source.packageCount()); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementReviewService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementReviewService.java index 15b8b5a46..e8e3a9d7e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementReviewService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementReviewService.java @@ -20,7 +20,7 @@ import org.springframework.util.StringUtils; import java.time.LocalDateTime; /** - * 测量审核应用服务。客服确认测量数据后将入库单推进到产品主状态 WAIT_SPLIT; + * 测量审核应用服务。客服确认测量数据后将入库单推进到产品主状态 INBOUNDED; * 计费、库位和其他资料不作为本版本的强制门禁。 */ @Service @@ -80,7 +80,7 @@ public class MeasurementReviewService { repository.insertReview(reviewRecord(tenantId, inboundId, request.measurementId(), result, request.reason(), false, null, false, operatorId, operatorName)); repository.insertHistory(history(tenantId, inboundId, "MEASURE_PENDING", - approved ? "WAIT_SPLIT" : "MEASURE_REJECTED", + approved ? "INBOUNDED" : "MEASURE_REJECTED", approved ? "MEASURE_APPROVE" : "MEASURE_REJECT", request.reason(), operatorId, operatorName)); idempotency.complete(context, inboundId); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java index a643fb2b2..9a53a5342 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java @@ -52,7 +52,7 @@ public class SmartLoadingService { public Plan detail(long planId) { LoadingPlanEntity plan = repository.plan(planId); if (plan == null) throw new BizException(ResultCode.NOT_FOUND, "装柜计划不存在"); - return draftView(plan, requireBooking(plan.getContainerBookingId())); + return view(plan, requireBooking(plan.getContainerBookingId())); } @Transactional diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/UnifiedInboundQueryService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/UnifiedInboundQueryService.java index 014a70ed6..b5cd37f46 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/UnifiedInboundQueryService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/UnifiedInboundQueryService.java @@ -24,11 +24,11 @@ import java.util.Map; import java.util.Set; import java.util.stream.Collectors; -/** Read-only adapter from internal inbound states to the seven product main statuses. */ +/** Read-only adapter from internal inbound states to the product main statuses. */ @Service public class UnifiedInboundQueryService { private static final Set MAIN_STATUSES = Set.of( - "PENDING_REVIEW", "WAIT_MEASURE", "INBOUNDED", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"); + "PENDING_REVIEW", "WAIT_MEASURE", "INBOUNDED", "LOADING", "LOADED", "DELIVERED"); private static final Set REVIEW_STATUSES = Set.of("UNRECORDED", "PENDING", "APPROVED", "REJECTED"); @Resource private InboundOrderRepository inbounds; @@ -120,8 +120,8 @@ public class UnifiedInboundQueryService { if (status == null) return null; return switch (status) { case "WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED" -> "WAIT_MEASURE"; - case "WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING", "WAIT_SPLIT" -> "WAIT_SPLIT"; - case "INBOUNDED" -> "INBOUNDED"; + // WAIT_SPLIT is retained only to normalize records created before status unification. + case "WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING", "WAIT_SPLIT", "INBOUNDED" -> "INBOUNDED"; case "LOADING", "LOADED", "DELIVERED" -> status; default -> null; }; @@ -132,7 +132,7 @@ public class UnifiedInboundQueryService { return switch (mainStatus) { case "PENDING_REVIEW" -> "APPROVE_ORDER"; case "WAIT_MEASURE" -> "MEASURE_PENDING".equals(processStatus) ? "CONFIRM_MEASUREMENT" : null; - case "WAIT_SPLIT" -> !"WAIT_SPLIT".equals(processStatus) ? null + case "INBOUNDED" -> !List.of("INBOUNDED", "WAIT_SPLIT").contains(processStatus) ? null : "CONFIRMED".equals(preloadStatus) ? null : preloadStatus == null ? "SMART_SPLIT" : "CONFIRM_PRELOAD"; case "LOADING" -> "COMPLETE_LOADING"; @@ -146,8 +146,8 @@ public class UnifiedInboundQueryService { if (!StringUtils.hasText(mainStatus)) return List.of(); return switch (mainStatus) { case "WAIT_MEASURE" -> List.of("WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED"); - case "WAIT_SPLIT" -> List.of("WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING", "WAIT_SPLIT"); - case "INBOUNDED", "LOADING", "LOADED", "DELIVERED" -> List.of(mainStatus); + case "INBOUNDED" -> List.of("WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING", "WAIT_SPLIT", "INBOUNDED"); + case "LOADING", "LOADED", "DELIVERED" -> List.of(mainStatus); default -> List.of("__NO_MATCH__"); }; } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/split/mapper/SplitInboundMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/split/mapper/SplitInboundMapper.java index c608e8c06..159ef5853 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/split/mapper/SplitInboundMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/split/mapper/SplitInboundMapper.java @@ -13,7 +13,7 @@ public interface SplitInboundMapper { @Update("UPDATE fl_inbound_order SET process_status='LOADING', updated_by=#{operatorId}, " + "version=version+1 WHERE tenant_id=#{tenantId} AND id=#{inboundId} AND deleted=0 " - + "AND process_status='WAIT_SPLIT' AND version=#{expectedVersion}") + + "AND process_status='INBOUNDED' AND version=#{expectedVersion}") int beginLoading(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId, @Param("operatorId") long operatorId, @Param("expectedVersion") int expectedVersion); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/split/service/SplitPreloadService.java b/firstleg/src/main/java/com/baiyue/firstleg/split/service/SplitPreloadService.java index 4b4b66672..33112be3d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/split/service/SplitPreloadService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/split/service/SplitPreloadService.java @@ -42,7 +42,7 @@ public class SplitPreloadService { SplitActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, SMART_SPLIT, key); if (replay != null) return replay; InboundSnapshot inbound = requireInbound(tenantId, inboundId); - if (!"WAIT_SPLIT".equals(inbound.processStatus())) { + if (!"INBOUNDED".equals(inbound.processStatus())) { throw new BizException(ResultCode.CONFLICT, "仅待分柜订单可以生成预装清单"); } PreloadPlanEntity plan = repository.findPlan(tenantId, inboundId); @@ -67,7 +67,7 @@ public class SplitPreloadService { } PreloadPlanResponse result = response(repository.findPlan(tenantId, inboundId)); return complete(tenantId, inboundId, operatorId, SMART_SPLIT, key, - new SplitActionResponse("WAIT_SPLIT", result)); + new SplitActionResponse("INBOUNDED", result)); } @Transactional @@ -77,11 +77,11 @@ public class SplitPreloadService { SplitActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, START_CONFIRMING, key); if (replay != null) return replay; InboundSnapshot inbound = requireInbound(tenantId, inboundId); - requireStatus(inbound, "WAIT_SPLIT", "仅待分柜订单可以提交预装清单确认"); + requireStatus(inbound, "INBOUNDED", "仅待分柜订单可以提交预装清单确认"); PreloadPlanEntity plan = requirePlan(tenantId, inboundId); if (PreloadStatus.CONFIRMING.equals(plan.getStatus())) { return complete(tenantId, inboundId, operatorId, START_CONFIRMING, key, - new SplitActionResponse("WAIT_SPLIT", response(plan))); + new SplitActionResponse("INBOUNDED", response(plan))); } if (!PreloadStatus.PENDING.equals(plan.getStatus()) || repository.changePlanStatus(tenantId, inboundId, PreloadStatus.PENDING, @@ -89,7 +89,7 @@ public class SplitPreloadService { throw concurrent(); } return complete(tenantId, inboundId, operatorId, START_CONFIRMING, key, - new SplitActionResponse("WAIT_SPLIT", response(repository.findPlan(tenantId, inboundId)))); + new SplitActionResponse("INBOUNDED", response(repository.findPlan(tenantId, inboundId)))); } @Transactional @@ -103,7 +103,7 @@ public class SplitPreloadService { return complete(tenantId, inboundId, operatorId, CONFIRM_PRELOAD, key, new SplitActionResponse("LOADING", response(repository.findPlan(tenantId, inboundId)))); } - requireStatus(inbound, "WAIT_SPLIT", "仅待分柜订单可以确认预装清单"); + requireStatus(inbound, "INBOUNDED", "仅待分柜订单可以确认预装清单"); PreloadPlanEntity plan = requirePlan(tenantId, inboundId); if (PreloadStatus.CONFIRMED.equals(plan.getStatus())) { throw new BizException(ResultCode.CONFLICT, "预装清单已确认但订单状态尚未推进,请刷新重试"); @@ -115,7 +115,7 @@ public class SplitPreloadService { if (repository.beginLoading(tenantId, inboundId, operatorId, inbound.version()) != 1) { throw concurrent(); } - addHistory(tenantId, inboundId, "WAIT_SPLIT", "LOADING", CONFIRM_PRELOAD, + addHistory(tenantId, inboundId, "INBOUNDED", "LOADING", CONFIRM_PRELOAD, operatorId, operatorName); return complete(tenantId, inboundId, operatorId, CONFIRM_PRELOAD, key, new SplitActionResponse("LOADING", response(repository.findPlan(tenantId, inboundId)))); diff --git a/firstleg/src/main/resources/db/phase3-inbounded-status-unification.sql b/firstleg/src/main/resources/db/phase3-inbounded-status-unification.sql new file mode 100644 index 000000000..dc3078e9a --- /dev/null +++ b/firstleg/src/main/resources/db/phase3-inbounded-status-unification.sql @@ -0,0 +1,17 @@ +-- 将已废弃的 WAIT_SPLIT 统一为 INBOUNDED。 +-- 执行前请确认所有服务已升级至本次状态契约;脚本可重复执行。 +USE baiyue_firstleg; + +UPDATE fl_inbound_order +SET process_status = 'INBOUNDED', + updated_at = CURRENT_TIMESTAMP(3), + version = version + 1 +WHERE process_status = 'WAIT_SPLIT'; + +UPDATE fl_inbound_status_history +SET from_status = 'INBOUNDED' +WHERE from_status = 'WAIT_SPLIT'; + +UPDATE fl_inbound_status_history +SET to_status = 'INBOUNDED' +WHERE to_status = 'WAIT_SPLIT'; diff --git a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql index d6c805260..235f68c4d 100644 --- a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql +++ b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql @@ -4,6 +4,28 @@ CREATE DATABASE IF NOT EXISTS baiyue_firstleg DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; USE baiyue_firstleg; +-- 兼容已执行二期初始化的存量库:PDA 拣货需要累计每个托盘分配已拣箱数。 +DROP PROCEDURE IF EXISTS phase3_add_allocation_picked_count; +DELIMITER $$ +CREATE PROCEDURE phase3_add_allocation_picked_count() +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_inbound_pallet_allocation' + ) AND NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_inbound_pallet_allocation' + AND column_name = 'picked_package_count' + ) THEN + ALTER TABLE fl_inbound_pallet_allocation + ADD COLUMN picked_package_count INT UNSIGNED NOT NULL DEFAULT 0 + COMMENT '已拣货箱数' AFTER allocated_package_count; + END IF; +END$$ +DELIMITER ; +CALL phase3_add_allocation_picked_count(); +DROP PROCEDURE phase3_add_allocation_picked_count; + CREATE TABLE IF NOT EXISTS fl_container_booking ( id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID(雪花ID)', booking_no VARCHAR(40) NOT NULL COMMENT '平台唯一货柜预订单号', diff --git a/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml b/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml index 796de5b83..3a816343a 100644 --- a/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml +++ b/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml @@ -54,14 +54,14 @@ paths: /api/platform/v1/inbounds/{inboundId}/fees/calculate: post: summary: 按审核通过测量快照试算 FBA 费用 - description: 精确匹配唯一客户规则和唯一船司价格;测量审核后即使主流程已到 WAIT_SPLIT,也可独立试算且不回退主流程状态;需要 firstleg:fba-pricing:calculate 权限。 + description: 精确匹配唯一客户规则和唯一船司价格;测量审核后即使主流程已到 INBOUNDED,也可独立试算且不回退主流程状态;需要 firstleg:fba-pricing:calculate 权限。 parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/IdempotencyKey'}] requestBody: {content: {application/json: {schema: {$ref: '#/components/schemas/CalculateRequest'}}}} responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}} /api/platform/v1/inbounds/{inboundId}/fees/confirm: post: summary: 确认最新费用快照 - description: 确认费用只更新费用状态;主流程已到 WAIT_SPLIT 时保持不变。 + description: 确认费用只更新费用状态;主流程已到 INBOUNDED 时保持不变。 parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/IdempotencyKey'}] responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}} /api/platform/v1/inbounds/{inboundId}/fees/send: diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java index 1b2365bd7..22af59af5 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java @@ -48,7 +48,7 @@ class FbaPricingContractTest { .contains("/api/platform/v1/inbounds/{inboundId}/fees/send:") .contains("/api/platform/v1/inbounds/{inboundId}/fees/published:") .contains("Idempotency-Key") - .contains("主流程已到 WAIT_SPLIT,也可独立试算且不回退主流程状态") + .contains("主流程已到 INBOUNDED,也可独立试算且不回退主流程状态") .contains("该动作涉及后续仓储流程,本轮二期验收不调用") .contains("不回退 INBOUNDED 状态") .doesNotContain("应收") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java index 2522afe1a..0931f2bad 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java @@ -54,7 +54,7 @@ class InternalInboundOrderReferenceControllerTest { @Test void unifiedQueryRequiresInternalTokenAndForwardsBatchRequest() { UnifiedInboundQuery request = new UnifiedInboundQuery( - 10001L, null, null, "WAIT_SPLIT", null, null, null, + 10001L, null, null, "INBOUNDED", null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, 1, 20); when(unifiedQueryService.page(request)).thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0)); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundStatusUnificationSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundStatusUnificationSqlContractTest.java new file mode 100644 index 000000000..569f601df --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundStatusUnificationSqlContractTest.java @@ -0,0 +1,35 @@ +package com.baiyue.firstleg.repository; + +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class InboundStatusUnificationSqlContractTest { + @Test + void syncsStatusUnificationMigrationToUnifiedDeploymentScript() throws Exception { + String module = normalize(new ClassPathResource("db/phase3-inbounded-status-unification.sql") + .getContentAsString(StandardCharsets.UTF_8)).trim(); + String unified = normalize(Files.readString(unifiedPath(), StandardCharsets.UTF_8)); + + assertThat(unified) + .contains("-- 来源:firstleg/src/main/resources/db/phase3-inbounded-status-unification.sql") + .contains(module); + } + + private static Path unifiedPath() { + Path path = Path.of("deploy/sql/头程数据库统一增量-MySQL8.sql"); + if (Files.exists(path)) { + return path; + } + return Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql").normalize(); + } + + private static String normalize(String value) { + return value.replace("\r\n", "\n").replace("\uFEFF", ""); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java index 58a49ad2c..2e8c0f92b 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java @@ -21,6 +21,7 @@ class SmartLoadingCandidateMapperContractTest { .contains("m2.status='APPROVED'") .contains("i.inbound_detail_id IS NOT NULL") .contains("o.warehouse_id=#{warehouseId}") + .contains("o.process_status='INBOUNDED'") .contains("SELECT SUM(li.package_count)") .contains("li.deleted=0") .contains("ORDER BY o.inbounded_at ASC, o.id ASC, i.id ASC"); @@ -37,6 +38,7 @@ class SmartLoadingCandidateMapperContractTest { .contains("m.status='APPROVED'") .contains("SELECT MAX(m2.id)") .contains("o.warehouse_id=#{warehouseId}") + .contains("o.process_status='INBOUNDED'") .contains("SELECT SUM(li.package_count)") .contains("FOR UPDATE"); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java index 9ddda2d3c..952aa768f 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java @@ -105,7 +105,7 @@ class FbaPricingServiceTest { void calculatesAtWaitSplitWithoutRollingBackProcessStatus() { readyRepository(); InboundPricingEntity inbound = inbound("100.000", "1.0000"); - inbound.setProcessStatus("WAIT_SPLIT"); + inbound.setProcessStatus("INBOUNDED"); CustomerChargeRuleEntity rule = rule("KG", "167.000000", 3, "CEILING"); CarrierFbaRateEntity rate = rate("KG", "2.000000", "0.00"); when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); @@ -115,8 +115,8 @@ class FbaPricingServiceTest { FeeSnapshotResponse response = service.calculate(TENANT, OPERATOR, INBOUND, NOW, "wait-split"); assertThat(response.feeStatus()).isEqualTo("CALCULATED"); - assertThat(response.nextProcessStatus()).isEqualTo("WAIT_SPLIT"); - verify(repository).markInboundCalculated(TENANT, INBOUND, "WAIT_SPLIT", "KG", + assertThat(response.nextProcessStatus()).isEqualTo("INBOUNDED"); + verify(repository).markInboundCalculated(TENANT, INBOUND, "INBOUNDED", "KG", new BigDecimal("167.000")); } @@ -124,7 +124,7 @@ class FbaPricingServiceTest { @DisplayName("待分柜边界确认费用后仍保持主流程状态") void confirmsAtWaitSplitWithoutRollingBackProcessStatus() { InboundPricingEntity inbound = inbound("10.000", "1.0000"); - inbound.setProcessStatus("WAIT_SPLIT"); + inbound.setProcessStatus("INBOUNDED"); inbound.setFeeStatus("CALCULATED"); InboundFeeEntity fee = savedFee("CALCULATED"); when(repository.findLatestFee(TENANT, INBOUND)).thenReturn(fee); @@ -141,7 +141,7 @@ class FbaPricingServiceTest { FeeSnapshotResponse response = service.confirm( TENANT, OPERATOR, INBOUND, "confirm-wait-split"); - assertThat(response.nextProcessStatus()).isEqualTo("WAIT_SPLIT"); + assertThat(response.nextProcessStatus()).isEqualTo("INBOUNDED"); verify(repository).markInboundConfirmed(TENANT, INBOUND); } @@ -183,7 +183,7 @@ class FbaPricingServiceTest { void returnsOriginalSnapshotForRepeatedCalculation() { InboundPricingIdempotencyEntity completed = completed("FEE_CALCULATE", 501L); InboundPricingEntity inbound = inbound("10", "1"); - inbound.setProcessStatus("WAIT_SPLIT"); + inbound.setProcessStatus("INBOUNDED"); when(repository.findIdempotency(TENANT, OPERATOR, "FEE_CALCULATE", "same")).thenReturn(completed); when(repository.findFee(TENANT, 501L)).thenReturn(savedFee("CALCULATED")); when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); @@ -193,7 +193,7 @@ class FbaPricingServiceTest { FeeSnapshotResponse response = service.calculate(TENANT, OPERATOR, INBOUND, NOW, "same"); assertThat(response.feeId()).isEqualTo(501L); - assertThat(response.nextProcessStatus()).isEqualTo("WAIT_SPLIT"); + assertThat(response.nextProcessStatus()).isEqualTo("INBOUNDED"); verify(repository, never()).matchRules(any(), any()); verify(repository, never()).insertFee(any()); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java index b234b81e7..068c70a34 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java @@ -1,7 +1,10 @@ package com.baiyue.firstleg.service; import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.firstleg.client.OrderCargoClient; import com.baiyue.firstleg.dto.InboundMeasurementRequests; +import com.baiyue.firstleg.dto.MeasurementBatchCommand; import com.baiyue.firstleg.dto.InboundMeasurementResponses; import com.baiyue.firstleg.entity.InboundMeasurementEntity; import com.baiyue.firstleg.entity.InboundMeasurementItemEntity; @@ -24,6 +27,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -34,6 +38,7 @@ import static org.mockito.Mockito.when; class InboundMeasurementServiceTest { @MockBean private InboundMeasurementRepository repository; @MockBean private MeasurementBatchService batchService; + @MockBean private OrderCargoClient orderCargoClient; @Resource private InboundMeasurementService service; @Test @@ -165,7 +170,7 @@ class InboundMeasurementServiceTest { @Test void editingAndResubmittingInboundMeasurementDoesNotRollBackMainStatus() { - InboundOrderEntity inbound = inbound("WAIT_SPLIT"); + InboundOrderEntity inbound = inbound("INBOUNDED"); inbound.setMeasureReviewStatus("APPROVED"); InboundMeasurementEntity measurement = new InboundMeasurementEntity(); measurement.setId(30L); @@ -183,13 +188,40 @@ class InboundMeasurementServiceTest { InboundMeasurementResponses.MeasurementSubmitted result = service.submit(1L, 10L, 9L, new InboundMeasurementRequests.SubmitRequest(30L, 2)); - assertThat(result.processStatus()).isEqualTo("WAIT_SPLIT"); + assertThat(result.processStatus()).isEqualTo("INBOUNDED"); assertThat(result.measureReviewStatus()).isEqualTo("APPROVED"); assertThat(measurement.getStatus()).isEqualTo("APPROVED"); verify(repository, never()).updateMeasurement(any()); verify(repository).updateInbound(inbound); } + @Test + void savingDraftValidatesCargoAndPersistsInboundDetailBinding() { + InboundOrderEntity inbound = inbound("WAIT_MEASURE"); + inbound.setOrderId(80L); + InboundMeasurementEntity saved = new InboundMeasurementEntity(); + saved.setId(30L); + saved.setVersion(0); + saved.setStatus("DRAFT"); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(orderCargoClient.detail(eq(""), eq(1L), eq(80L), eq(80001L))).thenReturn( + ApiResponse.success(new OrderCargoClient.CargoBinding(80001L, 80L, 1, "Test cargo", + 2, new BigDecimal("6.500"), new BigDecimal("0.0480")))); + when(batchService.saveBatch(any())).thenReturn(saved); + + InboundMeasurementResponses.DraftSaved result = service.saveDraft(1L, 10L, 9L, + new InboundMeasurementRequests.SaveDraftRequest(null, 0, null, null, null, null, + List.of(new InboundMeasurementRequests.ItemRequest(80001L, "001", "A", 2, + new BigDecimal("40"), new BigDecimal("30"), new BigDecimal("20"), + new BigDecimal("3.250"), null, null, null, null, null, null)))); + + assertThat(result.measurementId()).isEqualTo(30L); + ArgumentCaptor command = ArgumentCaptor.forClass(MeasurementBatchCommand.class); + verify(batchService).saveBatch(command.capture()); + assertThat(command.getValue().items().get(0).inboundDetailId()).isEqualTo(80001L); + verify(orderCargoClient).detail("", 1L, 80L, 80001L); + } + private InboundOrderEntity inbound(String status) { InboundOrderEntity inbound = new InboundOrderEntity(); inbound.setId(10L); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementBatchServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementBatchServiceTest.java index 20e4d530f..6947c8dcc 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementBatchServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementBatchServiceTest.java @@ -79,6 +79,7 @@ class MeasurementBatchServiceTest { ArgumentCaptor> items = ArgumentCaptor.forClass(List.class); verify(repository).replaceItems(org.mockito.ArgumentMatchers.eq(1L), org.mockito.ArgumentMatchers.eq(100L), items.capture()); + assertThat(items.getValue().get(0).getInboundDetailId()).isEqualTo(80001L); assertThat(items.getValue().get(0).getTotalWeightKg()).isEqualByComparingTo("6.500"); } @@ -131,7 +132,7 @@ class MeasurementBatchServiceTest { private MeasurementBatchCommand.Item item(BigDecimal unitWeight) { return new MeasurementBatchCommand.Item( - "001", "A", 2, new BigDecimal("40"), new BigDecimal("30"), + 80001L, "001", "A", 2, new BigDecimal("40"), new BigDecimal("30"), new BigDecimal("20"), unitWeight, 500L, null, new BigDecimal("41"), new BigDecimal("31"), new BigDecimal("21"), "备注"); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java index cd1a3c8c9..3907b012f 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java @@ -76,7 +76,7 @@ class MeasurementReviewServiceTest { ArgumentCaptor.forClass(InboundStatusHistoryEntity.class); verify(fixture.repository).insertHistory(history.capture()); assertThat(history.getValue().getFromStatus()).isEqualTo("MEASURE_PENDING"); - assertThat(history.getValue().getToStatus()).isEqualTo("WAIT_SPLIT"); + assertThat(history.getValue().getToStatus()).isEqualTo("INBOUNDED"); } @Test diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/UnifiedInboundQueryServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/UnifiedInboundQueryServiceTest.java index 7d3d2a564..45990e094 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/UnifiedInboundQueryServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/UnifiedInboundQueryServiceTest.java @@ -14,9 +14,10 @@ class UnifiedInboundQueryServiceTest { assertThat(UnifiedInboundQueryService.mainStatus("MEASURING")).isEqualTo("WAIT_MEASURE"); assertThat(UnifiedInboundQueryService.mainStatus("MEASURE_PENDING")).isEqualTo("WAIT_MEASURE"); assertThat(UnifiedInboundQueryService.mainStatus("MEASURE_REJECTED")).isEqualTo("WAIT_MEASURE"); - assertThat(UnifiedInboundQueryService.mainStatus("WAIT_PRICING")).isEqualTo("WAIT_SPLIT"); - assertThat(UnifiedInboundQueryService.mainStatus("FEE_CONFIRMING")).isEqualTo("WAIT_SPLIT"); - assertThat(UnifiedInboundQueryService.mainStatus("INBOUNDING")).isEqualTo("WAIT_SPLIT"); + assertThat(UnifiedInboundQueryService.mainStatus("WAIT_PRICING")).isEqualTo("INBOUNDED"); + assertThat(UnifiedInboundQueryService.mainStatus("FEE_CONFIRMING")).isEqualTo("INBOUNDED"); + assertThat(UnifiedInboundQueryService.mainStatus("INBOUNDING")).isEqualTo("INBOUNDED"); + assertThat(UnifiedInboundQueryService.mainStatus("WAIT_SPLIT")).isEqualTo("INBOUNDED"); assertThat(UnifiedInboundQueryService.mainStatus("INBOUNDED")).isEqualTo("INBOUNDED"); assertThat(UnifiedInboundQueryService.mainStatus("LOADING")).isEqualTo("LOADING"); assertThat(UnifiedInboundQueryService.mainStatus("LOADED")).isEqualTo("LOADED"); @@ -29,13 +30,14 @@ class UnifiedInboundQueryServiceTest { assertThat(UnifiedInboundQueryService.nextAction("WAIT_MEASURE", "MEASURING", null)).isNull(); assertThat(UnifiedInboundQueryService.nextAction("WAIT_MEASURE", "MEASURE_PENDING", null)) .isEqualTo("CONFIRM_MEASUREMENT"); - assertThat(UnifiedInboundQueryService.nextAction("WAIT_SPLIT", "WAIT_SPLIT", null)) + assertThat(UnifiedInboundQueryService.nextAction("INBOUNDED", "INBOUNDED", null)) .isEqualTo("SMART_SPLIT"); - assertThat(UnifiedInboundQueryService.nextAction("WAIT_SPLIT", "WAIT_SPLIT", "PENDING")) + assertThat(UnifiedInboundQueryService.nextAction("INBOUNDED", "INBOUNDED", "PENDING")) .isEqualTo("CONFIRM_PRELOAD"); - assertThat(UnifiedInboundQueryService.nextAction("WAIT_SPLIT", "INBOUNDING", null)).isNull(); - assertThat(UnifiedInboundQueryService.nextAction("WAIT_SPLIT", "WAIT_SPLIT", "CONFIRMED")).isNull(); - assertThat(UnifiedInboundQueryService.nextAction("INBOUNDED", "INBOUNDED", null)).isNull(); + assertThat(UnifiedInboundQueryService.nextAction("INBOUNDED", "INBOUNDING", null)).isNull(); + assertThat(UnifiedInboundQueryService.nextAction("INBOUNDED", "INBOUNDED", "CONFIRMED")).isNull(); + assertThat(UnifiedInboundQueryService.nextAction("INBOUNDED", "WAIT_SPLIT", null)) + .isEqualTo("SMART_SPLIT"); assertThat(UnifiedInboundQueryService.nextAction("LOADED", "LOADED", null)) .isEqualTo("COMPLETE_DELIVERY"); assertThat(UnifiedInboundQueryService.nextAction("DELIVERED", "DELIVERED", null)).isNull(); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadServiceTest.java index 30b98bb0c..09dd84108 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadServiceTest.java @@ -44,19 +44,19 @@ class SplitPreloadServiceTest { @Test void smartSplitCreatesEditablePendingPlan() { when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString())).thenReturn(true); - when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "WAIT_SPLIT", 3)); + when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "INBOUNDED", 3)); when(repository.findPlan(10, 20)).thenReturn(null); SmartSplitRequest request = new SmartSplitRequest(0, List.of(new PreloadItem("C1", "A", 2, BigDecimal.ONE, null)), "r"); SplitActionResponse result = service.smartSplit(10, 20, 7, "admin", "k2", request); - assertThat(result.mainStatus()).isEqualTo("WAIT_SPLIT"); + assertThat(result.mainStatus()).isEqualTo("INBOUNDED"); verify(repository).createPlan(any(PreloadPlanEntity.class)); } @Test void confirmsPlanAndMovesInboundToLoading() { when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString())).thenReturn(true); - when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "WAIT_SPLIT", 4)); + when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "INBOUNDED", 4)); PreloadPlanEntity plan = new PreloadPlanEntity(); plan.setTenantId(10L); plan.setInboundId(20L); plan.setStatus("CONFIRMING"); plan.setVersion(1); plan.setItemsJson("[]"); diff --git a/order/src/main/java/com/baiyue/order/dto/UnifiedOrderQuery.java b/order/src/main/java/com/baiyue/order/dto/UnifiedOrderQuery.java index fecdc4dd6..cc3d7d8ee 100644 --- a/order/src/main/java/com/baiyue/order/dto/UnifiedOrderQuery.java +++ b/order/src/main/java/com/baiyue/order/dto/UnifiedOrderQuery.java @@ -12,7 +12,7 @@ import java.time.LocalDateTime; public record UnifiedOrderQuery( @Size(max = 64) String keyword, @Size(max = 64) String orderNo, - @Schema(allowableValues = {"PENDING_REVIEW", "WAIT_MEASURE", "INBOUNDED", "WAIT_SPLIT", + @Schema(allowableValues = {"PENDING_REVIEW", "WAIT_MEASURE", "INBOUNDED", "LOADING", "LOADED", "DELIVERED"}) @Size(max = 32) String mainStatus, Long customerId, diff --git a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java index ac48ef282..b4016f935 100644 --- a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java +++ b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java @@ -30,7 +30,7 @@ import java.util.stream.Collectors; @Service public class UnifiedOrderQueryService { private static final Set MAIN_STATUSES = Set.of( - "PENDING_REVIEW", "WAIT_MEASURE", "INBOUNDED", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"); + "PENDING_REVIEW", "WAIT_MEASURE", "INBOUNDED", "LOADING", "LOADED", "DELIVERED"); private static final Set REVIEW_STATUSES = Set.of("UNRECORDED", "PENDING", "APPROVED", "REJECTED"); private static final List ACTIVE_ORDER_STATUSES = List.copyOf(EnumSet.of( OrderStatus.SUBMITTED, OrderStatus.APPROVED, OrderStatus.ACCEPTED, @@ -181,8 +181,9 @@ public class UnifiedOrderQueryService { if (status == null) return null; return switch (status) { case "WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED" -> "WAIT_MEASURE"; - case "WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING", "WAIT_SPLIT" -> "WAIT_SPLIT"; - case "INBOUNDED", "LOADING", "LOADED", "DELIVERED" -> status; + // WAIT_SPLIT is retained only to normalize records created before status unification. + case "WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING", "WAIT_SPLIT", "INBOUNDED" -> "INBOUNDED"; + case "LOADING", "LOADED", "DELIVERED" -> status; default -> null; }; } diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index f3182588a..3bf897154 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -262,7 +262,7 @@ components: required: false schema: type: string - enum: [PENDING_REVIEW, WAIT_MEASURE, INBOUNDED, WAIT_SPLIT, LOADING, LOADED, DELIVERED] + enum: [PENDING_REVIEW, WAIT_MEASURE, INBOUNDED, LOADING, LOADED, DELIVERED] UnifiedInboundCode: {name: inboundCode, in: query, required: false, description: 客户提交后生成的入仓码,待审核订单同样可查询, schema: {type: string, maxLength: 40}} UnifiedInternalOrderStatus: diff --git a/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java b/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java index 59df0cb15..b41eb2d32 100644 --- a/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java @@ -105,15 +105,15 @@ class UnifiedOrderQueryServiceTest { @Test void fulfillmentStatusFilterUsesFirstlegPagingWithoutPostPageFiltering() { - UnifiedOrderQuery query = query("WAIT_SPLIT"); - var summary = summary("WAIT_SPLIT", "CONFIRM_PRELOAD"); + UnifiedOrderQuery query = query("INBOUNDED"); + var summary = summary("INBOUNDED", "CONFIRM_PRELOAD"); when(firstleg.page(eq("internal-token"), any())) .thenReturn(ApiResponse.success(new PageResult<>(List.of(summary), 1, 20, 1, 1))); var result = service.page(null, query); assertThat(result.records()).singleElement().satisfies(row -> { - assertThat(row.mainStatus()).isEqualTo("WAIT_SPLIT"); + assertThat(row.mainStatus()).isEqualTo("INBOUNDED"); assertThat(row.nextAction()).isEqualTo("CONFIRM_PRELOAD"); assertThat(row.inboundId()).isEqualTo(88L); assertThat(row.inboundVersion()).isEqualTo(7); @@ -182,7 +182,7 @@ class UnifiedOrderQueryServiceTest { private FirstlegUnifiedOrderClient.Summary summary(String status, String action) { var inbound = new FirstlegUnifiedOrderClient.Inbound( - 88L, 10001L, 77L, "FL-77", "RK-77", "WAIT_SPLIT", "APPROVED", "APPROVED", 7, + 88L, 10001L, 77L, "FL-77", "RK-77", "INBOUNDED", "APPROVED", "APPROVED", 7, 30001L, "测试客户", 11L, "深圳仓", 22L, "ONT8", "美森", 10, null, null, null, null, null, null, null, null, null, null, null, null, LocalDateTime.of(2026, 8, 6, 10, 0), LocalDateTime.of(2026, 8, 6, 11, 0)); diff --git a/scripts/phase2-test-env-acceptance.ps1 b/scripts/phase2-test-env-acceptance.ps1 index 73f36eb68..647d3c1fb 100644 --- a/scripts/phase2-test-env-acceptance.ps1 +++ b/scripts/phase2-test-env-acceptance.ps1 @@ -408,16 +408,16 @@ try { $approvedInboundResponse = Invoke-Api GET "/api/platform/inbounds/$($ids.inboundId)?tenantId=$TenantId" $platformToken $approvedInbound = Require-Success '审核后回查入库单' $approvedInboundResponse $versions.inbound = [int]$approvedInbound.summary.version - Require-Condition '二期边界停在 WAIT_SPLIT' $approvedInboundResponse ($approvedInbound.summary.processStatus -eq 'WAIT_SPLIT' -and $approvedInbound.summary.measureReviewStatus -eq 'APPROVED') "processStatus=$($approvedInbound.summary.processStatus), reviewStatus=$($approvedInbound.summary.measureReviewStatus)" + Require-Condition '二期边界停在 INBOUNDED' $approvedInboundResponse ($approvedInbound.summary.processStatus -eq 'INBOUNDED' -and $approvedInbound.summary.measureReviewStatus -eq 'APPROVED') "processStatus=$($approvedInbound.summary.processStatus), reviewStatus=$($approvedInbound.summary.measureReviewStatus)" $feeResponse = Invoke-Api POST "/api/platform/v1/inbounds/$($ids.inboundId)/fees/calculate?tenantId=$TenantId" $platformToken @{} @{ 'Idempotency-Key' = (New-Key 'fee-calculate') } $calculatedFee = Require-Success '二期计费接口试算' $feeResponse $ids.feeId = [string]$calculatedFee.feeId - Require-Condition '计费试算保持 WAIT_SPLIT 主流程边界' $feeResponse ($calculatedFee.nextProcessStatus -eq 'WAIT_SPLIT') "nextProcessStatus=$($calculatedFee.nextProcessStatus)" + Require-Condition '计费试算保持 INBOUNDED 主流程边界' $feeResponse ($calculatedFee.nextProcessStatus -eq 'INBOUNDED') "nextProcessStatus=$($calculatedFee.nextProcessStatus)" $feeConfirmResponse = Invoke-Api POST "/api/platform/v1/inbounds/$($ids.inboundId)/fees/confirm?tenantId=$TenantId" $platformToken $null @{ 'Idempotency-Key' = (New-Key 'fee-confirm') } $confirmedFee = Require-Success '确认二期费用快照' $feeConfirmResponse - Require-Condition '费用确认后仍保持 WAIT_SPLIT 主流程边界' $feeConfirmResponse ($confirmedFee.feeStatus -eq 'CONFIRMED' -and $confirmedFee.nextProcessStatus -eq 'WAIT_SPLIT') "feeStatus=$($confirmedFee.feeStatus), nextProcessStatus=$($confirmedFee.nextProcessStatus)" + Require-Condition '费用确认后仍保持 INBOUNDED 主流程边界' $feeConfirmResponse ($confirmedFee.feeStatus -eq 'CONFIRMED' -and $confirmedFee.nextProcessStatus -eq 'INBOUNDED') "feeStatus=$($confirmedFee.feeStatus), nextProcessStatus=$($confirmedFee.nextProcessStatus)" } catch { $fatal = $_.Exception.Message From a09639ff056762943cbbce2dd72d13d75ef2148b Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Fri, 7 Aug 2026 23:43:41 +0800 Subject: [PATCH 8/8] =?UTF-8?q?=E6=9A=82=E5=AD=98=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/PlatformFirstLegOrderController.java | 13 ------------- .../controller/OrderControllerPermissionTest.java | 2 -- scripts/phase2-test-env-acceptance.ps1 | 3 --- 3 files changed, 18 deletions(-) diff --git a/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java b/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java index 1fa1b7c87..7661d39c0 100644 --- a/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java +++ b/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java @@ -170,19 +170,6 @@ public class PlatformFirstLegOrderController { return batchTransit(user, tenantId, request.orderIds(), OrderStatus.APPROVED, null); } - @BusinessAudit(event = "ORDER_ASSIGN_CARRIER", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "指派订单承运商") - @Operation(summary = "指派承运商", description = "可选地为待审核订单选择启用的公共承运商,名称快照由服务端主数据回填;该动作不是审核前置条件。") - @PutMapping("/{id}/carrier") - @PreAuthorize("hasAuthority('platform:order:approve')") - public ApiResponse assignCarrier(@AuthenticationPrincipal AuthenticatedUser user, - @Parameter(description = "目标租户 ID", required = true) @RequestParam long tenantId, - @Parameter(description = "订单 ID", required = true) @PathVariable long id, - @Valid @RequestBody OrderRequests.CarrierRequest request) { - long scopedTenant = OrderScopeGuard.platform(user, tenantId); - service.assignCarrier(scopedTenant, user.userId(), id, request); - return ApiResponse.success(null); - } - @BusinessAudit(event = "ORDER_REJECT", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "Reject order / 驳回订单") @Operation(summary = "Reject / 驳回", description = "Rejects a submitted order with a required reason. / 使用必填原因驳回已提交订单。") @PostMapping("/{id}/reject") diff --git a/order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java b/order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java index 2f9f6cbd2..7686f0daa 100644 --- a/order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java +++ b/order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java @@ -41,8 +41,6 @@ class OrderControllerPermissionTest { AuthenticatedUser.class, long.class, long.class, OrderRequests.SaveRequest.class); assertAuthority(PlatformFirstLegOrderController.class, "submit", "hasAuthority('platform:order:submit')", AuthenticatedUser.class, long.class, long.class, String.class); - assertAuthority(PlatformFirstLegOrderController.class, "assignCarrier", "hasAuthority('platform:order:approve')", - AuthenticatedUser.class, long.class, long.class, OrderRequests.CarrierRequest.class); assertAuthority(PlatformFirstLegOrderController.class, "batchApprove", "hasAuthority('platform:order:approve')", AuthenticatedUser.class, long.class, OrderRequests.BatchReviewRequest.class); assertAuthority(PlatformFirstLegOrderController.class, "batchReject", "hasAuthority('platform:order:reject')", diff --git a/scripts/phase2-test-env-acceptance.ps1 b/scripts/phase2-test-env-acceptance.ps1 index 647d3c1fb..9bf019567 100644 --- a/scripts/phase2-test-env-acceptance.ps1 +++ b/scripts/phase2-test-env-acceptance.ps1 @@ -308,9 +308,6 @@ try { $unifiedPending = Require-Success '统一订单详情查询待审核状态' $unifiedPendingResponse Require-Condition '统一订单主状态为 PENDING_REVIEW' $unifiedPendingResponse ($unifiedPending.summary.mainStatus -eq 'PENDING_REVIEW') "mainStatus=$($unifiedPending.summary.mainStatus)" - $carrierAssignResponse = Invoke-Api PUT "/api/platform/v1/orders/first-leg/$($ids.orderId)/carrier?tenantId=$TenantId" $platformToken @{ carrierId = $ids.carrierId } - $null = Require-Success '平台指派承运商' $carrierAssignResponse - $approveOrderResponse = Invoke-Api POST "/api/platform/v1/orders/first-leg/$($ids.orderId)/approve?tenantId=$TenantId" $platformToken $null = Require-Success '平台审核通过订单' $approveOrderResponse