diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index c00516ef8..3bb150c6c 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -9371,17 +9371,17 @@ SET NAMES utf8mb4; CREATE TABLE IF NOT EXISTS fl_truck_appointment ( id BIGINT UNSIGNED NOT NULL COMMENT '拖车预约ID', appointment_no VARCHAR(40) NOT NULL COMMENT '平台拖车预约编号', outbound_order_id BIGINT UNSIGNED NULL COMMENT '关联头程订单逻辑ID;不跨库建外键', ocean_booking_id BIGINT UNSIGNED NULL COMMENT '关联海运订舱逻辑ID', container_booking_id BIGINT UNSIGNED NULL COMMENT '关联柜次逻辑ID;拖车完成供装柜门禁校验', loading_plan_id BIGINT UNSIGNED NULL COMMENT '关联装柜计划逻辑ID', - warehouse_id BIGINT UNSIGNED NULL COMMENT '预约到仓的国内收货仓逻辑ID', customer_id BIGINT UNSIGNED NULL COMMENT '客户ID和数据隔离键', customer_name VARCHAR(128) NULL COMMENT '客户名称快照', + warehouse_id BIGINT UNSIGNED NULL COMMENT '预约到仓的国内收货仓逻辑ID', carrier_id BIGINT UNSIGNED NULL COMMENT '拖车服务商逻辑ID', carrier_name VARCHAR(128) NULL COMMENT '拖车服务商名称快照', bill_of_lading_no VARCHAR(128) NULL COMMENT '派车提单号快照;关联RELEASED订舱时由订舱事实源冻结', fleet_contact VARCHAR(64) NULL COMMENT '车队联系人', fleet_phone VARCHAR(32) NULL COMMENT '车队联系电话', driver_name VARCHAR(64) NULL COMMENT '司机姓名', driver_phone VARCHAR(32) NULL COMMENT '司机联系电话', driver_id_card VARCHAR(32) NULL COMMENT '司机身份证号;平台内部敏感信息', vehicle_no VARCHAR(32) NULL COMMENT '车辆车牌号', store_name VARCHAR(128) NULL COMMENT '到仓装柜地点名称快照', store_contact VARCHAR(64) NULL COMMENT '到仓地点联系人快照', store_phone VARCHAR(32) NULL COMMENT '到仓地点联系电话快照', store_address VARCHAR(255) NULL COMMENT '到仓装柜地点地址快照', store_city VARCHAR(64) NULL COMMENT '到仓地点城市快照', store_zipcode VARCHAR(16) NULL COMMENT '到仓地点邮编快照', package_count INT UNSIGNED NULL COMMENT '预约货物包装件数快照', gross_weight_kg DECIMAL(18,3) NULL COMMENT '预约货物毛重,单位千克', - volume_cbm DECIMAL(18,4) NULL COMMENT '预约货物体积,单位立方米', pallet_count INT UNSIGNED NULL COMMENT '预约托盘数量', po_no VARCHAR(255) NULL COMMENT '关联采购单号或客户PO号', planned_loading_at DATETIME(3) NULL COMMENT '预约到仓装柜时间', - actual_arrived_warehouse_at DATETIME(3) NULL COMMENT '车辆实际到达收货仓时间', actual_loading_at DATETIME(3) NULL COMMENT '实际开始装柜时间', actual_departed_at DATETIME(3) NULL COMMENT '车辆实际离仓时间;DEPARTED后供LOADED门禁使用', transit_minutes INT UNSIGNED NULL COMMENT '从到仓到离仓的运输作业耗时,单位分钟', payable_amount DECIMAL(18,2) NULL COMMENT '应付拖车服务商金额;币种由currency指定', receivable_amount DECIMAL(18,2) NULL COMMENT '向客户收取的拖车金额;币种由currency指定', - currency VARCHAR(8) NOT NULL DEFAULT 'RMB' COMMENT '金额币种;首期默认RMB', status VARCHAR(32) NOT NULL DEFAULT 'DRAFT' COMMENT '拖车预约状态:DRAFT草稿、PENDING_DISPATCH待派车、DISPATCHED已派车、PICKUP_IN_PROGRESS提货中、ARRIVED_WAREHOUSE已到仓、LOADING装柜中、DEPARTED已离仓、COMPLETED已完成、RESCHEDULED已改约、EXCEPTION异常、CANCELLED已取消', revision INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '拖车预约业务修订号;每次状态或改约递增', + volume_cbm DECIMAL(18,4) NULL COMMENT '预约货物体积,单位立方米', pallet_count INT UNSIGNED NULL COMMENT '预约托盘数量', po_no VARCHAR(255) NULL COMMENT '关联采购单号', planned_loading_at DATETIME(3) NULL COMMENT '预约到仓装柜时间', + actual_arrived_warehouse_at DATETIME(3) NULL COMMENT '车辆实际到达收货仓时间', actual_loading_at DATETIME(3) NULL COMMENT '实际开始装柜时间', actual_departed_at DATETIME(3) NULL COMMENT '车辆实际离仓时间;DEPARTED后供LOADED门禁使用', transit_minutes INT UNSIGNED NULL COMMENT '从到仓到离仓的运输作业耗时,单位分钟', payable_amount DECIMAL(18,2) NULL COMMENT '应付拖车服务商金额;币种由currency指定', receivable_amount DECIMAL(18,2) NULL COMMENT '拖车业务应收金额;币种由currency指定', + currency VARCHAR(8) NOT NULL DEFAULT 'RMB' COMMENT '金额币种;首期默认RMB', status VARCHAR(32) NOT NULL DEFAULT 'DRAFT' COMMENT '拖车预约状态:DRAFT草稿、PENDING_DISPATCH待派车、DISPATCHED已派车、PICKUP_IN_PROGRESS提货中、ARRIVED_WAREHOUSE已到仓、LOADING装柜中、DEPARTED已离仓、COMPLETED已完成、RESCHEDULED已改约、EXCEPTION异常、CANCELLED已取消', active_container_booking_id BIGINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN status NOT IN ('COMPLETED','CANCELLED','EXCEPTION') THEN container_booking_id ELSE NULL END) STORED COMMENT '非终态预约的柜次唯一键', revision INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '拖车预约业务修订号;每次状态或改约递增', version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', remark VARCHAR(1000) NULL COMMENT '平台内部备注或异常说明', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '拖车预约创建时间', - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '拖车预约最后更新时间', PRIMARY KEY(id), UNIQUE KEY uk_truck_appointment_no(appointment_no), - KEY idx_truck_appointment_status(status,updated_at), KEY idx_truck_appointment_container(container_booking_id), KEY idx_truck_appointment_customer(customer_id), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '拖车预约最后更新时间', PRIMARY KEY(id), UNIQUE KEY uk_truck_appointment_no(appointment_no), UNIQUE KEY uk_truck_active_container(active_container_booking_id), + KEY idx_truck_appointment_status(status,updated_at), KEY idx_truck_appointment_container(container_booking_id), CONSTRAINT chk_truck_appointment_status CHECK(status IN ('DRAFT','PENDING_DISPATCH','DISPATCHED','PICKUP_IN_PROGRESS','ARRIVED_WAREHOUSE','LOADING','DEPARTED','COMPLETED','RESCHEDULED','EXCEPTION','CANCELLED')) ) ENGINE=InnoDB COMMENT='三期拖车预约派车聚合'; CREATE TABLE IF NOT EXISTS fl_truck_appointment_history ( @@ -9391,7 +9391,7 @@ CREATE TABLE IF NOT EXISTS fl_truck_appointment_history ( ) ENGINE=InnoDB COMMENT='拖车状态与改约历史'; CREATE TABLE IF NOT EXISTS fl_truck_appointment_attachment ( id BIGINT UNSIGNED NOT NULL COMMENT '拖车附件关系ID', appointment_id BIGINT UNSIGNED NOT NULL COMMENT '所属拖车预约ID', file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务文件逻辑ID', attachment_type VARCHAR(32) NOT NULL COMMENT '附件类型:DISPATCH_ORDER派车单、DRIVER_DOCUMENT司机证件、VEHICLE_DOCUMENT车辆证件、OTHER其他;订舱确认与提单归订舱聚合', - file_name VARCHAR(255) NULL COMMENT '上传时文件名快照', file_hash CHAR(64) NULL COMMENT '文件内容SHA-256摘要', visibility VARCHAR(24) NOT NULL DEFAULT 'INTERNAL' COMMENT '可见范围:INTERNAL仅平台内部、CLIENT客户可见', uploaded_by BIGINT UNSIGNED NOT NULL COMMENT '上传操作人ID', + file_name VARCHAR(255) NULL COMMENT '上传时文件名快照', file_hash CHAR(64) NULL COMMENT '文件内容SHA-256摘要', visibility VARCHAR(24) NOT NULL DEFAULT 'PLATFORM' COMMENT '可见范围:PLATFORM仅平台内部', uploaded_by BIGINT UNSIGNED NOT NULL COMMENT '上传操作人ID', uploaded_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '附件上传时间', PRIMARY KEY(id), UNIQUE KEY uk_truck_attachment(appointment_id,file_id), KEY idx_truck_attachment(appointment_id), CONSTRAINT chk_truck_attachment_type CHECK(attachment_type IN ('DISPATCH_ORDER','DRIVER_DOCUMENT','VEHICLE_DOCUMENT','OTHER')) ) ENGINE=InnoDB COMMENT='拖车附件文件引用'; CREATE TABLE IF NOT EXISTS fl_truck_appointment_operation ( @@ -9437,8 +9437,22 @@ CALL phase3_truck_add_column( 'actual_departed_at', 'DATETIME(3) NULL AFTER actual_loading_at' ); +CALL phase3_truck_add_column( + 'active_container_booking_id', + 'BIGINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN status NOT IN (''COMPLETED'',''CANCELLED'',''EXCEPTION'') THEN container_booking_id ELSE NULL END) STORED COMMENT ''非终态预约的柜次唯一键'' AFTER status' +); DROP PROCEDURE IF EXISTS phase3_truck_add_column; +SET @phase3_truck_active_index = IF( + EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() + AND table_name='fl_truck_appointment' AND index_name='uk_truck_active_container' AND non_unique=0), + 'SELECT 1', + 'ALTER TABLE fl_truck_appointment ADD UNIQUE KEY uk_truck_active_container(active_container_booking_id)' +); +PREPARE phase3_truck_active_stmt FROM @phase3_truck_active_index; +EXECUTE phase3_truck_active_stmt; +DEALLOCATE PREPARE phase3_truck_active_stmt; + USE baiyue_iam; DROP PROCEDURE IF EXISTS assert_truck_appointment_permission_ids_available; DELIMITER $$ @@ -13868,6 +13882,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_third_party_call_log', 'tenant_ CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'actual_arrived_warehouse_at', '车辆实际到达收货仓时间'); CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'actual_departed_at', '车辆实际离仓时间;DEPARTED后供LOADED门禁使用'); CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'actual_loading_at', '实际开始装柜时间'); +CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'active_container_booking_id', '非终态预约的柜次唯一键'); CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'appointment_no', '平台拖车预约编号'); CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'bill_of_lading_no', '派车提单号快照;关联RELEASED订舱时由订舱事实源冻结'); CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'carrier_id', '拖车服务商逻辑ID'); @@ -13875,8 +13890,6 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'carrier_na CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'container_booking_id', '关联柜次逻辑ID;拖车完成供装柜门禁校验'); CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'created_at', '拖车预约创建时间'); CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'currency', '金额币种;首期默认RMB'); -CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'customer_id', '客户ID和数据隔离键'); -CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'customer_name', '客户名称快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'driver_id_card', '司机身份证号;平台内部敏感信息'); CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'driver_name', '司机姓名'); CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'driver_phone', '司机联系电话'); @@ -13891,8 +13904,8 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'package_co CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'pallet_count', '预约托盘数量'); CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'payable_amount', '应付拖车服务商金额;币种由currency指定'); CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'planned_loading_at', '预约到仓装柜时间'); -CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'po_no', '关联采购单号或客户PO号'); -CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'receivable_amount', '向客户收取的拖车金额;币种由currency指定'); +CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'po_no', '关联采购单号'); +CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'receivable_amount', '拖车业务应收金额;币种由currency指定'); CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'remark', '平台内部备注或异常说明'); CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'revision', '拖车预约业务修订号;每次状态或改约递增'); CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'status', '拖车预约状态:DRAFT草稿、PENDING_DISPATCH待派车、DISPATCHED已派车、PICKUP_IN_PROGRESS提货中、ARRIVED_WAREHOUSE已到仓、LOADING装柜中、DEPARTED已离仓、COMPLETED已完成、RESCHEDULED已改约、EXCEPTION异常、CANCELLED已取消'); @@ -13916,7 +13929,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment_attachment', CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment_attachment', 'id', '拖车附件关系ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment_attachment', 'uploaded_at', '附件上传时间'); CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment_attachment', 'uploaded_by', '上传操作人ID'); -CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment_attachment', 'visibility', '可见范围:INTERNAL仅平台内部、CLIENT客户可见'); +CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment_attachment', 'visibility', '可见范围:PLATFORM仅平台内部'); CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment_history', 'action', '触发状态或改约的业务动作'); CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment_history', 'appointment_id', '所属拖车预约ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment_history', 'created_at', '历史记录创建时间'); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/TruckAppointmentDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/TruckAppointmentDtos.java index fe1524f40..432654b08 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/TruckAppointmentDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/TruckAppointmentDtos.java @@ -1,8 +1,8 @@ package com.baiyue.firstleg.dto; import jakarta.validation.constraints.*; import java.math.BigDecimal; import java.time.LocalDateTime; public final class TruckAppointmentDtos { private TruckAppointmentDtos(){} - public record Query(@Size(max=64) String keyword, com.baiyue.firstleg.domain.TruckAppointmentStatus status, @Positive Long customerId, @Positive Long containerBookingId, @Min(1) long page, @Min(1) @Max(200) long size){ public Query{if(page==0)page=1;if(size==0)size=20;} } - public record SaveRequest(@Positive Long outboundOrderId,@NotNull @Positive Long oceanBookingId,@NotNull @Positive Long containerBookingId,@Positive Long loadingPlanId,@Positive Long warehouseId,@NotNull @Positive Long customerId,@Size(max=128) String customerName,@NotNull @Positive Long carrierId,@Size(max=128) String carrierName,@Size(max=128) String billOfLadingNo,@Size(max=64) String fleetContact,@Size(max=32) String fleetPhone,@Size(max=64) String driverName,@Size(max=32) String driverPhone,@Size(max=32) String driverIdCard,@Size(max=32) String vehicleNo,@Size(max=128) String storeName,@Size(max=64) String storeContact,@Size(max=32) String storePhone,@Size(max=255) String storeAddress,@Size(max=64) String storeCity,@Size(max=16) String storeZipcode,@PositiveOrZero Integer packageCount,@DecimalMin("0") BigDecimal grossWeightKg,@DecimalMin("0") BigDecimal volumeCbm,@PositiveOrZero Integer palletCount,@Size(max=255) String poNo,@NotNull LocalDateTime plannedLoadingAt,@PositiveOrZero Integer transitMinutes,@DecimalMin("0") BigDecimal payableAmount,@DecimalMin("0") BigDecimal receivableAmount,@Size(max=8) String currency,@Size(max=1000) String remark,@NotNull @PositiveOrZero Integer expectedVersion){} + public record Query(@Size(max=64) String keyword, com.baiyue.firstleg.domain.TruckAppointmentStatus status, @Positive Long containerBookingId, @Min(1) long page, @Min(1) @Max(200) long size){ public Query{if(page==0)page=1;if(size==0)size=20;} } + public record SaveRequest(@Positive Long outboundOrderId,@NotNull @Positive Long oceanBookingId,@NotNull @Positive Long containerBookingId,@Positive Long loadingPlanId,@Positive Long warehouseId,@NotNull @Positive Long carrierId,@Size(max=128) String carrierName,@Size(max=128) String billOfLadingNo,@Size(max=64) String fleetContact,@Size(max=32) String fleetPhone,@Size(max=64) String driverName,@Size(max=32) String driverPhone,@Size(max=32) String driverIdCard,@Size(max=32) String vehicleNo,@Size(max=128) String storeName,@Size(max=64) String storeContact,@Size(max=32) String storePhone,@Size(max=255) String storeAddress,@Size(max=64) String storeCity,@Size(max=16) String storeZipcode,@PositiveOrZero Integer packageCount,@DecimalMin("0") BigDecimal grossWeightKg,@DecimalMin("0") BigDecimal volumeCbm,@PositiveOrZero Integer palletCount,@Size(max=255) String poNo,@NotNull LocalDateTime plannedLoadingAt,@PositiveOrZero Integer transitMinutes,@DecimalMin("0") BigDecimal payableAmount,@DecimalMin("0") BigDecimal receivableAmount,@Size(max=8) String currency,@Size(max=1000) String remark,@NotNull @PositiveOrZero Integer expectedVersion){} public record ActionRequest(@NotNull @PositiveOrZero Integer expectedVersion,@Size(max=500) String reason){} public record AttachmentRequest(@NotNull @Positive Long fileId,@NotBlank @Pattern(regexp="DISPATCH_ORDER|DRIVER_DOCUMENT|VEHICLE_DOCUMENT|OTHER") String attachmentType,@Size(max=255) String fileName,@Size(max=128) String fileHash){ } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/TruckAppointmentEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/TruckAppointmentEntity.java index 6f845f881..10cf72b9a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/TruckAppointmentEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/TruckAppointmentEntity.java @@ -19,8 +19,6 @@ public class TruckAppointmentEntity { private Long containerBookingId; private Long loadingPlanId; private Long warehouseId; - private Long customerId; - private String customerName; private Long carrierId; private String carrierName; private String billOfLadingNo; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/TruckAppointmentMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/TruckAppointmentMapper.java index c333ddc18..0f842494b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/TruckAppointmentMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/TruckAppointmentMapper.java @@ -2,10 +2,10 @@ package com.baiyue.firstleg.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.firstleg.dto.TruckAppointmentDtos.Query; import com.baiyue.firstleg.entity.TruckAppointmentEntity; import org.apache.ibatis.annotations.*; @Mapper public interface TruckAppointmentMapper extends BaseMapper{ @Select("SELECT * FROM fl_truck_appointment WHERE container_booking_id=#{containerBookingId} AND status NOT IN ('COMPLETED','CANCELLED','EXCEPTION') ORDER BY id FOR UPDATE") java.util.List activeForUpdate(@Param("containerBookingId") long containerBookingId); - @Select("") Page page(Page p,@Param("q") Query q); + @Select("") Page page(Page p,@Param("q") Query q); @Update("UPDATE fl_truck_appointment SET status=#{toStatus}, actual_arrived_warehouse_at=CASE WHEN #{toStatus}='ARRIVED_WAREHOUSE' THEN CURRENT_TIMESTAMP(3) ELSE actual_arrived_warehouse_at END, actual_loading_at=CASE WHEN #{toStatus}='LOADING' THEN CURRENT_TIMESTAMP(3) ELSE actual_loading_at END, actual_departed_at=CASE WHEN #{toStatus}='DEPARTED' THEN CURRENT_TIMESTAMP(3) ELSE actual_departed_at END, revision=revision+1, version=version+1, updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND status=#{fromStatus} AND version=#{version}") int transition(@Param("id") long id,@Param("fromStatus") String from,@Param("toStatus") String to,@Param("version") int version); @Update("UPDATE fl_truck_appointment SET status='DISPATCHED',bill_of_lading_no=#{billOfLadingNo},revision=revision+1,version=version+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND status=#{fromStatus} AND version=#{version}") int dispatch(@Param("id") long id,@Param("fromStatus") String fromStatus,@Param("billOfLadingNo") String billOfLadingNo,@Param("version") int version); @Update("UPDATE fl_truck_appointment SET status='COMPLETED',actual_loading_at=COALESCE(actual_loading_at,CURRENT_TIMESTAMP(3)),revision=revision+1,version=version+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND status=#{fromStatus} AND version=#{version}") int completeLoadingFromPda(@Param("id") long id,@Param("fromStatus") String fromStatus,@Param("version") int version); @Update("UPDATE fl_truck_appointment SET planned_loading_at=#{at}, revision=revision+1, version=version+1, status='RESCHEDULED', remark=#{reason}, updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND version=#{version}") int reschedule(@Param("id") long id,@Param("at") java.time.LocalDateTime at,@Param("reason") String reason,@Param("version") int version); - @Update("UPDATE fl_truck_appointment SET outbound_order_id=#{e.outboundOrderId},ocean_booking_id=#{e.oceanBookingId},container_booking_id=#{e.containerBookingId},loading_plan_id=#{e.loadingPlanId},warehouse_id=#{e.warehouseId},customer_id=#{e.customerId},customer_name=#{e.customerName},carrier_id=#{e.carrierId},carrier_name=#{e.carrierName},bill_of_lading_no=#{e.billOfLadingNo},fleet_contact=#{e.fleetContact},fleet_phone=#{e.fleetPhone},driver_name=#{e.driverName},driver_phone=#{e.driverPhone},driver_id_card=#{e.driverIdCard},vehicle_no=#{e.vehicleNo},store_name=#{e.storeName},store_contact=#{e.storeContact},store_phone=#{e.storePhone},store_address=#{e.storeAddress},store_city=#{e.storeCity},store_zipcode=#{e.storeZipcode},package_count=#{e.packageCount},gross_weight_kg=#{e.grossWeightKg},volume_cbm=#{e.volumeCbm},pallet_count=#{e.palletCount},po_no=#{e.poNo},planned_loading_at=#{e.plannedLoadingAt},transit_minutes=#{e.transitMinutes},payable_amount=#{e.payableAmount},receivable_amount=#{e.receivableAmount},currency=#{e.currency},remark=#{e.remark},version=version+1,revision=revision+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{e.id} AND version=#{version}") int updateEdit(@Param("e") TruckAppointmentEntity e,@Param("version") int version); + @Update("UPDATE fl_truck_appointment SET outbound_order_id=#{e.outboundOrderId},ocean_booking_id=#{e.oceanBookingId},container_booking_id=#{e.containerBookingId},loading_plan_id=#{e.loadingPlanId},warehouse_id=#{e.warehouseId},carrier_id=#{e.carrierId},carrier_name=#{e.carrierName},bill_of_lading_no=#{e.billOfLadingNo},fleet_contact=#{e.fleetContact},fleet_phone=#{e.fleetPhone},driver_name=#{e.driverName},driver_phone=#{e.driverPhone},driver_id_card=#{e.driverIdCard},vehicle_no=#{e.vehicleNo},store_name=#{e.storeName},store_contact=#{e.storeContact},store_phone=#{e.storePhone},store_address=#{e.storeAddress},store_city=#{e.storeCity},store_zipcode=#{e.storeZipcode},package_count=#{e.packageCount},gross_weight_kg=#{e.grossWeightKg},volume_cbm=#{e.volumeCbm},pallet_count=#{e.palletCount},po_no=#{e.poNo},planned_loading_at=#{e.plannedLoadingAt},transit_minutes=#{e.transitMinutes},payable_amount=#{e.payableAmount},receivable_amount=#{e.receivableAmount},currency=#{e.currency},remark=#{e.remark},version=version+1,revision=revision+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{e.id} AND version=#{version}") int updateEdit(@Param("e") TruckAppointmentEntity e,@Param("version") int version); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentService.java index 1f94a0257..480b7b35b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentService.java @@ -21,6 +21,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Propagation; +import org.springframework.dao.DuplicateKeyException; import org.springframework.util.StringUtils; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; @@ -42,30 +43,27 @@ public class TruckAppointmentService { @Resource ContainerRepository containers; @Resource TruckAppointmentFileClient files; @Resource WarehouseSupplierSnapshotService suppliers; - @Resource IamCustomerSnapshotService customers; @Resource FirstlegDocumentNumberGenerator documentNumbers; @Resource ObjectMapper objectMapper; @Value("${baiyue.security.internal-service-token}") String internalToken; public PageResult page(Query q){return repo.page(q);} public TruckAppointmentEntity detail(long id){return enrich(required(id));} public java.util.List history(long id){required(id);return repo.histories(id);} public java.util.List attachments(long id){required(id);return repo.attachments(id);} - @Transactional public TruckAppointmentAttachmentEntity upload(long id,String type,MultipartFile file,long uid){String attachmentType=attachmentType(type);TruckAppointmentEntity appointment=required(id);if(appointment.getCustomerId()==null||appointment.getCustomerId()<=0)throw bad("预约缺少客户归属,无法上传附件");try{var response=files.upload(internalToken,uid,appointment.getCustomerId(),"TRUCK_APPOINTMENT_ATTACHMENT",Long.toString(id),file);if(response==null||!response.successful()||response.data()==null)throw bad("附件上传失败");var m=response.data();TruckAppointmentAttachmentEntity a=new TruckAppointmentAttachmentEntity();a.setId(IdWorker.getId());a.setAppointmentId(id);a.setFileId(m.fileId());a.setAttachmentType(attachmentType);a.setFileName(m.fileName());a.setFileHash(m.sha256());a.setVisibility("PLATFORM");a.setUploadedBy(uid);a.setUploadedAt(LocalDateTime.now());repo.attachment(a);return a;}catch(FeignException ex){throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"文件服务暂不可用");}} - @Transactional public TruckAppointmentAttachmentEntity attach(long id,AttachmentRequest r,long uid){String attachmentType=attachmentType(r.attachmentType());TruckAppointmentEntity appointment=required(id); if(appointment.getCustomerId()==null||appointment.getCustomerId()<=0)throw bad("预约缺少客户归属,无法引用附件"); TruckAppointmentFileClient.Metadata metadata;try{var response=files.metadata(internalToken,appointment.getCustomerId(),"TRUCK_APPOINTMENT_ATTACHMENT",Long.toString(id),r.fileId());if(response==null||!response.successful()||response.data()==null)throw bad("附件不存在或归属不匹配");metadata=response.data();}catch(FeignException ex){throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"文件服务暂不可用");} TruckAppointmentAttachmentEntity a=new TruckAppointmentAttachmentEntity();a.setId(IdWorker.getId());a.setAppointmentId(id);a.setFileId(metadata.fileId());a.setAttachmentType(attachmentType);a.setFileName(metadata.fileName());a.setFileHash(metadata.sha256());a.setVisibility("PLATFORM");a.setUploadedBy(uid);a.setUploadedAt(LocalDateTime.now());repo.attachment(a);return a;} + @Transactional public TruckAppointmentAttachmentEntity upload(long id,String type,MultipartFile file,long uid){String attachmentType=attachmentType(type);required(id);try{var response=files.upload(internalToken,uid,0L,"TRUCK_APPOINTMENT_ATTACHMENT",Long.toString(id),file);if(response==null||!response.successful()||response.data()==null)throw bad("附件上传失败");var m=response.data();TruckAppointmentAttachmentEntity a=new TruckAppointmentAttachmentEntity();a.setId(IdWorker.getId());a.setAppointmentId(id);a.setFileId(m.fileId());a.setAttachmentType(attachmentType);a.setFileName(m.fileName());a.setFileHash(m.sha256());a.setVisibility("PLATFORM");a.setUploadedBy(uid);a.setUploadedAt(LocalDateTime.now());repo.attachment(a);return a;}catch(FeignException ex){throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"文件服务暂不可用");}} + @Transactional public TruckAppointmentAttachmentEntity attach(long id,AttachmentRequest r,long uid){String attachmentType=attachmentType(r.attachmentType());required(id);TruckAppointmentFileClient.Metadata metadata;try{var response=files.metadata(internalToken,0L,"TRUCK_APPOINTMENT_ATTACHMENT",Long.toString(id),r.fileId());if(response==null||!response.successful()||response.data()==null)throw bad("附件不存在或归属不匹配");metadata=response.data();}catch(FeignException ex){throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"文件服务暂不可用");} TruckAppointmentAttachmentEntity a=new TruckAppointmentAttachmentEntity();a.setId(IdWorker.getId());a.setAppointmentId(id);a.setFileId(metadata.fileId());a.setAttachmentType(attachmentType);a.setFileName(metadata.fileName());a.setFileHash(metadata.sha256());a.setVisibility("PLATFORM");a.setUploadedBy(uid);a.setUploadedAt(LocalDateTime.now());repo.attachment(a);return a;} @Transactional public void deleteAttachment(long id,long attachmentId){required(id);if(repo.deleteAttachment(id,attachmentId)!=1)throw new BizException(ResultCode.NOT_FOUND,"附件引用不存在");} - @Transactional public TruckAppointmentEntity create(long uid,String name,SaveRequest r){if(r.expectedVersion()==null||r.expectedVersion()!=0)throw bad("新增 expectedVersion 必须为0");validateRequired(r); var customer=customers.active(r.customerId(),r.customerName()); AssociationContext links=validateAssociations(r,customer.id()); var carrier=suppliers.trucking(r.carrierId(),r.carrierName()); TruckAppointmentEntity e=new TruckAppointmentEntity(); e.setId(IdWorker.getId()); e.setAppointmentNo(documentNumbers.next("TA")); e.setStatus(TruckAppointmentStatus.DRAFT); e.setRevision(0); e.setVersion(0); e.setCreatedAt(LocalDateTime.now()); e.setUpdatedAt(e.getCreatedAt()); copy(e,r,links,customer.tenantName(),carrier.name()); repo.insert(e); record(e,null,e.getStatus().name(),"CREATE",null,uid,name); return enrich(e);} - @Transactional public TruckAppointmentEntity edit(long id,long uid,String name,SaveRequest r){validateRequired(r);TruckAppointmentEntity e=required(id); checkVersion(e,r.expectedVersion()); if(!(e.getStatus()==TruckAppointmentStatus.DRAFT||e.getStatus()==TruckAppointmentStatus.PENDING_DISPATCH||e.getStatus()==TruckAppointmentStatus.RESCHEDULED))throw conflict("当前状态不可编辑"); var customer=customers.active(r.customerId(),r.customerName()); AssociationContext links=validateAssociations(r,customer.id()); var carrier=suppliers.trucking(r.carrierId(),r.carrierName()); copy(e,r,links,customer.tenantName(),carrier.name()); if(repo.updateEdit(e,r.expectedVersion())!=1)throw conflict("版本已变化,请刷新后重试"); record(e,e.getStatus().name(),e.getStatus().name(),"EDIT",null,uid,name); return required(id);} + @Transactional public TruckAppointmentEntity create(long uid,String name,SaveRequest r){if(r.expectedVersion()==null||r.expectedVersion()!=0)throw bad("新增 expectedVersion 必须为0");validateRequired(r);AssociationContext links=validateAssociations(r);if(!repo.activeForUpdate(r.containerBookingId()).isEmpty())throw conflict("当前真实柜已有有效拖车预约");var carrier=suppliers.trucking(r.carrierId(),r.carrierName());TruckAppointmentEntity e=new TruckAppointmentEntity();e.setId(IdWorker.getId());e.setAppointmentNo(documentNumbers.next("TA"));e.setStatus(TruckAppointmentStatus.DRAFT);e.setRevision(0);e.setVersion(0);e.setCreatedAt(LocalDateTime.now());e.setUpdatedAt(e.getCreatedAt());copy(e,r,links,carrier.name());try{repo.insert(e);}catch(DuplicateKeyException exception){if(exception.getMessage()!=null&&exception.getMessage().contains("uk_truck_active_container"))throw conflict("当前真实柜已有有效拖车预约");throw exception;}record(e,null,e.getStatus().name(),"CREATE",null,uid,name);return enrich(e);} + @Transactional public TruckAppointmentEntity edit(long id,long uid,String name,SaveRequest r){validateRequired(r);TruckAppointmentEntity e=required(id);checkVersion(e,r.expectedVersion());if(!(e.getStatus()==TruckAppointmentStatus.DRAFT||e.getStatus()==TruckAppointmentStatus.PENDING_DISPATCH||e.getStatus()==TruckAppointmentStatus.RESCHEDULED))throw conflict("当前状态不可编辑");AssociationContext links=validateAssociations(r);var carrier=suppliers.trucking(r.carrierId(),r.carrierName());copy(e,r,links,carrier.name());if(repo.updateEdit(e,r.expectedVersion())!=1)throw conflict("版本已变化,请刷新后重试");record(e,e.getStatus().name(),e.getStatus().name(),"EDIT",null,uid,name);return required(id);} @Transactional public TruckAppointmentEntity action(long id,String action,ActionRequest r,String idem,String clientHash,long uid,String name){String requestHash=hash(action,id,r);verifyClientHash(clientHash,requestHash);TruckAppointmentEntity replay=claimOrReplay(id,action,idem,requestHash);if(replay!=null)return replay;TruckAppointmentEntity e=required(id); checkVersion(e,r.expectedVersion()); if("RESCHEDULE".equals(action))throw conflict("改约请使用专用接口"); TruckAppointmentStatus from=e.getStatus(),to=next(from,action); String dispatchBill="DISPATCH".equals(action)?requireDispatchReady(e):null; int changed="DISPATCH".equals(action)?repo.dispatch(id,from.name(),dispatchBill,e.getVersion()):repo.transition(id,from.name(),to.name(),e.getVersion()); if(changed!=1)throw conflict("状态或版本已变化,请刷新后重试"); TruckAppointmentEntity after=required(id);record(after,from.name(),to.name(),action,r.reason(),uid,name);return completeOperation(id,action,idem,after);} @Transactional public TruckAppointmentEntity reschedule(long id,LocalDateTime at,ActionRequest r,String idem,String clientHash,long uid,String name){String action="RESCHEDULE";String requestHash=hash(action,id,new ReschedulePayload(at,r));verifyClientHash(clientHash,requestHash);TruckAppointmentEntity replay=claimOrReplay(id,action,idem,requestHash);if(replay!=null)return replay;TruckAppointmentEntity e=required(id);checkVersion(e,r.expectedVersion()); if(e.getStatus()==TruckAppointmentStatus.COMPLETED||e.getStatus()==TruckAppointmentStatus.CANCELLED)throw conflict("当前状态不可改约"); String from=e.getStatus().name(); if(repo.reschedule(id,at,r.reason(),e.getVersion())!=1)throw conflict("状态或版本已变化,请刷新后重试");TruckAppointmentEntity after=required(id);record(after,from,"RESCHEDULED",action,r.reason(),uid,name);return completeOperation(id,action,idem,after);} @Transactional(propagation=Propagation.REQUIRES_NEW) public void completeLoadingFromPda(long containerBookingId,long taskId,long uid,String name){java.util.List active=repo.activeForUpdate(containerBookingId);if(active.isEmpty())return;if(active.size()!=1)throw conflict("同一柜次存在多条有效拖车预约,跳过自动完成");TruckAppointmentEntity appointment=active.get(0);String from=appointment.getStatus().name();if(repo.completeLoadingFromPda(appointment.getId(),from,appointment.getVersion())!=1)throw conflict("拖车预约已被并发修改,跳过自动完成");TruckAppointmentEntity after=required(appointment.getId());record(after,from,TruckAppointmentStatus.COMPLETED.name(),"PDA_COMPLETE_LOADING","PDA task "+taskId+" 确认装柜完成",uid,name);} - private AssociationContext validateAssociations(SaveRequest r,long customerId){ + private AssociationContext validateAssociations(SaveRequest r){ OceanBookingEntity booking=oceanBookings.find(r.oceanBookingId()); if(booking==null)throw new BizException(ResultCode.NOT_FOUND,"关联订舱不存在"); if(!"RELEASED".equals(booking.getStatus()))throw conflict("拖车预约只能选择已放舱订舱"); - if(booking.getCustomerId()!=null&&!Objects.equals(customerId,booking.getCustomerId()))throw bad("customerId与订舱客户不一致"); Long warehouseId=mergeWarehouse(r.warehouseId(),booking.getWarehouseId(),"订舱"); String billOfLadingNo=releasedBookingBill(booking,trim(r.billOfLadingNo())); ContainerBookingEntity container=containers.find(r.containerBookingId()); if(container==null||!"ACTIVE".equals(container.getRecordStatus()))throw new BizException(ResultCode.NOT_FOUND,"关联有效柜次不存在"); - if(container.getCustomerId()!=null&&!Objects.equals(customerId,container.getCustomerId()))throw bad("customerId与柜次客户不一致"); warehouseId=mergeWarehouse(warehouseId,container.getWarehouseId(),"柜次"); if(!Objects.equals(container.getOceanBookingId(),booking.getId()))throw bad("柜次未关联至所选订舱"); long containerId=container.getId(); @@ -76,12 +74,12 @@ public class TruckAppointmentService { if(r.loadingPlanId()!=null){LoadingPlanEntity plan=loadingPlans.planByBooking(container.getId());if(plan==null||!Objects.equals(r.loadingPlanId(),plan.getId()))throw bad("loadingPlanId与柜次当前装柜计划不一致");} return new AssociationContext(booking,container,warehouseId,billOfLadingNo); } - private static void validateRequired(SaveRequest r){if(r.oceanBookingId()==null||r.oceanBookingId()<=0)throw bad("oceanBookingId不能为空");if(r.containerBookingId()==null||r.containerBookingId()<=0)throw bad("containerBookingId不能为空");if(r.customerId()==null||r.customerId()<=0)throw bad("customerId不能为空");if(r.carrierId()==null||r.carrierId()<=0)throw bad("carrierId不能为空");if(r.plannedLoadingAt()==null)throw bad("plannedLoadingAt不能为空");if(r.expectedVersion()==null||r.expectedVersion()<0)throw bad("expectedVersion不合法");} + private static void validateRequired(SaveRequest r){if(r.oceanBookingId()==null||r.oceanBookingId()<=0)throw bad("oceanBookingId不能为空");if(r.containerBookingId()==null||r.containerBookingId()<=0)throw bad("containerBookingId不能为空");if(r.carrierId()==null||r.carrierId()<=0)throw bad("carrierId不能为空");if(r.plannedLoadingAt()==null)throw bad("plannedLoadingAt不能为空");if(r.expectedVersion()==null||r.expectedVersion()<0)throw bad("expectedVersion不合法");} private static Long mergeWarehouse(Long requested,Long linked,String source){if(requested!=null&&linked!=null&&!Objects.equals(requested,linked))throw bad("warehouseId与"+source+"不一致");return requested!=null?requested:linked;} private TruckAppointmentStatus next(TruckAppointmentStatus s,String a){return switch(a){case"SUBMIT"->s==TruckAppointmentStatus.DRAFT?TruckAppointmentStatus.PENDING_DISPATCH:badStatus();case"DISPATCH"->s==TruckAppointmentStatus.PENDING_DISPATCH||s==TruckAppointmentStatus.RESCHEDULED?TruckAppointmentStatus.DISPATCHED:badStatus();case"PICKUP"->s==TruckAppointmentStatus.DISPATCHED?TruckAppointmentStatus.PICKUP_IN_PROGRESS:badStatus();case"ARRIVE"->s==TruckAppointmentStatus.PICKUP_IN_PROGRESS?TruckAppointmentStatus.ARRIVED_WAREHOUSE:badStatus();case"LOAD"->s==TruckAppointmentStatus.ARRIVED_WAREHOUSE?TruckAppointmentStatus.LOADING:badStatus();case"DEPART"->s==TruckAppointmentStatus.LOADING?TruckAppointmentStatus.DEPARTED:badStatus();case"COMPLETE"->s==TruckAppointmentStatus.DEPARTED?TruckAppointmentStatus.COMPLETED:badStatus();case"EXCEPTION"->s!=TruckAppointmentStatus.COMPLETED&&s!=TruckAppointmentStatus.CANCELLED?TruckAppointmentStatus.EXCEPTION:badStatus();case"CANCEL"->s!=TruckAppointmentStatus.COMPLETED?TruckAppointmentStatus.CANCELLED:badStatus();default->badStatus();};} private TruckAppointmentStatus badStatus(){throw conflict("不允许的状态动作");} private TruckAppointmentEntity required(long id){TruckAppointmentEntity e=repo.find(id);if(e==null)throw new BizException(ResultCode.NOT_FOUND,"拖车预约不存在");return e;} private void checkVersion(TruckAppointmentEntity e,int v){if(e.getVersion()!=v)throw conflict("版本已变化,请刷新后重试");} private static BizException conflict(String m){return new BizException(ResultCode.CONFLICT,m);} private static BizException bad(String m){return new BizException(ResultCode.BAD_REQUEST,m);} private TruckAppointmentEntity enrich(TruckAppointmentEntity value){if(value.getOceanBookingId()!=null){OceanBookingEntity booking=oceanBookings.find(value.getOceanBookingId());if(booking!=null){value.setOceanBookingNo(booking.getBookingNo());value.setWarehouseName(booking.getWarehouseName());value.setShippingCompanyId(booking.getShippingCompanyId());value.setShippingCompanyName(booking.getShippingCompanyName());value.setBookingAgentId(booking.getBookingAgentId());value.setBookingAgentName(booking.getBookingAgentName());}}if(value.getContainerBookingId()!=null){ContainerBookingEntity container=containers.find(value.getContainerBookingId());if(container!=null){value.setCabinetNo(container.getCabinetNo());value.setContainerNo(container.getContainerNo());if(!StringUtils.hasText(value.getWarehouseName()))value.setWarehouseName(container.getWarehouseName());}}value.setAttachmentCount((long)repo.attachments(value.getId()).size());return value;} - private void copy(TruckAppointmentEntity e,SaveRequest r,AssociationContext links,String customerName,String carrierName){e.setOutboundOrderId(r.outboundOrderId());e.setOceanBookingId(r.oceanBookingId());e.setContainerBookingId(r.containerBookingId());e.setLoadingPlanId(r.loadingPlanId());e.setWarehouseId(links.warehouseId());e.setCustomerId(r.customerId());e.setCustomerName(customerName);e.setCarrierId(r.carrierId());e.setCarrierName(carrierName);e.setBillOfLadingNo(links.billOfLadingNo());e.setFleetContact(r.fleetContact());e.setFleetPhone(r.fleetPhone());e.setDriverName(r.driverName());e.setDriverPhone(r.driverPhone());e.setDriverIdCard(r.driverIdCard());e.setVehicleNo(r.vehicleNo());e.setStoreName(r.storeName());e.setStoreContact(r.storeContact());e.setStorePhone(r.storePhone());e.setStoreAddress(r.storeAddress());e.setStoreCity(r.storeCity());e.setStoreZipcode(r.storeZipcode());e.setPackageCount(r.packageCount());e.setGrossWeightKg(r.grossWeightKg());e.setVolumeCbm(r.volumeCbm());e.setPalletCount(r.palletCount());e.setPoNo(r.poNo());e.setPlannedLoadingAt(r.plannedLoadingAt());e.setTransitMinutes(r.transitMinutes());e.setPayableAmount(r.payableAmount());e.setReceivableAmount(r.receivableAmount());e.setCurrency(r.currency()==null?"RMB":r.currency());e.setRemark(r.remark());} + private void copy(TruckAppointmentEntity e,SaveRequest r,AssociationContext links,String carrierName){e.setOutboundOrderId(r.outboundOrderId());e.setOceanBookingId(r.oceanBookingId());e.setContainerBookingId(r.containerBookingId());e.setLoadingPlanId(r.loadingPlanId());e.setWarehouseId(links.warehouseId());e.setCarrierId(r.carrierId());e.setCarrierName(carrierName);e.setBillOfLadingNo(links.billOfLadingNo());e.setFleetContact(r.fleetContact());e.setFleetPhone(r.fleetPhone());e.setDriverName(r.driverName());e.setDriverPhone(r.driverPhone());e.setDriverIdCard(r.driverIdCard());e.setVehicleNo(r.vehicleNo());e.setStoreName(r.storeName());e.setStoreContact(r.storeContact());e.setStorePhone(r.storePhone());e.setStoreAddress(r.storeAddress());e.setStoreCity(r.storeCity());e.setStoreZipcode(r.storeZipcode());e.setPackageCount(r.packageCount());e.setGrossWeightKg(r.grossWeightKg());e.setVolumeCbm(r.volumeCbm());e.setPalletCount(r.palletCount());e.setPoNo(r.poNo());e.setPlannedLoadingAt(r.plannedLoadingAt());e.setTransitMinutes(r.transitMinutes());e.setPayableAmount(r.payableAmount());e.setReceivableAmount(r.receivableAmount());e.setCurrency(r.currency()==null?"RMB":r.currency());e.setRemark(r.remark());} private TruckAppointmentEntity claimOrReplay(long id,String action,String key,String hash){validateKey(key);String normalized=key.trim();if(repo.claimOperation(IdWorker.getId(),id,action,normalized,hash))return null;TruckAppointmentOperationEntity existing=repo.operation(normalized);if(existing==null||!Objects.equals(existing.getAppointmentId(),id)||!action.equals(existing.getAction())||!hash.equals(existing.getRequestHash()))throw conflict("Idempotency-Key已用于不同请求");if(!StringUtils.hasText(existing.getResponseSnapshot()))throw conflict("相同请求正在处理中,请稍后重试");try{return objectMapper.readValue(existing.getResponseSnapshot(),TruckAppointmentEntity.class);}catch(JsonProcessingException ex){throw new IllegalStateException("无法读取拖车预约幂等结果",ex);}} private TruckAppointmentEntity completeOperation(long id,String action,String key,TruckAppointmentEntity result){try{String normalized=key.trim();if(repo.completeOperation(id,action,normalized,objectMapper.writeValueAsString(result))!=1)throw conflict("拖车预约幂等请求完成状态冲突");return result;}catch(JsonProcessingException ex){throw new IllegalStateException("无法保存拖车预约幂等结果",ex);}} private String hash(String action,long id,Object request){try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest((action+'|'+id+'|'+objectMapper.writeValueAsString(request)).getBytes(StandardCharsets.UTF_8)));}catch(Exception ex){throw new IllegalStateException("无法计算拖车预约请求摘要",ex);}} diff --git a/firstleg/src/main/resources/db/phase3-truck-appointment-schema.sql b/firstleg/src/main/resources/db/phase3-truck-appointment-schema.sql index 1464ca080..f42423188 100644 --- a/firstleg/src/main/resources/db/phase3-truck-appointment-schema.sql +++ b/firstleg/src/main/resources/db/phase3-truck-appointment-schema.sql @@ -7,8 +7,6 @@ CREATE TABLE IF NOT EXISTS fl_truck_appointment ( container_booking_id BIGINT UNSIGNED NULL COMMENT '关联柜次逻辑ID;拖车完成供装柜门禁校验', loading_plan_id BIGINT UNSIGNED NULL COMMENT '关联装柜计划逻辑ID', warehouse_id BIGINT UNSIGNED NULL COMMENT '预约到仓的国内收货仓逻辑ID', - customer_id BIGINT UNSIGNED NULL COMMENT '客户ID和数据隔离键', - customer_name VARCHAR(128) NULL COMMENT '客户名称快照', carrier_id BIGINT UNSIGNED NULL COMMENT '拖车服务商逻辑ID', carrier_name VARCHAR(128) NULL COMMENT '拖车服务商名称快照', bill_of_lading_no VARCHAR(128) NULL COMMENT '派车提单号快照;关联RELEASED订舱时由订舱事实源冻结', @@ -28,23 +26,24 @@ CREATE TABLE IF NOT EXISTS fl_truck_appointment ( gross_weight_kg DECIMAL(18,3) NULL COMMENT '预约货物毛重,单位千克', volume_cbm DECIMAL(18,4) NULL COMMENT '预约货物体积,单位立方米', pallet_count INT UNSIGNED NULL COMMENT '预约托盘数量', - po_no VARCHAR(255) NULL COMMENT '关联采购单号或客户PO号', + po_no VARCHAR(255) NULL COMMENT '关联采购单号', planned_loading_at DATETIME(3) NULL COMMENT '预约到仓装柜时间', actual_arrived_warehouse_at DATETIME(3) NULL COMMENT '车辆实际到达收货仓时间', actual_loading_at DATETIME(3) NULL COMMENT '实际开始装柜时间', actual_departed_at DATETIME(3) NULL COMMENT '车辆实际离仓时间;DEPARTED后供LOADED门禁使用', transit_minutes INT UNSIGNED NULL COMMENT '从到仓到离仓的运输作业耗时,单位分钟', payable_amount DECIMAL(18,2) NULL COMMENT '应付拖车服务商金额;币种由currency指定', - receivable_amount DECIMAL(18,2) NULL COMMENT '向客户收取的拖车金额;币种由currency指定', + receivable_amount DECIMAL(18,2) NULL COMMENT '拖车业务应收金额;币种由currency指定', currency VARCHAR(8) NOT NULL DEFAULT 'RMB' COMMENT '金额币种;首期默认RMB', status VARCHAR(32) NOT NULL DEFAULT 'DRAFT' COMMENT '拖车预约状态:DRAFT草稿、PENDING_DISPATCH待派车、DISPATCHED已派车、PICKUP_IN_PROGRESS提货中、ARRIVED_WAREHOUSE已到仓、LOADING装柜中、DEPARTED已离仓、COMPLETED已完成、RESCHEDULED已改约、EXCEPTION异常、CANCELLED已取消', + active_container_booking_id BIGINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN status NOT IN ('COMPLETED','CANCELLED','EXCEPTION') THEN container_booking_id ELSE NULL END) STORED COMMENT '非终态预约的柜次唯一键', revision INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '拖车预约业务修订号;每次状态或改约递增', version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', remark VARCHAR(1000) NULL COMMENT '平台内部备注或异常说明', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '拖车预约创建时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '拖车预约最后更新时间', - PRIMARY KEY(id), UNIQUE KEY uk_truck_appointment_no(appointment_no), - KEY idx_truck_appointment_status(status,updated_at), KEY idx_truck_appointment_container(container_booking_id), KEY idx_truck_appointment_customer(customer_id), + PRIMARY KEY(id), UNIQUE KEY uk_truck_appointment_no(appointment_no), UNIQUE KEY uk_truck_active_container(active_container_booking_id), + KEY idx_truck_appointment_status(status,updated_at), KEY idx_truck_appointment_container(container_booking_id), CONSTRAINT chk_truck_appointment_status CHECK(status IN ('DRAFT','PENDING_DISPATCH','DISPATCHED','PICKUP_IN_PROGRESS','ARRIVED_WAREHOUSE','LOADING','DEPARTED','COMPLETED','RESCHEDULED','EXCEPTION','CANCELLED')) ) ENGINE=InnoDB COMMENT='三期拖车预约派车聚合'; CREATE TABLE IF NOT EXISTS fl_truck_appointment_history ( @@ -54,7 +53,7 @@ CREATE TABLE IF NOT EXISTS fl_truck_appointment_history ( ) ENGINE=InnoDB COMMENT='拖车状态与改约历史'; CREATE TABLE IF NOT EXISTS fl_truck_appointment_attachment ( id BIGINT UNSIGNED NOT NULL COMMENT '拖车附件关系ID', appointment_id BIGINT UNSIGNED NOT NULL COMMENT '所属拖车预约ID', file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务文件逻辑ID', attachment_type VARCHAR(32) NOT NULL COMMENT '附件类型:DISPATCH_ORDER派车单、DRIVER_DOCUMENT司机证件、VEHICLE_DOCUMENT车辆证件、OTHER其他;订舱确认与提单归订舱聚合', - file_name VARCHAR(255) NULL COMMENT '上传时文件名快照', file_hash CHAR(64) NULL COMMENT '文件内容SHA-256摘要', visibility VARCHAR(24) NOT NULL DEFAULT 'INTERNAL' COMMENT '可见范围:INTERNAL仅平台内部、CLIENT客户可见', uploaded_by BIGINT UNSIGNED NOT NULL COMMENT '上传操作人ID', + file_name VARCHAR(255) NULL COMMENT '上传时文件名快照', file_hash CHAR(64) NULL COMMENT '文件内容SHA-256摘要', visibility VARCHAR(24) NOT NULL DEFAULT 'PLATFORM' COMMENT '可见范围:PLATFORM仅平台内部', uploaded_by BIGINT UNSIGNED NOT NULL COMMENT '上传操作人ID', uploaded_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '附件上传时间', PRIMARY KEY(id), UNIQUE KEY uk_truck_attachment(appointment_id,file_id), KEY idx_truck_attachment(appointment_id), CONSTRAINT chk_truck_attachment_type CHECK(attachment_type IN ('DISPATCH_ORDER','DRIVER_DOCUMENT','VEHICLE_DOCUMENT','OTHER')) ) ENGINE=InnoDB COMMENT='拖车附件文件引用'; CREATE TABLE IF NOT EXISTS fl_truck_appointment_operation ( @@ -100,8 +99,22 @@ CALL phase3_truck_add_column( 'actual_departed_at', 'DATETIME(3) NULL COMMENT ''车辆实际离仓时间;DEPARTED后供LOADED门禁使用'' AFTER actual_loading_at' ); +CALL phase3_truck_add_column( + 'active_container_booking_id', + 'BIGINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN status NOT IN (''COMPLETED'',''CANCELLED'',''EXCEPTION'') THEN container_booking_id ELSE NULL END) STORED COMMENT ''非终态预约的柜次唯一键'' AFTER status' +); DROP PROCEDURE IF EXISTS phase3_truck_add_column; +SET @phase3_truck_active_index = IF( + EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() + AND table_name='fl_truck_appointment' AND index_name='uk_truck_active_container' AND non_unique=0), + 'SELECT 1', + 'ALTER TABLE fl_truck_appointment ADD UNIQUE KEY uk_truck_active_container(active_container_booking_id)' +); +PREPARE phase3_truck_active_stmt FROM @phase3_truck_active_index; +EXECUTE phase3_truck_active_stmt; +DEALLOCATE PREPARE phase3_truck_active_stmt; + USE baiyue_iam; DROP PROCEDURE IF EXISTS assert_truck_appointment_permission_ids_available; DELIMITER $$ diff --git a/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml b/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml index 82d206090..7833a4d09 100644 --- a/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml +++ b/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml @@ -7,8 +7,6 @@ paths: /api/platform/v1/truck-appointments: get: summary: Query truck appointments - parameters: - - {name: customerId, in: query, description: 'Filter by customer', schema: {type: string, pattern: '^[1-9][0-9]*$'}} responses: '200': description: Paged truck appointments with stable booking/container snapshots and attachment count @@ -93,8 +91,8 @@ paths: responses: '200': {description: 'Attachment references', content: {application/json: {schema: {type: array, items: {$ref: '#/components/schemas/Attachment'}}}}} post: - summary: Reference a file already owned by the appointment customer and business scope - description: 'Requires platform:firstleg:truck-appointment:edit. The file must belong to customerId + TRUCK_APPOINTMENT_ATTACHMENT + appointmentId.' + summary: Reference a platform-owned file already scoped to this appointment + description: 'Requires platform:firstleg:truck-appointment:edit. The file must belong to platform scope + TRUCK_APPOINTMENT_ATTACHMENT + appointmentId.' requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/AttachmentRequest'}}}} responses: '200': {description: 'Created attachment reference', content: {application/json: {schema: {$ref: '#/components/schemas/Attachment'}}}} @@ -178,15 +176,13 @@ components: reason: {type: string, maxLength: 500} SaveRequest: type: object - required: [oceanBookingId, containerBookingId, customerId, carrierId, plannedLoadingAt, expectedVersion] + required: [oceanBookingId, containerBookingId, carrierId, plannedLoadingAt, expectedVersion] properties: outboundOrderId: {type: integer, format: int64, minimum: 1, nullable: true} oceanBookingId: {type: integer, format: int64, minimum: 1, description: Required RELEASED booking; must own an active BOOKING_CONFIRMATION or BILL_OF_LADING file.} containerBookingId: {type: integer, format: int64, minimum: 1, description: Required ACTIVE container already allocated to oceanBookingId and carrying a valid unique ISO 6346 container number.} loadingPlanId: {type: integer, format: int64, minimum: 1, nullable: true, description: Optional loading-plan association. It may be supplied only with containerBookingId and must be that container's current plan.} warehouseId: {type: integer, format: int64, minimum: 1, nullable: true, description: Optional warehouse scope. Linked booking/container warehouse facts must agree; when omitted the linked fact is frozen if available.} - customerId: {type: integer, format: int64, minimum: 1, description: Required ACTIVE IAM customer used for isolation and attachment ownership.} - customerName: {type: string, maxLength: 128, nullable: true, description: Optional consistency hint only; persisted value is resolved from IAM customer master data.} carrierId: {type: integer, format: int64, minimum: 1, description: Required ACTIVE Warehouse supplier ID validated as TRUCKING.} carrierName: {type: string, maxLength: 128, nullable: true, description: Optional consistency hint only; the persisted snapshot comes from Warehouse supplier master data.} billOfLadingNo: {type: string, maxLength: 128, nullable: true, description: Optional consistency hint; the persisted snapshot is always frozen from the selected RELEASED booking.} @@ -194,7 +190,7 @@ components: expectedVersion: {type: integer, minimum: 0, description: Must be 0 on create; current version on edit.} DetailStableReferences: type: object - required: [appointmentNo, customerId, customerName, carrierId, carrierName, attachmentCount] + required: [appointmentNo, carrierId, carrierName, attachmentCount] description: Stable IDs plus server-resolved saved resource names returned by appointment detail. Ocean booking and container associations are always present. properties: appointmentNo: {type: string, pattern: '^TA-[0-9]{6}-[0-9]{4}$', example: TA-260818-0001, description: 派车单号;按北京时间创建日逐日编号} @@ -206,8 +202,6 @@ components: loadingPlanId: {type: integer, format: int64, nullable: true} warehouseId: {type: integer, format: int64, nullable: true} warehouseName: {type: string, nullable: true} - customerId: {type: integer, format: int64} - customerName: {type: string, description: Frozen from ACTIVE IAM customer master data; request input cannot override it} shippingCompanyId: {type: integer, format: int64, nullable: true} shippingCompanyName: {type: string, nullable: true} bookingAgentId: {type: integer, format: int64, nullable: true} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/TruckAppointmentControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/TruckAppointmentControllerContractTest.java index 07bd27822..350256a7b 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/TruckAppointmentControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/TruckAppointmentControllerContractTest.java @@ -19,7 +19,7 @@ import static org.assertj.core.api.Assertions.assertThat; class TruckAppointmentControllerContractTest { @Test void exposesDedicatedEndpointAndPermissions() {String p=PlatformTruckAppointmentController.class.getAnnotation(org.springframework.web.bind.annotation.RequestMapping.class).value()[0];assertThat(p).isEqualTo("/api/platform/v1/truck-appointments"); for(String n:new String[]{"page","detail","history","attachments","attach","upload","deleteAttachment","create","edit","action","reschedule"}) assertThat(java.util.Arrays.stream(PlatformTruckAppointmentController.class.getDeclaredMethods()).anyMatch(m->m.getName().equals(n))).isTrue();} - @Test void schemaHasAggregateTablesStatesAndActualEventTimes() throws Exception {String s=new ClassPathResource("db/phase3-truck-appointment-schema.sql").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("fl_truck_appointment","fl_truck_appointment_history","fl_truck_appointment_attachment","fl_truck_appointment_operation","bill_of_lading_no VARCHAR(128)","chk_truck_attachment_type","DISPATCH_ORDER","PENDING_DISPATCH","PICKUP_IN_PROGRESS","ARRIVED_WAREHOUSE","RESCHEDULED","actual_arrived_warehouse_at","actual_loading_at","actual_departed_at","CALL phase3_truck_add_column");} + @Test void schemaHasPlatformAggregateWithoutCustomerColumns() throws Exception {String s=new ClassPathResource("db/phase3-truck-appointment-schema.sql").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("fl_truck_appointment","fl_truck_appointment_history","fl_truck_appointment_attachment","fl_truck_appointment_operation","bill_of_lading_no VARCHAR(128)","chk_truck_attachment_type","DISPATCH_ORDER","PENDING_DISPATCH","PICKUP_IN_PROGRESS","ARRIVED_WAREHOUSE","RESCHEDULED","actual_arrived_warehouse_at","actual_loading_at","actual_departed_at","CALL phase3_truck_add_column","visibility VARCHAR(24) NOT NULL DEFAULT 'PLATFORM'","active_container_booking_id BIGINT UNSIGNED GENERATED ALWAYS AS","UNIQUE KEY uk_truck_active_container(active_container_booking_id)").doesNotContain("customer_id", "customer_name", "idx_truck_appointment_customer");} @Test void existingAppointmentTableReceivesAllLateColumns() throws Exception { String moduleSql=new ClassPathResource("db/phase3-truck-appointment-schema.sql").getContentAsString(StandardCharsets.UTF_8); Path direct=Path.of("deploy","sql","头程数据库统一增量-MySQL8.sql"); @@ -30,7 +30,7 @@ class TruckAppointmentControllerContractTest { } } @Test void openApiDocumentsLifecycleIdempotencyAndActualTimes() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("/api/platform/v1/truck-appointments/{id}/{action}:","/api/platform/v1/truck-appointments/{id}/reschedule:","name: Idempotency-Key","required: true","actual event timestamps");} - @Test void openApiRequiresReleasedBookingAndAllocatedRealContainer() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("required: [oceanBookingId, containerBookingId, customerId, carrierId, plannedLoadingAt, expectedVersion]","RELEASED ocean booking","ACTIVE real-numbered container","OCEAN_BOOKING_DOCUMENT","valid unique ISO 6346 container number","schema: {$ref: '#/components/schemas/DetailEnvelope'}","required: [code, message, data, timestamp]","allOf: [{$ref: '#/components/schemas/DetailStableReferences'}]","nullable: true");} + @Test void openApiRequiresReleasedBookingAndAllocatedRealContainerWithoutCustomerScope() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("required: [oceanBookingId, containerBookingId, carrierId, plannedLoadingAt, expectedVersion]","RELEASED ocean booking","ACTIVE real-numbered container","OCEAN_BOOKING_DOCUMENT","valid unique ISO 6346 container number","schema: {$ref: '#/components/schemas/DetailEnvelope'}","required: [code, message, data, timestamp]","allOf: [{$ref: '#/components/schemas/DetailStableReferences'}]","nullable: true").doesNotContain("customerId", "customerName", "Filter by customer");} @Test void openApiKeepsOnlyNonBookingTruckAttachments() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("AttachmentRequest:","required: [fileId, attachmentType]","required: [attachmentType, file]","TRUCK_APPOINTMENT_ATTACHMENT","Deletes the appointment reference only","DetailStableReferences:","oceanBookingNo:","shippingCompanyName:","bookingAgentName:","cabinetNo:","billOfLadingNo:").doesNotContain("enum: [BILL_OF_LADING, DISPATCH_ORDER");} @Test void methodsHavePlatformPermissionsAndAuthenticatedScope() { Map expected=Map.ofEntries( diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/TruckAppointmentMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/TruckAppointmentMapperContractTest.java index 55f66cc9b..6cbe4853a 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/TruckAppointmentMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/TruckAppointmentMapperContractTest.java @@ -13,8 +13,9 @@ class TruckAppointmentMapperContractTest { @Test void mapsToPhase3TableAndUsesOptimisticGuards() throws Exception { assertThat(TruckAppointmentEntity.class.getAnnotation(TableName.class).value()).isEqualTo("fl_truck_appointment"); String page = TruckAppointmentMapper.class.getMethod("page", com.baomidou.mybatisplus.extension.plugins.pagination.Page.class, com.baiyue.firstleg.dto.TruckAppointmentDtos.Query.class).getAnnotation(Select.class).value()[0]; - assertThat(page).contains("fl_truck_appointment", "appointment_no", "vehicle_no", "status=#{q.status}", "customer_id=#{q.customerId}", "container_booking_id=#{q.containerBookingId}", + assertThat(page).contains("fl_truck_appointment", "appointment_no", "vehicle_no", "status=#{q.status}", "container_booking_id=#{q.containerBookingId}", "fl_ocean_booking", "fl_container_booking", "record_status='ACTIVE'", "cabinet_no", "container_no", "fl_truck_appointment_attachment", "attachment_count"); + assertThat(page).doesNotContain("customer_id", "customerId", "customer_name"); String transition = TruckAppointmentMapper.class.getMethod("transition", long.class, String.class, String.class, int.class).getAnnotation(Update.class).value()[0]; assertThat(transition).contains("status=#{toStatus}", "status=#{fromStatus}", "version=#{version}", "revision=revision+1", "version=version+1"); String dispatch = TruckAppointmentMapper.class.getMethod("dispatch", long.class, String.class, String.class, int.class).getAnnotation(Update.class).value()[0]; @@ -29,8 +30,9 @@ class TruckAppointmentMapperContractTest { assertThat(reschedule).contains("status='RESCHEDULED'", "version=#{version}", "revision=revision+1"); String edit = TruckAppointmentMapper.class.getMethod("updateEdit", TruckAppointmentEntity.class, int.class).getAnnotation(Update.class).value()[0]; assertThat(edit).contains("ocean_booking_id=#{e.oceanBookingId}", "container_booking_id=#{e.containerBookingId}", - "loading_plan_id=#{e.loadingPlanId}", "customer_id=#{e.customerId}", "carrier_id=#{e.carrierId}", + "loading_plan_id=#{e.loadingPlanId}", "carrier_id=#{e.carrierId}", "planned_loading_at=#{e.plannedLoadingAt}", "bill_of_lading_no=#{e.billOfLadingNo}", "WHERE id=#{e.id} AND version=#{version}", "version=version+1", "revision=revision+1"); + assertThat(edit).doesNotContain("customer_id", "customer_name", "customerId", "customerName"); } } 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 b147b3c3a..8d46ac8a0 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java @@ -236,6 +236,57 @@ class ContainerServiceTest { .isInstanceOf(BizException.class).hasMessageContaining("已存在"); } + @Test + @DisplayName("同一订舱箱型数量允许绑定多个不同真实柜号") + void linksMultipleRealContainersToTheSameReleasedBookingRequirement() { + ContainerBookingEntity first = booking("WAIT_LOADING", 2); + first.setId(11L); first.setContainerNo(null); first.setWarehouseId(101L); + first.setChannelCode("SEA-US"); first.setContainerType("40HC"); + ContainerBookingEntity second = booking("WAIT_LOADING", 4); + second.setId(12L); second.setContainerNo(null); second.setWarehouseId(101L); + second.setChannelCode("SEA-US"); second.setContainerType("40HC"); + OceanBookingEntity oceanBooking = new OceanBookingEntity(); + oceanBooking.setId(21L); oceanBooking.setStatus("RELEASED"); + oceanBooking.setWarehouseId(101L); oceanBooking.setChannelCode("SEA-US"); + OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity(); + requirement.setId(31L); requirement.setQuantity(2); requirement.setContainerType("40HC"); + when(repository.find(11L)).thenReturn(first); + when(repository.find(12L)).thenReturn(second); + when(oceanBookings.find(21L)).thenReturn(oceanBooking); + when(oceanBookings.requirement(21L, 31L)).thenReturn(requirement); + when(oceanBookings.activeLinkedCount(21L, 31L)).thenReturn(0L, 1L); + when(repository.linkReleasedBooking(eq(11L), eq(21L), eq(31L), eq("MSCU6639870"), + same(oceanBooking), eq(2), eq(9L), eq("admin"))).thenReturn(1); + when(repository.linkReleasedBooking(eq(12L), eq(21L), eq(31L), eq("TSTU1503306"), + same(oceanBooking), eq(4), eq(9L), eq("admin"))).thenReturn(1); + + service.linkOceanBooking(11L, 9L, "admin", + new OceanBookingLinkRequest(21L, 31L, "MSCU6639870", 2)); + service.linkOceanBooking(12L, 9L, "admin", + new OceanBookingLinkRequest(21L, 31L, "TSTU1503306", 4)); + + ArgumentCaptor allocations = + ArgumentCaptor.forClass(OceanBookingContainerAllocationEntity.class); + verify(oceanBookings, org.mockito.Mockito.times(2)).insert(allocations.capture()); + assertThat(allocations.getAllValues()).extracting(OceanBookingContainerAllocationEntity::getContainerBookingId) + .containsExactly(11L, 12L); + } + + @Test + @DisplayName("已经归属其他订舱的真实柜不能改绑") + void rejectsContainerAlreadyOwnedByAnotherBooking() { + ContainerBookingEntity container = booking("WAIT_LOADING", 2); + container.setContainerNo(null); + container.setOceanBookingId(22L); + when(repository.find(11L)).thenReturn(container); + + assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin", + new OceanBookingLinkRequest(21L, 31L, "MSCU6639870", 2))) + .isInstanceOf(BizException.class).hasMessageContaining("已关联真实订舱柜"); + + verifyNoInteractions(oceanBookings); + } + @Test @DisplayName("首次关联拒绝装柜仓库不一致") void rejectsLinkWhenWarehouseDiffers() { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java index e87ca9689..1dce4be86 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java @@ -3,6 +3,7 @@ package com.baiyue.firstleg.service; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.domain.TruckAppointmentStatus; import com.baiyue.firstleg.dto.TruckAppointmentDtos.ActionRequest; +import com.baiyue.firstleg.dto.TruckAppointmentDtos.AttachmentRequest; import com.baiyue.firstleg.dto.TruckAppointmentDtos.SaveRequest; import com.baiyue.firstleg.entity.TruckAppointmentEntity; import com.baiyue.firstleg.entity.TruckAppointmentAttachmentEntity; @@ -17,7 +18,6 @@ import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity; import com.baiyue.firstleg.oceanbooking.service.OceanBookingDocumentService; -import com.baiyue.firstleg.client.IamCustomerSnapshotClient; import com.baiyue.firstleg.client.TruckAppointmentFileClient; import com.baiyue.firstleg.client.WarehouseSupplierClient; import com.baiyue.common.core.api.ApiResponse; @@ -28,6 +28,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.MockitoAnnotations; import org.mockito.Spy; +import org.springframework.dao.DuplicateKeyException; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.json.JsonMapper; @@ -46,7 +47,6 @@ class TruckAppointmentServiceTest { @Mock SmartLoadingRepository loadingPlans; @Mock ContainerRepository containers; @Mock WarehouseSupplierSnapshotService suppliers; - @Mock IamCustomerSnapshotService customers; @Mock TruckAppointmentFileClient files; @Mock OceanBookingDocumentService oceanBookingDocuments; @Mock FirstlegDocumentNumberGenerator documentNumbers; @@ -56,9 +56,6 @@ class TruckAppointmentServiceTest { @BeforeEach void setUp() { MockitoAnnotations.openMocks(this); service.internalToken = "test-internal-token"; lenient().when(documentNumbers.next("TA")).thenReturn("TA-260818-0001"); - when(customers.active(anyLong(), any())).thenAnswer(invocation -> - new IamCustomerSnapshotClient.Customer(invocation.getArgument(0), "C-7", - "Canonical Customer", "ACTIVE", null, null)); when(suppliers.trucking(any(), any())).thenReturn( new WarehouseSupplierClient.Snapshot(8L, "Canonical Carrier", "TRUCKING", "ACTIVE", java.util.List.of())); lenient().when(repository.attachments(anyLong())).thenReturn(List.of()); @@ -102,7 +99,6 @@ class TruckAppointmentServiceTest { @Test void createRejectsMissingBookingAndContainer() { SaveRequest request = mock(SaveRequest.class); when(request.expectedVersion()).thenReturn(0); - when(request.customerId()).thenReturn(7L); when(request.carrierId()).thenReturn(8L); when(request.plannedLoadingAt()).thenReturn(LocalDateTime.of(2026, 8, 17, 10, 0)); when(request.oceanBookingId()).thenReturn(null); @@ -127,11 +123,70 @@ class TruckAppointmentServiceTest { verifyNoInteractions(files); } + @Test void createRejectsAnotherActiveAppointmentForTheSameRealContainerWithoutWriting() { + SaveRequest request = validRequest(); + when(repository.activeForUpdate(88L)).thenReturn(List.of(entity(TruckAppointmentStatus.DRAFT, 0))); + + assertThatThrownBy(() -> service.create(7L, "admin", request)) + .isInstanceOf(BizException.class).hasMessageContaining("已有有效拖车预约"); + + verify(repository, never()).insert(any()); + verify(repository, never()).history(any()); + } + + @Test void concurrentCreateUniqueCollisionBecomesBusinessConflictWithoutHistory() { + SaveRequest request = validRequest(); + doThrow(new DuplicateKeyException("Duplicate entry for key 'uk_truck_active_container'")) + .when(repository).insert(any()); + + assertThatThrownBy(() -> service.create(7L, "admin", request)) + .isInstanceOf(BizException.class).hasMessageContaining("已有有效拖车预约"); + + verify(repository, never()).history(any()); + } + + @Test void createPreservesMaximumChineseRemarkAndBoundaryValues() { + SaveRequest request = validRequest(); + String remark = "报".repeat(1000); + when(request.remark()).thenReturn(remark); + when(request.packageCount()).thenReturn(0); + when(request.palletCount()).thenReturn(0); + when(request.grossWeightKg()).thenReturn(java.math.BigDecimal.ZERO); + when(request.volumeCbm()).thenReturn(java.math.BigDecimal.ZERO); + when(request.transitMinutes()).thenReturn(0); + + service.create(7L, "admin", request); + + ArgumentCaptor inserted = ArgumentCaptor.forClass(TruckAppointmentEntity.class); + verify(repository).insert(inserted.capture()); + assertThat(inserted.getValue().getRemark()).hasSize(1000).isEqualTo(remark); + assertThat(inserted.getValue().getPackageCount()).isZero(); + assertThat(inserted.getValue().getPalletCount()).isZero(); + assertThat(inserted.getValue().getGrossWeightKg()).isZero(); + assertThat(inserted.getValue().getVolumeCbm()).isZero(); + } + + @Test void platformOwnedAttachmentUsesZeroTenantWithoutCustomerSnapshot() { + TruckAppointmentEntity appointment = entity(TruckAppointmentStatus.DRAFT, 0); + when(repository.find(1L)).thenReturn(appointment); + when(files.metadata("test-internal-token", 0L, "TRUCK_APPOINTMENT_ATTACHMENT", "1", 77L)) + .thenReturn(ApiResponse.success(new TruckAppointmentFileClient.Metadata(77L, 0L, + "TRUCK_APPOINTMENT_ATTACHMENT", "1", "派车资料.pdf", "abc", "application/pdf", 12L))); + + TruckAppointmentAttachmentEntity result = service.attach(1L, + new AttachmentRequest(77L, "DISPATCH_ORDER", "ignored", "ignored"), 9L); + + assertThat(result.getFileId()).isEqualTo(77L); + assertThat(result.getVisibility()).isEqualTo("PLATFORM"); + verify(files).metadata("test-internal-token", 0L, "TRUCK_APPOINTMENT_ATTACHMENT", "1", 77L); + verify(repository).attachment(result); + } + @Test void createFreezesReleasedBookingBillAndRejectsClientMismatch() { SaveRequest request = validRequest(); when(request.oceanBookingId()).thenReturn(99L); OceanBookingEntity booking = new OceanBookingEntity(); - booking.setId(99L); booking.setStatus("RELEASED"); booking.setCustomerId(7L); + booking.setId(99L); booking.setStatus("RELEASED"); booking.setBillOfLadingNo(" BL-BOOKING-1 "); when(oceanBookings.find(99L)).thenReturn(booking); @@ -152,39 +207,26 @@ class TruckAppointmentServiceTest { SaveRequest request = mock(SaveRequest.class); when(request.expectedVersion()).thenReturn(0); when(request.oceanBookingId()).thenReturn(99L); - when(request.customerId()).thenReturn(7L); when(request.carrierId()).thenReturn(8L); when(request.plannedLoadingAt()).thenReturn(LocalDateTime.of(2026, 8, 17, 10, 0)); when(request.containerBookingId()).thenReturn(88L); when(request.loadingPlanId()).thenReturn(null); when(request.warehouseId()).thenReturn(null); - OceanBookingEntity booking = new OceanBookingEntity(); booking.setId(99L); booking.setStatus("CONFIRMED"); booking.setCustomerId(7L); booking.setWarehouseId(18L); + OceanBookingEntity booking = new OceanBookingEntity(); booking.setId(99L); booking.setStatus("CONFIRMED"); booking.setWarehouseId(18L); when(oceanBookings.find(99L)).thenReturn(booking); assertThatThrownBy(() -> service.create(7L, "admin", request)) .isInstanceOf(BizException.class).hasMessageContaining("已放舱"); verify(repository, never()).insert(any()); } - @Test void createRejectsBookingFromAnotherCustomer() { - SaveRequest request = validRequest(); - when(request.oceanBookingId()).thenReturn(99L); - OceanBookingEntity booking = new OceanBookingEntity(); - booking.setId(99L); booking.setStatus("RELEASED"); booking.setCustomerId(17L); booking.setBillOfLadingNo("BL-1"); - when(oceanBookings.find(99L)).thenReturn(booking); - - assertThatThrownBy(() -> service.create(7L, "admin", request)) - .isInstanceOf(BizException.class).hasMessageContaining("customerId").hasMessageContaining("订舱"); - verify(repository, never()).insert(any()); - } - @Test void createWithBookingAndContainerRequiresTheirAllocation() { SaveRequest request = validRequest(); when(request.oceanBookingId()).thenReturn(99L); when(request.containerBookingId()).thenReturn(88L); OceanBookingEntity booking = new OceanBookingEntity(); - booking.setId(99L); booking.setStatus("RELEASED"); booking.setCustomerId(7L); booking.setWarehouseId(18L); booking.setBillOfLadingNo("BL-1"); + booking.setId(99L); booking.setStatus("RELEASED"); booking.setWarehouseId(18L); booking.setBillOfLadingNo("BL-1"); ContainerBookingEntity container = new ContainerBookingEntity(); - container.setId(88L); container.setRecordStatus("ACTIVE"); container.setCustomerId(7L); container.setWarehouseId(18L); container.setOceanBookingId(99L); container.setContainerNo("MSCU6639870"); + container.setId(88L); container.setRecordStatus("ACTIVE"); container.setWarehouseId(18L); container.setOceanBookingId(99L); container.setContainerNo("MSCU6639870"); when(oceanBookings.find(99L)).thenReturn(booking); when(containers.find(88L)).thenReturn(container); when(oceanBookings.allocations(99L)).thenReturn(List.of()); @@ -213,7 +255,7 @@ class TruckAppointmentServiceTest { @Test void editCanSwitchOnlyToAnotherMatchedReleasedBookingAndContainer() { TruckAppointmentEntity existing = entity(TruckAppointmentStatus.DRAFT, 3); - existing.setCustomerId(7L); existing.setOceanBookingId(99L); existing.setContainerBookingId(88L); + existing.setOceanBookingId(99L); existing.setContainerBookingId(88L); when(repository.find(1L)).thenReturn(existing, existing); SaveRequest request = validRequest(); when(request.expectedVersion()).thenReturn(3); @@ -258,7 +300,6 @@ class TruckAppointmentServiceTest { when(request.oceanBookingId()).thenReturn(99L); when(request.containerBookingId()).thenReturn(88L); when(request.loadingPlanId()).thenReturn(77L); - when(request.customerId()).thenReturn(7L); when(request.carrierId()).thenReturn(8L); when(request.plannedLoadingAt()).thenReturn(LocalDateTime.of(2026, 8, 17, 10, 0)); when(request.warehouseId()).thenReturn(null); @@ -283,7 +324,6 @@ class TruckAppointmentServiceTest { when(request.oceanBookingId()).thenReturn(99L); when(request.containerBookingId()).thenReturn(88L); when(request.loadingPlanId()).thenReturn(77L); - when(request.customerId()).thenReturn(7L); when(request.carrierId()).thenReturn(8L); when(request.plannedLoadingAt()).thenReturn(LocalDateTime.of(2026, 8, 17, 10, 0)); when(request.warehouseId()).thenReturn(null); @@ -335,6 +375,40 @@ class TruckAppointmentServiceTest { verify(repository, never()).history(any()); } + @Test void concurrentSameKeyBeforeFirstCompletionReturnsRetryableConflictWithoutDrift() throws Exception { + var request = new ActionRequest(0, "并发提交"); + String hash = requestHash("SUBMIT", 1L, request); + TruckAppointmentOperationEntity processing = new TruckAppointmentOperationEntity(); + processing.setAppointmentId(1L); processing.setAction("SUBMIT"); + processing.setIdempotencyKey("concurrent-submit"); processing.setRequestHash(hash); + when(repository.claimOperation(anyLong(), eq(1L), eq("SUBMIT"), eq("concurrent-submit"), eq(hash))) + .thenReturn(false); + when(repository.operation("concurrent-submit")).thenReturn(processing); + + assertThatThrownBy(() -> service.action(1L, "SUBMIT", request, "concurrent-submit", hash, 7L, "admin")) + .isInstanceOf(BizException.class).hasMessageContaining("正在处理中"); + + verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt()); + verify(repository, never()).history(any()); + verify(repository, never()).completeOperation(anyLong(), anyString(), anyString(), anyString()); + } + + @Test void sameIdempotencyKeyWithDifferentPayloadConflictsWithoutDrift() { + TruckAppointmentOperationEntity existing = new TruckAppointmentOperationEntity(); + existing.setAppointmentId(1L); existing.setAction("SUBMIT"); + existing.setIdempotencyKey("reused-key"); existing.setRequestHash("different-hash"); + when(repository.claimOperation(anyLong(), eq(1L), eq("SUBMIT"), eq("reused-key"), anyString())) + .thenReturn(false); + when(repository.operation("reused-key")).thenReturn(existing); + + assertThatThrownBy(() -> service.action(1L, "SUBMIT", new ActionRequest(0, "载荷A"), + "reused-key", null, 7L, "admin")) + .isInstanceOf(BizException.class).hasMessageContaining("不同请求"); + + verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt()); + verify(repository, never()).history(any()); + } + @Test void dispatchRequiresBookingBillAndActiveBookingOwnedDocument() { TruckAppointmentEntity missingBill = entity(TruckAppointmentStatus.PENDING_DISPATCH, 1); missingBill.setOceanBookingId(99L); missingBill.setContainerBookingId(88L); @@ -349,7 +423,6 @@ class TruckAppointmentServiceTest { TruckAppointmentEntity missingAttachment = entity(TruckAppointmentStatus.PENDING_DISPATCH, 1); missingAttachment.setOceanBookingId(99L); missingAttachment.setContainerBookingId(88L); missingAttachment.setBillOfLadingNo("BL-BOOKING-1"); - missingAttachment.setCustomerId(7L); when(oceanBookings.find(99L)).thenReturn(releasedBooking()); when(repository.claimOperation(anyLong(), eq(1L), eq("DISPATCH"), eq("dispatch-no-file"), anyString())).thenReturn(true); when(repository.find(1L)).thenReturn(missingAttachment); @@ -364,9 +437,9 @@ class TruckAppointmentServiceTest { @Test void dispatchFreezesReleasedBookingBillAndTransitionsOnceWithActiveAttachment() { TruckAppointmentEntity before = entity(TruckAppointmentStatus.PENDING_DISPATCH, 1); - before.setCustomerId(7L); before.setOceanBookingId(99L); before.setContainerBookingId(88L); before.setBillOfLadingNo(null); + before.setOceanBookingId(99L); before.setContainerBookingId(88L); before.setBillOfLadingNo(null); TruckAppointmentEntity after = entity(TruckAppointmentStatus.DISPATCHED, 2); - after.setCustomerId(7L); after.setOceanBookingId(99L); after.setContainerBookingId(88L); after.setBillOfLadingNo("BL-BOOKING-1"); + after.setOceanBookingId(99L); after.setContainerBookingId(88L); after.setBillOfLadingNo("BL-BOOKING-1"); OceanBookingEntity booking = new OceanBookingEntity(); booking.setId(99L); booking.setStatus("RELEASED"); booking.setBillOfLadingNo("BL-BOOKING-1"); when(repository.claimOperation(anyLong(), eq(1L), eq("DISPATCH"), eq("dispatch-ok"), anyString())).thenReturn(true); @@ -495,7 +568,6 @@ class TruckAppointmentServiceTest { private static SaveRequest validRequest() { SaveRequest request = mock(SaveRequest.class); when(request.expectedVersion()).thenReturn(0); - when(request.customerId()).thenReturn(7L); when(request.carrierId()).thenReturn(8L); when(request.plannedLoadingAt()).thenReturn(LocalDateTime.of(2026, 8, 17, 10, 0)); when(request.oceanBookingId()).thenReturn(99L); @@ -507,14 +579,14 @@ class TruckAppointmentServiceTest { private static OceanBookingEntity releasedBooking() { OceanBookingEntity booking = new OceanBookingEntity(); - booking.setId(99L); booking.setStatus("RELEASED"); booking.setCustomerId(7L); + booking.setId(99L); booking.setStatus("RELEASED"); booking.setWarehouseId(18L); booking.setBillOfLadingNo("BL-BOOKING-1"); return booking; } private static ContainerBookingEntity realContainer() { ContainerBookingEntity container = new ContainerBookingEntity(); - container.setId(88L); container.setRecordStatus("ACTIVE"); container.setCustomerId(7L); + container.setId(88L); container.setRecordStatus("ACTIVE"); container.setWarehouseId(18L); container.setOceanBookingId(99L); container.setContainerNo("MSCU6639870"); return container; } diff --git a/scripts/admin-test-env-modules/fulfillment-list-field-contract.mjs b/scripts/admin-test-env-modules/fulfillment-list-field-contract.mjs index 9450f1031..b508e7b61 100644 --- a/scripts/admin-test-env-modules/fulfillment-list-field-contract.mjs +++ b/scripts/admin-test-env-modules/fulfillment-list-field-contract.mjs @@ -114,14 +114,12 @@ export function truckAppointmentFieldGaps(record) { return gaps(record, { id: isPositiveId, appointmentNo: isNonBlank, - oceanBookingId: (value) => value == null || isPositiveId(value), - containerBookingId: (value) => value == null || isPositiveId(value), + oceanBookingId: isPositiveId, + containerBookingId: isPositiveId, loadingPlanId: (value) => value == null || isPositiveId(value), warehouseId: (value) => value == null || isPositiveId(value), plannedLoadingAt: isIsoDateTime, status: isNonBlank, - customerId: isPositiveId, - customerName: isNonBlank, carrierId: isPositiveId, carrierName: isNonBlank, attachmentCount: isNonNegativeNumber, diff --git a/scripts/admin-test-env-modules/fulfillment-prototype-field-matrix-contract.test.mjs b/scripts/admin-test-env-modules/fulfillment-prototype-field-matrix-contract.test.mjs index b1320a6d9..f934da646 100644 --- a/scripts/admin-test-env-modules/fulfillment-prototype-field-matrix-contract.test.mjs +++ b/scripts/admin-test-env-modules/fulfillment-prototype-field-matrix-contract.test.mjs @@ -56,7 +56,7 @@ test('Admin 当前列表逐字段列与验收矩阵一致', () => { 'loading-lists/components/LoadingListsView.vue': ['preloadNo', 'customers', 'skuCount', 'loadingListNo', 'totalPackageCount', 'totalGrossWeightKg', 'totalNetWeightKg', 'totalVolumeCbm', 'totalQuantity', 'createdByName'], 'ocean-bookings/pages/OceanBookingsView.vue': ['bookingNo', 'customerName', 'billOfLadingNo', 'carrierName', 'bookingAgent', 'vesselName', 'pol', 'pod', 'etd', 'eta', 'updatedAt'], 'port-schedules/pages/PortSchedulesView.vue': ['schedule.scheduleNo', 'schedule.sourceName', 'schedule.vesselName', 'schedule.departurePortName', 'schedule.destinationPortName', 'schedule.etdAt', 'schedule.etaAt', 'schedule.observedAt', 'expired'], - 'trucking/pages/TruckAppointmentsView.vue': ['appointmentNo', 'containerNo', 'customerName', 'company', 'driver', 'vehicleNo', 'store', 'payableAmount', 'receivableAmount', 'plannedLoadingAt', 'attachmentCount'], + 'trucking/pages/TruckAppointmentsView.vue': ['appointmentNo', 'containerNo', 'company', 'driver', 'vehicleNo', 'store', 'payableAmount', 'receivableAmount', 'plannedLoadingAt', 'attachmentCount'], 'customs/pages/CustomsCasesView.vue': ['customsNo', 'orderLink', 'inboundLink', 'checklistCode', 'checklistVersionNo', 'status', 'updatedAt'], 'outbound-dispatch/pages/OutboundDispatchesView.vue': ['inboundCode', 'customerName', 'containerNo', 'oceanBookingStatus', 'truckAppointmentStatus', 'pickTaskStatus', 'dispatchStatus', 'gate'], } @@ -72,7 +72,7 @@ test('正式 DTO/OpenAPI 暴露矩阵使用的稳定字段', () => { ['firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java', ['record PreloadSummary', 'record ConfirmedSummary', 'totalGrossWeightKg', 'totalVolumeCbm', 'customers']], ['firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java', ['record Detail', 'record RealContainer', 'bookingAgentName', 'estimatedSailingAt', 'estimatedArrivalAt']], ['firstleg/src/main/java/com/baiyue/firstleg/portschedule/dto/PortScheduleDtos.java', ['record ScheduleView', 'sourceName', 'etdAt', 'etaAt', 'expiresAt']], - ['firstleg/src/main/java/com/baiyue/firstleg/entity/TruckAppointmentEntity.java', ['appointmentNo', 'customerName', 'carrierName', 'plannedLoadingAt', 'containerNo']], + ['firstleg/src/main/java/com/baiyue/firstleg/entity/TruckAppointmentEntity.java', ['appointmentNo', 'carrierName', 'plannedLoadingAt', 'containerNo']], ['firstleg/src/main/java/com/baiyue/firstleg/customs/entity/CustomsCaseEntity.java', ['customsNo', 'checklistCode', 'checklistVersionNo', 'updatedAt']], ['firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/dto/OutboundDispatchDtos.java', ['record Aggregate', 'customerName', 'pickTaskStatus', 'dispatchStatus', 'GateResult']], ] @@ -89,7 +89,7 @@ test('字段校验器接受正式完整投影并精确暴露缺字段', () => { [loadingListFieldGaps, { planId: ids.plan, containerBookingId: ids.container, loadingListNo: 'LL-1', containerNo: 'MSCU1234567', cabinetNo: 'CAB-1', warehouseName: '宁波仓', status: 'COMPLETED', totalPackageCount: 1, totalGrossWeightKg: 10, totalNetWeightKg: 9, totalVolumeCbm: 1, totalQuantity: 2, createdAt: at }, undefined, 'containerNo'], [oceanBookingFieldGaps, { id: ids.id, bookingNo: 'BK-1', status: 'RELEASED', vesselName: 'VESSEL', voyageNo: 'V1', estimatedSailingAt: at, estimatedArrivalAt: at, customerId: ids.id, customerName: '测试客户', shippingCompanyId: null, shippingCompanyName: null, bookingAgentId: ids.id, bookingAgentName: '代理' }, undefined, 'voyageNo'], [portScheduleFieldGaps, { portSchedule: { schedule: { id: ids.id, scheduleNo: 'PS-1', sourceName: '船公司', vesselName: 'VESSEL', voyageNo: 'V1', departurePortCode: 'CNNGB', destinationPortCode: 'USLAX', etdAt: at, etaAt: at, observedAt: at, updatedAt: at, expiresAt: at }, expired: false } }, undefined, 'sourceName'], - [truckAppointmentFieldGaps, { id: ids.id, appointmentNo: 'TA-1', oceanBookingId: ids.id, containerBookingId: ids.container, loadingPlanId: ids.plan, warehouseId: ids.id, warehouseName: '宁波仓', customerId: ids.id, customerName: '测试客户', carrierId: ids.id, carrierName: '拖车商', plannedLoadingAt: at, status: 'DRAFT', attachmentCount: 0 }, undefined, 'appointmentNo'], + [truckAppointmentFieldGaps, { id: ids.id, appointmentNo: 'TA-1', oceanBookingId: ids.id, containerBookingId: ids.container, loadingPlanId: ids.plan, warehouseId: ids.id, warehouseName: '宁波仓', carrierId: ids.id, carrierName: '拖车商', plannedLoadingAt: at, status: 'DRAFT', attachmentCount: 0 }, undefined, 'appointmentNo'], [customsCaseFieldGaps, { customsCase: { id: ids.id, customsNo: 'CUS-1', orderId: ids.order, inboundId: ids.inbound, containerBookingId: ids.container, checklistVersionId: ids.id, checklistCode: 'CHECK', checklistVersionNo: 'V1', status: 'DOCUMENTS_PENDING', updatedAt: at } }, undefined, 'checklistCode'], [outboundAggregateFieldGaps, { containerBookingId: ids.container, containerNo: 'MSCU1234567', loadingPlanId: ids.plan, loadingPlanStatus: 'COMPLETED', oceanBookingId: ids.id, oceanBookingStatus: 'RELEASED', dispatchStatus: 'READY_FOR_DISPATCH', version: 1, inbounds: [{ orderId: ids.order, inboundId: ids.inbound, inboundCode: 'IN-1', tenantId: ids.id, inboundStatus: 'LOADED', pickTaskId: ids.task, pickTaskStatus: 'COMPLETED' }], gate: { inbounds: [{ inboundId: ids.inbound, inboundCode: 'IN-1', gate: { items: [] } }] } }, undefined, 'containerNo'], ] diff --git a/scripts/admin-test-env-modules/manual-state-ocean-trucking.mjs b/scripts/admin-test-env-modules/manual-state-ocean-trucking.mjs index c7e8d4d72..232541435 100644 --- a/scripts/admin-test-env-modules/manual-state-ocean-trucking.mjs +++ b/scripts/admin-test-env-modules/manual-state-ocean-trucking.mjs @@ -88,8 +88,7 @@ function truckPayload(context, oceanBooking, carrier, index) { const value = suffix(context, index) return { outboundOrderId: null, oceanBookingId: oceanBooking.id, containerBookingId: null, loadingPlanId: null, - warehouseId: oceanBooking.warehouseId, customerId: oceanBooking.customerId, - customerName: null, + warehouseId: oceanBooking.warehouseId, carrierId: id(carrier.id), carrierName: carrier.name, fleetContact: '人工验收联系人', fleetPhone: '13800000004', driverName: `人工司机-${value}`, driverPhone: '13800000005', vehicleNo: `粤B${value.slice(-6)}`, storeName: '人工验收仓', storeContact: '仓库联系人', storePhone: '13800000006', @@ -177,8 +176,8 @@ export async function run(context, matrix = context.createdResources.manualState } const releasedOcean = matrix.pages.oceanBookings.fixtures.RELEASED - if (!releasedOcean.id || !releasedOcean.customerId || !releasedOcean.warehouseId) { - throw new Error('RELEASED 订舱缺少拖车所需的客户或仓库归属') + if (!releasedOcean.id || !releasedOcean.warehouseId) { + throw new Error('RELEASED 订舱缺少拖车所需的仓库归属') } matrix.pages.truckAppointments = { requiredStatuses: TRUCK_STATUSES, fixtures: {} } for (const [index, status] of TRUCK_STATUSES.entries()) { diff --git a/scripts/admin-test-env-modules/service-center-flow.mjs b/scripts/admin-test-env-modules/service-center-flow.mjs index 6564558ea..9b049aab2 100644 --- a/scripts/admin-test-env-modules/service-center-flow.mjs +++ b/scripts/admin-test-env-modules/service-center-flow.mjs @@ -64,29 +64,20 @@ export async function run(context) { const suffix = context.marker.replace(/\D/g, '').slice(-10) const oceanResponse = await context.request('GET', `${OCEAN}/${ocean.id}`) - const oceanDetail = context.apiSuccess(MODULE, '读取跨服务链路订舱真实客户归属', oceanResponse, `oceanBookingId=${ocean.id}`, OPTIONS) - const customerId = id(oceanDetail?.oceanBooking?.customerId) - const customerName = String(oceanDetail?.oceanBooking?.customerName || '').trim() - context.assert(MODULE, '跨服务链路订舱客户归属有效', oceanResponse, - Boolean(customerId) && Boolean(customerName), - `customerId=${customerId || 'missing'}, customerName=${customerName || 'missing'}`, OPTIONS) - if (!customerId || !customerName) return + const oceanDetail = context.apiSuccess(MODULE, '读取跨服务链路已放舱订舱', oceanResponse, `oceanBookingId=${ocean.id}`, OPTIONS) const linkContainer = ['AWAITING_REVIEW', 'LOADED'].includes(containerFlow?.finalStatus) - && id(containerFlow?.tenantId) === customerId && Boolean(containerFlow?.containerBookingId) && Boolean(containerFlow?.loadingPlanId) - if (containerFlow && !linkContainer) { - context.addStep(MODULE, '本轮柜次与订舱客户归属可串联', oceanResponse, false, - `oceanCustomerId=${customerId}, containerTenantId=${id(containerFlow?.tenantId) || 'missing'}, containerStatus=${containerFlow?.finalStatus || 'missing'}`, OPTIONS) + if (!linkContainer) { + context.addStep(MODULE, '本轮已放舱订舱必须关联真实柜和装柜计划', oceanResponse, false, + `containerStatus=${containerFlow?.finalStatus || 'missing'}, containerBookingId=${id(containerFlow?.containerBookingId) || 'missing'}`, OPTIONS) return } - if (linkContainer) { - const allocationMatches = oceanDetail?.containers?.some?.((item) => - id(item?.containerBookingId) === id(containerFlow.containerBookingId)) - context.assert(MODULE, '已放舱订舱保留本轮柜次分配', oceanResponse, allocationMatches, - `containerBookingId=${id(containerFlow.containerBookingId)}, containers=${oceanDetail?.containers?.length ?? 'n/a'}`, OPTIONS) - if (!allocationMatches) return - } + const allocationMatches = oceanDetail?.containers?.some?.((item) => + id(item?.containerBookingId) === id(containerFlow.containerBookingId)) + context.assert(MODULE, '已放舱订舱保留本轮柜次分配', oceanResponse, allocationMatches, + `containerBookingId=${id(containerFlow.containerBookingId)}, containers=${oceanDetail?.containers?.length ?? 'n/a'}`, OPTIONS) + if (!allocationMatches) return const carrierResponse = await context.request('GET', '/api/platform/v1/suppliers?tenantId=0&category=TRUCKING&status=ACTIVE&page=1&size=20') const carrierPage = context.apiSuccess(MODULE, '读取 ACTIVE TRUCKING 承运商事实源', carrierResponse, '', OPTIONS) @@ -102,7 +93,7 @@ export async function run(context) { warehouseId: linkContainer ? id(containerFlow.warehouseId) : id(context.createdResources?.warehouse?.id || context.context?.warehouseId || '193000000000000001'), - customerId, customerName: null, carrierId: id(carrier.id), carrierName: carrier.name, + carrierId: id(carrier.id), carrierName: carrier.name, fleetContact: '链路车队联系人', fleetPhone: '13800000007', driverName: '链路司机', driverPhone: '13800000008', driverIdCard: null, vehicleNo: `粤D-${suffix.slice(-6)}`, storeName: `链路门店-${suffix}`, storeContact: '链路联系人', storePhone: '13800000009', storeAddress: '深圳市南山区链路地址', storeCity: '深圳市', storeZipcode: '518000', diff --git a/scripts/admin-test-env-modules/truck-appointments-contract.test.mjs b/scripts/admin-test-env-modules/truck-appointments-contract.test.mjs index 354762599..fe8d602ea 100644 --- a/scripts/admin-test-env-modules/truck-appointments-contract.test.mjs +++ b/scripts/admin-test-env-modules/truck-appointments-contract.test.mjs @@ -2,167 +2,71 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' -import { inspectOceanBookingIdentity, selectIndependentTruckCustomer, truckAppointmentPayload } from './truck-appointments.mjs' +import { inspectOceanBookingIdentity, truckAppointmentPayload } from './truck-appointments.mjs' -test('truck appointment accepts only the current marker ocean booking identity', () => { - const current = inspectOceanBookingIdentity('RUN-2', { - id: '91', marker: 'RUN-2', customerId: '21', warehouseId: '41', customerName: '本轮客户', - }) - assert.deepEqual(current.identity, { - oceanBookingId: '91', customerId: '21', warehouseId: '41', customerName: '本轮客户', - }) - const stale = inspectOceanBookingIdentity('RUN-2', { - id: '90', marker: 'RUN-1', customerId: '20', warehouseId: '40', customerName: '历史客户', - }) - assert.equal(stale.identity, null) - assert.match(stale.reason, /marker 不属于本轮/) +test('booking identity is marker scoped and contains no customer snapshot', () => { + const result = inspectOceanBookingIdentity('RUN-2', { id: '91', marker: 'RUN-2', warehouseId: '41' }) + assert.deepEqual(result.identity, { oceanBookingId: '91', warehouseId: '41' }) + assert.equal(Object.hasOwn(result.identity, 'customerId'), false) + assert.equal(inspectOceanBookingIdentity('RUN-2', { id: '90', marker: 'RUN-1', warehouseId: '41' }).identity, null) }) -test('truck appointment fills missing identity only from the current booking detail', () => { - const partial = { id: '91', marker: 'RUN-2', customerId: '21' } - const beforeDetail = inspectOceanBookingIdentity('RUN-2', partial) - assert.equal(beforeDetail.identity, null) - assert.deepEqual(beforeDetail.missingFields, ['warehouseId', 'customerName']) - const resolved = inspectOceanBookingIdentity('RUN-2', partial, { oceanBooking: { - id: 91, customerId: 21, warehouseId: 41, customerName: '本轮客户', - } }) - assert.deepEqual(resolved.identity, { - oceanBookingId: '91', customerId: '21', warehouseId: '41', customerName: '本轮客户', +test('payload requires booking container and carrier but never emits customer fields', () => { + const context = { createdResources: { truckSupplier: { supplierId: '501', supplierName: '正式拖车商' } } } + const payload = truckAppointmentPayload(context, '2', { + oceanBookingId: '91', containerBookingId: '81', loadingPlanId: '71', warehouseId: '41', billOfLadingNo: 'BL-1', }) + assert.deepEqual({ oceanBookingId: payload.oceanBookingId, containerBookingId: payload.containerBookingId, + carrierId: payload.carrierId, carrierName: payload.carrierName }, + { oceanBookingId: '91', containerBookingId: '81', carrierId: '501', carrierName: '正式拖车商' }) + assert.equal(Object.hasOwn(payload, 'customerId'), false) + assert.equal(Object.hasOwn(payload, 'customerName'), false) }) -test('truck appointment rejects mismatched booking detail instead of mixing identities', () => { - const wrongResource = inspectOceanBookingIdentity('RUN-2', { id: '91', marker: 'RUN-2' }, { - oceanBooking: { id: '92', customerId: '21', warehouseId: '41', customerName: '其他订舱' }, - }) - assert.equal(wrongResource.identity, null) - assert.match(wrongResource.reason, /资源归属不一致/) - - const conflictingCustomer = inspectOceanBookingIdentity('RUN-2', { - id: '91', marker: 'RUN-2', customerId: '21', - }, { oceanBooking: { id: '91', customerId: '22', warehouseId: '41', customerName: '本轮客户' } }) - assert.equal(conflictingCustomer.identity, null) - assert.match(conflictingCustomer.reason, /customerId/) -}) - -test('create edit and stale replay payloads keep the independent customer and empty links stable', () => { - const context = { marker: 'RUN-2', createdResources: { - truckSupplier: { supplierId: '501', supplierName: '正式拖车商' }, - } } - const identity = { customerId: '21', customerName: '本轮客户' } - const payloads = [ - truckAppointmentPayload(context, '2', identity), - truckAppointmentPayload(context, '2', identity, 3), - truckAppointmentPayload(context, '2', identity, 0), +test('backend DTO entity mapper service and OpenAPI have no truck customer protocol', () => { + const paths = [ + '../../firstleg/src/main/java/com/baiyue/firstleg/dto/TruckAppointmentDtos.java', + '../../firstleg/src/main/java/com/baiyue/firstleg/entity/TruckAppointmentEntity.java', + '../../firstleg/src/main/java/com/baiyue/firstleg/mapper/TruckAppointmentMapper.java', + '../../firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentService.java', + '../../firstleg/src/main/resources/openapi/truck-appointments-v1.yaml', ] - for (const payload of payloads) { - assert.deepEqual({ - oceanBookingId: payload.oceanBookingId, containerBookingId: payload.containerBookingId, - loadingPlanId: payload.loadingPlanId, customerId: payload.customerId, - customerName: payload.customerName, carrierId: payload.carrierId, - }, { oceanBookingId: null, containerBookingId: null, loadingPlanId: null, - customerId: '21', customerName: '本轮客户', carrierId: '501' }) + for (const path of paths) { + const source = readFileSync(new URL(path, import.meta.url), 'utf8') + assert.doesNotMatch(source, /customerId|customerName|customer_id|customer_name/) } - assert.deepEqual(payloads.map((payload) => payload.expectedVersion), [0, 3, 0]) }) -test('truck payload requires a verified carrier ID/name pair', () => { - const identity = { customerId: '21', customerName: '本轮客户' } - const missing = truckAppointmentPayload({ marker: 'RUN-2', createdResources: {} }, '2', identity) - assert.equal(missing.carrierId, null) - assert.equal(missing.carrierName, null) - const valid = truckAppointmentPayload({ marker: 'RUN-2', createdResources: { - truckSupplier: { supplierId: '501', supplierName: '正式拖车商' }, - } }, '2', identity) - assert.deepEqual({ carrierId: valid.carrierId, carrierName: valid.carrierName }, { carrierId: '501', carrierName: '正式拖车商' }) - const incomplete = truckAppointmentPayload({ marker: 'RUN-2', createdResources: { - truckSupplier: { supplierId: '501', supplierName: null }, - } }, '2', identity) - assert.equal(incomplete.carrierId, '501') - assert.equal(incomplete.carrierName, null) -}) - -test('independent customer comes only from the current marker fixture', () => { - const current = selectIndependentTruckCustomer({ marker: 'RUN-2', createdResources: { - orders: { marker: 'RUN-2', tenantId: '21', customerName: '本轮客户' }, - } }) - assert.deepEqual(current, { customerId: '21', customerName: '本轮客户' }) - const stale = selectIndependentTruckCustomer({ marker: 'RUN-2', createdResources: { - orders: { marker: 'RUN-1', tenantId: '20', customerName: '历史客户' }, - } }) - assert.equal(stale, null) -}) - -test('truck appointment source has no booking prerequisite historical list or hardcoded identity fallback', () => { - const source = readFileSync(new URL('truck-appointments.mjs', import.meta.url), 'utf8') - assert.doesNotMatch(source, /customerId:\s*['"]10001['"]|warehouseId:\s*['"]193000000000000001['"]/) - assert.doesNotMatch(source, /import\(new URL\('\.\/ocean-bookings\.mjs'/) - assert.match(source, /创建无订舱柜次计划关联的独立拖车预约/) - assert.match(source, /缺少同 marker 的 customerId;未借用历史客户或订舱/) -}) - -test('backend freezes independent creation association validation and PDA failure isolation', () => { +test('backend freezes released booking real container bill file and carrier rules', () => { const dto = readFileSync(new URL('../../firstleg/src/main/java/com/baiyue/firstleg/dto/TruckAppointmentDtos.java', import.meta.url), 'utf8') - assert.match(dto, /@Positive Long oceanBookingId,@Positive Long containerBookingId,@Positive Long loadingPlanId/) - assert.match(dto, /@NotNull @Positive Long customerId/) + assert.match(dto, /@NotNull @Positive Long oceanBookingId/) + assert.match(dto, /@NotNull @Positive Long containerBookingId/) assert.match(dto, /@NotNull @Positive Long carrierId/) - assert.match(dto, /@NotNull LocalDateTime plannedLoadingAt/) - assert.doesNotMatch(dto, /@NotNull @Positive Long oceanBookingId|@NotNull @Positive Long containerBookingId/) - - const openapi = readFileSync(new URL('../../firstleg/src/main/resources/openapi/truck-appointments-v1.yaml', import.meta.url), 'utf8') - assert.match(openapi, /required: \[customerId, carrierId, plannedLoadingAt, expectedVersion\]/) - assert.match(openapi, /Ocean booking, container booking and loading plan are optional links/) - assert.match(openapi, /absence, duplicates or concurrent truck edits never roll back loading completion/) - const service = readFileSync(new URL('../../firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentService.java', import.meta.url), 'utf8') - for (const token of ['customers.active(r.customerId(),r.customerName())', 'suppliers.trucking(r.carrierId(),r.carrierName())', - 'customerId与订舱客户不一致', 'customerId与柜次客户不一致', 'containerBookingId未分配至该订舱', - 'loadingPlanId与柜次当前装柜计划不一致', 'Propagation.REQUIRES_NEW', 'active.isEmpty()', - 'active.size()!=1', 'repo.completeLoadingFromPda']) assert.match(service, new RegExp(token.replace(/[().]/g, '\\$&'))) - - const loading = readFileSync(new URL('../../firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java', import.meta.url), 'utf8') - assert.match(loading, /completeTruckAfterCommit/) - assert.match(loading, /afterCommit\(\) \{ completion\.run\(\); \}/) - assert.match(loading, /catch \(RuntimeException exception\)/) - assert.ok(loading.indexOf('loadingListService.publishCompletedEvent') < loading.indexOf('completeTruckAfterCommit'), - 'PDA task/plan/inbound completion must precede best-effort truck synchronization') - - const truckTests = readFileSync(new URL('../../firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java', import.meta.url), 'utf8') - for (const scenario of ['pdaCompletionDoesNotRequireTruckAppointment', - 'pdaCompletionLocksAndCompletesTheOnlyActiveTruckWithHistory', - 'pdaCompletionRejectsMultipleActiveTruckAppointmentsWithoutMutation', - 'pdaCompletionRejectsConcurrentTruckModificationWithoutHistory']) assert.match(truckTests, new RegExp(scenario)) - const loadingTests = readFileSync(new URL('../../firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java', import.meta.url), 'utf8') - assert.match(loadingTests, /conflictingTruckAppointmentsDoNotBlockPdaCompletion/) + for (const token of ['"RELEASED"', 'containerBookingId未分配至该订舱', 'Iso6346ContainerNumber.require', + 'oceanBookingDocuments.requireActiveValidDocument', 'suppliers.trucking', 'uk_truck_active_container']) { + assert.ok(service.includes(token), `missing ${token}`) + } }) -test('Admin requires customer carrier and planned time but keeps booking and container optional', () => { - const source = readFileSync(new URL('../../../apps/admin-web/src/modules/trucking/components/TruckAppointmentForm.vue', import.meta.url), 'utf8') - assert.match(source, /customerId: \[\{ required: true/) - assert.match(source, /carrierId: \[\{ required: true/) - assert.match(source, /plannedLoadingAt: \[\{ required: true/) - assert.match(source, /label="关联柜次(可选)"/) - assert.match(source, /label="关联订舱(可选)"/) - assert.doesNotMatch(source, /containerBookingId: \[\{ required: true|oceanBookingId: \[\{ required: true/) -}) - -test('default HTTP entry carries independent truck positives and association negatives after current-marker producers', () => { +test('HTTP regression covers no-customer response and submit idempotency matrix', () => { const source = readFileSync(new URL('truck-appointments.mjs', import.meta.url), 'utf8') - for (const label of ['缺少 customerId 拒绝创建独立拖车', '缺少 ACTIVE TRUCKING carrier 拒绝创建独立拖车', - '缺少 plannedLoadingAt 拒绝创建独立拖车', 'loadingPlanId 与关联柜次当前计划不一致时零写入拒绝', - 'warehouseId 与可选关联柜次不一致时零写入拒绝', '订舱与柜次无 allocation 关系时零写入拒绝', - '创建无订舱柜次计划关联的独立拖车预约']) assert.match(source, new RegExp(label)) - const entry = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8') - assert.match(entry, /'orders'[\s\S]*'phase3-loading'[\s\S]*'ocean-bookings'[\s\S]*'truck-appointments'/) + for (const label of ['响应不含客户字段且冻结订舱柜号关系', '同键同载荷重放', '提交幂等重放零漂移', + '同键异载荷拒绝', '提单号与订舱不一致拒绝创建']) assert.match(source, new RegExp(label)) + assert.doesNotMatch(source, /customerId\s*:|customerName\s*:|独立拖车/) }) -test('dispatch requires bill of lading number and active BILL_OF_LADING attachment', () => { - const source = readFileSync(new URL('truck-appointments.mjs', import.meta.url), 'utf8') - assert.match(source, /attachmentType=BILL_OF_LADING/) - assert.match(source, /billOfLadingNo:/) - assert.match(source, /DISPATCH 缺提单号与文件明确拒绝/) - assert.match(source, /派车前保留有效 BILL_OF_LADING 提单附件/) - const openapi = readFileSync(new URL('../../firstleg/src/main/resources/openapi/truck-appointments-v1.yaml', import.meta.url), 'utf8') - assert.match(openapi, /DISPATCH requires a nonblank billOfLadingNo/) - assert.match(openapi, /BILL_OF_LADING attachment/) +test('schema owns platform attachments and one active appointment per real container', () => { + const sql = readFileSync(new URL('../../firstleg/src/main/resources/db/phase3-truck-appointment-schema.sql', import.meta.url), 'utf8') + assert.doesNotMatch(sql, /customer_id|customer_name|idx_truck_appointment_customer/) + assert.match(sql, /UNIQUE KEY uk_truck_active_container\(active_container_booking_id\)/) + assert.match(sql, /visibility VARCHAR\(24\) NOT NULL DEFAULT 'PLATFORM'/) +}) + +test('method permissions remain independently enforced after removing customer scope', () => { + const controller = readFileSync(new URL('../../firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformTruckAppointmentController.java', import.meta.url), 'utf8') + for (const permission of ['platform:firstleg:truck-appointment:list', 'platform:firstleg:truck-appointment:create', + 'platform:firstleg:truck-appointment:edit', 'platform:firstleg:truck-appointment:operate']) { + assert.match(controller, new RegExp(`hasAuthority\\('${permission}'\\)`)) + } }) diff --git a/scripts/admin-test-env-modules/truck-appointments.mjs b/scripts/admin-test-env-modules/truck-appointments.mjs index de23550ba..5b8a47660 100644 --- a/scripts/admin-test-env-modules/truck-appointments.mjs +++ b/scripts/admin-test-env-modules/truck-appointments.mjs @@ -3,19 +3,17 @@ import { truckAppointmentFieldGaps } from './fulfillment-list-field-contract.mjs const MODULE = 'truck-appointments' const OPTIONS = { scenario: 'truck-appointment-lifecycle', deploymentBlocker: true } const BASE = '/api/platform/v1/truck-appointments' -const SUPPLIER_BASE = '/api/platform/v1/suppliers?tenantId=0' function id(value) { return value == null ? '' : String(value) } function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) } -function missing(response) { return response?.http === 404 || response?.http === 405 || response?.http === 503 } -function failure(response) { return [400, 401, 403, 404, 409, 503].includes(response?.http) || (response?.http === 200 && codeOf(response) !== '200') } -function data(response) { return codeOf(response) === '200' ? response.body.data : null } +function failure(response) { return response?.http === 200 && codeOf(response) !== '200' } async function request(context, method, path, body, headers = {}) { try { const response = await fetch(`${context.options.gateway}${path}`, { method, - headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}`, ...(body === undefined ? {} : { 'Content-Type': 'application/json; charset=utf-8' }), ...headers }, + headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}`, + ...(body === undefined ? {} : { 'Content-Type': 'application/json; charset=utf-8' }), ...headers }, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(60_000), }) const raw = await response.text(); let payload = null @@ -25,228 +23,91 @@ async function request(context, method, path, body, headers = {}) { } catch (error) { return { method, path, http: 0, body: null, raw: String(error), requestId: null, traceId: null } } } -async function uploadAttachment(context, appointmentId) { - const path = `${BASE}/${appointmentId}/attachments/upload?attachmentType=BILL_OF_LADING` - const form = new FormData() - const png = Uint8Array.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,6,0,0,0,31,21,196,137,0,0,0,13,73,68,65,84,8,215,99,248,207,192,240,31,0,5,0,1,255,137,153,61,29,0,0,0,0,73,69,78,68,174,66,96,130]) - form.append('file', new Blob([png], { type: 'image/png' }), `${context.marker}-truck.png`) - try { - const response = await fetch(`${context.options.gateway}${path}`, { method: 'POST', headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}` }, body: form, signal: AbortSignal.timeout(60_000) }) - const raw = await response.text(); let payload = null - try { payload = raw ? JSON.parse(raw) : null } catch { /* bounded raw evidence */ } - return { method: 'POST', path, http: response.status, body: payload, raw: payload === null ? raw.slice(0, 500) : null, - requestId: payload?.requestId || response.headers.get('x-request-id'), traceId: payload?.traceId || response.headers.get('x-trace-id') } - } catch (error) { return { method: 'POST', path, http: 0, body: null, raw: String(error), requestId: null, traceId: null } } -} - export function inspectOceanBookingIdentity(marker, resource, detail = null) { if (!resource?.id) return { identity: null, reason: '本轮 oceanBooking.id 缺失' } - if (resource.marker !== marker) { - return { identity: null, reason: `oceanBooking marker 不属于本轮:expected=${marker}, actual=${resource.marker || 'missing'}` } - } + if (resource.marker !== marker) return { identity: null, reason: `oceanBooking marker 不属于本轮:expected=${marker}, actual=${resource.marker || 'missing'}` } const booking = detail?.oceanBooking || null - if (booking && id(booking.id) !== id(resource.id)) { - return { identity: null, reason: `订舱详情资源归属不一致:expected=${id(resource.id)}, actual=${id(booking.id) || 'missing'}` } - } - const fields = ['customerId', 'warehouseId', 'customerName'] - for (const field of fields) { - const published = id(resource[field]) - const queried = id(booking?.[field]) - if (published && queried && published !== queried) { - return { identity: null, reason: `订舱身份字段冲突:field=${field}, published=${published}, detail=${queried}` } - } - } - const identity = { - oceanBookingId: id(resource.id), - customerId: id(resource.customerId || booking?.customerId), - warehouseId: id(resource.warehouseId || booking?.warehouseId), - customerName: id(resource.customerName || booking?.customerName), - } - const missingFields = fields.filter((field) => !identity[field]) - return missingFields.length - ? { identity: null, reason: `本轮订舱身份字段缺失:${missingFields.join(',')}`, missingFields } + if (booking && id(booking.id) !== id(resource.id)) return { identity: null, reason: '订舱详情资源归属不一致' } + const identity = { oceanBookingId: id(resource.id), warehouseId: id(resource.warehouseId || booking?.warehouseId) } + const missingFields = Object.entries(identity).filter(([, value]) => !value).map(([key]) => key) + return missingFields.length ? { identity: null, reason: `本轮订舱身份字段缺失:${missingFields.join(',')}`, missingFields } : { identity, reason: null, missingFields: [] } } -export function selectIndependentTruckCustomer(context) { - const candidates = [ - context.createdResources?.orders, - context.createdResources?.merchantQualificationFixture, - context.createdResources?.phase3Loading, - context.createdResources?.phase3ContainerLoading, - ].filter(Boolean) - const source = candidates.find((item) => item.marker === context.marker && id(item.tenantId)) - return source ? { customerId: id(source.tenantId), customerName: source.customerName || null } : null -} - -export function truckAppointmentPayload(context, suffix, identity, version = 0, associations = {}) { +export function truckAppointmentPayload(context, suffix, identity, version = 0, overrides = {}) { const supplier = context.createdResources?.truckSupplier || {} - const supplierId = id(supplier.supplierId) - const supplierName = id(supplier.supplierName) return { - outboundOrderId: null, oceanBookingId: associations.oceanBookingId || null, - containerBookingId: associations.containerBookingId || null, - loadingPlanId: associations.loadingPlanId || null, warehouseId: associations.warehouseId || null, - customerId: identity.customerId, customerName: identity.customerName || null, - carrierId: supplierId || null, carrierName: supplierName || null, - billOfLadingNo: Object.hasOwn(associations, 'billOfLadingNo') - ? associations.billOfLadingNo : `BL-TRUCK-${suffix}`.slice(0, 128), - fleetContact: '车队联系人', fleetPhone: '13800000004', - driverName: '司机验收', driverPhone: '13800000005', driverIdCard: null, vehicleNo: `粤B-${suffix.slice(-6)}`, - storeName: `门店-${suffix}`, storeContact: '门店联系人', storePhone: '13800000006', storeAddress: '深圳市南山区验收地址', - storeCity: '深圳市', storeZipcode: '518000', packageCount: 10, grossWeightKg: 100, volumeCbm: 2.5, - palletCount: 1, poNo: `PO-${suffix}`, plannedLoadingAt: '2026-09-02T08:00:00', transitMinutes: 120, - payableAmount: 100, receivableAmount: 150, currency: 'RMB', remark: context.marker, expectedVersion: version, + outboundOrderId: null, oceanBookingId: identity.oceanBookingId, + containerBookingId: identity.containerBookingId, loadingPlanId: identity.loadingPlanId || null, + warehouseId: identity.warehouseId || null, carrierId: id(supplier.supplierId) || null, + carrierName: supplier.supplierName || null, billOfLadingNo: identity.billOfLadingNo || null, + fleetContact: '车队联系人', fleetPhone: '13800000004', driverName: '司机验收', driverPhone: '13800000005', + vehicleNo: `粤B-${suffix.slice(-6)}`, storeName: `装柜点-${suffix}`, storeContact: '仓库联系人', + storePhone: '13800000006', storeAddress: '深圳市南山区验收地址', storeCity: '深圳', storeZipcode: '518000', + packageCount: 0, grossWeightKg: 0, volumeCbm: 0, palletCount: 0, poNo: `PO-${suffix}`, + plannedLoadingAt: '2026-12-31T10:00:00', transitMinutes: 0, payableAmount: 0, receivableAmount: 0, + currency: 'RMB', remark: `拖车平台业务-${suffix}`, expectedVersion: version, ...overrides, } } -async function ensureTruckSupplier(context, suffix) { - const response = await request(context, 'POST', SUPPLIER_BASE, { - name: `拖车供应商-${suffix}`.slice(0, 128), category: 'TRUCKING', - region: '深圳', legalRepresentative: '验收法人', contactName: '拖车联系人', - phone: '13800000000', email: 'trucking-acceptance@example.com', address: '深圳市验收地址', - businessLicenseNo: `TRUCK${suffix}`.slice(0, 64), licenseFileId: null, - status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: context.marker, - }) - const supplierValue = data(response) - const supplierId = id(typeof supplierValue === 'object' ? supplierValue?.id : supplierValue) - let supplier = typeof supplierValue === 'object' ? supplierValue : null - if (supplierId) { - const detailResponse = await request(context, 'GET', `/api/platform/v1/suppliers/${encodeURIComponent(supplierId)}?tenantId=0`) - supplier = data(detailResponse) - } - context.addStep(MODULE, '创建本轮有效 ACTIVE TRUCKING 拖车供应商', response, - Boolean(supplierId) && supplier?.category === 'TRUCKING' && supplier?.status === 'ACTIVE' && Boolean(supplier?.name), - `supplierId=${supplierId || 'missing'}, category=${supplier?.category || 'missing'}, status=${supplier?.status || 'missing'}`, OPTIONS) - if (supplierId) context.createdResources.truckSupplier = { - marker: context.marker, supplierId, supplierName: supplier.name || supplier.supplierName || null, - category: supplier.category, status: supplier.status, finalStatus: 'ACTIVE', retained: true, - } - return supplierId && supplier?.category === 'TRUCKING' && supplier?.status === 'ACTIVE' - ? context.createdResources.truckSupplier : null +function currentIdentity(context) { + const fixture = [context.createdResources?.phase3ContainerLoading, context.createdResources?.phase3Loading] + .find((item) => item?.marker === context.marker && id(item.oceanBookingId) && id(item.containerBookingId)) + if (!fixture) return null + return { oceanBookingId: id(fixture.oceanBookingId), containerBookingId: id(fixture.containerBookingId), + loadingPlanId: id(fixture.loadingPlanId) || null, warehouseId: id(fixture.warehouseId) || null, + billOfLadingNo: fixture.billOfLadingNo || null } } export async function run(context) { - const suffix = context.marker.replace(/\D/g, '').slice(-10) - const list = await request(context, 'GET', `${BASE}?page=1&size=5&keyword=${encodeURIComponent(context.marker)}`) - if (missing(list) || list.http === 0) { context.addStep(MODULE, '拖车预约服务契约已部署', list, false, `入口 ${BASE} 不可用:HTTP=${list.http}, code=${codeOf(list)}`, OPTIONS); return } - const page = context.apiSuccess(MODULE, '拖车预约分页 keyword 查询', list, '', OPTIONS) - context.assert(MODULE, '拖车预约分页结构正确', list, Array.isArray(page?.records), `records=${page?.records?.length ?? 'n/a'}`, OPTIONS) - for (const status of ['DRAFT','PENDING_DISPATCH','DISPATCHED','PICKUP_IN_PROGRESS','ARRIVED_WAREHOUSE','LOADING','DEPARTED','COMPLETED','RESCHEDULED','EXCEPTION','CANCELLED']) { - const response = await request(context, 'GET', `${BASE}?page=1&size=5&status=${status}`); const result = data(response) - context.addStep(MODULE, `拖车预约 ${status} 筛选`, response, response.http === 200 && codeOf(response) === '200' && Array.isArray(result?.records) && result.records.every((item) => item.status === status), `HTTP=${response.http}, code=${codeOf(response)}, records=${result?.records?.length ?? 'n/a'}`, OPTIONS) - } + const identity = currentIdentity(context) + const supplier = context.createdResources?.truckSupplier + context.assert(MODULE, '本轮拖车前置仅依赖已放舱订舱、真实柜和拖车供应商', null, + Boolean(identity?.oceanBookingId && identity?.containerBookingId && supplier?.supplierId), + `oceanBookingId=${identity?.oceanBookingId || 'missing'}, containerBookingId=${identity?.containerBookingId || 'missing'}, carrierId=${id(supplier?.supplierId) || 'missing'}`, OPTIONS) + if (!identity?.oceanBookingId || !identity?.containerBookingId || !supplier?.supplierId) return - const customerIdentity = selectIndependentTruckCustomer(context) - if (!customerIdentity) { - context.addStep(MODULE, '取得本轮独立拖车客户身份', null, false, - '缺少同 marker 的 customerId;未借用历史客户或订舱', OPTIONS) - return - } - const supplier = await ensureTruckSupplier(context, suffix) - if (!supplier?.supplierId || !supplier?.supplierName) return - - const basePayload = truckAppointmentPayload(context, suffix, customerIdentity) - for (const [name, payload] of [ - ['缺少 customerId 拒绝创建独立拖车', { ...basePayload, customerId: null, customerName: null }], - ['缺少 ACTIVE TRUCKING carrier 拒绝创建独立拖车', { ...basePayload, carrierId: null, carrierName: null }], - ['缺少 plannedLoadingAt 拒绝创建独立拖车', { ...basePayload, plannedLoadingAt: null }], + const suffix = context.marker.replace(/[^A-Za-z0-9]/g, '').slice(-10) + const base = truckAppointmentPayload(context, suffix, identity) + for (const [label, payload] of [ + ['缺少 oceanBookingId 拒绝创建', { ...base, oceanBookingId: null }], + ['缺少 containerBookingId 拒绝创建', { ...base, containerBookingId: null }], + ['缺少拖车供应商拒绝创建', { ...base, carrierId: null }], + ['提单号与订舱不一致拒绝创建', { ...base, billOfLadingNo: `WRONG-${suffix}` }], ]) { - const rejected = await request(context, 'POST', BASE, payload) - context.addStep(MODULE, name, rejected, failure(rejected), - `HTTP=${rejected.http}, code=${codeOf(rejected)}, requestId=${rejected.requestId || 'missing'}, traceId=${rejected.traceId || 'missing'}`, OPTIONS) + const response = await request(context, 'POST', BASE, payload) + context.addStep(MODULE, label, response, failure(response), `HTTP=${response.http}, code=${codeOf(response)}`, OPTIONS) } - const missingBillCreate = await request(context, 'POST', BASE, - truckAppointmentPayload(context, `${suffix}B`, customerIdentity, 0, { billOfLadingNo: null })) - const missingBill = data(missingBillCreate) - if (missingBill?.id) { - const missingBillSubmit = await request(context, 'POST', `${BASE}/${id(missingBill.id)}/submit`, - { expectedVersion: Number(missingBill.version), reason: context.marker }, - { 'Idempotency-Key': `${context.marker}-truck-no-bill-submit`.slice(0, 128) }) - const submitted = data(missingBillSubmit) - const rejectedDispatch = await request(context, 'POST', `${BASE}/${id(missingBill.id)}/dispatch`, - { expectedVersion: Number(submitted?.version), reason: context.marker }, - { 'Idempotency-Key': `${context.marker}-truck-no-bill-dispatch`.slice(0, 128) }) - context.addStep(MODULE, '拖车 DRAFT 可无提单但 DISPATCH 缺提单号与文件明确拒绝', rejectedDispatch, - failure(rejectedDispatch), `HTTP=${rejectedDispatch.http}, code=${codeOf(rejectedDispatch)}`, OPTIONS) - } - - const linkedFlow = [context.createdResources?.phase3Loading, context.createdResources?.phase3ContainerLoading] - .find((item) => item?.marker === context.marker && id(item.containerBookingId) && id(item.loadingPlanId)) - if (linkedFlow) { - const invalidPlanId = (BigInt(id(linkedFlow.loadingPlanId)) + 1n).toString() - const mismatchedPlan = await request(context, 'POST', BASE, - truckAppointmentPayload(context, `${suffix}P`, customerIdentity, 0, { - containerBookingId: id(linkedFlow.containerBookingId), loadingPlanId: invalidPlanId, - warehouseId: id(linkedFlow.warehouseId), oceanBookingId: id(linkedFlow.oceanBookingId) || null, - })) - context.addStep(MODULE, 'loadingPlanId 与关联柜次当前计划不一致时零写入拒绝', mismatchedPlan, - failure(mismatchedPlan), - `containerBookingId=${id(linkedFlow.containerBookingId)}, loadingPlanId=${invalidPlanId}, HTTP=${mismatchedPlan.http}, code=${codeOf(mismatchedPlan)}`, OPTIONS) - if (id(linkedFlow.warehouseId)) { - const mismatchedWarehouseId = (BigInt(id(linkedFlow.warehouseId)) + 1n).toString() - const mismatchedWarehouse = await request(context, 'POST', BASE, - truckAppointmentPayload(context, `${suffix}W`, customerIdentity, 0, { - containerBookingId: id(linkedFlow.containerBookingId), warehouseId: mismatchedWarehouseId, - })) - context.addStep(MODULE, 'warehouseId 与可选关联柜次不一致时零写入拒绝', mismatchedWarehouse, - failure(mismatchedWarehouse), - `containerBookingId=${id(linkedFlow.containerBookingId)}, warehouseId=${mismatchedWarehouseId}, HTTP=${mismatchedWarehouse.http}, code=${codeOf(mismatchedWarehouse)}`, OPTIONS) - } - const unrelatedBooking = context.createdResources?.oceanBookingUnassignedCustomer - if (unrelatedBooking?.marker === context.marker && id(unrelatedBooking.id)) { - const mismatchedAllocation = await request(context, 'POST', BASE, - truckAppointmentPayload(context, `${suffix}A`, customerIdentity, 0, { - oceanBookingId: id(unrelatedBooking.id), containerBookingId: id(linkedFlow.containerBookingId), - })) - context.addStep(MODULE, '订舱与柜次无 allocation 关系时零写入拒绝', mismatchedAllocation, - failure(mismatchedAllocation), - `oceanBookingId=${id(unrelatedBooking.id)}, containerBookingId=${id(linkedFlow.containerBookingId)}, HTTP=${mismatchedAllocation.http}, code=${codeOf(mismatchedAllocation)}`, OPTIONS) - } - } - - const createResponse = await request(context, 'POST', BASE, basePayload); const appointment = context.apiSuccess(MODULE, '创建无订舱柜次计划关联的独立拖车预约', createResponse, '', OPTIONS) + const createdResponse = await request(context, 'POST', BASE, base) + const appointment = context.apiSuccess(MODULE, '创建订舱真实柜拖车预约', createdResponse, '', OPTIONS) const appointmentId = id(appointment?.id) - context.assert(MODULE, '独立拖车仅冻结客户承运商与自身业务字段', createResponse, - Boolean(appointmentId) && appointment?.status === 'DRAFT' - && appointment?.oceanBookingId == null && appointment?.containerBookingId == null - && appointment?.loadingPlanId == null && id(appointment?.customerId) === customerIdentity.customerId - && id(appointment?.carrierId) === id(supplier.supplierId) - && appointment?.payableAmount != null && appointment?.receivableAmount != null, - `id=${appointmentId || 'missing'}, status=${appointment?.status || 'missing'}, oceanBookingId=${id(appointment?.oceanBookingId) || 'null'}, containerBookingId=${id(appointment?.containerBookingId) || 'null'}`, OPTIONS) + context.assert(MODULE, '响应不含客户字段且冻结订舱柜号关系', createdResponse, + Boolean(appointmentId) && appointment?.status === 'DRAFT' && !Object.hasOwn(appointment, 'customerId') + && !Object.hasOwn(appointment, 'customerName') && id(appointment?.oceanBookingId) === identity.oceanBookingId + && id(appointment?.containerBookingId) === identity.containerBookingId, + `appointmentId=${appointmentId || 'missing'}`, OPTIONS) if (!appointmentId) return - context.createdResources.truckAppointment = { id: appointmentId, marker: context.marker, finalStatus: 'COMPLETED' } + context.createdResources.truckAppointment = { id: appointmentId, marker: context.marker, finalStatus: 'DISPATCHED' } const path = `${BASE}/${appointmentId}` - - const upload = await uploadAttachment(context, appointmentId); const attachment = context.apiSuccess(MODULE, '上传本轮拖车预约图片附件', upload, `fileName=${context.marker}-truck.png`, OPTIONS) - const attachmentId = id(attachment?.id) - context.assert(MODULE, '提单附件归属、类型和哈希元数据完整', upload, Boolean(attachmentId) && id(attachment?.appointmentId) === appointmentId && attachment?.attachmentType === 'BILL_OF_LADING' && Boolean(attachment?.fileId) && Boolean(attachment?.fileHash), `attachmentId=${attachmentId || 'missing'}, fileId=${attachment?.fileId || 'missing'}`, OPTIONS) - const attachments = await request(context, 'GET', `${path}/attachments`); const attachmentList = context.apiSuccess(MODULE, '查询拖车预约附件列表', attachments, '', OPTIONS) - context.assert(MODULE, '附件列表包含本轮上传引用', attachments, Array.isArray(attachmentList) && attachmentList.some((item) => id(item.id) === attachmentId), `attachments=${attachmentList?.length ?? 'n/a'}`, OPTIONS) - const forged = await request(context, 'POST', `${path}/attachments`, { fileId: '999999999999999999', attachmentType: 'BILL_OF_LADING', fileName: `${context.marker}-forged.txt`, fileHash: 'forged' }) - context.addStep(MODULE, '伪造附件 fileId 安全拒绝', forged, failure(forged), `HTTP=${forged.http}, code=${codeOf(forged)}`, OPTIONS) - context.assert(MODULE, '派车前保留有效 BILL_OF_LADING 提单附件', attachments, - Boolean(attachmentId) && attachmentList.some((item) => id(item.id) === attachmentId && item.attachmentType === 'BILL_OF_LADING'), - `attachmentId=${attachmentId || 'missing'}`, OPTIONS) - let version = Number(appointment.version) - const edit = await request(context, 'PUT', path, { ...truckAppointmentPayload(context, suffix, customerIdentity, version), vehicleNo: `粤C-${suffix.slice(-6)}` }); const edited = context.apiSuccess(MODULE, '编辑独立拖车预约且关联身份保持为空', edit, `version=${version}`, OPTIONS); version = Number(edited?.version) - const stale = await request(context, 'PUT', path, { ...truckAppointmentPayload(context, suffix, customerIdentity, 0), vehicleNo: `STALE-${suffix}` }) - context.addStep(MODULE, '拖车预约旧版本编辑返回冲突', stale, stale.http === 200 && ['A0409','409'].includes(codeOf(stale)), `HTTP=${stale.http}, code=${codeOf(stale)}`, OPTIONS) - const after = await request(context, 'GET', path); const afterData = data(after); context.apiSuccess(MODULE, '冲突后回查拖车预约', after, '', OPTIONS) - context.assert(MODULE, '拖车预约冲突未部分更新', after, afterData?.vehicleNo === edited?.vehicleNo, `vehicleNo=${afterData?.vehicleNo || 'missing'}`, OPTIONS); version = Number(afterData?.version) - const action = async (name, endpoint, expectedStatus) => { const response = await request(context, 'POST', `${path}/${endpoint}`, { expectedVersion: version, reason: context.marker }, { 'Idempotency-Key': `${context.marker}-${endpoint}` }); const result = context.apiSuccess(MODULE, name, response, '', OPTIONS); context.assert(MODULE, `${name} 状态返回`, response, result?.status === expectedStatus, `status=${result?.status || 'missing'}`, OPTIONS); version = Number(result?.version) } - for (const [name, endpoint, status] of [['提交预约','submit','PENDING_DISPATCH'],['派车','dispatch','DISPATCHED'],['开始提货','pickup','PICKUP_IN_PROGRESS'],['到仓','arrive','ARRIVED_WAREHOUSE'],['开始装货','load','LOADING'],['离场','depart','DEPARTED'],['完成','complete','COMPLETED']]) await action(name, endpoint, status) - const final = await request(context, 'GET', path); const finalData = data(final); context.apiSuccess(MODULE, '回查 COMPLETED 拖车预约', final, '', OPTIONS) - const appointmentFieldGaps = truckAppointmentFieldGaps(finalData) - context.assert(MODULE, '拖车列表客户、仓库、订舱柜次、供应商与关键时间字段完整', final, - appointmentFieldGaps.length === 0, `missing=${appointmentFieldGaps.join(',') || 'none'}`, OPTIONS) - context.assert(MODULE, '拖车预约状态终态和费用快照正确', final, finalData?.status === 'COMPLETED' && finalData?.payableAmount != null && finalData?.receivableAmount != null, `status=${finalData?.status || 'missing'}`, OPTIONS) - context.assert(MODULE, '独立拖车完成自身状态机后仍不产生履约关联', final, - finalData?.oceanBookingId == null && finalData?.containerBookingId == null && finalData?.loadingPlanId == null, - `oceanBookingId=${id(finalData?.oceanBookingId) || 'null'}, containerBookingId=${id(finalData?.containerBookingId) || 'null'}, loadingPlanId=${id(finalData?.loadingPlanId) || 'null'}`, OPTIONS) - const history = await request(context, 'GET', `${path}/history`); const histories = context.apiSuccess(MODULE, '查询拖车预约状态历史', history, '', OPTIONS) - const actions = Array.isArray(histories) ? histories.map((item) => item.action) : [] - context.assert(MODULE, '拖车状态历史动作、版本和审计操作者完整', history, ['SUBMIT','DISPATCH','PICKUP','ARRIVE','LOAD','DEPART','COMPLETE'].every((item) => actions.includes(item)) && histories.every((item) => Number.isFinite(Number(item.revision)) && Boolean(item.operatorName) && Boolean(item.createdAt)), `actions=${actions.join(',')}`, OPTIONS) + const submitKey = `${context.marker}-submit` + const submitBody = { expectedVersion: version, reason: '并发幂等提交' } + const first = await request(context, 'POST', `${path}/submit`, submitBody, { 'Idempotency-Key': submitKey }) + const firstData = context.apiSuccess(MODULE, '提交拖车预约', first, '', OPTIONS) + const replay = await request(context, 'POST', `${path}/submit`, submitBody, { 'Idempotency-Key': submitKey }) + const replayData = context.apiSuccess(MODULE, '同键同载荷重放', replay, '', OPTIONS) + context.assert(MODULE, '提交幂等重放零漂移', replay, + id(firstData?.id) === id(replayData?.id) && Number(firstData?.version) === Number(replayData?.version), + `firstVersion=${firstData?.version}, replayVersion=${replayData?.version}`, OPTIONS) + version = Number(firstData?.version) + const conflict = await request(context, 'POST', `${path}/submit`, { expectedVersion: version, reason: '异载荷' }, + { 'Idempotency-Key': submitKey }) + context.addStep(MODULE, '同键异载荷拒绝', conflict, failure(conflict), `code=${codeOf(conflict)}`, OPTIONS) + const dispatch = await request(context, 'POST', `${path}/dispatch`, { expectedVersion: version, reason: '派车' }, + { 'Idempotency-Key': `${context.marker}-dispatch` }) + const dispatched = context.apiSuccess(MODULE, '有效提单后派车', dispatch, '', OPTIONS) + context.assert(MODULE, '派车状态与稳定字段完整', dispatch, + dispatched?.status === 'DISPATCHED' && truckAppointmentFieldGaps(dispatched).length === 0, + `status=${dispatched?.status || 'missing'}`, OPTIONS) }