diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 82e10bf4b..0f6f2397d 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -2725,13 +2725,12 @@ CREATE TABLE ll_us_truck_order ( id bigint NOT NULL COMMENT '主键ID', customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', order_no varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '订单号', - transport_type varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '运输类型', + vehicle_type VARCHAR(32) NULL COMMENT '车辆类型:PALLET_15为15拖,PALLET_30为30拖;拼车未提供时为空', origin varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '起运地', origin_address varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '起运地址', destination varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '目的地', destination_address varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的地址', - container_type varchar(32) COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '柜型;拼车来源未提供时为空,不推断为整柜', - pallet_count int NOT NULL DEFAULT '1' COMMENT '客户申报托盘数', + pallet_count INT NOT NULL DEFAULT 0 COMMENT '确认托盘数;独立下单首次实收确定,拼车来源取真实预报', package_count int DEFAULT NULL COMMENT '可选箱数,零或空值均有效', received_pallet_count int NOT NULL DEFAULT '0' COMMENT 'PDA实收托盘数', allocated_pallet_count int NOT NULL DEFAULT '0' COMMENT '已分配到合车行程的托盘数', @@ -2739,9 +2738,12 @@ CREATE TABLE ll_us_truck_order ( total_volume_cbm decimal(18,6) NOT NULL DEFAULT '0.000000' COMMENT '总体积(立方米)', budget_amount decimal(18,2) DEFAULT NULL COMMENT '预算金额', currency varchar(3) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'USD' COMMENT '币种', - contact_name varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '联系人姓名', - contact_info varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '联系人联系方式', - requested_date date NOT NULL COMMENT '要求服务日期', + origin_contact_name VARCHAR(128) NULL COMMENT '起运地联系人', + origin_contact_phone VARCHAR(128) NULL COMMENT '起运地联系电话', + destination_contact_name VARCHAR(128) NULL COMMENT '目的地联系人', + destination_contact_phone VARCHAR(128) NULL COMMENT '目的地联系电话', + pickup_at DATETIME NULL COMMENT '取货时间,按填写的日期时分保存,不转换时区', + delivery_at DATETIME NULL COMMENT '送货时间,按填写的日期时分保存;拼车来源未提供时为空', status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '美国卡车订单状态', remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注', idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务幂等键', @@ -2754,7 +2756,7 @@ CREATE TABLE ll_us_truck_order ( UNIQUE KEY uk_ll_us_truck_order_no (customer_id,order_no), UNIQUE KEY uk_ll_us_truck_order_key (customer_id,idempotency_key), KEY idx_ll_us_truck_order_list (customer_id,status,created_at), - KEY idx_ll_us_truck_order_date (customer_id,requested_date,created_at) + KEY idx_ll_us_truck_order_date (customer_id,pickup_at,created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='美国卡车运输订单'; CREATE TABLE ll_us_truck_order_operation ( diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridge.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridge.java index 96d7e93db..b16a9e1af 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridge.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridge.java @@ -73,11 +73,11 @@ public class FleetPoolOrderBridge { loose.insert(order); // No placeholder ll_order: incoming owns the one physical tail order. } else { var order=new UsTruckOrder();order.setId(row.getBusinessOrderId());order.setCustomerId(in.customerId());order.setOrderNo(row.getBusinessOrderNo()); - order.setTransportType(transport);order.setOrigin(in.warehouseName());order.setDestination(in.destinationLabel()); - order.setContainerType(null); // Pool inquiry does not collect a container specification. + order.setOrigin(in.warehouseName());order.setDestination(in.destinationLabel()); order.setPalletCount(in.expectedPalletCount());order.setPackageCount(in.packageCount());order.setReceivedPalletCount(0);order.setAllocatedPalletCount(0); order.setTotalWeightKg(in.weightKg());order.setTotalVolumeCbm(in.volumeCbm());order.setCurrency(in.currency()); - order.setContactName(in.contactName());order.setContactInfo(in.contactPhone());order.setRequestedDate(in.plannedDepartureAt().toLocalDate()); + // 来源未提供的车型、目的联系人与送货时间保持未知。 + order.setOriginContactName(in.contactName());order.setOriginContactPhone(in.contactPhone());order.setPickupAt(in.plannedDepartureAt()); order.setStatus(row.getFulfillmentStage());order.setRemark(in.remark());order.setIdempotencyKey("FLEET_POOL:"+in.bookingNo()); order.setRequestHash(hash);order.setCreatedBy(operator);order.setVersion(0L);order.setCreatedAt(now);order.setUpdatedAt(now); trucks.insert(order); // No simulated ll_us_truck_order_pallet rows. diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/dto/UsTruckOrderRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/dto/UsTruckOrderRequests.java index 97ec10406..22e6a1ab7 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/dto/UsTruckOrderRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/dto/UsTruckOrderRequests.java @@ -11,75 +11,73 @@ import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.Size; import java.math.BigDecimal; -import java.time.LocalDate; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; import java.util.List; public final class UsTruckOrderRequests { + /** 仅提供请求模型。 */ private UsTruckOrderRequests() {} + /** 客户或平台提交独立本土卡车需求,时间按填写值保存。 */ public record Create( - @Size(max = 32) String transportType, + @NotBlank @Pattern(regexp = "PALLET_15|PALLET_30") String vehicleType, @NotBlank @Size(max = 255) String origin, @Size(max = 500) String originAddress, @NotBlank @Size(max = 255) String destination, @Size(max = 500) String destinationAddress, - @Size(max = 32) String containerType, - @NotNull @Min(1) Integer palletCount, @Min(0) Integer packageCount, @NotNull @DecimalMin("0.00") BigDecimal totalWeightKg, @NotNull @DecimalMin("0.00") BigDecimal totalVolumeCbm, @DecimalMin("0.00") BigDecimal budgetAmount, @Size(min = 3, max = 3) String currency, - @NotBlank @Size(max = 128) String contactName, - @NotBlank @Size(max = 128) String contactInfo, - @NotNull LocalDate requestedDate, + @NotBlank @Size(max = 128) String originContactName, + @NotBlank @Size(max = 128) String originContactPhone, + @NotBlank @Size(max = 128) String destinationContactName, + @NotBlank @Size(max = 128) String destinationContactPhone, + @NotNull LocalDateTime pickupAt, + @NotNull LocalDateTime deliveryAt, @Size(max = 1000) String remark, - @NotBlank @Size(max = 255) String idempotencyKey) { - /** Backward-compatible constructor for clients still sending legacy transport fields. */ - public Create(String transportType, String origin, String originAddress, String destination, - String destinationAddress, String containerType, Integer packageCount, - BigDecimal totalWeightKg, BigDecimal totalVolumeCbm, BigDecimal budgetAmount, - String currency, String contactName, String contactInfo, LocalDate requestedDate, - String remark, String idempotencyKey) { - this(transportType, origin, originAddress, destination, destinationAddress, containerType, - packageCount == null || packageCount < 1 ? 1 : packageCount, packageCount, - totalWeightKg, totalVolumeCbm, budgetAmount, currency, contactName, contactInfo, - requestedDate, remark, idempotencyKey); - } - } + @NotBlank @Size(max = 255) String idempotencyKey) {} public record Query( @Min(1) long page, @Min(1) @Max(200) long size, - String orderNo, String routeKeyword, String status, String transportType, - String customerId, LocalDate requestedFrom, LocalDate requestedTo) {} + String orderNo, String routeKeyword, String status, String vehicleType, + String customerId, @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime pickupFrom, + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime pickupTo) {} public record MerchantQuery( @Min(1) long page, @Min(1) @Max(200) long size, - String orderNo, String routeKeyword, String status, String transportType, - LocalDate requestedFrom, LocalDate requestedTo) { + String orderNo, String routeKeyword, String status, String vehicleType, + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime pickupFrom, + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime pickupTo) { + /** 客户范围由认证身份决定,查询模型不接收客户标识。 */ public Query scoped() { - return new Query(page, size, orderNo, routeKeyword, status, transportType, - null, requestedFrom, requestedTo); + return new Query(page, size, orderNo, routeKeyword, status, vehicleType, + null, pickupFrom, pickupTo); } } + /** 待审核订单可更新需求;两端联系信息和时间须完整填写。 */ public record Update( @NotNull @Min(0) Long version, - @Size(max = 32) String transportType, + @NotBlank @Pattern(regexp = "PALLET_15|PALLET_30") String vehicleType, @Size(max = 255) String origin, @Size(max = 500) String originAddress, @Size(max = 255) String destination, @Size(max = 500) String destinationAddress, - @Size(max = 32) String containerType, - @Min(1) Integer palletCount, @Min(0) Integer packageCount, @DecimalMin("0.00") BigDecimal totalWeightKg, @DecimalMin("0.00") BigDecimal totalVolumeCbm, @DecimalMin("0.00") BigDecimal budgetAmount, @Size(min = 3, max = 3) String currency, - @Size(max = 128) String contactName, - @Size(max = 128) String contactInfo, - LocalDate requestedDate, + @NotBlank @Size(max = 128) String originContactName, + @NotBlank @Size(max = 128) String originContactPhone, + @NotBlank @Size(max = 128) String destinationContactName, + @NotBlank @Size(max = 128) String destinationContactPhone, + @NotNull LocalDateTime pickupAt, + @NotNull LocalDateTime deliveryAt, @Size(max = 1000) String remark) {} public record ChangeStatus( diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/dto/UsTruckOrderResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/dto/UsTruckOrderResponses.java index d3f9ccad2..ac4556fa8 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/dto/UsTruckOrderResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/dto/UsTruckOrderResponses.java @@ -1,20 +1,21 @@ package com.baiyue.lastleg.trucking.dto; import java.math.BigDecimal; -import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; public final class UsTruckOrderResponses { + /** 仅提供响应模型。 */ private UsTruckOrderResponses() {} - /** Pool orders retain null containerType; forecast physical pallets do not imply a container type. */ + /** 独立订单展示客户填写的车型和两端信息;拼车来源未知字段保持为空。 */ public record Summary( - Long id, String customerId, String orderNo, String transportType, - String origin, String destination, String containerType, int palletCount, int packageCount, + Long id, String customerId, String orderNo, String vehicleType, + String origin, String destination, int palletCount, int packageCount, int receivedPalletCount, int allocatedPalletCount, BigDecimal totalWeightKg, BigDecimal totalVolumeCbm, BigDecimal budgetAmount, - String currency, String contactName, String contactInfo, LocalDate requestedDate, + String currency, String originContactName, String originContactPhone, + String destinationContactName, String destinationContactPhone, LocalDateTime pickupAt, LocalDateTime deliveryAt, String status, long version, LocalDateTime createdAt, LocalDateTime updatedAt, com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.Arrangement fleetArrangement) {} public record History( diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrder.java index 6f6d5eb79..49cace0b8 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrder.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrder.java @@ -6,7 +6,6 @@ import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; import java.math.BigDecimal; -import java.time.LocalDate; import java.time.LocalDateTime; @Data @@ -15,12 +14,11 @@ public class UsTruckOrder { @TableId(type = IdType.INPUT) private Long id; private String customerId; private String orderNo; - private String transportType; + private String vehicleType; private String origin; private String originAddress; private String destination; private String destinationAddress; - private String containerType; private Integer palletCount; private Integer packageCount; private Integer receivedPalletCount; @@ -29,9 +27,12 @@ public class UsTruckOrder { private BigDecimal totalVolumeCbm; private BigDecimal budgetAmount; private String currency; - private String contactName; - private String contactInfo; - private LocalDate requestedDate; + private String originContactName; + private String originContactPhone; + private String destinationContactName; + private String destinationContactPhone; + private LocalDateTime pickupAt; + private LocalDateTime deliveryAt; private String status; private String remark; private String idempotencyKey; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/repository/UsTruckOrderRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/repository/UsTruckOrderRepository.java new file mode 100644 index 000000000..603047e03 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/repository/UsTruckOrderRepository.java @@ -0,0 +1,67 @@ +package com.baiyue.lastleg.trucking.repository; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baiyue.lastleg.trucking.dto.UsTruckOrderRequests; +import com.baiyue.lastleg.trucking.entity.*; +import com.baiyue.lastleg.trucking.mapper.*; +import org.springframework.stereotype.Repository; +import org.springframework.util.StringUtils; +import java.util.List; + +/** 卡车订单持久化边界;事务和状态决策由服务负责。 */ +@Repository +public class UsTruckOrderRepository { + private final UsTruckOrderMapper orders; + private final UsTruckOrderPalletMapper pallets; + private final UsTruckOrderStatusHistoryMapper histories; + private final UsTruckOrderOperationMapper operations; + + /** 绑定本服务的订单、托盘和不可变历史 Mapper。 */ + public UsTruckOrderRepository(UsTruckOrderMapper orders, UsTruckOrderPalletMapper pallets, + UsTruckOrderStatusHistoryMapper histories, UsTruckOrderOperationMapper operations) { + this.orders = orders; this.pallets = pallets; this.histories = histories; this.operations = operations; + } + + /** 按客户、路线、状态、车型和取货时间组合分页条件。 */ + public Page page(String customerId, UsTruckOrderRequests.Query query) { + LambdaQueryWrapper w = new LambdaQueryWrapper() + .eq(StringUtils.hasText(customerId), UsTruckOrder::getCustomerId, customerId) + .like(StringUtils.hasText(query.orderNo()), UsTruckOrder::getOrderNo, query.orderNo()) + .and(StringUtils.hasText(query.routeKeyword()), x -> x.like(UsTruckOrder::getOrigin, query.routeKeyword()) + .or().like(UsTruckOrder::getDestination, query.routeKeyword())) + .eq(StringUtils.hasText(query.status()), UsTruckOrder::getStatus, + StringUtils.hasText(query.status()) ? query.status().trim().toUpperCase() : null) + .eq(StringUtils.hasText(query.vehicleType()), UsTruckOrder::getVehicleType, + StringUtils.hasText(query.vehicleType()) ? query.vehicleType() : null) + .eq(StringUtils.hasText(query.customerId()), UsTruckOrder::getCustomerId, query.customerId()) + .ge(query.pickupFrom() != null, UsTruckOrder::getPickupAt, query.pickupFrom()) + .le(query.pickupTo() != null, UsTruckOrder::getPickupAt, query.pickupTo()) + .orderByDesc(UsTruckOrder::getCreatedAt).orderByDesc(UsTruckOrder::getId); + return orders.selectPage(Page.of(query.page(), query.size()), w); + } + /** 按客户和订单号读取。 */ + public UsTruckOrder findNo(String customerId, String orderNo) { return orders.findNo(customerId, orderNo); } + /** 在当前事务中按客户和订单号加行锁。 */ + public UsTruckOrder lockNo(String customerId, String orderNo) { return orders.lockNo(customerId, orderNo); } + /** 按客户和创建幂等键读取。 */ + public UsTruckOrder findKey(String customerId, String key) { return orders.findKey(customerId, key); } + /** 保存新订单。 */ + public void insertOrder(UsTruckOrder order) { orders.insert(order); } + /** 保存已锁定订单的当前状态。 */ + public void updateOrder(UsTruckOrder order) { orders.updateById(order); } + /** 读取本客户订单的托盘。 */ + public List pallets(String customerId, Long orderId) { return pallets.listOrder(customerId, orderId); } + /** 保存实际托盘记录。 */ + public void insertPallet(UsTruckOrderPallet row) { pallets.insert(row); } + /** 更新实际托盘处理状态。 */ + public void updatePallet(UsTruckOrderPallet row) { pallets.updateById(row); } + /** 读取本客户订单的不可变历史。 */ + public List histories(String customerId, Long orderId) { return histories.listOrder(customerId, orderId); } + /** 保存订单历史。 */ + public void insertHistory(UsTruckOrderStatusHistory row) { histories.insert(row); } + /** 按客户、订单和幂等键读取操作结果。 */ + public UsTruckOrderOperation findOperation(String customerId, Long orderId, String key) { return operations.findKey(customerId, orderId, key); } + /** 保存不可变操作摘要。 */ + public void insertOperation(UsTruckOrderOperation row) { operations.insert(row); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java index e94948bce..e2537549f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java @@ -1,20 +1,14 @@ package com.baiyue.lastleg.trucking.service; -import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.common.core.api.PageResult; -import com.baiyue.common.core.container.ContainerSpecificationCatalog; import com.baiyue.lastleg.trucking.dto.UsTruckOrderRequests; import com.baiyue.lastleg.trucking.dto.UsTruckOrderResponses; import com.baiyue.lastleg.trucking.entity.UsTruckOrder; import com.baiyue.lastleg.trucking.entity.UsTruckOrderOperation; import com.baiyue.lastleg.trucking.entity.UsTruckOrderPallet; import com.baiyue.lastleg.trucking.entity.UsTruckOrderStatusHistory; -import com.baiyue.lastleg.trucking.mapper.UsTruckOrderMapper; -import com.baiyue.lastleg.trucking.mapper.UsTruckOrderOperationMapper; -import com.baiyue.lastleg.trucking.mapper.UsTruckOrderPalletMapper; -import com.baiyue.lastleg.trucking.mapper.UsTruckOrderStatusHistoryMapper; import com.baiyue.lastleg.security.OverseasPermissions; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; @@ -26,12 +20,9 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; import org.springframework.web.server.ResponseStatusException; -import java.math.BigDecimal; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; -import java.time.LocalDate; import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; import java.util.HexFormat; import java.util.HashSet; import java.util.List; @@ -44,8 +35,6 @@ public class UsTruckOrderService { @Resource private com.baiyue.lastleg.service.LastlegOrderNoGenerator orderNoGenerator; public static final String MANAGE_PERMISSION = OverseasPermissions.TRUCK_STATUS; public static final String PENDING_REVIEW = "PENDING_REVIEW"; - /** Kept for source compatibility; new orders use PENDING_REVIEW. */ - @Deprecated public static final String PENDING_CONFIRMATION = PENDING_REVIEW; public static final String WAITING_INBOUND = "WAITING_INBOUND"; public static final String RECEIVING = "RECEIVING"; public static final String WAITING_CONSOLIDATION = "WAITING_CONSOLIDATION"; @@ -63,43 +52,41 @@ public class UsTruckOrderService { PENDING_DISPATCH, DISPATCHED, IN_TRANSIT, COMPLETED, REJECTED, CANCELLED); private static final Set CANCELLABLE = Set.of(PENDING_REVIEW, WAITING_INBOUND, RECEIVING, PALLET_OVERAGE_REVIEW, WAITING_CONSOLIDATION, PARTIALLY_ALLOCATED, ALLOCATED, PENDING_DISPATCH); - private static final Set TRANSPORT_TYPES = Set.of("PORT_PICKUP", "WAREHOUSE_TO_WAREHOUSE", "WAREHOUSE_TO_DOOR"); + private static final Set VEHICLE_TYPES = Set.of("PALLET_15", "PALLET_30"); - @Resource private UsTruckOrderMapper orders; - @Resource private UsTruckOrderOperationMapper operations; - @Resource private UsTruckOrderPalletMapper pallets; - @Resource private UsTruckOrderStatusHistoryMapper histories; + @Resource private com.baiyue.lastleg.trucking.repository.UsTruckOrderRepository repository; @Resource private ObjectMapper objectMapper; + /** 创建卡车需求,校验两端资料并保留创建幂等结果。 */ @Transactional public UsTruckOrderResponses.Detail create(String customerId, String operatorId, UsTruckOrderRequests.Create request) { String key = request.idempotencyKey().trim(); String hash = hash(request); - UsTruckOrder replay = orders.findKey(customerId, key); + UsTruckOrder replay = repository.findKey(customerId, key); if (replay != null) return replay(customerId, replay, hash); - if (StringUtils.hasText(request.transportType())) validateTransport(request.transportType()); + validateRequirements(request.vehicleType(), request.originContactName(), request.originContactPhone(), + request.destinationContactName(), request.destinationContactPhone(), request.pickupAt(), request.deliveryAt()); LocalDateTime now = LocalDateTime.now(); UsTruckOrder order = new UsTruckOrder(); order.setId(IdWorker.getId()); order.setCustomerId(customerId); order.setOrderNo(orderNoGenerator.next("TRUCK", request.origin(), order.getId())); - order.setTransportType(StringUtils.hasText(request.transportType()) ? norm(request.transportType()) : null); + order.setVehicleType(request.vehicleType()); order.setOrigin(trim(request.origin())); order.setOriginAddress(trim(request.originAddress())); order.setDestination(trim(request.destination())); order.setDestinationAddress(trim(request.destinationAddress())); - order.setContainerType(StringUtils.hasText(request.containerType()) - ? ContainerSpecificationCatalog.require(request.containerType()).containerType() - : ContainerSpecificationCatalog.DEFAULT_CONTAINER_TYPE); - order.setPalletCount(request.palletCount()); order.setPackageCount(value(request.packageCount())); + // 车型只表达用车需求;实际托数由收货记录,不能按车型虚构货量。 + order.setPalletCount(0); order.setPackageCount(value(request.packageCount())); order.setReceivedPalletCount(0); order.setAllocatedPalletCount(0); order.setTotalWeightKg(request.totalWeightKg()); order.setTotalVolumeCbm(request.totalVolumeCbm()); order.setBudgetAmount(request.budgetAmount()); order.setCurrency(defaultCurrency(request.currency())); - order.setContactName(request.contactName().trim()); order.setContactInfo(request.contactInfo().trim()); - order.setRequestedDate(request.requestedDate()); order.setStatus(PENDING_REVIEW); + order.setOriginContactName(request.originContactName().trim()); order.setOriginContactPhone(request.originContactPhone().trim()); + order.setDestinationContactName(request.destinationContactName().trim()); order.setDestinationContactPhone(request.destinationContactPhone().trim()); + order.setPickupAt(request.pickupAt()); order.setDeliveryAt(request.deliveryAt()); order.setStatus(PENDING_REVIEW); order.setRemark(trim(request.remark())); order.setIdempotencyKey(key); order.setRequestHash(hash); order.setCreatedBy(operatorId); order.setVersion(0L); order.setCreatedAt(now); order.setUpdatedAt(now); - try { orders.insert(order); } + try { repository.insertOrder(order); } catch (DuplicateKeyException e) { - UsTruckOrder concurrent = orders.findKey(customerId, key); + UsTruckOrder concurrent = repository.findKey(customerId, key); if (concurrent != null) return replay(customerId, concurrent, hash); throw e; } @@ -107,57 +94,48 @@ public class UsTruckOrderService { return detail(customerId, order.getOrderNo()); } + /** 按客户范围分页读取订单并批量关联承运安排。 */ @Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.REPEATABLE_READ) public PageResult page(String customerId, UsTruckOrderRequests.Query query) { validateQuery(query); - LambdaQueryWrapper w = new LambdaQueryWrapper() - .eq(StringUtils.hasText(customerId), UsTruckOrder::getCustomerId, customerId) - .like(StringUtils.hasText(query.orderNo()), UsTruckOrder::getOrderNo, query.orderNo()) - .and(StringUtils.hasText(query.routeKeyword()), x -> x.like(UsTruckOrder::getOrigin, query.routeKeyword()) - .or().like(UsTruckOrder::getDestination, query.routeKeyword())) - .eq(StringUtils.hasText(query.status()), UsTruckOrder::getStatus, - StringUtils.hasText(query.status()) ? norm(query.status()) : null) - .eq(StringUtils.hasText(query.transportType()), UsTruckOrder::getTransportType, - StringUtils.hasText(query.transportType()) ? norm(query.transportType()) : null) - .eq(StringUtils.hasText(query.customerId()), UsTruckOrder::getCustomerId, query.customerId()) - .ge(query.requestedFrom() != null, UsTruckOrder::getRequestedDate, query.requestedFrom()) - .le(query.requestedTo() != null, UsTruckOrder::getRequestedDate, query.requestedTo()) - .orderByDesc(UsTruckOrder::getCreatedAt).orderByDesc(UsTruckOrder::getId); - Page result = orders.selectPage(Page.of(query.page(), query.size()), w); + Page result = repository.page(customerId, query); var arrangements = fleetPool.arrangements(result.getRecords().stream().map(this::poolKey).toList()); return new PageResult<>(result.getRecords().stream().map(o -> summary(o, arrangements.get(poolKey(o)))).toList(), result.getCurrent(), result.getSize(), result.getTotal(), result.getPages()); } + /** 读取本客户的订单、实际托盘及操作历史。 */ @Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.REPEATABLE_READ) public UsTruckOrderResponses.Detail detail(String customerId, String orderNo) { - UsTruckOrder order = orders.findNo(customerId, orderNo); + UsTruckOrder order = repository.findNo(customerId, orderNo); if (order == null) throw notFound(); - return detail(order, histories.listOrder(customerId, order.getId())); + return detail(order, repository.histories(customerId, order.getId())); } + /** 按版本更新待审核订单的车型、联系人和取送时间。 */ @Transactional public UsTruckOrderResponses.Detail update(String customerId, String orderNo, String operatorId, UsTruckOrderRequests.Update request) { UsTruckOrder order = lock(customerId, orderNo); if (!request.version().equals(value(order.getVersion()))) throw conflict("Order was changed; refresh and retry"); if (!PENDING_REVIEW.equals(order.getStatus())) throw conflict("Only pending review orders can be edited"); - if (request.transportType() != null) { validateTransport(request.transportType()); order.setTransportType(norm(request.transportType())); } + validateRequirements(request.vehicleType(), request.originContactName(), request.originContactPhone(), + request.destinationContactName(), request.destinationContactPhone(), request.pickupAt(), request.deliveryAt()); + order.setVehicleType(request.vehicleType()); setText(request.origin(), order::setOrigin); setPresent(request.originAddress(), order::setOriginAddress); setText(request.destination(), order::setDestination); setPresent(request.destinationAddress(), order::setDestinationAddress); - setText(request.containerType(), v -> order.setContainerType( - ContainerSpecificationCatalog.require(v).containerType())); - if (request.palletCount() != null) order.setPalletCount(request.palletCount()); if (request.packageCount() != null) order.setPackageCount(request.packageCount()); if (request.totalWeightKg() != null) order.setTotalWeightKg(request.totalWeightKg()); if (request.totalVolumeCbm() != null) order.setTotalVolumeCbm(request.totalVolumeCbm()); if (request.budgetAmount() != null) order.setBudgetAmount(request.budgetAmount()); if (request.currency() != null) order.setCurrency(defaultCurrency(request.currency())); - setText(request.contactName(), order::setContactName); setText(request.contactInfo(), order::setContactInfo); - if (request.requestedDate() != null) order.setRequestedDate(request.requestedDate()); setPresent(request.remark(), order::setRemark); - touch(order); orders.updateById(order); + order.setOriginContactName(request.originContactName().trim()); order.setOriginContactPhone(request.originContactPhone().trim()); + order.setDestinationContactName(request.destinationContactName().trim()); order.setDestinationContactPhone(request.destinationContactPhone().trim()); + order.setPickupAt(request.pickupAt()); order.setDeliveryAt(request.deliveryAt()); setPresent(request.remark(), order::setRemark); + touch(order); repository.updateOrder(order); history(order, "UPDATE", order.getStatus(), order.getStatus(), operatorId, null, "UPDATE", LocalDateTime.now()); return detail(customerId, orderNo); } + /** 按版本幂等取消允许取消的订单。 */ @Transactional public UsTruckOrderResponses.Detail changeStatus(String customerId, String orderNo, String operatorId, UsTruckOrderRequests.ChangeStatus request) { @@ -172,6 +150,7 @@ public class UsTruckOrderService { order.getStatus(), CANCELLED, request.remark()); } + /** 审核订单,仅对真实已知预报建立待收托盘。 */ @Transactional public UsTruckOrderResponses.Detail review(String customerId, String orderNo, String operatorId, UsTruckOrderRequests.Review request) { @@ -186,6 +165,7 @@ public class UsTruckOrderService { PENDING_REVIEW, next, request.remark()); } + /** 记录实际收货,首次实收确定独立订单托数。 */ @Transactional public UsTruckOrderResponses.Detail receivePallets(String customerId, String orderNo, String operatorId, UsTruckOrderRequests.ReceivePallets request) { @@ -198,9 +178,12 @@ public class UsTruckOrderService { } createPlannedPallets(order); - List current = pallets.listOrder(customerId, order.getId()); - int planned = value(order.getPalletCount()); + List current = repository.pallets(customerId, order.getId()); int actual = request.actualPalletCount(); + // 独立下单没有预报托数,首次实收形成确认基准,后续超量仍需审核。 + boolean firstReceipt = value(order.getPalletCount()) == 0; + int planned = firstReceipt ? actual : value(order.getPalletCount()); + order.setPalletCount(planned); List packageCounts = request.palletPackageCounts() == null ? List.of() : request.palletPackageCounts(); if (!packageCounts.isEmpty() && packageCounts.size() != actual) { @@ -213,7 +196,7 @@ public class UsTruckOrderService { LocalDateTime now = LocalDateTime.now(); for (int sequence = 1; sequence <= actual; sequence++) { UsTruckOrderPallet pallet = sequence <= current.size() ? current.get(sequence - 1) - : newPallet(order, sequence, "PDA_OVERAGE", now); + : newPallet(order, sequence, firstReceipt ? "PDA_RECEIPT" : "PDA_OVERAGE", now); pallet.setStatus(sequence > planned ? "OVERAGE_PENDING_REVIEW" : "RECEIVED"); if (!packageCounts.isEmpty()) pallet.setPackageCount(packageCounts.get(sequence - 1)); else if (actual == 1) pallet.setPackageCount(request.packageCount()); @@ -221,9 +204,9 @@ public class UsTruckOrderService { pallet.setUpdatedAt(now); if (pallet.getId() == null) { pallet.setId(IdWorker.getId()); - pallets.insert(pallet); + repository.insertPallet(pallet); } else { - pallets.updateById(pallet); + repository.updatePallet(pallet); } } order.setReceivedPalletCount(actual); @@ -232,12 +215,13 @@ public class UsTruckOrderService { : actual == planned ? WAITING_CONSOLIDATION : RECEIVING; order.setStatus(next); touch(order); - orders.updateById(order); + repository.updateOrder(order); recordOperation(order, "RECEIVE_PALLETS", request.idempotencyKey(), request, operatorId, now); history(order, "RECEIVE_PALLETS", from, next, operatorId, request.idempotencyKey(), request.remark(), now); return detail(customerId, orderNo); } + /** 审核后续实收超出已确认托数的部分。 */ @Transactional public UsTruckOrderResponses.Detail reviewPalletOverage(String customerId, String orderNo, String operatorId, UsTruckOrderRequests.ReviewPalletOverage request) { @@ -250,14 +234,14 @@ public class UsTruckOrderService { throw conflict("Only pallet-overage-review orders can be reviewed"); } boolean approve = "APPROVE".equals(norm(request.decision())); - List rows = pallets.listOrder(customerId, order.getId()); + List rows = repository.pallets(customerId, order.getId()); LocalDateTime now = LocalDateTime.now(); int accepted = value(order.getPalletCount()); for (UsTruckOrderPallet pallet : rows) { if (!"OVERAGE_PENDING_REVIEW".equals(pallet.getStatus())) continue; pallet.setStatus(approve ? "RECEIVED" : "OVERAGE_REJECTED"); pallet.setUpdatedAt(now); - pallets.updateById(pallet); + repository.updatePallet(pallet); } if (approve) { accepted = value(order.getReceivedPalletCount()); @@ -267,13 +251,14 @@ public class UsTruckOrderService { } order.setStatus(WAITING_CONSOLIDATION); touch(order); - orders.updateById(order); + repository.updateOrder(order); recordOperation(order, "REVIEW_PALLET_OVERAGE", request.idempotencyKey(), request, operatorId, now); history(order, "REVIEW_PALLET_OVERAGE", PALLET_OVERAGE_REVIEW, WAITING_CONSOLIDATION, operatorId, request.idempotencyKey(), request.remark(), now); return detail(customerId, orderNo); } + /** 分配已实收且尚未分配的订单托盘。 */ @Transactional public UsTruckOrderResponses.Detail allocatePallets(String customerId, String orderNo, String operatorId, UsTruckOrderRequests.AllocatePallets request) { @@ -287,7 +272,7 @@ public class UsTruckOrderService { } Set requested = new HashSet<>(request.palletIds()); if (requested.size() != request.palletIds().size()) throw badRequest("palletIds must be unique"); - List rows = pallets.listOrder(customerId, order.getId()); + List rows = repository.pallets(customerId, order.getId()); List selected = rows.stream().filter(row -> requested.contains(row.getId())).toList(); if (selected.size() != requested.size() || selected.stream().anyMatch(row -> !"RECEIVED".equals(row.getStatus()))) { throw conflict("Selected pallets must belong to the order and be received but unallocated"); @@ -296,7 +281,7 @@ public class UsTruckOrderService { for (UsTruckOrderPallet pallet : selected) { pallet.setStatus("ALLOCATED"); pallet.setUpdatedAt(now); - pallets.updateById(pallet); + repository.updatePallet(pallet); } int allocated = (int) rows.stream().filter(row -> "ALLOCATED".equals(row.getStatus())).count(); int accepted = value(order.getReceivedPalletCount()); @@ -306,13 +291,14 @@ public class UsTruckOrderService { order.setAllocatedPalletCount(allocated); order.setStatus(next); touch(order); - orders.updateById(order); + repository.updateOrder(order); recordOperation(order, "ALLOCATE_PALLETS", request.idempotencyKey(), request, operatorId, now); history(order, "ALLOCATE_PALLETS", from, next, operatorId, request.idempotencyKey(), request.remark(), now); return detail(customerId, orderNo); } + /** 将全部分配完成的订单推进到待派车。 */ @Transactional public UsTruckOrderResponses.Detail prepareDispatch(String customerId, String orderNo, String operatorId, UsTruckOrderRequests.Command request) { @@ -320,24 +306,28 @@ public class UsTruckOrderService { PENDING_DISPATCH, request); } + /** 确认订单已派车并记录操作历史。 */ @Transactional public UsTruckOrderResponses.Detail dispatch(String customerId, String orderNo, String operatorId, UsTruckOrderRequests.Command request) { return command(customerId, orderNo, operatorId, "DISPATCH", PENDING_DISPATCH, DISPATCHED, request); } + /** 确认订单进入运输阶段。 */ @Transactional public UsTruckOrderResponses.Detail startTransit(String customerId, String orderNo, String operatorId, UsTruckOrderRequests.Command request) { return command(customerId, orderNo, operatorId, "START_TRANSIT", DISPATCHED, IN_TRANSIT, request); } + /** 确认运输完成并保留幂等结果。 */ @Transactional public UsTruckOrderResponses.Detail complete(String customerId, String orderNo, String operatorId, UsTruckOrderRequests.Command request) { return command(customerId, orderNo, operatorId, "COMPLETE", IN_TRANSIT, COMPLETED, request); } + /** 复核状态版本后执行单一履约动作。 */ private UsTruckOrderResponses.Detail command(String customerId, String orderNo, String operatorId, String operationType, String expectedStatus, String nextStatus, UsTruckOrderRequests.Command request) { @@ -357,22 +347,24 @@ public class UsTruckOrderService { expectedStatus, nextStatus, request.remark()); } + /** 原子更新状态并写入操作和历史。 */ private UsTruckOrderResponses.Detail transition(UsTruckOrder order, String operatorId, String operationType, String idempotencyKey, Object request, String from, String to, String remark) { LocalDateTime now = LocalDateTime.now(); order.setStatus(to); touch(order); - orders.updateById(order); + repository.updateOrder(order); recordOperation(order, operationType, idempotencyKey, request, operatorId, now); history(order, operationType, from, to, operatorId, idempotencyKey, remark, now); return detail(order.getCustomerId(), order.getOrderNo()); } + /** 核对操作幂等键和请求摘要。 */ private UsTruckOrderResponses.Detail replayOperation(UsTruckOrder order, String operationType, String idempotencyKey, Object request) { String key = idempotencyKey.trim(); - UsTruckOrderOperation existing = operations.findKey(order.getCustomerId(), order.getId(), key); + UsTruckOrderOperation existing = repository.findOperation(order.getCustomerId(), order.getId(), key); if (existing == null) return null; if (!operationType.equals(existing.getOperationType()) || !hash(request).equals(existing.getRequestHash())) { throw conflict("Idempotency key was reused with a different truck operation payload"); @@ -380,6 +372,7 @@ public class UsTruckOrderService { return detail(order.getCustomerId(), order.getOrderNo()); } + /** 记录不可变操作摘要和结果版本。 */ private void recordOperation(UsTruckOrder order, String operationType, String idempotencyKey, Object request, String operatorId, LocalDateTime now) { UsTruckOrderOperation operation = new UsTruckOrderOperation(); @@ -392,19 +385,23 @@ public class UsTruckOrderService { operation.setResultVersion(order.getVersion()); operation.setCreatedBy(operatorId); operation.setCreatedAt(now); - operations.insert(operation); + repository.insertOperation(operation); } + /** 阻止旧版本覆盖当前订单。 */ private void requireVersion(UsTruckOrder order, Long requestedVersion) { if (!requestedVersion.equals(value(order.getVersion()))) { throw conflict("Order was changed; refresh and retry"); } } - private UsTruckOrder lock(String customerId, String orderNo) { UsTruckOrder o = orders.lockNo(customerId, orderNo); if (o == null) throw notFound(); fleetPool.requireStandalone(customerId, "US_TRUCK", o.getId()); return o; } + /** 按客户范围锁单并校验独立订单资格。 */ + private UsTruckOrder lock(String customerId, String orderNo) { UsTruckOrder o = repository.lockNo(customerId, orderNo); if (o == null) throw notFound(); fleetPool.requireStandalone(customerId, "US_TRUCK", o.getId()); return o; } + /** 复核创建请求摘要并返回既有订单。 */ private UsTruckOrderResponses.Detail replay(String customerId, UsTruckOrder order, String hash) { if (!hash.equals(order.getRequestHash())) throw conflict("Idempotency key was reused with different truck data"); return detail(customerId, order.getOrderNo()); } + /** 读取订单资料与实际托盘和操作记录。 */ private UsTruckOrderResponses.Detail detail(UsTruckOrder o, List hs) { - List palletDetails = pallets.listOrder(o.getCustomerId(), o.getId()).stream() + List palletDetails = repository.pallets(o.getCustomerId(), o.getId()).stream() .map(this::pallet) .toList(); return new UsTruckOrderResponses.Detail(summary(o), o.getOriginAddress(), o.getDestinationAddress(), @@ -413,30 +410,37 @@ public class UsTruckOrderService { h.getFromStatus(), h.getToStatus(), h.getOperatorId(), h.getIdempotencyKey(), h.getRemark(), h.getCreatedAt())).toList()); } + /** 将实际托盘记录转换为响应。 */ private UsTruckOrderResponses.Pallet pallet(UsTruckOrderPallet p) { return new UsTruckOrderResponses.Pallet(p.getId(), p.getPalletNo(), value(p.getSequenceNo()), p.getSource(), p.getStatus(), p.getWarehousePalletId(), p.getPackageCount(), p.getReceivedAt()); } + /** 构造客户及业务订单关联键。 */ private com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.OrderKey poolKey(UsTruckOrder o) { return new com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.OrderKey(o.getCustomerId(), "US_TRUCK", o.getId()); } + /** 转换需求字段与承运安排摘要。 */ private UsTruckOrderResponses.Summary summary(UsTruckOrder o) { return summary(o, fleetPool.arrangement(poolKey(o))); } - private UsTruckOrderResponses.Summary summary(UsTruckOrder o, com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.Arrangement arrangement) { return new UsTruckOrderResponses.Summary(o.getId(), o.getCustomerId(), o.getOrderNo(), o.getTransportType(), o.getOrigin(), o.getDestination(), o.getContainerType(), value(o.getPalletCount()), value(o.getPackageCount()), value(o.getReceivedPalletCount()), value(o.getAllocatedPalletCount()), o.getTotalWeightKg(), o.getTotalVolumeCbm(), o.getBudgetAmount(), o.getCurrency(), o.getContactName(), o.getContactInfo(), o.getRequestedDate(), o.getStatus(), value(o.getVersion()), o.getCreatedAt(), o.getUpdatedAt(), arrangement); } + /** 转换需求字段与承运安排摘要。 */ + private UsTruckOrderResponses.Summary summary(UsTruckOrder o, com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.Arrangement arrangement) { return new UsTruckOrderResponses.Summary(o.getId(), o.getCustomerId(), o.getOrderNo(), o.getVehicleType(), o.getOrigin(), o.getDestination(), value(o.getPalletCount()), value(o.getPackageCount()), value(o.getReceivedPalletCount()), value(o.getAllocatedPalletCount()), o.getTotalWeightKg(), o.getTotalVolumeCbm(), o.getBudgetAmount(), o.getCurrency(), o.getOriginContactName(), o.getOriginContactPhone(), o.getDestinationContactName(), o.getDestinationContactPhone(), o.getPickupAt(), o.getDeliveryAt(), o.getStatus(), value(o.getVersion()), o.getCreatedAt(), o.getUpdatedAt(), arrangement); } + /** 保留订单状态变更和操作人证据。 */ private void history(UsTruckOrder o, String operationType, String from, String to, String operator, String idempotencyKey, String remark, LocalDateTime at) { UsTruckOrderStatusHistory h = new UsTruckOrderStatusHistory(); h.setId(IdWorker.getId()); h.setOrderId(o.getId()); h.setCustomerId(o.getCustomerId()); h.setOperationType(operationType); h.setFromStatus(from); h.setToStatus(to); h.setOperatorId(operator); h.setIdempotencyKey(trim(idempotencyKey)); h.setRemark(trim(remark)); - h.setCreatedAt(at); histories.insert(h); + h.setCreatedAt(at); repository.insertHistory(h); } + /** 仅为已知的预报数量建立待收托盘。 */ private void createPlannedPallets(UsTruckOrder order) { - int existing = pallets.listOrder(order.getCustomerId(), order.getId()).size(); + int existing = repository.pallets(order.getCustomerId(), order.getId()).size(); LocalDateTime now = LocalDateTime.now(); for (int sequence = existing + 1; sequence <= value(order.getPalletCount()); sequence++) { UsTruckOrderPallet pallet = newPallet(order, sequence, "ORDER_PLAN", now); pallet.setId(IdWorker.getId()); - pallets.insert(pallet); + repository.insertPallet(pallet); } } + /** 建立指定序号的订单托盘记录。 */ private UsTruckOrderPallet newPallet(UsTruckOrder order, int sequence, String source, LocalDateTime now) { UsTruckOrderPallet pallet = new UsTruckOrderPallet(); pallet.setOrderId(order.getId()); pallet.setCustomerId(order.getCustomerId()); @@ -445,17 +449,45 @@ public class UsTruckOrderService { pallet.setCreatedAt(now); pallet.setUpdatedAt(now); return pallet; } - private void validateQuery(UsTruckOrderRequests.Query q) { if (StringUtils.hasText(q.status()) && !STATUSES.contains(norm(q.status()))) throw badRequest("Unsupported truck order status"); if (StringUtils.hasText(q.transportType())) validateTransport(q.transportType()); if (q.requestedFrom() != null && q.requestedTo() != null && q.requestedFrom().isAfter(q.requestedTo())) throw badRequest("requestedFrom must not be after requestedTo"); } - private void validateTransport(String v) { if (!TRANSPORT_TYPES.contains(norm(v))) throw badRequest("Unsupported transport type"); } + /** 校验状态、车型及取货时间筛选区间。 */ + private void validateQuery(UsTruckOrderRequests.Query q) { + if (StringUtils.hasText(q.status()) && !STATUSES.contains(norm(q.status()))) throw badRequest("Unsupported truck order status"); + if (StringUtils.hasText(q.vehicleType()) && !VEHICLE_TYPES.contains(q.vehicleType())) throw badRequest("请选择有效的车辆类型"); + if (q.pickupFrom() != null && q.pickupTo() != null && q.pickupFrom().isAfter(q.pickupTo())) throw badRequest("取货时间起点不能晚于终点"); + } + + /** 校验两端必填资料和先取后送的时间顺序;不进行时区转换。 */ + private void validateRequirements(String vehicleType, String originName, String originPhone, + String destinationName, String destinationPhone, + LocalDateTime pickupAt, LocalDateTime deliveryAt) { + if (vehicleType == null || !VEHICLE_TYPES.contains(vehicleType)) throw badRequest("请选择有效的车辆类型"); + if (!StringUtils.hasText(originName) || !StringUtils.hasText(originPhone) + || !StringUtils.hasText(destinationName) || !StringUtils.hasText(destinationPhone)) throw badRequest("请完整填写起运地和目的地联系人及联系电话"); + if (pickupAt == null || deliveryAt == null) throw badRequest("请填写取货时间和送货时间"); + if (deliveryAt.isBefore(pickupAt)) throw badRequest("送货时间不能早于取货时间"); + } + /** 计算请求摘要以校验幂等重放。 */ private String hash(Object v) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(objectMapper.writeValueAsBytes(v))); } catch (JsonProcessingException | NoSuchAlgorithmException e) { throw new IllegalStateException("Cannot hash truck request", e); } } + /** 推进订单版本和更新时间。 */ private static void touch(UsTruckOrder o) { o.setVersion(value(o.getVersion()) + 1); o.setUpdatedAt(LocalDateTime.now()); } + /** 仅更新非空业务文字。 */ private static void setText(String v, Consumer c) { if (StringUtils.hasText(v)) c.accept(v.trim()); } + /** 更新请求明确携带的可选文字。 */ private static void setPresent(String v, Consumer c) { if (v != null) c.accept(trim(v)); } + /** 统一处理可选文字空值。 */ private static String trim(String v) { return v == null || v.isBlank() ? null : v.trim(); } + /** 统一状态与币种编码大小写。 */ private static String norm(String v) { return v.trim().toUpperCase(); } + /** 未指定币种时使用美元。 */ private static String defaultCurrency(String v) { return StringUtils.hasText(v) ? norm(v) : "USD"; } - private static int value(Integer v) { return v == null ? 0 : v; } private static long value(Long v) { return v == null ? 0 : v; } + /** 将计数空值转换为零。 */ + private static int value(Integer v) { return v == null ? 0 : v; } + /** 将计数空值转换为零。 */ + private static long value(Long v) { return v == null ? 0 : v; } + /** 构造请求校验错误。 */ private static ResponseStatusException badRequest(String m) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, m); } + /** 构造状态或并发冲突错误。 */ private static ResponseStatusException conflict(String m) { return new ResponseStatusException(HttpStatus.CONFLICT, m); } + /** 构造客户范围内订单不存在错误。 */ private static ResponseStatusException notFound() { return new ResponseStatusException(HttpStatus.NOT_FOUND, "US truck order not found"); } } diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 8c824d878..86b64ec2e 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -2784,13 +2784,12 @@ CREATE TABLE IF NOT EXISTS ll_us_truck_order ( id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', order_no VARCHAR(64) NOT NULL COMMENT '订单号', - transport_type VARCHAR(32) NOT NULL COMMENT '运输类型', + vehicle_type VARCHAR(32) NULL COMMENT '车辆类型:PALLET_15为15拖,PALLET_30为30拖;拼车未提供时为空', origin VARCHAR(255) NOT NULL COMMENT '起运地', origin_address VARCHAR(500) NULL COMMENT '起运地址', destination VARCHAR(255) NOT NULL COMMENT '目的地', destination_address VARCHAR(500) NULL COMMENT '目的地址', - container_type VARCHAR(32) NULL DEFAULT NULL COMMENT '柜型;拼车来源未提供时为空,不推断为整柜', - pallet_count INT NOT NULL DEFAULT 1 COMMENT '客户申报托盘数', + pallet_count INT NOT NULL DEFAULT 0 COMMENT '确认托盘数;独立下单首次实收确定,拼车来源取真实预报', package_count INT NULL COMMENT '可选箱数,零或空值均有效', received_pallet_count INT NOT NULL DEFAULT 0 COMMENT 'PDA实收托盘数', allocated_pallet_count INT NOT NULL DEFAULT 0 COMMENT '已分配到合车行程的托盘数', @@ -2798,9 +2797,12 @@ CREATE TABLE IF NOT EXISTS ll_us_truck_order ( total_volume_cbm DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '总体积(立方米)', budget_amount DECIMAL(18,2) NULL COMMENT '预算金额', currency VARCHAR(3) NOT NULL DEFAULT 'USD' COMMENT '币种', - contact_name VARCHAR(128) NOT NULL COMMENT '联系人姓名', - contact_info VARCHAR(128) NOT NULL COMMENT '联系人联系方式', - requested_date DATE NOT NULL COMMENT '要求服务日期', + origin_contact_name VARCHAR(128) NULL COMMENT '起运地联系人', + origin_contact_phone VARCHAR(128) NULL COMMENT '起运地联系电话', + destination_contact_name VARCHAR(128) NULL COMMENT '目的地联系人', + destination_contact_phone VARCHAR(128) NULL COMMENT '目的地联系电话', + pickup_at DATETIME NULL COMMENT '取货时间,按填写的日期时分保存,不转换时区', + delivery_at DATETIME NULL COMMENT '送货时间,按填写的日期时分保存;拼车来源未提供时为空', status VARCHAR(32) NOT NULL COMMENT '美国卡车订单状态', remark VARCHAR(1000) NULL COMMENT '备注', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', @@ -2813,7 +2815,7 @@ CREATE TABLE IF NOT EXISTS ll_us_truck_order ( UNIQUE KEY uk_ll_us_truck_order_no(customer_id,order_no), UNIQUE KEY uk_ll_us_truck_order_key(customer_id,idempotency_key), KEY idx_ll_us_truck_order_list(customer_id,status,created_at), - KEY idx_ll_us_truck_order_date(customer_id,requested_date,created_at) + KEY idx_ll_us_truck_order_date(customer_id,pickup_at,created_at) ) ENGINE=InnoDB COMMENT='美国卡车运输订单'; CREATE TABLE IF NOT EXISTS ll_us_truck_order_pallet ( diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index bc034e68a..f0c18bbe1 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -1421,9 +1421,9 @@ paths: - { name: orderNo, in: query, schema: { type: string } } - { name: routeKeyword, in: query, schema: { type: string } } - { name: status, in: query, schema: { $ref: '#/components/schemas/TruckStatus' } } - - { name: transportType, in: query, schema: { type: string } } - - { name: requestedFrom, in: query, schema: { type: string, format: date } } - - { name: requestedTo, in: query, schema: { type: string, format: date } } + - { name: vehicleType, in: query, schema: { type: string } } + - { name: pickupFrom, in: query, schema: { type: string, format: date-time } } + - { name: pickupTo, in: query, schema: { type: string, format: date-time } } responses: { '200': { description: 当前客户本土卡订单分页, content: { application/json: { schema: { $ref: '#/components/schemas/TruckPageResponse' } } } } } post: summary: 客户创建美国本土卡订单 @@ -3610,11 +3610,10 @@ components: id: { type: string, pattern: '^[0-9]+$' } customerId: { type: string } orderNo: { type: string } - transportType: { type: string, nullable: true } + vehicleType: { type: string, enum: [PALLET_15, PALLET_30], description: '15拖或30拖;拼车来源未知时为空', nullable: true } origin: { type: string } destination: { type: string } - containerType: { type: string, nullable: true, enum: [40GP, 40HQ, 45HQ], description: 已知时为平台统一柜型;池询价未提供时未知,不默认40HQ } - palletCount: { type: integer } + palletCount: { type: integer, description: 已确认托数;新建独立订单为0,首次实收确定,不从车型推导 } packageCount: { type: integer } receivedPalletCount: { type: integer } allocatedPalletCount: { type: integer } @@ -3622,9 +3621,12 @@ components: totalVolumeCbm: { type: number } budgetAmount: { type: number, nullable: true } currency: { type: string, nullable: true } - contactName: { type: string, nullable: true } - contactInfo: { type: string, nullable: true } - requestedDate: { type: string, format: date, nullable: true } + originContactName: { type: string, nullable: true, maxLength: 128 } + originContactPhone: { type: string, nullable: true, maxLength: 128 } + destinationContactName: { type: string, nullable: true, maxLength: 128 } + destinationContactPhone: { type: string, nullable: true, maxLength: 128 } + pickupAt: { type: string, nullable: true, format: date-time, description: '取货日期时分,使用无偏移量的本地时间,不转换时区' } + deliveryAt: { type: string, nullable: true, format: date-time, description: '送货日期时分,不能早于取货时间,不转换时区' } status: { $ref: '#/components/schemas/TruckStatus' } version: { type: integer, format: int64 } createdAt: { type: string, format: date-time } @@ -4031,45 +4033,47 @@ components: remark: { type: string, nullable: true, maxLength: 1000 } TruckCreate: type: object - required: [origin, destination, palletCount, totalWeightKg, totalVolumeCbm, contactName, contactInfo, requestedDate, idempotencyKey] + required: [vehicleType, origin, destination, totalWeightKg, totalVolumeCbm, originContactName, originContactPhone, destinationContactName, destinationContactPhone, pickupAt, deliveryAt, idempotencyKey] properties: - transportType: { type: string, enum: [PORT_PICKUP, WAREHOUSE_TO_WAREHOUSE, WAREHOUSE_TO_DOOR] } + vehicleType: { type: string, enum: [PALLET_15, PALLET_30], description: '15拖或30拖;拼车来源未知时为空' } origin: { type: string, maxLength: 255 } originAddress: { type: string, nullable: true, maxLength: 500 } destination: { type: string, maxLength: 255 } destinationAddress: { type: string, nullable: true, maxLength: 500 } - containerType: { type: string, enum: [40GP, 40HQ, 45HQ], default: 40HQ, maxLength: 32 } - palletCount: { type: integer, minimum: 1 } packageCount: { type: integer, nullable: true, minimum: 0 } totalWeightKg: { type: number, minimum: 0 } totalVolumeCbm: { type: number, minimum: 0 } budgetAmount: { type: number, nullable: true, minimum: 0 } currency: { type: string, minLength: 3, maxLength: 3, default: USD } - contactName: { type: string, maxLength: 128 } - contactInfo: { type: string, maxLength: 128 } - requestedDate: { type: string, format: date } + originContactName: { type: string, maxLength: 128 } + originContactPhone: { type: string, maxLength: 128 } + destinationContactName: { type: string, maxLength: 128 } + destinationContactPhone: { type: string, maxLength: 128 } + pickupAt: { type: string, format: date-time, description: '取货日期时分,使用无偏移量的本地时间,不转换时区' } + deliveryAt: { type: string, format: date-time, description: '送货日期时分,不能早于取货时间,不转换时区' } remark: { type: string, nullable: true, maxLength: 1000 } idempotencyKey: { type: string, maxLength: 255 } TruckUpdate: type: object - required: [version] + required: [version, vehicleType, originContactName, originContactPhone, destinationContactName, destinationContactPhone, pickupAt, deliveryAt] properties: version: { type: integer, format: int64, minimum: 0 } - transportType: { type: string, enum: [PORT_PICKUP, WAREHOUSE_TO_WAREHOUSE, WAREHOUSE_TO_DOOR] } + vehicleType: { type: string, enum: [PALLET_15, PALLET_30], description: '15拖或30拖;拼车来源未知时为空' } origin: { type: string, maxLength: 255 } originAddress: { type: string, nullable: true, maxLength: 500 } destination: { type: string, maxLength: 255 } destinationAddress: { type: string, nullable: true, maxLength: 500 } - containerType: { type: string, enum: [40GP, 40HQ, 45HQ], maxLength: 32 } - palletCount: { type: integer, minimum: 1 } packageCount: { type: integer, minimum: 0 } totalWeightKg: { type: number, minimum: 0 } totalVolumeCbm: { type: number, minimum: 0 } budgetAmount: { type: number, nullable: true, minimum: 0 } currency: { type: string, minLength: 3, maxLength: 3 } - contactName: { type: string, maxLength: 128 } - contactInfo: { type: string, maxLength: 128 } - requestedDate: { type: string, format: date } + originContactName: { type: string, maxLength: 128 } + originContactPhone: { type: string, maxLength: 128 } + destinationContactName: { type: string, maxLength: 128 } + destinationContactPhone: { type: string, maxLength: 128 } + pickupAt: { type: string, format: date-time, description: '取货日期时分,使用无偏移量的本地时间,不转换时区' } + deliveryAt: { type: string, format: date-time, description: '送货日期时分,不能早于取货时间,不转换时区' } remark: { type: string, nullable: true, maxLength: 1000 } LooseboardStatus: { type: string, enum: [PENDING_ACCEPTANCE, WAITING_INBOUND, RECEIVING, WAITING_CONSOLIDATION, PENDING_DISPATCH, DISPATCHED, IN_TRANSIT, COMPLETED, CANCELLED] } LooseboardStatusChange: diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridgeTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridgeTest.java index 2bb70c73c..fb281fd2b 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridgeTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridgeTest.java @@ -96,14 +96,14 @@ class FleetPoolOrderBridgeTest { incoming();service.receiving("9","WT-7","pda");service.acceptedReceipt(7,receipt(),3,"op"); assertThat(looseOrder.getPalletRequired()).isNull();assertThat(looseOrder.getContainerType()).isNull(); } - @Test void truckInquiryPreservesUnknownContainerWhileZeroActualCountsRemainRealUntilReceipt(){ + @Test void truckInquiryPreservesUnknownVehicleWhileZeroActualCountsRemainRealUntilReceipt(){ var source=input("US_TRUCK");service.confirm(source,"op"); - assertThat(truckOrder.getContainerType()).isNull();assertThat(truckOrder.getPalletCount()).isEqualTo(source.expectedPalletCount()); + assertThat(truckOrder.getVehicleType()).isNull();assertThat(truckOrder.getPalletCount()).isEqualTo(source.expectedPalletCount()); assertThat(truckOrder.getReceivedPalletCount()).isZero();assertThat(truckOrder.getAllocatedPalletCount()).isZero(); - assertThat(truckOrder.getPackageCount()).isEqualTo(source.packageCount());assertThat(truckOrder.getTransportType()).isEqualTo("WAREHOUSE_TO_WAREHOUSE"); + assertThat(truckOrder.getPackageCount()).isEqualTo(source.packageCount());assertThat(truckOrder.getDestinationContactName()).isNull();assertThat(truckOrder.getDeliveryAt()).isNull(); assertThat(truckOrder.getOriginAddress()).isNull();assertThat(truckOrder.getDestinationAddress()).isNull();assertThat(truckOrder.getBudgetAmount()).isNull(); incoming();service.receiving("9","WT-7","pda");service.acceptedReceipt(7,receipt(),3,"op"); - assertThat(truckOrder.getReceivedPalletCount()).isEqualTo(2);assertThat(truckOrder.getContainerType()).isNull(); + assertThat(truckOrder.getReceivedPalletCount()).isEqualTo(2);assertThat(truckOrder.getVehicleType()).isNull(); } @Test void incomingMustMatchCustomerWarehouseAndDestinationAndNeverBindSecondTailOrder(){ service.confirm(input("LOCAL_LOOSEBOARD"),"op"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/UsTruckOrderPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/UsTruckOrderPersistenceTest.java new file mode 100644 index 000000000..114b41743 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/UsTruckOrderPersistenceTest.java @@ -0,0 +1,64 @@ +package com.baiyue.lastleg.trucking; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.lastleg.trucking.entity.UsTruckOrder; +import com.baiyue.lastleg.trucking.mapper.*; +import com.baiyue.lastleg.trucking.repository.UsTruckOrderRepository; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.UUID; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; + +class UsTruckOrderPersistenceTest { + /** 使用模块基线和真实 Mapper 验证新字段保存、回读、更新与客户隔离。 */ + @Test + void contactsVehicleAndLocalMinuteTimesRoundTripThroughActualMapper() throws Exception { + var ds = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); + String baseline; + try (var input = getClass().getResourceAsStream("/db/lastleg.sql")) { + baseline = new String(input.readAllBytes(), StandardCharsets.UTF_8); + } + int start = baseline.indexOf("CREATE TABLE IF NOT EXISTS ll_us_truck_order ("); + String ddl = baseline.substring(start, baseline.indexOf(';', start)); + // H2 使用真实字段定义,去掉 MySQL 专属的索引和表选项。 + ddl = ddl.replaceAll("(?m)^\\s*(?:UNIQUE KEY|KEY) [^\\n]*\\n?", ""); + ddl = ddl.substring(0, ddl.indexOf(") ENGINE=")) + ")"; + ddl = ddl.replaceAll(",\\s*\\)$", ")"); + new JdbcTemplate(ds).execute(ddl); + var config = new MybatisConfiguration(); + config.setMapUnderscoreToCamelCase(true); + config.addMapper(UsTruckOrderMapper.class); + var factory = new MybatisSqlSessionFactoryBean(); + factory.setDataSource(ds); factory.setConfiguration(config); + var mapper = new SqlSessionTemplate(factory.getObject()).getMapper(UsTruckOrderMapper.class); + var repository = new UsTruckOrderRepository(mapper, mock(UsTruckOrderPalletMapper.class), mock(UsTruckOrderStatusHistoryMapper.class), mock(UsTruckOrderOperationMapper.class)); + var order = new UsTruckOrder(); + order.setId(1L); order.setCustomerId("C-1"); order.setOrderNo("TRUCK-TEST-1"); + order.setVehicleType("PALLET_15"); order.setOrigin("Los Angeles"); order.setDestination("Ontario"); + order.setOriginContactName("发货人"); order.setOriginContactPhone("+1 (555) 0100"); + order.setDestinationContactName("收货人"); order.setDestinationContactPhone("+1-555-0101"); + order.setPickupAt(LocalDateTime.of(2026, 10, 20, 9, 30)); order.setDeliveryAt(LocalDateTime.of(2026, 10, 20, 15, 45)); + order.setTotalWeightKg(BigDecimal.ZERO); order.setTotalVolumeCbm(BigDecimal.ZERO); + order.setCurrency("USD"); order.setStatus("PENDING_REVIEW"); order.setIdempotencyKey("roundtrip"); order.setRequestHash("0".repeat(64)); + repository.insertOrder(order); + var saved = repository.findNo("C-1", order.getOrderNo()); + assertEquals("PALLET_15", saved.getVehicleType()); + assertEquals("发货人", saved.getOriginContactName()); assertEquals("+1 (555) 0100", saved.getOriginContactPhone()); + assertEquals("收货人", saved.getDestinationContactName()); assertEquals("+1-555-0101", saved.getDestinationContactPhone()); + assertEquals(order.getPickupAt(), saved.getPickupAt()); assertEquals(order.getDeliveryAt(), saved.getDeliveryAt()); + assertEquals(0, saved.getPalletCount()); + assertNull(repository.findNo("C-2", order.getOrderNo())); assertNull(repository.findKey("C-2", "roundtrip")); + saved.setVehicleType("PALLET_30"); saved.setDestinationContactPhone("+1-555-0102"); saved.setDeliveryAt(saved.getDeliveryAt().plusMinutes(15)); + repository.updateOrder(saved); + var updated = repository.findKey("C-1", "roundtrip"); + assertEquals("PALLET_30", updated.getVehicleType()); assertEquals("+1-555-0102", updated.getDestinationContactPhone()); + assertEquals(LocalDateTime.of(2026, 10, 20, 16, 0), updated.getDeliveryAt()); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/UsTruckOrderRequestsTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/UsTruckOrderRequestsTest.java new file mode 100644 index 000000000..5539a87d3 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/UsTruckOrderRequestsTest.java @@ -0,0 +1,35 @@ +package com.baiyue.lastleg.trucking; + +import com.baiyue.lastleg.trucking.dto.UsTruckOrderRequests; +import jakarta.validation.Validation; +import org.junit.jupiter.api.Test; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.stream.Collectors; +import static org.junit.jupiter.api.Assertions.*; + +class UsTruckOrderRequestsTest { + /** 新增的七项需求必须由服务端校验,不能仅依赖页面星号。 */ + @Test + void bothEndpointsVehicleAndTimesAreMandatory() { + try (var factory = Validation.buildDefaultValidatorFactory()) { + var request = new UsTruckOrderRequests.Create(null, "LA", null, "ONT", null, 0, + BigDecimal.ZERO, BigDecimal.ZERO, null, "USD", " ", null, null, "", null, null, null, "required"); + var fields = factory.getValidator().validate(request).stream().map(v -> v.getPropertyPath().toString()).collect(Collectors.toSet()); + assertEquals(java.util.Set.of("vehicleType", "originContactName", "originContactPhone", "destinationContactName", "destinationContactPhone", "pickupAt", "deliveryAt"), fields); + } + } + + /** 两种当前车型及国际电话原样受理,未知车型拒绝。 */ + @Test + void onlyCurrentVehicleTypesAreAccepted() { + try (var factory = Validation.buildDefaultValidatorFactory()) { + for (String vehicle : java.util.List.of("PALLET_15", "PALLET_30", "OTHER")) { + var request = new UsTruckOrderRequests.Create(vehicle, "LA", null, "ONT", null, 0, + BigDecimal.ZERO, BigDecimal.ZERO, null, "USD", "A", "+1 (555) 0100", "B", "+1-555-0101", + LocalDateTime.of(2026, 10, 20, 9, 30), LocalDateTime.of(2026, 10, 20, 15, 45), null, "vehicle"); + assertEquals(!vehicle.equals("OTHER"), factory.getValidator().validate(request).isEmpty()); + } + } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/UsTruckOrderSchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/UsTruckOrderSchemaContractTest.java index 1258d91fd..4eca2719d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/UsTruckOrderSchemaContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/UsTruckOrderSchemaContractTest.java @@ -18,7 +18,7 @@ class UsTruckOrderSchemaContractTest { assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_us_truck_order")); assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_us_truck_order_status_history")); assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_us_truck_order_pallet")); - assertTrue(sql.contains("pallet_count INT NOT NULL DEFAULT 1")); + assertTrue(sql.contains("pallet_count INT NOT NULL DEFAULT 0")); assertTrue(sql.contains("received_pallet_count INT NOT NULL DEFAULT 0")); assertTrue(sql.contains("allocated_pallet_count INT NOT NULL DEFAULT 0")); assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_us_truck_order_operation")); @@ -50,7 +50,7 @@ class UsTruckOrderSchemaContractTest { } @Test - void poolUnknownContainerSurvivesOmittedNullInsertColumnInBothBaselines() throws Exception { + void newTruckDemandFieldsExistAndRemovedInputsAreAbsentInBothBaselines() throws Exception { String baseline; try (var input = getClass().getResourceAsStream("/db/lastleg.sql")) { baseline = new String(input.readAllBytes(), StandardCharsets.UTF_8); @@ -59,8 +59,9 @@ class UsTruckOrderSchemaContractTest { for (String sql : java.util.List.of(baseline, unified)) { String normalized = sql.toLowerCase().replace("create table if not exists", "create table"); String block = tableBlock(normalized, "ll_us_truck_order"); - assertTrue(java.util.regex.Pattern.compile("(?:^|,)\\s*container_type\\s+varchar\\(32\\)\\s+(?:collate\\s+\\w+\\s+)?(?:null\\s+)?default\\s+null\\b").matcher(block).find(), - "Unknown container must remain null when MyBatis omits its insert column"); + for (String column : java.util.List.of("vehicle_type", "origin_contact_name", "origin_contact_phone", "destination_contact_name", "destination_contact_phone", "pickup_at", "delivery_at")) assertTrue(block.contains(column)); + for (String removed : java.util.List.of("transport_type", "container_type", "requested_date")) org.junit.jupiter.api.Assertions.assertFalse(block.contains(removed)); + } } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/controller/UsTruckOrderControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/controller/UsTruckOrderControllerTest.java index f486612ee..bb353860b 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/controller/UsTruckOrderControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/controller/UsTruckOrderControllerTest.java @@ -75,9 +75,9 @@ class UsTruckOrderControllerTest { @Test void platformCreateUsesExplicitCustomerAndCreatePermission() { - var request = new UsTruckOrderRequests.Create("PORT_PICKUP", "Los Angeles", null, - "Ontario", null, "40HQ", 1, 0, java.math.BigDecimal.ZERO, java.math.BigDecimal.ZERO, - null, "USD", "Alice", "+1-555-0100", java.time.LocalDate.now(), null, "create-key"); + var request = new UsTruckOrderRequests.Create("PALLET_15", "Los Angeles", null, + "Ontario", null, 0, java.math.BigDecimal.ZERO, java.math.BigDecimal.ZERO, + null, "USD", "Alice", "+1-555-0100", "Bob", "+1-555-0101", java.time.LocalDateTime.now(), java.time.LocalDateTime.now().plusHours(1), null, "create-key"); platform.create(user(0L, "PLATFORM", Set.of(com.baiyue.lastleg.security.OverseasPermissions.TRUCK_CREATE)), "100", request); verify(service).create("100", "7", request); @@ -85,8 +85,8 @@ class UsTruckOrderControllerTest { @Test void platformUpdateUsesExplicitCustomerAndEditPermission() { - var request = new UsTruckOrderRequests.Update(0L, null, null, null, null, null, null, - null, null, null, null, null, null, null, null, null, null); + var request = new UsTruckOrderRequests.Update(0L, "PALLET_30", null, null, null, null, + null, null, null, null, null, "Alice", "100", "Bob", "200", java.time.LocalDateTime.now(), java.time.LocalDateTime.now().plusHours(1), null); platform.update(user(0L, "PLATFORM", Set.of(com.baiyue.lastleg.security.OverseasPermissions.TRUCK_EDIT)), "100", "TK1", request); verify(service).update("100", "TK1", "7", request); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/service/UsTruckOrderServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/service/UsTruckOrderServiceTest.java index bc9413b18..d5e6a928f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/service/UsTruckOrderServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/service/UsTruckOrderServiceTest.java @@ -54,18 +54,15 @@ class UsTruckOrderServiceTest { void setUp() { service = new UsTruckOrderService(); org.springframework.test.util.ReflectionTestUtils.setField(service, "fleetPool", org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.business.FleetPoolOrderQueries.class)); - ReflectionTestUtils.setField(service, "orders", orders); - ReflectionTestUtils.setField(service, "pallets", pallets); - ReflectionTestUtils.setField(service, "histories", histories); - ReflectionTestUtils.setField(service, "operations", operations); + ReflectionTestUtils.setField(service, "repository", new com.baiyue.lastleg.trucking.repository.UsTruckOrderRepository(orders, pallets, histories, operations)); ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules()); ReflectionTestUtils.setField(service, "orderNoGenerator", orderNoGenerator); order = order(UsTruckOrderService.PENDING_REVIEW, 0L, 2); lenient().when(orders.lockNo("100", "TK1")).thenAnswer(call -> order); - when(orders.findNo("100", "TK1")).thenAnswer(call -> order); - when(pallets.listOrder(anyString(), anyLong())).thenAnswer(call -> palletRows); + lenient().when(orders.findNo("100", "TK1")).thenAnswer(call -> order); + lenient().when(pallets.listOrder(anyString(), anyLong())).thenAnswer(call -> palletRows); lenient().when(histories.listOrder("100", 1L)).thenAnswer(call -> historyRows); - when(histories.insert(any(UsTruckOrderStatusHistory.class))).thenAnswer(call -> { + lenient().when(histories.insert(any(UsTruckOrderStatusHistory.class))).thenAnswer(call -> { historyRows.add(call.getArgument(0)); return 1; }); lenient().when(pallets.insert(any(UsTruckOrderPallet.class))).thenAnswer(call -> { @@ -82,18 +79,39 @@ class UsTruckOrderServiceTest { } @Test - void createDefaultsTheContainerTypeAndRejectsRemovedTypes() { + void createStoresBothContactsAndMinuteTimesWithoutInventingPallets() { when(orderNoGenerator.next(anyString(), anyString(), anyLong())).thenReturn("TK1"); when(histories.listOrder(anyString(), anyLong())).thenReturn(historyRows); - when(orders.insert(any(UsTruckOrder.class))).thenAnswer(call -> { - order = call.getArgument(0); - return 1; - }); + when(orders.insert(any(UsTruckOrder.class))).thenAnswer(call -> { order = call.getArgument(0); return 1; }); + var request = create("PALLET_15", "new-truck"); + var detail = service.create("100", "operator", request); + assertEquals("PALLET_15", detail.summary().vehicleType()); + assertEquals("Alice", detail.summary().originContactName()); + assertEquals("Bob", detail.summary().destinationContactName()); + assertEquals("+1-555-0101", detail.summary().destinationContactPhone()); + assertEquals(request.pickupAt(), detail.summary().pickupAt()); + assertEquals(request.deliveryAt(), detail.summary().deliveryAt()); + assertEquals(0, detail.summary().palletCount()); + assertTrue(detail.pallets().isEmpty()); + assertThrows(ResponseStatusException.class, () -> service.create("100", "operator", create("OTHER", "invalid"))); + } - var detail = service.create("100", "operator", create(null, "default-type")); + @Test + void firstActualReceiptAcceptsRealQuantityWithoutForecastOverage() { + order.setPalletCount(0); + service.review("100", "TK1", "operator", new UsTruckOrderRequests.Review(0L, "APPROVE", "review-new", null)); + assertTrue(palletRows.isEmpty()); + var received = service.receivePallets("100", "TK1", "receiver", new UsTruckOrderRequests.ReceivePallets(1L, 3, 6, List.of(2, 2, 2), "first-real", null)); + assertEquals(UsTruckOrderService.WAITING_CONSOLIDATION, received.summary().status()); + assertEquals(3, received.summary().receivedPalletCount()); + assertTrue(palletRows.stream().allMatch(row -> "RECEIVED".equals(row.getStatus()))); + } - assertEquals("40HQ", detail.summary().containerType()); - assertThrows(BizException.class, () -> service.create("100", "operator", create("40HC", "retired-type"))); + @Test + void reversedTimesAreRejectedBeforeCreatingAnOrder() { + var request = create("PALLET_30", "reversed"); + var invalid = new UsTruckOrderRequests.Create(request.vehicleType(), request.origin(), request.originAddress(), request.destination(), request.destinationAddress(), request.packageCount(), request.totalWeightKg(), request.totalVolumeCbm(), request.budgetAmount(), request.currency(), request.originContactName(), request.originContactPhone(), request.destinationContactName(), request.destinationContactPhone(), request.deliveryAt(), request.pickupAt(), request.remark(), request.idempotencyKey()); + assertThrows(ResponseStatusException.class, () -> service.create("100", "operator", invalid)); } @Test @@ -188,16 +206,16 @@ class UsTruckOrderServiceTest { return new UsTruckOrderRequests.Command(version, key, key); } - private UsTruckOrderRequests.Create create(String containerType, String key) { - return new UsTruckOrderRequests.Create("PORT_PICKUP", "Los Angeles", null, "Ontario", null, - containerType, 1, 0, BigDecimal.ZERO, BigDecimal.ZERO, null, "USD", "Alice", - "+1-555-0100", LocalDate.now().plusDays(1), null, key); + private UsTruckOrderRequests.Create create(String vehicleType, String key) { + return new UsTruckOrderRequests.Create(vehicleType, "Los Angeles", null, "Ontario", null, + 0, BigDecimal.ZERO, BigDecimal.ZERO, null, "USD", "Alice", "+1-555-0100", "Bob", "+1-555-0101", + LocalDateTime.of(2026, 10, 20, 9, 30), LocalDateTime.of(2026, 10, 20, 15, 45), null, key); } private UsTruckOrder order(String status, long version, int pallets) { UsTruckOrder value = new UsTruckOrder(); value.setId(1L); value.setCustomerId("100"); value.setOrderNo("TK1"); - value.setTransportType("PORT_PICKUP"); value.setOrigin("Los Angeles"); value.setDestination("Ontario"); + value.setVehicleType("PALLET_15"); value.setOrigin("Los Angeles"); value.setDestination("Ontario"); value.setPalletCount(pallets); value.setPackageCount(0); value.setReceivedPalletCount(0); value.setAllocatedPalletCount(0); value.setStatus(status); value.setVersion(version); value.setCreatedAt(LocalDateTime.now()); value.setUpdatedAt(value.getCreatedAt()); diff --git a/scripts/admin-test-env-modules/fleet-capacity-pool-schema-contract.test.mjs b/scripts/admin-test-env-modules/fleet-capacity-pool-schema-contract.test.mjs index d05c38d18..fcc783607 100644 --- a/scripts/admin-test-env-modules/fleet-capacity-pool-schema-contract.test.mjs +++ b/scripts/admin-test-env-modules/fleet-capacity-pool-schema-contract.test.mjs @@ -56,11 +56,13 @@ test('customer identity joins use the same explicit collation as existing fulfil }) test('commercial schemas do not invent container or palletizing facts for pool orders', () => { - for (const name of ['ll_local_looseboard_order', 'll_us_truck_order']) { + for (const name of ['ll_local_looseboard_order']) { const definition = table(overseas, 'baiyue_lastleg', name) assert.match(definition, /container_type\s+varchar\(32\)(?:\s+COLLATE\s+\w+)?\s+(?:NULL\s+)?DEFAULT NULL/i) assert.doesNotMatch(definition, /container_type[^\n]*DEFAULT '40HQ'/i) } + assert.doesNotMatch(table(overseas, 'baiyue_lastleg', 'll_us_truck_order'), /container_type/i) + assert.match(table(overseas, 'baiyue_lastleg', 'll_us_truck_order'), /vehicle_type/i) assert.match(table(overseas, 'baiyue_lastleg', 'll_local_looseboard_order'), /pallet_required\s+varchar\(8\)(?:\s+COLLATE\s+\w+)?\s+(?:NULL\s+)?DEFAULT NULL/i) const sql = read('deploy/sql/migrations/overseas/baiyue_lastleg/V12__preserve_unknown_pool_order_fields.sql') assert.equal((sql.match(/MODIFY COLUMN/g) || []).length, 3) diff --git a/scripts/admin-test-env-modules/phase4-overseas-business.mjs b/scripts/admin-test-env-modules/phase4-overseas-business.mjs index d537777cc..2a78f8a75 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-business.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-business.mjs @@ -270,11 +270,11 @@ async function procurementFlow(context, prerequisite) { function truckPayload(context) { const suffix = context.marker.replace(/[^A-Za-z0-9]/g, '').slice(-16) return { - transportType: 'PORT_PICKUP', origin: `验收起运-${suffix}`, originAddress: 'Long Beach', - destination: `验收目的-${suffix}`, destinationAddress: 'Ontario', containerType: '40HQ', - palletCount: 1, packageCount: 2, totalWeightKg: 100, totalVolumeCbm: 2.5, budgetAmount: 88, - currency: 'USD', contactName: '四期验收联系人', contactInfo: '13800000000', - requestedDate: new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10), remark: context.marker, + vehicleType: 'PALLET_15', origin: `验收起运-${suffix}`, originAddress: 'Long Beach', + destination: `验收目的-${suffix}`, destinationAddress: 'Ontario', + packageCount: 2, totalWeightKg: 100, totalVolumeCbm: 2.5, budgetAmount: 88, + currency: 'USD', originContactName: '验收发货人', originContactPhone: '+1-555-0100', destinationContactName: '验收收货人', destinationContactPhone: '+1-555-0101', + pickupAt: `${new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10)}T09:30:00`, deliveryAt: `${new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10)}T15:45:00`, remark: context.marker, idempotencyKey: `${context.marker}-phase4-truck`.slice(0, 255), } } @@ -367,7 +367,7 @@ async function truckFlow(context, customerId) { const detail = await overseas(context, 'GET', '查询 marker 本土卡车详情', path) const before = summary(detail.data) const edited = await overseas(context, 'PATCH', '编辑 marker 本土卡车订单', path, - { version: Number(before?.version), destination: `${payload.destination}-EDIT`, remark: `${context.marker}-truck-edited` }) + { ...payload, idempotencyKey: undefined, version: Number(before?.version), destination: `${payload.destination}-EDIT`, remark: `${context.marker}-truck-edited` }) const after = summary(edited.data) context.assert(MODULE, '卡车编辑版本递增且普通字段回显', edited.response, after?.destination === `${payload.destination}-EDIT` && Number(after?.version) === Number(before?.version) + 1