diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index a34799f81..7deed37fe 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -9395,12 +9395,13 @@ CREATE TABLE IF NOT EXISTS fl_truck_appointment ( store_name VARCHAR(128) NULL COMMENT '到仓装柜地点名称快照', store_contact VARCHAR(64) NULL COMMENT '到仓地点联系人快照', store_phone VARCHAR(32) NULL COMMENT '到仓地点联系电话快照', store_address VARCHAR(255) NULL COMMENT '到仓装柜地点地址快照', store_city VARCHAR(64) NULL COMMENT '到仓地点城市快照', store_zipcode VARCHAR(16) NULL COMMENT '到仓地点邮编快照', package_count INT UNSIGNED NULL COMMENT '预约货物包装件数快照', gross_weight_kg DECIMAL(18,3) NULL COMMENT '预约货物毛重,单位千克', volume_cbm DECIMAL(18,4) NULL COMMENT '预约货物体积,单位立方米', pallet_count INT UNSIGNED NULL COMMENT '预约托盘数量', po_no VARCHAR(255) NULL COMMENT '关联采购单号', planned_loading_at DATETIME(3) NULL COMMENT '预约到仓装柜时间', - actual_arrived_warehouse_at DATETIME(3) NULL COMMENT '车辆实际到达收货仓时间', actual_loading_at DATETIME(3) NULL COMMENT '实际开始装柜时间', actual_departed_at DATETIME(3) NULL COMMENT '车辆实际离仓时间;DEPARTED后供LOADED门禁使用', transit_minutes INT UNSIGNED NULL COMMENT '从到仓到离仓的运输作业耗时,单位分钟', payable_amount DECIMAL(18,2) NULL COMMENT '应付拖车服务商金额;币种由currency指定', receivable_amount DECIMAL(18,2) NULL COMMENT '拖车业务应收金额;币种由currency指定', - currency VARCHAR(8) NOT NULL DEFAULT 'RMB' COMMENT '金额币种;首期默认RMB', status VARCHAR(32) NOT NULL DEFAULT 'DRAFT' COMMENT '拖车预约状态:DRAFT草稿、PENDING_DISPATCH待派车、DISPATCHED已派车、PICKUP_IN_PROGRESS提货中、ARRIVED_WAREHOUSE已到仓、LOADING装柜中、DEPARTED已离仓、COMPLETED已完成、RESCHEDULED已改约、EXCEPTION异常、CANCELLED已取消', active_container_booking_id BIGINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN status NOT IN ('COMPLETED','CANCELLED','EXCEPTION') THEN container_booking_id ELSE NULL END) STORED COMMENT '非终态预约的柜次唯一键', revision INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '拖车预约业务修订号;每次状态或改约递增', + actual_arrived_warehouse_at DATETIME(3) NULL COMMENT '车辆实际到达收货仓时间', actual_loading_at DATETIME(3) NULL COMMENT '实际开始装柜时间', actual_departed_at DATETIME(3) NULL COMMENT '车辆实际离仓时间;DEPARTED后供LOADED门禁使用', transit_minutes INT UNSIGNED NULL COMMENT '从到仓到离仓的运输作业耗时,单位分钟', payable_amount DECIMAL(18,2) NULL COMMENT '应付拖车服务商金额;币种由currency指定', + currency VARCHAR(8) NOT NULL DEFAULT 'RMB' COMMENT '应付金额币种;仅RMB或USD', status VARCHAR(32) NOT NULL DEFAULT 'DRAFT' COMMENT '拖车预约状态:DRAFT草稿、PENDING_DISPATCH待派车、DISPATCHED已派车、PICKUP_IN_PROGRESS提货中、ARRIVED_WAREHOUSE已到仓、LOADING装柜中、DEPARTED已离仓、COMPLETED已完成、RESCHEDULED已改约、EXCEPTION异常、CANCELLED已取消', active_container_booking_id BIGINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN status NOT IN ('COMPLETED','CANCELLED','EXCEPTION') THEN container_booking_id ELSE NULL END) STORED COMMENT '非终态预约的柜次唯一键', revision INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '拖车预约业务修订号;每次状态或改约递增', version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', remark VARCHAR(1000) NULL COMMENT '平台内部备注或异常说明', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '拖车预约创建时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '拖车预约最后更新时间', PRIMARY KEY(id), UNIQUE KEY uk_truck_appointment_no(appointment_no), UNIQUE KEY uk_truck_active_container(active_container_booking_id), KEY idx_truck_appointment_status(status,updated_at), KEY idx_truck_appointment_container(container_booking_id), - CONSTRAINT chk_truck_appointment_status CHECK(status IN ('DRAFT','PENDING_DISPATCH','DISPATCHED','PICKUP_IN_PROGRESS','ARRIVED_WAREHOUSE','LOADING','DEPARTED','COMPLETED','RESCHEDULED','EXCEPTION','CANCELLED')) + CONSTRAINT chk_truck_appointment_status CHECK(status IN ('DRAFT','PENDING_DISPATCH','DISPATCHED','PICKUP_IN_PROGRESS','ARRIVED_WAREHOUSE','LOADING','DEPARTED','COMPLETED','RESCHEDULED','EXCEPTION','CANCELLED')), + CONSTRAINT chk_truck_appointment_currency CHECK(currency IN ('RMB','USD')) ) ENGINE=InnoDB COMMENT='三期拖车预约派车聚合'; CREATE TABLE IF NOT EXISTS fl_truck_appointment_history ( id BIGINT UNSIGNED NOT NULL COMMENT '拖车预约历史ID', appointment_id BIGINT UNSIGNED NOT NULL COMMENT '所属拖车预约ID', revision INT UNSIGNED NOT NULL COMMENT '该预约业务修订号', action VARCHAR(32) NOT NULL COMMENT '触发状态或改约的业务动作', @@ -13909,7 +13910,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'carrier_id CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'carrier_name', '拖车服务商名称快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'container_booking_id', '关联柜次逻辑ID;拖车完成供装柜门禁校验'); CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'created_at', '拖车预约创建时间'); -CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'currency', '金额币种;首期默认RMB'); +CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'currency', '应付金额币种;仅RMB或USD'); CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'driver_id_card', '司机身份证号;平台内部敏感信息'); CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'driver_name', '司机姓名'); CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'driver_phone', '司机联系电话'); @@ -13925,7 +13926,6 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'pallet_cou CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'payable_amount', '应付拖车服务商金额;币种由currency指定'); CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'planned_loading_at', '预约到仓装柜时间'); CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'po_no', '关联采购单号'); -CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'receivable_amount', '拖车业务应收金额;币种由currency指定'); CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'remark', '平台内部备注或异常说明'); CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'revision', '拖车预约业务修订号;每次状态或改约递增'); CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'status', '拖车预约状态:DRAFT草稿、PENDING_DISPATCH待派车、DISPATCHED已派车、PICKUP_IN_PROGRESS提货中、ARRIVED_WAREHOUSE已到仓、LOADING装柜中、DEPARTED已离仓、COMPLETED已完成、RESCHEDULED已改约、EXCEPTION异常、CANCELLED已取消'); 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 432654b08..43e6ae875 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/TruckAppointmentDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/TruckAppointmentDtos.java @@ -2,7 +2,7 @@ package com.baiyue.firstleg.dto; import jakarta.validation.constraints.*; import java.math.BigDecimal; import java.time.LocalDateTime; public final class TruckAppointmentDtos { private TruckAppointmentDtos(){} public record Query(@Size(max=64) String keyword, com.baiyue.firstleg.domain.TruckAppointmentStatus status, @Positive Long containerBookingId, @Min(1) long page, @Min(1) @Max(200) long size){ public Query{if(page==0)page=1;if(size==0)size=20;} } - public record SaveRequest(@Positive Long outboundOrderId,@NotNull @Positive Long oceanBookingId,@NotNull @Positive Long containerBookingId,@Positive Long loadingPlanId,@Positive Long warehouseId,@NotNull @Positive Long carrierId,@Size(max=128) String carrierName,@Size(max=128) String billOfLadingNo,@Size(max=64) String fleetContact,@Size(max=32) String fleetPhone,@Size(max=64) String driverName,@Size(max=32) String driverPhone,@Size(max=32) String driverIdCard,@Size(max=32) String vehicleNo,@Size(max=128) String storeName,@Size(max=64) String storeContact,@Size(max=32) String storePhone,@Size(max=255) String storeAddress,@Size(max=64) String storeCity,@Size(max=16) String storeZipcode,@PositiveOrZero Integer packageCount,@DecimalMin("0") BigDecimal grossWeightKg,@DecimalMin("0") BigDecimal volumeCbm,@PositiveOrZero Integer palletCount,@Size(max=255) String poNo,@NotNull LocalDateTime plannedLoadingAt,@PositiveOrZero Integer transitMinutes,@DecimalMin("0") BigDecimal payableAmount,@DecimalMin("0") BigDecimal receivableAmount,@Size(max=8) String currency,@Size(max=1000) String remark,@NotNull @PositiveOrZero Integer expectedVersion){} + public record SaveRequest(@Positive Long outboundOrderId,@NotNull @Positive Long oceanBookingId,@NotNull @Positive Long containerBookingId,@Positive Long loadingPlanId,@Positive Long warehouseId,@NotNull @Positive Long carrierId,@Size(max=128) String carrierName,@Size(max=128) String billOfLadingNo,@Size(max=64) String fleetContact,@Size(max=32) String fleetPhone,@Size(max=64) String driverName,@Size(max=32) String driverPhone,@Size(max=32) String driverIdCard,@Size(max=32) String vehicleNo,@Size(max=128) String storeName,@Size(max=64) String storeContact,@Size(max=32) String storePhone,@Size(max=255) String storeAddress,@Size(max=64) String storeCity,@Size(max=16) String storeZipcode,@PositiveOrZero Integer packageCount,@DecimalMin("0") BigDecimal grossWeightKg,@DecimalMin("0") BigDecimal volumeCbm,@PositiveOrZero Integer palletCount,@Size(max=255) String poNo,@NotNull LocalDateTime plannedLoadingAt,@PositiveOrZero Integer transitMinutes,@DecimalMin("0") BigDecimal payableAmount,@Pattern(regexp="RMB|USD") String currency,@Size(max=1000) String remark,@NotNull @PositiveOrZero Integer expectedVersion){} public record ActionRequest(@NotNull @PositiveOrZero Integer expectedVersion,@Size(max=500) String reason){} public record AttachmentRequest(@NotNull @Positive Long fileId,@NotBlank @Pattern(regexp="DISPATCH_ORDER|DRIVER_DOCUMENT|VEHICLE_DOCUMENT|OTHER") String attachmentType,@Size(max=255) String fileName,@Size(max=128) String fileHash){ } } 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 10cf72b9a..3d8b72ee1 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/TruckAppointmentEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/TruckAppointmentEntity.java @@ -45,7 +45,6 @@ public class TruckAppointmentEntity { private LocalDateTime actualDepartedAt; private Integer transitMinutes; private BigDecimal payableAmount; - private BigDecimal receivableAmount; private String currency; private TruckAppointmentStatus status; private Integer revision; 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 0f842494b..8676a0dcc 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/TruckAppointmentMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/TruckAppointmentMapper.java @@ -7,5 +7,5 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.myba @Update("UPDATE fl_truck_appointment SET status='DISPATCHED',bill_of_lading_no=#{billOfLadingNo},revision=revision+1,version=version+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND status=#{fromStatus} AND version=#{version}") int dispatch(@Param("id") long id,@Param("fromStatus") String fromStatus,@Param("billOfLadingNo") String billOfLadingNo,@Param("version") int version); @Update("UPDATE fl_truck_appointment SET status='COMPLETED',actual_loading_at=COALESCE(actual_loading_at,CURRENT_TIMESTAMP(3)),revision=revision+1,version=version+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND status=#{fromStatus} AND version=#{version}") int completeLoadingFromPda(@Param("id") long id,@Param("fromStatus") String fromStatus,@Param("version") int version); @Update("UPDATE fl_truck_appointment SET planned_loading_at=#{at}, revision=revision+1, version=version+1, status='RESCHEDULED', remark=#{reason}, updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND version=#{version}") int reschedule(@Param("id") long id,@Param("at") java.time.LocalDateTime at,@Param("reason") String reason,@Param("version") int version); - @Update("UPDATE fl_truck_appointment SET outbound_order_id=#{e.outboundOrderId},ocean_booking_id=#{e.oceanBookingId},container_booking_id=#{e.containerBookingId},loading_plan_id=#{e.loadingPlanId},warehouse_id=#{e.warehouseId},carrier_id=#{e.carrierId},carrier_name=#{e.carrierName},bill_of_lading_no=#{e.billOfLadingNo},fleet_contact=#{e.fleetContact},fleet_phone=#{e.fleetPhone},driver_name=#{e.driverName},driver_phone=#{e.driverPhone},driver_id_card=#{e.driverIdCard},vehicle_no=#{e.vehicleNo},store_name=#{e.storeName},store_contact=#{e.storeContact},store_phone=#{e.storePhone},store_address=#{e.storeAddress},store_city=#{e.storeCity},store_zipcode=#{e.storeZipcode},package_count=#{e.packageCount},gross_weight_kg=#{e.grossWeightKg},volume_cbm=#{e.volumeCbm},pallet_count=#{e.palletCount},po_no=#{e.poNo},planned_loading_at=#{e.plannedLoadingAt},transit_minutes=#{e.transitMinutes},payable_amount=#{e.payableAmount},receivable_amount=#{e.receivableAmount},currency=#{e.currency},remark=#{e.remark},version=version+1,revision=revision+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{e.id} AND version=#{version}") int updateEdit(@Param("e") TruckAppointmentEntity e,@Param("version") int version); + @Update("UPDATE fl_truck_appointment SET outbound_order_id=#{e.outboundOrderId},ocean_booking_id=#{e.oceanBookingId},container_booking_id=#{e.containerBookingId},loading_plan_id=#{e.loadingPlanId},warehouse_id=#{e.warehouseId},carrier_id=#{e.carrierId},carrier_name=#{e.carrierName},bill_of_lading_no=#{e.billOfLadingNo},fleet_contact=#{e.fleetContact},fleet_phone=#{e.fleetPhone},driver_name=#{e.driverName},driver_phone=#{e.driverPhone},driver_id_card=#{e.driverIdCard},vehicle_no=#{e.vehicleNo},store_name=#{e.storeName},store_contact=#{e.storeContact},store_phone=#{e.storePhone},store_address=#{e.storeAddress},store_city=#{e.storeCity},store_zipcode=#{e.storeZipcode},package_count=#{e.packageCount},gross_weight_kg=#{e.grossWeightKg},volume_cbm=#{e.volumeCbm},pallet_count=#{e.palletCount},po_no=#{e.poNo},planned_loading_at=#{e.plannedLoadingAt},transit_minutes=#{e.transitMinutes},payable_amount=#{e.payableAmount},currency=#{e.currency},remark=#{e.remark},version=version+1,revision=revision+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{e.id} AND version=#{version}") int updateEdit(@Param("e") TruckAppointmentEntity e,@Param("version") int version); } 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 480b7b35b..500c7eeae 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentService.java @@ -36,6 +36,7 @@ import org.springframework.web.multipart.MultipartFile; @Service public class TruckAppointmentService { private static final Set ATTACHMENT_TYPES = Set.of("DISPATCH_ORDER", "DRIVER_DOCUMENT", "VEHICLE_DOCUMENT", "OTHER"); + private static final Set CURRENCIES = Set.of("RMB", "USD"); @Resource TruckAppointmentRepository repo; @Resource OceanBookingRepository oceanBookings; @Resource OceanBookingDocumentService oceanBookingDocuments; @@ -79,7 +80,7 @@ public class TruckAppointmentService { private TruckAppointmentStatus next(TruckAppointmentStatus s,String a){return switch(a){case"SUBMIT"->s==TruckAppointmentStatus.DRAFT?TruckAppointmentStatus.PENDING_DISPATCH:badStatus();case"DISPATCH"->s==TruckAppointmentStatus.PENDING_DISPATCH||s==TruckAppointmentStatus.RESCHEDULED?TruckAppointmentStatus.DISPATCHED:badStatus();case"PICKUP"->s==TruckAppointmentStatus.DISPATCHED?TruckAppointmentStatus.PICKUP_IN_PROGRESS:badStatus();case"ARRIVE"->s==TruckAppointmentStatus.PICKUP_IN_PROGRESS?TruckAppointmentStatus.ARRIVED_WAREHOUSE:badStatus();case"LOAD"->s==TruckAppointmentStatus.ARRIVED_WAREHOUSE?TruckAppointmentStatus.LOADING:badStatus();case"DEPART"->s==TruckAppointmentStatus.LOADING?TruckAppointmentStatus.DEPARTED:badStatus();case"COMPLETE"->s==TruckAppointmentStatus.DEPARTED?TruckAppointmentStatus.COMPLETED:badStatus();case"EXCEPTION"->s!=TruckAppointmentStatus.COMPLETED&&s!=TruckAppointmentStatus.CANCELLED?TruckAppointmentStatus.EXCEPTION:badStatus();case"CANCEL"->s!=TruckAppointmentStatus.COMPLETED?TruckAppointmentStatus.CANCELLED:badStatus();default->badStatus();};} private TruckAppointmentStatus badStatus(){throw conflict("不允许的状态动作");} private TruckAppointmentEntity required(long id){TruckAppointmentEntity e=repo.find(id);if(e==null)throw new BizException(ResultCode.NOT_FOUND,"拖车预约不存在");return e;} private void checkVersion(TruckAppointmentEntity e,int v){if(e.getVersion()!=v)throw conflict("版本已变化,请刷新后重试");} private static BizException conflict(String m){return new BizException(ResultCode.CONFLICT,m);} private static BizException bad(String m){return new BizException(ResultCode.BAD_REQUEST,m);} private TruckAppointmentEntity enrich(TruckAppointmentEntity value){if(value.getOceanBookingId()!=null){OceanBookingEntity booking=oceanBookings.find(value.getOceanBookingId());if(booking!=null){value.setOceanBookingNo(booking.getBookingNo());value.setWarehouseName(booking.getWarehouseName());value.setShippingCompanyId(booking.getShippingCompanyId());value.setShippingCompanyName(booking.getShippingCompanyName());value.setBookingAgentId(booking.getBookingAgentId());value.setBookingAgentName(booking.getBookingAgentName());}}if(value.getContainerBookingId()!=null){ContainerBookingEntity container=containers.find(value.getContainerBookingId());if(container!=null){value.setCabinetNo(container.getCabinetNo());value.setContainerNo(container.getContainerNo());if(!StringUtils.hasText(value.getWarehouseName()))value.setWarehouseName(container.getWarehouseName());}}value.setAttachmentCount((long)repo.attachments(value.getId()).size());return value;} - private void copy(TruckAppointmentEntity e,SaveRequest r,AssociationContext links,String carrierName){e.setOutboundOrderId(r.outboundOrderId());e.setOceanBookingId(r.oceanBookingId());e.setContainerBookingId(r.containerBookingId());e.setLoadingPlanId(r.loadingPlanId());e.setWarehouseId(links.warehouseId());e.setCarrierId(r.carrierId());e.setCarrierName(carrierName);e.setBillOfLadingNo(links.billOfLadingNo());e.setFleetContact(r.fleetContact());e.setFleetPhone(r.fleetPhone());e.setDriverName(r.driverName());e.setDriverPhone(r.driverPhone());e.setDriverIdCard(r.driverIdCard());e.setVehicleNo(r.vehicleNo());e.setStoreName(r.storeName());e.setStoreContact(r.storeContact());e.setStorePhone(r.storePhone());e.setStoreAddress(r.storeAddress());e.setStoreCity(r.storeCity());e.setStoreZipcode(r.storeZipcode());e.setPackageCount(r.packageCount());e.setGrossWeightKg(r.grossWeightKg());e.setVolumeCbm(r.volumeCbm());e.setPalletCount(r.palletCount());e.setPoNo(r.poNo());e.setPlannedLoadingAt(r.plannedLoadingAt());e.setTransitMinutes(r.transitMinutes());e.setPayableAmount(r.payableAmount());e.setReceivableAmount(r.receivableAmount());e.setCurrency(r.currency()==null?"RMB":r.currency());e.setRemark(r.remark());} + private void copy(TruckAppointmentEntity e,SaveRequest r,AssociationContext links,String carrierName){e.setOutboundOrderId(r.outboundOrderId());e.setOceanBookingId(r.oceanBookingId());e.setContainerBookingId(r.containerBookingId());e.setLoadingPlanId(r.loadingPlanId());e.setWarehouseId(links.warehouseId());e.setCarrierId(r.carrierId());e.setCarrierName(carrierName);e.setBillOfLadingNo(links.billOfLadingNo());e.setFleetContact(r.fleetContact());e.setFleetPhone(r.fleetPhone());e.setDriverName(r.driverName());e.setDriverPhone(r.driverPhone());e.setDriverIdCard(r.driverIdCard());e.setVehicleNo(r.vehicleNo());e.setStoreName(r.storeName());e.setStoreContact(r.storeContact());e.setStorePhone(r.storePhone());e.setStoreAddress(r.storeAddress());e.setStoreCity(r.storeCity());e.setStoreZipcode(r.storeZipcode());e.setPackageCount(r.packageCount());e.setGrossWeightKg(r.grossWeightKg());e.setVolumeCbm(r.volumeCbm());e.setPalletCount(r.palletCount());e.setPoNo(r.poNo());e.setPlannedLoadingAt(r.plannedLoadingAt());e.setTransitMinutes(r.transitMinutes());e.setPayableAmount(r.payableAmount());e.setCurrency(currency(r.currency()));e.setRemark(r.remark());} private TruckAppointmentEntity claimOrReplay(long id,String action,String key,String hash){validateKey(key);String normalized=key.trim();if(repo.claimOperation(IdWorker.getId(),id,action,normalized,hash))return null;TruckAppointmentOperationEntity existing=repo.operation(normalized);if(existing==null||!Objects.equals(existing.getAppointmentId(),id)||!action.equals(existing.getAction())||!hash.equals(existing.getRequestHash()))throw conflict("Idempotency-Key已用于不同请求");if(!StringUtils.hasText(existing.getResponseSnapshot()))throw conflict("相同请求正在处理中,请稍后重试");try{return objectMapper.readValue(existing.getResponseSnapshot(),TruckAppointmentEntity.class);}catch(JsonProcessingException ex){throw new IllegalStateException("无法读取拖车预约幂等结果",ex);}} private TruckAppointmentEntity completeOperation(long id,String action,String key,TruckAppointmentEntity result){try{String normalized=key.trim();if(repo.completeOperation(id,action,normalized,objectMapper.writeValueAsString(result))!=1)throw conflict("拖车预约幂等请求完成状态冲突");return result;}catch(JsonProcessingException ex){throw new IllegalStateException("无法保存拖车预约幂等结果",ex);}} private String hash(String action,long id,Object request){try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest((action+'|'+id+'|'+objectMapper.writeValueAsString(request)).getBytes(StandardCharsets.UTF_8)));}catch(Exception ex){throw new IllegalStateException("无法计算拖车预约请求摘要",ex);}} @@ -102,6 +103,7 @@ public class TruckAppointmentService { } private static String releasedBookingBill(OceanBookingEntity booking,String requested){String authoritative=trim(booking.getBillOfLadingNo());if(!StringUtils.hasText(authoritative))throw conflict("关联已放舱订舱缺少提单号");if(StringUtils.hasText(requested)&&!authoritative.equals(requested))throw bad("billOfLadingNo与关联订舱不一致");return authoritative;} private static String attachmentType(String value){String normalized=trim(value);if(!ATTACHMENT_TYPES.contains(normalized))throw bad("不支持的拖车附件类型");return normalized;} + private static String currency(String value){if(!StringUtils.hasText(value))return "RMB";String normalized=value.trim();if(!CURRENCIES.contains(normalized))throw bad("currency仅支持RMB或USD");return normalized;} private static String trim(String value){return StringUtils.hasText(value)?value.trim():null;} private record AssociationContext(OceanBookingEntity booking,ContainerBookingEntity container,Long warehouseId,String billOfLadingNo){} private void record(TruckAppointmentEntity e,String from,String to,String action,String reason,long uid,String name){TruckAppointmentHistoryEntity h=new TruckAppointmentHistoryEntity();h.setId(IdWorker.getId());h.setAppointmentId(e.getId());h.setRevision(e.getRevision());h.setAction(action);h.setFromStatus(from);h.setToStatus(to);h.setReason(reason);h.setOperatorId(uid);h.setOperatorName(name);h.setCreatedAt(LocalDateTime.now());repo.history(h);} 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 f42423188..d271e784e 100644 --- a/firstleg/src/main/resources/db/phase3-truck-appointment-schema.sql +++ b/firstleg/src/main/resources/db/phase3-truck-appointment-schema.sql @@ -33,8 +33,7 @@ CREATE TABLE IF NOT EXISTS fl_truck_appointment ( actual_departed_at DATETIME(3) NULL COMMENT '车辆实际离仓时间;DEPARTED后供LOADED门禁使用', transit_minutes INT UNSIGNED NULL COMMENT '从到仓到离仓的运输作业耗时,单位分钟', payable_amount DECIMAL(18,2) NULL COMMENT '应付拖车服务商金额;币种由currency指定', - receivable_amount DECIMAL(18,2) NULL COMMENT '拖车业务应收金额;币种由currency指定', - currency VARCHAR(8) NOT NULL DEFAULT 'RMB' COMMENT '金额币种;首期默认RMB', + currency VARCHAR(8) NOT NULL DEFAULT 'RMB' COMMENT '应付金额币种;仅RMB或USD', status VARCHAR(32) NOT NULL DEFAULT 'DRAFT' COMMENT '拖车预约状态:DRAFT草稿、PENDING_DISPATCH待派车、DISPATCHED已派车、PICKUP_IN_PROGRESS提货中、ARRIVED_WAREHOUSE已到仓、LOADING装柜中、DEPARTED已离仓、COMPLETED已完成、RESCHEDULED已改约、EXCEPTION异常、CANCELLED已取消', active_container_booking_id BIGINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN status NOT IN ('COMPLETED','CANCELLED','EXCEPTION') THEN container_booking_id ELSE NULL END) STORED COMMENT '非终态预约的柜次唯一键', revision INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '拖车预约业务修订号;每次状态或改约递增', @@ -44,7 +43,8 @@ CREATE TABLE IF NOT EXISTS fl_truck_appointment ( updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '拖车预约最后更新时间', PRIMARY KEY(id), UNIQUE KEY uk_truck_appointment_no(appointment_no), UNIQUE KEY uk_truck_active_container(active_container_booking_id), KEY idx_truck_appointment_status(status,updated_at), KEY idx_truck_appointment_container(container_booking_id), - CONSTRAINT chk_truck_appointment_status CHECK(status IN ('DRAFT','PENDING_DISPATCH','DISPATCHED','PICKUP_IN_PROGRESS','ARRIVED_WAREHOUSE','LOADING','DEPARTED','COMPLETED','RESCHEDULED','EXCEPTION','CANCELLED')) + CONSTRAINT chk_truck_appointment_status CHECK(status IN ('DRAFT','PENDING_DISPATCH','DISPATCHED','PICKUP_IN_PROGRESS','ARRIVED_WAREHOUSE','LOADING','DEPARTED','COMPLETED','RESCHEDULED','EXCEPTION','CANCELLED')), + CONSTRAINT chk_truck_appointment_currency CHECK(currency IN ('RMB','USD')) ) ENGINE=InnoDB COMMENT='三期拖车预约派车聚合'; CREATE TABLE IF NOT EXISTS fl_truck_appointment_history ( id BIGINT UNSIGNED NOT NULL COMMENT '拖车预约历史ID', appointment_id BIGINT UNSIGNED NOT NULL COMMENT '所属拖车预约ID', revision INT UNSIGNED NOT NULL COMMENT '该预约业务修订号', action VARCHAR(32) NOT NULL COMMENT '触发状态或改约的业务动作', diff --git a/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml b/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml index 7833a4d09..8beee8d42 100644 --- a/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml +++ b/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml @@ -187,6 +187,8 @@ components: carrierName: {type: string, maxLength: 128, nullable: true, description: Optional consistency hint only; the persisted snapshot comes from Warehouse supplier master data.} billOfLadingNo: {type: string, maxLength: 128, nullable: true, description: Optional consistency hint; the persisted snapshot is always frozen from the selected RELEASED booking.} plannedLoadingAt: {type: string, format: date-time} + payableAmount: {type: number, format: double, minimum: 0, nullable: true, description: Amount payable to the trucking supplier.} + currency: {type: string, enum: [RMB, USD], default: RMB, description: Payable amount currency.} expectedVersion: {type: integer, minimum: 0, description: Must be 0 on create; current version on edit.} DetailStableReferences: type: object @@ -209,4 +211,6 @@ components: carrierId: {type: integer, format: int64} carrierName: {type: string} billOfLadingNo: {type: string, maxLength: 128, nullable: true, description: Frozen bill of lading snapshot used by dispatch readiness.} + payableAmount: {type: number, format: double, minimum: 0, nullable: true} + currency: {type: string, enum: [RMB, USD]} attachmentCount: {type: integer, format: int64, minimum: 0, description: Number of attachment references owned by this appointment} 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 350256a7b..a359e77f7 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 schemaHasPlatformAggregateWithoutCustomerColumns() throws Exception {String s=new ClassPathResource("db/phase3-truck-appointment-schema.sql").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("fl_truck_appointment","fl_truck_appointment_history","fl_truck_appointment_attachment","fl_truck_appointment_operation","bill_of_lading_no VARCHAR(128)","chk_truck_attachment_type","DISPATCH_ORDER","PENDING_DISPATCH","PICKUP_IN_PROGRESS","ARRIVED_WAREHOUSE","RESCHEDULED","actual_arrived_warehouse_at","actual_loading_at","actual_departed_at","CALL phase3_truck_add_column","visibility VARCHAR(24) NOT NULL DEFAULT 'PLATFORM'","active_container_booking_id BIGINT UNSIGNED GENERATED ALWAYS AS","UNIQUE KEY uk_truck_active_container(active_container_booking_id)").doesNotContain("customer_id", "customer_name", "idx_truck_appointment_customer");} + @Test void schemaHasPlatformAggregateWithoutCustomerOrReceivableColumns() throws Exception {String s=new ClassPathResource("db/phase3-truck-appointment-schema.sql").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("fl_truck_appointment","fl_truck_appointment_history","fl_truck_appointment_attachment","fl_truck_appointment_operation","bill_of_lading_no VARCHAR(128)","chk_truck_attachment_type","DISPATCH_ORDER","PENDING_DISPATCH","PICKUP_IN_PROGRESS","ARRIVED_WAREHOUSE","RESCHEDULED","actual_arrived_warehouse_at","actual_loading_at","actual_departed_at","CALL phase3_truck_add_column","visibility VARCHAR(24) NOT NULL DEFAULT 'PLATFORM'","active_container_booking_id BIGINT UNSIGNED GENERATED ALWAYS AS","UNIQUE KEY uk_truck_active_container(active_container_booking_id)","chk_truck_appointment_currency CHECK(currency IN ('RMB','USD'))").doesNotContain("customer_id", "customer_name", "idx_truck_appointment_customer", "receivable_amount");} @Test void existingAppointmentTableReceivesAllLateColumns() throws Exception { String moduleSql=new ClassPathResource("db/phase3-truck-appointment-schema.sql").getContentAsString(StandardCharsets.UTF_8); Path direct=Path.of("deploy","sql","头程数据库统一增量-MySQL8.sql"); @@ -32,6 +32,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 openApiRequiresReleasedBookingAndAllocatedRealContainerWithoutCustomerScope() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("required: [oceanBookingId, containerBookingId, carrierId, plannedLoadingAt, expectedVersion]","RELEASED ocean booking","ACTIVE real-numbered container","OCEAN_BOOKING_DOCUMENT","valid unique ISO 6346 container number","schema: {$ref: '#/components/schemas/DetailEnvelope'}","required: [code, message, data, timestamp]","allOf: [{$ref: '#/components/schemas/DetailStableReferences'}]","nullable: true").doesNotContain("customerId", "customerName", "Filter by customer");} @Test void openApiKeepsOnlyNonBookingTruckAttachments() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("AttachmentRequest:","required: [fileId, attachmentType]","required: [attachmentType, file]","TRUCK_APPOINTMENT_ATTACHMENT","Deletes the appointment reference only","DetailStableReferences:","oceanBookingNo:","shippingCompanyName:","bookingAgentName:","cabinetNo:","billOfLadingNo:").doesNotContain("enum: [BILL_OF_LADING, DISPATCH_ORDER");} + @Test void openApiExposesOnlyPayableWithFixedCurrencies() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("payableAmount:","enum: [RMB, USD]","default: RMB").doesNotContain("receivableAmount");} @Test void methodsHavePlatformPermissionsAndAuthenticatedScope() { Map expected=Map.ofEntries( Map.entry("page","platform:firstleg:truck-appointment:list"), Map.entry("detail","platform:firstleg:truck-appointment:list"), diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java index 9b9ea8c0f..97b020c93 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java @@ -155,7 +155,7 @@ class Phase3FinalUnifiedSqlContractTest { .contains("存在缺失中文注释的业务字段") .doesNotContain("CREATE PROCEDURE upgrade_column_comment"); assertThat(count(sql, "CALL queue_table_comment(")).isEqualTo(144); - assertThat(count(sql, "CALL queue_column_comment(")).isGreaterThanOrEqualTo(2379); + assertThat(count(sql, "CALL queue_column_comment(")).isGreaterThanOrEqualTo(2378); assertThat(sql) .contains("CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'supplier_id'") .contains("CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'supplier_name'") 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 6cbe4853a..e786d8061 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/TruckAppointmentMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/TruckAppointmentMapperContractTest.java @@ -34,5 +34,6 @@ class TruckAppointmentMapperContractTest { "planned_loading_at=#{e.plannedLoadingAt}", "bill_of_lading_no=#{e.billOfLadingNo}", "WHERE id=#{e.id} AND version=#{version}", "version=version+1", "revision=revision+1"); assertThat(edit).doesNotContain("customer_id", "customer_name", "customerId", "customerName"); + assertThat(edit).doesNotContain("receivable_amount", "receivableAmount"); } } 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 1dce4be86..4eac505d4 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java @@ -164,6 +164,31 @@ class TruckAppointmentServiceTest { assertThat(inserted.getValue().getPalletCount()).isZero(); assertThat(inserted.getValue().getGrossWeightKg()).isZero(); assertThat(inserted.getValue().getVolumeCbm()).isZero(); + assertThat(inserted.getValue().getCurrency()).isEqualTo("RMB"); + } + + @Test void createAcceptsUsd() { + SaveRequest request = validRequest(); + when(request.currency()).thenReturn("USD"); + when(request.payableAmount()).thenReturn(new java.math.BigDecimal("123.45")); + + service.create(7L, "admin", request); + + ArgumentCaptor inserted = ArgumentCaptor.forClass(TruckAppointmentEntity.class); + verify(repository).insert(inserted.capture()); + assertThat(inserted.getValue().getCurrency()).isEqualTo("USD"); + assertThat(inserted.getValue().getPayableAmount()).isEqualByComparingTo("123.45"); + } + + @Test void createRejectsUnsupportedCurrencyWithoutWriting() { + SaveRequest request = validRequest(); + when(request.currency()).thenReturn("CNY"); + + assertThatThrownBy(() -> service.create(7L, "admin", request)) + .isInstanceOf(BizException.class).hasMessageContaining("RMB或USD"); + + verify(repository, never()).insert(any()); + verify(repository, never()).history(any()); } @Test void platformOwnedAttachmentUsesZeroTenantWithoutCustomerSnapshot() { 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 b508e7b61..3a20ab712 100644 --- a/scripts/admin-test-env-modules/fulfillment-list-field-contract.mjs +++ b/scripts/admin-test-env-modules/fulfillment-list-field-contract.mjs @@ -122,6 +122,8 @@ export function truckAppointmentFieldGaps(record) { status: isNonBlank, carrierId: isPositiveId, carrierName: isNonBlank, + payableAmount: isNonNegativeNumber, + currency: (value) => value === 'RMB' || value === 'USD', 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 f934da646..07070d4ea 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', 'company', 'driver', 'vehicleNo', 'store', 'payableAmount', 'receivableAmount', 'plannedLoadingAt', 'attachmentCount'], + 'trucking/pages/TruckAppointmentsView.vue': ['appointmentNo', 'containerNo', 'company', 'driver', 'vehicleNo', 'store', 'payableAmount', 'currency', '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'], } @@ -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: '宁波仓', 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: '拖车商', payableAmount: 0, currency: 'RMB', 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 232541435..ded979608 100644 --- a/scripts/admin-test-env-modules/manual-state-ocean-trucking.mjs +++ b/scripts/admin-test-env-modules/manual-state-ocean-trucking.mjs @@ -95,7 +95,7 @@ function truckPayload(context, oceanBooking, carrier, index) { storeAddress: '深圳市南山区人工验收地址', storeCity: '深圳市', storeZipcode: '518000', packageCount: 10, grossWeightKg: 100, volumeCbm: 2.5, palletCount: 1, poNo: `PO-${value}`, plannedLoadingAt: '2026-09-16T08:00:00', transitMinutes: 120, payableAmount: 100, - receivableAmount: 150, currency: 'RMB', remark: `${context.marker} 人工列表状态 ${index}`, expectedVersion: 0 } + currency: 'RMB', remark: `${context.marker} 人工列表状态 ${index}`, expectedVersion: 0 } } async function createTruck(context, oceanBooking, index) { diff --git a/scripts/admin-test-env-modules/service-center-flow.mjs b/scripts/admin-test-env-modules/service-center-flow.mjs index 9b049aab2..398a79d07 100644 --- a/scripts/admin-test-env-modules/service-center-flow.mjs +++ b/scripts/admin-test-env-modules/service-center-flow.mjs @@ -98,7 +98,7 @@ export async function run(context) { driverIdCard: null, vehicleNo: `粤D-${suffix.slice(-6)}`, storeName: `链路门店-${suffix}`, storeContact: '链路联系人', storePhone: '13800000009', storeAddress: '深圳市南山区链路地址', storeCity: '深圳市', storeZipcode: '518000', packageCount: 1, grossWeightKg: 1, volumeCbm: 0.1, palletCount: 0, poNo: `FLOW-PO-${suffix}`, - plannedLoadingAt: '2026-09-03T08:00:00', transitMinutes: 60, payableAmount: 1, receivableAmount: 2, + plannedLoadingAt: '2026-09-03T08:00:00', transitMinutes: 60, payableAmount: 1, currency: 'RMB', remark: context.marker, expectedVersion: 0, } if (linkContainer) { 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 3d291b9b2..bfdd18387 100644 --- a/scripts/admin-test-env-modules/truck-appointments-contract.test.mjs +++ b/scripts/admin-test-env-modules/truck-appointments-contract.test.mjs @@ -58,6 +58,7 @@ test('payload requires booking container and carrier but never emits customer fi { oceanBookingId: '91', containerBookingId: '81', carrierId: '501', carrierName: '正式拖车商' }) assert.equal(Object.hasOwn(payload, 'customerId'), false) assert.equal(Object.hasOwn(payload, 'customerName'), false) + assert.equal(Object.hasOwn(payload, 'receivableAmount'), false) }) test('backend DTO entity mapper service and OpenAPI have no truck customer protocol', () => { @@ -91,13 +92,15 @@ test('HTTP regression is self-contained and covers no-customer and idempotency m for (const label of ['自包含创建 DRAFT 订舱', '上传当前有效 BILL_OF_LADING 文件', '放舱自包含订舱', '绑定本轮真实 ISO 柜号', '拖车请求不含客户字段', '同柜第二条非终态预约拒绝且零漂移', '同键同载荷重放', '同键重放版本零漂移', '同键异载荷拒绝', '错误订舱拒绝创建', - '错误柜号归属拒绝创建', '错误提单拒绝创建', '错误承运商拒绝创建', + '错误柜号归属拒绝创建', '错误提单拒绝创建', '错误承运商拒绝创建', '非法拖车币种拒绝创建', + 'RMB应付费用响应无客户及应收字段', '编辑为USD应付费用', 'USD应付费用保存且无应收字段', '派车命令返回状态身份与版本正确', '派车后回查拖车完整详情', '详情字段完整且订舱柜号身份无客户字段漂移']) assert.match(source, new RegExp(label)) assert.match(source, /const detailResponse = await request\(context, 'GET', path\); const detail = data\(detailResponse\)/) assert.match(source, /const detailGaps = truckAppointmentFieldGaps\(detail\)/) assert.doesNotMatch(source, /truckAppointmentFieldGaps\(dispatched\)/) assert.doesNotMatch(source, /phase3ContainerLoading|phase3Loading/) + assert.doesNotMatch(source, /receivableAmount\s*:/) const entry = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8') assert.match(entry, /\['truck-appointments', \['merchant-qualification-fixture'\]\]/) assert.doesNotMatch(entry, /\['truck-appointments', \[[^\]]*phase3-loading/) diff --git a/scripts/admin-test-env-modules/truck-appointments.mjs b/scripts/admin-test-env-modules/truck-appointments.mjs index 74702a1e9..077376ab9 100644 --- a/scripts/admin-test-env-modules/truck-appointments.mjs +++ b/scripts/admin-test-env-modules/truck-appointments.mjs @@ -55,7 +55,7 @@ export function truckAppointmentPayload(context, suffix, identity, version = 0, 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, + plannedLoadingAt: '2026-12-31T10:00:00', transitMinutes: 0, payableAmount: 0, currency: 'RMB', remark: `拖车平台业务-${suffix}`, expectedVersion: version, ...overrides } } @@ -172,6 +172,7 @@ export async function run(context) { ['错误柜号归属拒绝创建', { ...base, containerBookingId: (BigInt(identity.containerBookingId) + 1n).toString() }], ['错误提单拒绝创建', { ...base, billOfLadingNo: `WRONG-${identity.suffix}` }], ['错误承运商拒绝创建', { ...base, carrierId: (BigInt(identity.truckSupplier.supplierId) + 1n).toString() }], + ['非法拖车币种拒绝创建', { ...base, currency: 'CNY' }], ]) { const response = await request(context, 'POST', TRUCKS, payload) context.addStep(MODULE, label, response, failure(response), `code=${codeOf(response)}`, OPTIONS) @@ -179,12 +180,21 @@ export async function run(context) { const createdResponse = await request(context, 'POST', TRUCKS, base) const appointment = context.apiSuccess(MODULE, '创建合法拖车预约', createdResponse, '', OPTIONS) const appointmentId = id(appointment?.id); if (!appointmentId) return - context.assert(MODULE, '拖车响应不含客户字段', createdResponse, - !Object.hasOwn(appointment, 'customerId') && !Object.hasOwn(appointment, 'customerName'), '', OPTIONS) + context.assert(MODULE, 'RMB应付费用响应无客户及应收字段', createdResponse, + appointment?.currency === 'RMB' && Number(appointment?.payableAmount) === 0 + && !Object.hasOwn(appointment, 'receivableAmount') + && !Object.hasOwn(appointment, 'customerId') && !Object.hasOwn(appointment, 'customerName'), '', OPTIONS) + const path = `${TRUCKS}/${appointmentId}` + const usdResponse = await request(context, 'PUT', path, + { ...base, currency: 'USD', payableAmount: 12.34, expectedVersion: Number(appointment.version) }) + const usdAppointment = context.apiSuccess(MODULE, '编辑为USD应付费用', usdResponse, '', OPTIONS) + context.assert(MODULE, 'USD应付费用保存且无应收字段', usdResponse, + usdAppointment?.currency === 'USD' && Number(usdAppointment?.payableAmount) === 12.34 + && !Object.hasOwn(usdAppointment || {}, 'receivableAmount'), '', OPTIONS) const duplicate = await request(context, 'POST', TRUCKS, base) context.addStep(MODULE, '同柜第二条非终态预约拒绝且零漂移', duplicate, failure(duplicate), `code=${codeOf(duplicate)}`, OPTIONS) - const path = `${TRUCKS}/${appointmentId}`; const submitKey = `${context.marker}-truck-submit`.slice(0, 128) - const submitBody = { expectedVersion: Number(appointment.version), reason: '幂等提交' } + const submitKey = `${context.marker}-truck-submit`.slice(0, 128) + const submitBody = { expectedVersion: Number(usdAppointment?.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 }) @@ -206,9 +216,11 @@ export async function run(context) { context.assert(MODULE, '详情字段完整且订舱柜号身份无客户字段漂移', detailResponse, detail?.status === 'DISPATCHED' && detailGaps.length === 0 && !Object.hasOwn(detail || {}, 'customerId') && !Object.hasOwn(detail || {}, 'customerName') + && !Object.hasOwn(detail || {}, 'receivableAmount') + && detail?.currency === 'USD' && Number(detail?.payableAmount) === 12.34 && id(detail?.oceanBookingId) === identity.oceanBookingId && id(detail?.containerBookingId) === identity.containerBookingId, - `missing=${detailGaps.join(',') || 'none'}, oceanBookingId=${id(detail?.oceanBookingId) || 'missing'}, containerBookingId=${id(detail?.containerBookingId) || 'missing'}`, OPTIONS) + `missing=${detailGaps.join(',') || 'none'}, currency=${detail?.currency || 'missing'}, payableAmount=${detail?.payableAmount ?? 'missing'}, oceanBookingId=${id(detail?.oceanBookingId) || 'missing'}, containerBookingId=${id(detail?.containerBookingId) || 'missing'}`, OPTIONS) context.createdResources.truckAppointment = { id: appointmentId, marker: context.marker, finalStatus: 'DISPATCHED', oceanBookingId: identity.oceanBookingId, containerBookingId: identity.containerBookingId } }