diff --git a/deploy/sql/README.md b/deploy/sql/README.md index 24ec7e9e8..61905f623 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -1,5 +1,7 @@ # 单测试环境数据库空库初始化 +本土散板简化下单的当前结构同步于 Lastleg 源基线及海外统一空库基线:移除散板柜型与旧要求日期,增加商品名称、申报预计托数及当地取货/送达时间,移除运输方式,增加起运地及目的地各自的联系人和联系电话;四项联系信息、取货及送货时间均必填;打盘与运输安排允许未知。预计托数不代表实收。字段语义唯一见工作区 `docs/海外仓业务/README.md`,不回填测试存量,不提供自动重置或启动时 SQL;需与匹配 Lastleg、Admin 和 Client 一起发布。本说明不表示 SQL 已执行。 + `migrations/overseas/baiyue_lastleg/V23__align_return_movement_collation.sql` 将退货进销存 UNION 涉及的八张表、22 个文本字段及表默认规则显式统一为 `utf8mb4_unicode_ci`,同步 Lastleg 两份源基线与海外统一空库基线。执行前核对字段长度、默认值、可空性及目标规则下唯一索引无冲突;只修改规则,不回填或删除数据,不重放空库基线。DDL 非事务,失败时停止并复查实际已完成项;可按最终定义重跑。脚本存在不表示测试库已执行,执行后用原始计数/分页查询验证,无需发布查询转换代码。 `migrations/overseas/baiyue_lastleg/V22__selfop_warehouse_daily_sales.sql` 新增自营仓库每日汇总表及销量版本 `warehouse_daily_ready` 标记。与匹配 Lastleg 一起发布;汇总随 SKU 日事实批次原子构建,完整性验证后方可发布。已有未构建汇总的销量版本须重新构建发布,不回填旧版本、不回退旧查询;空版本同样必须有就绪证据。源基线与海外空库基线同步。 diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 0f6f2397d..d4a6b28a1 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -1487,22 +1487,21 @@ CREATE TABLE ll_local_looseboard_order ( customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', lastleg_order_id bigint DEFAULT NULL COMMENT '关联尾程主订单ID', order_no varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '本土散板订单号', - transport_mode varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '运输方式:拼车或优先拼载', pallet_required varchar(8) COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '是否需要打盘:YES或NO;拼车来源未提供时为空', - transport_type varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '运输类型', + transport_type VARCHAR(32) NULL DEFAULT NULL COMMENT '接单运输安排;下单不预设', 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 '柜型;拼车来源未提供时为空,不推断为整柜', + product_name VARCHAR(255) NULL COMMENT '商品名称', estimated_pallet_count INT NULL COMMENT '客户申报预计托数,不代表实际收货', package_count int NOT NULL DEFAULT '0' COMMENT '货物件数', total_weight_kg decimal(18,3) NOT NULL DEFAULT '0.000' COMMENT '总重量(千克)', total_volume_cbm decimal(18,6) NOT NULL DEFAULT '0.000000' COMMENT '总体积(立方米)', budget_amount decimal(18,2) DEFAULT NULL COMMENT '预算总价', currency char(3) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'USD' COMMENT '预算币种', - contact_name varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '联系人姓名', - contact_info varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '联系人联系方式', - requested_date date DEFAULT 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(3) NULL COMMENT '取货时间(当地时间,不转换时区)', delivery_at DATETIME(3) NULL COMMENT '送货时间(当地时间,不转换时区)', status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING_ACCEPTANCE' COMMENT '本土散板订单状态', remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注', idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '创建幂等键', @@ -1516,7 +1515,7 @@ CREATE TABLE ll_local_looseboard_order ( UNIQUE KEY uk_ll_local_looseboard_key (customer_id,idempotency_key), UNIQUE KEY uk_ll_local_looseboard_lastleg (lastleg_order_id), KEY idx_ll_local_looseboard_list (customer_id,status,created_at), - KEY idx_ll_local_looseboard_date (customer_id,requested_date,created_at) + KEY idx_ll_local_looseboard_date (customer_id,pickup_at,created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='本土散板订单'; CREATE TABLE ll_monthly_exchange_rate ( 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 b16a9e1af..19d19c6cb 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 @@ -3,9 +3,7 @@ package com.baiyue.lastleg.fleet.business; import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.baiyue.lastleg.looseboard.entity.*; -import com.baiyue.lastleg.looseboard.mapper.*; import com.baiyue.lastleg.trucking.entity.*; -import com.baiyue.lastleg.trucking.mapper.*; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.http.HttpStatus; @@ -22,34 +20,32 @@ import static com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.*; /** Same-database bridge. Never creates inventory, publishes fees, or locks a parent trip. */ @Service public class FleetPoolOrderBridge { - private final FleetPoolOrderMapper links; - private final LocalLooseboardOrderMapper loose; - private final LocalLooseboardHistoryMapper looseHistory; - private final UsTruckOrderMapper trucks; - private final UsTruckOrderStatusHistoryMapper truckHistory; + private final FleetPoolOrderRepository repository; private final FleetPoolOrderNumberGenerator numbers; private final ObjectMapper json; private final FleetPoolDeliveryEvidence deliveryEvidence; private final com.baiyue.lastleg.appointmentplanning.AppointmentSourceLinker appointmentSources; - public FleetPoolOrderBridge(FleetPoolOrderMapper links,LocalLooseboardOrderMapper loose, - LocalLooseboardHistoryMapper looseHistory,UsTruckOrderMapper trucks, - UsTruckOrderStatusHistoryMapper truckHistory,FleetPoolOrderNumberGenerator numbers,ObjectMapper json,FleetPoolDeliveryEvidence deliveryEvidence, + /** 注入同库商业委托仓储及履约协作服务。 */ + public FleetPoolOrderBridge(FleetPoolOrderRepository repository, + FleetPoolOrderNumberGenerator numbers,ObjectMapper json,FleetPoolDeliveryEvidence deliveryEvidence, com.baiyue.lastleg.appointmentplanning.AppointmentSourceLinker appointmentSources) { - this.links=links;this.loose=loose;this.looseHistory=looseHistory;this.trucks=trucks; - this.truckHistory=truckHistory;this.numbers=numbers;this.json=json;this.deliveryEvidence=deliveryEvidence; + this.repository=repository; + this.numbers=numbers;this.json=json;this.deliveryEvidence=deliveryEvidence; this.appointmentSources=appointmentSources; } + /** 批量读取预订对应的当前商业履约阶段。 */ @Transactional(readOnly=true) public Map fulfillmentStages(List bookingIds) { if(bookingIds.isEmpty())return Map.of(); var result=new HashMap(); - for(var link:links.bookings(bookingIds))result.put(link.getBookingId(),link.getFulfillmentStage()); + for(var link:repository.bookings(bookingIds))result.put(link.getBookingId(),link.getFulfillmentStage()); return Map.copyOf(result); } + /** 在承运确认事务内创建唯一商业订单,重放时核对来源哈希。 */ @Transactional(propagation=Propagation.MANDATORY) public BusinessOrderRef confirm(ConfirmedBooking in,String operator) { validate(in); - String hash=hash(in); var prior=links.lockBooking(in.bookingId()); + String hash=hash(in); var prior=repository.lockBooking(in.bookingId()); if(prior!=null){if(!hash.equals(prior.getCreationRequestHash()))throw conflict("同一承运来源不能创建不同商业订单");return ref(prior);} var now=LocalDateTime.now();var row=new FleetPoolOrderLink(); row.setId(IdWorker.getId());row.setBookingId(in.bookingId());row.setBookingNo(in.bookingNo()); @@ -62,15 +58,16 @@ public class FleetPoolOrderBridge { String transport=in.destinationKey().startsWith("FBA:")?"WAREHOUSE_TO_WAREHOUSE":"WAREHOUSE_TO_DOOR"; if("LOCAL_LOOSEBOARD".equals(in.businessType())) { var order=new LocalLooseboardOrder();order.setId(row.getBusinessOrderId());order.setCustomerId(in.customerId());order.setOrderNo(row.getBusinessOrderNo()); - order.setTransportMode("SHARED_TRUCK");order.setTransportType(transport); - // A pallet count/space is not a palletizing service request or a container specification. - order.setPalletRequired(null);order.setContainerType(null); + order.setTransportType(transport); + // 货名与预计托数沿真实询价保存;不从数量推断打盘服务,也不预设取送时间。 + order.setPalletRequired(null);order.setProductName(in.cargoName());order.setEstimatedPalletCount(in.expectedPalletCount()); order.setOrigin(in.warehouseName());order.setDestination(in.destinationLabel());order.setPackageCount(in.packageCount()); 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); - loose.insert(order); // No placeholder ll_order: incoming owns the one physical tail order. + repository.insertLoose(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.setOrigin(in.warehouseName());order.setDestination(in.destinationLabel()); @@ -80,33 +77,36 @@ public class FleetPoolOrderBridge { 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. + repository.insertTruck(order); // No simulated ll_us_truck_order_pallet rows. } - links.insert(row);history(row,null,row.getFulfillmentStage(),operator,"确认承运并建立商业委托",now); + repository.insertLink(row);history(row,null,row.getFulfillmentStage(),operator,"确认承运并建立商业委托",now); return ref(row); } + /** 锁定预订并关联唯一真实来货,不创建第二份实物来源。 */ @Transactional(propagation=Propagation.MANDATORY) public void bindIncoming(String bookingNo,String customerId,String incomingNo,long lastlegOrderId, String warehouseId,String destinationKey,String operator) { - var row=links.lockBookingNo(bookingNo); - if(row==null){if(links.poolBooking(bookingNo)>0)throw conflict("池来源预订缺少唯一商业订单关联");return;} + var row=repository.lockBookingNo(bookingNo); + if(row==null){if(repository.poolBooking(bookingNo)>0)throw conflict("池来源预订缺少唯一商业订单关联");return;} owned(row,customerId); if(!Objects.equals(row.getWarehouseId(),warehouseId)||!Objects.equals(row.getDestinationKey(),destinationKey))throw conflict("来货仓库或目的地与池来源商业委托不一致"); if(row.getIncomingNo()!=null){if(!row.getIncomingNo().equals(incomingNo)||!Objects.equals(row.getLastlegOrderId(),lastlegOrderId))throw conflict("商业委托已绑定其他来货");return;} if(!"WAITING_INBOUND".equals(row.getFulfillmentStage()))throw conflict("商业委托当前不能登记来货"); - row.setIncomingNo(incomingNo);row.setLastlegOrderId(lastlegOrderId);touch(row,LocalDateTime.now());links.updateById(row); + row.setIncomingNo(incomingNo);row.setLastlegOrderId(lastlegOrderId);touch(row,LocalDateTime.now());repository.updateLink(row); if("LOCAL_LOOSEBOARD".equals(row.getBusinessType())) { - var order=loose.lockNo(customerId,row.getBusinessOrderNo());requireOrder(order!=null); + var order=repository.lockLoose(customerId,row.getBusinessOrderNo());requireOrder(order!=null); if(order.getLastlegOrderId()!=null&&!order.getLastlegOrderId().equals(lastlegOrderId))throw conflict("散板商业单已存在另一尾程来源"); - order.setLastlegOrderId(lastlegOrderId);order.setVersion(order.getVersion()+1);order.setUpdatedAt(row.getUpdatedAt());loose.updateById(order); + order.setLastlegOrderId(lastlegOrderId);order.setVersion(order.getVersion()+1);order.setUpdatedAt(row.getUpdatedAt());repository.updateLoose(order); } appointmentSources.materialized(customerId, bookingNo, lastlegOrderId); } + /** 按真实来货开始事件推进收货状态。 */ @Transactional(propagation=Propagation.MANDATORY) public void receiving(String customerId,String incomingNo,String operator) { - var row=links.lockIncoming(customerId,incomingNo);if(row==null)return; + var row=repository.lockIncoming(customerId,incomingNo);if(row==null)return; if("WAITING_INBOUND".equals(row.getFulfillmentStage()))move(row,"RECEIVING",operator,"PDA开始实际收货 "+incomingNo,LocalDateTime.now()); } + /** 沿已确认实托及实占托位保存实际收货。 */ @Transactional(propagation=Propagation.MANDATORY) public void acceptedReceipt(long bookingId,Receipt evidence,int palletSpaces,String operator) { var row=requiredBooking(bookingId); @@ -123,6 +123,7 @@ public class FleetPoolOrderBridge { row.setActualPackageCount(packages);row.setActualPalletCount(unique.size());row.setActualPalletSpaces(palletSpaces); move(row,"WAITING_CONSOLIDATION",operator,"实收核量确认 "+evidence.incomingNo(),LocalDateTime.now()); } + /** 沿真实排车关联推进商业履约。 */ @Transactional(propagation=Propagation.MANDATORY) public void boundDispatch(long bookingId,String customerId,String dispatchNo,String operator) { var row=requiredBooking(bookingId);owned(row,customerId); @@ -130,6 +131,7 @@ public class FleetPoolOrderBridge { if(!"WAITING_CONSOLIDATION".equals(row.getFulfillmentStage())||dispatchNo==null||dispatchNo.isBlank())throw conflict("商业单仅实收核量后可关联发运"); row.setDispatchNo(dispatchNo);move(row,"PENDING_DISPATCH",operator,"关联实际排车 "+dispatchNo,LocalDateTime.now()); } + /** 校验商业履约边界或组装真实业务引用。 */ @Transactional(propagation=Propagation.MANDATORY) public void unboundDispatch(long bookingId,String customerId,String dispatchNo,String operator) { var row=requiredBooking(bookingId);owned(row,customerId); @@ -137,6 +139,7 @@ public class FleetPoolOrderBridge { if(!Objects.equals(row.getDispatchNo(),dispatchNo)||!"PENDING_DISPATCH".equals(row.getFulfillmentStage()))throw conflict("商业单当前不能解除发运关联"); row.setDispatchNo(null);move(row,"WAITING_CONSOLIDATION",operator,"解除未执行排车 "+dispatchNo,LocalDateTime.now()); } + /** 校验商业履约边界或组装真实业务引用。 */ @Transactional(propagation=Propagation.MANDATORY) public void cancelBeforeIncoming(long bookingId,String customerId,String operator,String reason) { var row=requiredBooking(bookingId);owned(row,customerId); @@ -144,19 +147,21 @@ public class FleetPoolOrderBridge { if(row.getIncomingNo()!=null||!"WAITING_INBOUND".equals(row.getFulfillmentStage()))throw conflict("池来源已登记来货,首期不支持取消或跨车改配"); move(row,"CANCELLED",operator,reason,LocalDateTime.now()); } + /** 沿真实整车发车事实推进运输状态。 */ @Transactional(propagation=Propagation.MANDATORY) public void departed(String customerId,String dispatchNo,Map sourcePackages,String operator,LocalDateTime at) { - for(var row:links.lockDispatch(customerId,dispatchNo)) { + for(var row:repository.lockDispatch(customerId,dispatchNo)) { if(row.getActualPackageCount()==null||!Objects.equals(sourcePackages.get(row.getLastlegOrderId()),row.getActualPackageCount()))throw conflict("商业单实际出库来源或完整货量不匹配"); if("IN_TRANSIT".equals(row.getFulfillmentStage()))continue; if(!"PENDING_DISPATCH".equals(row.getFulfillmentStage()))throw conflict("商业单未关联实际发运"); move(row,"IN_TRANSIT",operator,"整车实际出库 "+dispatchNo,at); } } + /** 核对全部签收证据后推进商业完成。 */ @Transactional(propagation=Propagation.MANDATORY) public void signed(SignedDelivery delivery,Map actualSourcePackages,String operator,LocalDateTime at) { if(delivery.dispatchNo()==null)return; - for(var row:links.lockDispatch(delivery.customerId(),delivery.dispatchNo())) { + for(var row:repository.lockDispatch(delivery.customerId(),delivery.dispatchNo())) { if(!actualSourcePackages.containsKey(row.getLastlegOrderId()))continue; if(row.getActualPackageCount()==null||actualSourcePackages.get(row.getLastlegOrderId())!=row.getActualPackageCount().longValue())throw conflict("商业单签收实际来源数量不完整"); if("COMPLETED".equals(row.getFulfillmentStage()))continue; @@ -165,12 +170,14 @@ public class FleetPoolOrderBridge { move(row,"COMPLETED",operator,"真实全部签收 "+delivery.deliveryNo(),at); } } + /** 校验商业履约边界或组装真实业务引用。 */ private boolean wholeSourceSigned(FleetPoolOrderLink row) { return deliveryEvidence.fullySigned(row.getCustomerId(),row.getIncomingNo(),row.getLastlegOrderId(),row.getActualPackageCount()); } + /** 批量读取预订的业务编号。 */ public Map businessOrderNumbers(Collection ids) { if(ids==null||ids.isEmpty())return Map.of();var result=new HashMap(); - links.bookings(ids).forEach(row->result.put(row.getBookingId(),row.getBusinessOrderNo()));return result; + repository.bookings(ids).forEach(row->result.put(row.getBookingId(),row.getBusinessOrderNo()));return result; } static void validate(ConfirmedBooking in) { if(in==null||in.bookingId()<=0||in.businessType()==null||!Set.of("LOCAL_LOOSEBOARD","US_TRUCK").contains(in.businessType()) @@ -183,37 +190,47 @@ public class FleetPoolOrderBridge { ||("US_TRUCK".equals(in.businessType())&&(in.expectedPalletCount()==null||in.expectedPalletCount()<1))) throw conflict("池来源商业委托缺少完整预报、独立实托数或运输身份"); } + /** 在同一事务更新商业阶段与不可变历史。 */ private void move(FleetPoolOrderLink row,String stage,String operator,String reason,LocalDateTime at) { String from=row.getFulfillmentStage();if(stage.equals(from))return; - row.setFulfillmentStage(stage);row.setStageOccurredAt(at);touch(row,at);links.updateById(row); + row.setFulfillmentStage(stage);row.setStageOccurredAt(at);touch(row,at);repository.updateLink(row); if("LOCAL_LOOSEBOARD".equals(row.getBusinessType())) { - var order=loose.lockNo(row.getCustomerId(),row.getBusinessOrderNo());requireOrder(order!=null); - order.setStatus(stage);order.setVersion(order.getVersion()+1);order.setUpdatedAt(at);loose.updateById(order); + var order=repository.lockLoose(row.getCustomerId(),row.getBusinessOrderNo());requireOrder(order!=null); + order.setStatus(stage);order.setVersion(order.getVersion()+1);order.setUpdatedAt(at);repository.updateLoose(order); } else { - var order=trucks.lockNo(row.getCustomerId(),row.getBusinessOrderNo());requireOrder(order!=null); + var order=repository.lockTruck(row.getCustomerId(),row.getBusinessOrderNo());requireOrder(order!=null); order.setStatus(stage);order.setVersion(order.getVersion()+1);order.setUpdatedAt(at); if(row.getActualPalletCount()!=null)order.setReceivedPalletCount(row.getActualPalletCount()); if(row.getActualPalletCount()!=null)order.setAllocatedPalletCount(row.getDispatchNo()==null?0:row.getActualPalletCount()); - trucks.updateById(order); + repository.updateTruck(order); } history(row,from,stage,operator,reason,at); } + /** 保存来源业务号和履约事件的不可变记录。 */ private void history(FleetPoolOrderLink row,String from,String to,String operator,String reason,LocalDateTime at) { String key="FLEET_POOL:"+row.getBookingNo()+":"+to; if("LOCAL_LOOSEBOARD".equals(row.getBusinessType())) { var h=new LocalLooseboardHistory();h.setId(IdWorker.getId());h.setOrderId(row.getBusinessOrderId());h.setCustomerId(row.getCustomerId()); - h.setOperationType("FLEET_FULFILLMENT");h.setFromStatus(from);h.setToStatus(to);h.setOperatorId(operator);h.setIdempotencyKey(key);h.setRemark(reason);h.setCreatedAt(at);looseHistory.insert(h); + h.setOperationType("FLEET_FULFILLMENT");h.setFromStatus(from);h.setToStatus(to);h.setOperatorId(operator);h.setIdempotencyKey(key);h.setRemark(reason);h.setCreatedAt(at);repository.insertLooseHistory(h); } else { var h=new UsTruckOrderStatusHistory();h.setId(IdWorker.getId());h.setOrderId(row.getBusinessOrderId());h.setCustomerId(row.getCustomerId()); - h.setOperationType("FLEET_FULFILLMENT");h.setFromStatus(from);h.setToStatus(to);h.setOperatorId(operator);h.setIdempotencyKey(key);h.setRemark(reason);h.setCreatedAt(at);truckHistory.insert(h); + h.setOperationType("FLEET_FULFILLMENT");h.setFromStatus(from);h.setToStatus(to);h.setOperatorId(operator);h.setIdempotencyKey(key);h.setRemark(reason);h.setCreatedAt(at);repository.insertTruckHistory(h); } } + /** 校验商业履约边界或组装真实业务引用。 */ private String hash(Object input){try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json.writeValueAsString(input).getBytes(StandardCharsets.UTF_8)));}catch(Exception e){throw new IllegalStateException(e);}} - private FleetPoolOrderLink requiredBooking(long bookingId){var row=links.lockBooking(bookingId);if(row==null)throw conflict("池来源预订缺少唯一商业订单关联");return row;} + /** 锁定并读取必需的商业委托。 */ + private FleetPoolOrderLink requiredBooking(long bookingId){var row=repository.lockBooking(bookingId);if(row==null)throw conflict("池来源预订缺少唯一商业订单关联");return row;} + /** 校验商业履约边界或组装真实业务引用。 */ private static void touch(FleetPoolOrderLink row,LocalDateTime at){row.setRecordVersion(row.getRecordVersion()+1);row.setUpdatedAt(at);} + /** 校验商业履约边界或组装真实业务引用。 */ private static BusinessOrderRef ref(FleetPoolOrderLink row){return new BusinessOrderRef(row.getBusinessType(),row.getBusinessOrderNo());} + /** 校验商业履约边界或组装真实业务引用。 */ private static void owned(FleetPoolOrderLink row,String customer){if(!Objects.equals(row.getCustomerId(),customer))throw conflict("商业委托客户归属不一致");} + /** 校验商业履约边界或组装真实业务引用。 */ private static boolean blank(String value){return value==null||value.isBlank();} + /** 校验商业履约边界或组装真实业务引用。 */ private static void requireOrder(boolean ok){if(!ok)throw conflict("池来源商业订单不存在");} + /** 校验商业履约边界或组装真实业务引用。 */ private static ResponseStatusException conflict(String message){return new ResponseStatusException(HttpStatus.CONFLICT,message);} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderRepository.java new file mode 100644 index 000000000..b21ec7bf8 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderRepository.java @@ -0,0 +1,56 @@ +package com.baiyue.lastleg.fleet.business; + +import com.baiyue.lastleg.looseboard.entity.*; +import com.baiyue.lastleg.looseboard.mapper.*; +import com.baiyue.lastleg.trucking.entity.*; +import com.baiyue.lastleg.trucking.mapper.*; +import org.springframework.stereotype.Repository; +import java.util.List; +import java.util.Collection; + +@Repository +public class FleetPoolOrderRepository { + private final FleetPoolOrderMapper links; + private final LocalLooseboardOrderMapper loose; + private final LocalLooseboardHistoryMapper looseHistory; + private final UsTruckOrderMapper trucks; + private final UsTruckOrderStatusHistoryMapper truckHistory; + + /** 注入同库商业委托及历史 Mapper,事务由服务统一管理。 */ + public FleetPoolOrderRepository(FleetPoolOrderMapper links, LocalLooseboardOrderMapper loose, + LocalLooseboardHistoryMapper looseHistory, UsTruckOrderMapper trucks, UsTruckOrderStatusHistoryMapper truckHistory) { + this.links=links;this.loose=loose;this.looseHistory=looseHistory;this.trucks=trucks;this.truckHistory=truckHistory; + } + /** 批量读取预订关联。 */ + public List bookings(Collection ids) { return links.bookings(ids); } + /** 锁定预订关联。 */ + public FleetPoolOrderLink lockBooking(long id) { return links.lockBooking(id); } + /** 按预订业务号锁定关联。 */ + public FleetPoolOrderLink lockBookingNo(String number) { return links.lockBookingNo(number); } + /** 查询预订是否属于运力池。 */ + public int poolBooking(String number) { return links.poolBooking(number); } + /** 按客户和来货号锁定关联。 */ + public FleetPoolOrderLink lockIncoming(String customer, String number) { return links.lockIncoming(customer, number); } + /** 按客户和排车号锁定关联。 */ + public List lockDispatch(String customer, String number) { return links.lockDispatch(customer, number); } + /** 保存唯一商业关联。 */ + public void insertLink(FleetPoolOrderLink row) { links.insert(row); } + /** 更新已锁定商业关联。 */ + public void updateLink(FleetPoolOrderLink row) { links.updateById(row); } + /** 保存散板商业主单。 */ + public void insertLoose(LocalLooseboardOrder order) { loose.insert(order); } + /** 锁定同客户散板主单。 */ + public LocalLooseboardOrder lockLoose(String customer, String number) { return loose.lockNo(customer, number); } + /** 保存已锁定散板事实。 */ + public void updateLoose(LocalLooseboardOrder order) { loose.updateById(order); } + /** 保存卡车商业主单。 */ + public void insertTruck(UsTruckOrder order) { trucks.insert(order); } + /** 锁定同客户卡车主单。 */ + public UsTruckOrder lockTruck(String customer, String number) { return trucks.lockNo(customer, number); } + /** 保存已锁定卡车事实。 */ + public void updateTruck(UsTruckOrder order) { trucks.updateById(order); } + /** 保存散板履约历史。 */ + public void insertLooseHistory(LocalLooseboardHistory history) { looseHistory.insert(history); } + /** 保存卡车履约历史。 */ + public void insertTruckHistory(UsTruckOrderStatusHistory history) { truckHistory.insert(history); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/dto/LocalLooseboardRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/dto/LocalLooseboardRequests.java index beef08211..427f5081e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/dto/LocalLooseboardRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/dto/LocalLooseboardRequests.java @@ -10,49 +10,72 @@ import jakarta.validation.constraints.Size; import java.math.BigDecimal; import java.time.LocalDate; +import java.time.LocalDateTime; +import jakarta.validation.constraints.AssertTrue; +import com.fasterxml.jackson.annotation.JsonIgnore; public final class LocalLooseboardRequests { + /** 仅作为散板请求或响应模型的命名空间。 */ private LocalLooseboardRequests() { } public record Create( - @NotBlank @Pattern(regexp = "SHARED_TRUCK|PRIORITY_CONSOLIDATION") String transportMode, - @NotBlank @Pattern(regexp = "YES|NO") String palletRequired, - @NotBlank @Pattern(regexp = "PORT_PICKUP|WAREHOUSE_TO_WAREHOUSE|WAREHOUSE_TO_DOOR") String transportType, @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, + @Size(max = 255) String productName, + @NotNull @Min(0) Integer estimatedPalletCount, @NotNull @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, - @Size(max = 128) String contactName, - @Size(max = 128) String contactInfo, - LocalDate requestedDate, + @Pattern(regexp = "USD|CNY") String currency, + @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) { } + @NotBlank @Size(max = 255) String idempotencyKey) { + /** 取送时间均须填写,送货不能早于取货。 */ + @JsonIgnore @AssertTrue(message = "送货时间不能早于取货时间") + public boolean isScheduleValid() { + return validSchedule(pickupAt, deliveryAt); + } + } public record Update( @NotNull @Min(0) Long version, - @NotBlank @Pattern(regexp = "SHARED_TRUCK|PRIORITY_CONSOLIDATION") String transportMode, - @NotBlank @Pattern(regexp = "YES|NO") String palletRequired, - @NotBlank @Pattern(regexp = "PORT_PICKUP|WAREHOUSE_TO_WAREHOUSE|WAREHOUSE_TO_DOOR") String transportType, @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, + @Size(max = 255) String productName, + @NotNull @Min(0) Integer estimatedPalletCount, @NotNull @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, - @Size(max = 128) String contactName, - @Size(max = 128) String contactInfo, - LocalDate requestedDate, - @Size(max = 1000) String remark) { } + @Pattern(regexp = "USD|CNY") String currency, + @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) { + /** 取送时间均须填写,送货不能早于取货。 */ + @JsonIgnore @AssertTrue(message = "送货时间不能早于取货时间") + public boolean isScheduleValid() { + return validSchedule(pickupAt, deliveryAt); + } + } + + /** 统一校验平台及客户新增、编辑的需求时间。 */ + public static boolean validSchedule(LocalDateTime pickup, LocalDateTime delivery) { + return pickup != null && delivery != null && !delivery.isBefore(pickup); + } public record Query( @Min(1) long page, @@ -61,7 +84,6 @@ public final class LocalLooseboardRequests { String orderNo, String routeKeyword, String status, - String transportMode, LocalDate requestedFrom, LocalDate requestedTo) { } @@ -71,7 +93,6 @@ public final class LocalLooseboardRequests { String orderNo, String routeKeyword, String status, - String transportMode, LocalDate requestedFrom, LocalDate requestedTo) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/dto/LocalLooseboardResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/dto/LocalLooseboardResponses.java index c822371f8..19bfe8057 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/dto/LocalLooseboardResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/dto/LocalLooseboardResponses.java @@ -1,19 +1,19 @@ package com.baiyue.lastleg.looseboard.dto; import java.math.BigDecimal; -import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; public final class LocalLooseboardResponses { + /** 仅作为散板请求或响应模型的命名空间。 */ private LocalLooseboardResponses() { } - /** Pool orders retain null containerType/palletRequired when the inquiry did not specify these facts. */ + /** 需求与内部安排分别返回;未知联系人、商品及时间不补造。 */ public record Summary( - Long id, String customerId, String orderNo, String transportMode, String palletRequired, - String transportType, String origin, String destination, String containerType, int packageCount, + Long id, String customerId, String orderNo, String palletRequired, + String transportType, String origin, String destination, String productName, Integer estimatedPalletCount, int packageCount, BigDecimal totalWeightKg, BigDecimal totalVolumeCbm, BigDecimal budgetAmount, String currency, - String contactName, String contactInfo, LocalDate requestedDate, String status, long version, + 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/looseboard/entity/LocalLooseboardOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/entity/LocalLooseboardOrder.java index 1017aaf3d..cba5b1ee9 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/entity/LocalLooseboardOrder.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/entity/LocalLooseboardOrder.java @@ -1,12 +1,13 @@ package com.baiyue.lastleg.looseboard.entity; import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.FieldStrategy; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; import java.math.BigDecimal; -import java.time.LocalDate; import java.time.LocalDateTime; @Data @@ -16,24 +17,27 @@ public class LocalLooseboardOrder { private String customerId; private Long lastlegOrderId; private String orderNo; - private String transportMode; private String palletRequired; private String transportType; private String origin; - private String originAddress; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String originAddress; private String destination; - private String destinationAddress; - private String containerType; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String destinationAddress; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String productName; + private Integer estimatedPalletCount; private Integer packageCount; private BigDecimal totalWeightKg; private BigDecimal totalVolumeCbm; - private BigDecimal budgetAmount; + @TableField(updateStrategy = FieldStrategy.ALWAYS) 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; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private LocalDateTime pickupAt; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private LocalDateTime deliveryAt; private String status; - private String remark; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String remark; private String idempotencyKey; private String requestHash; private String createdBy; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/repository/LocalLooseboardRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/repository/LocalLooseboardRepository.java new file mode 100644 index 000000000..e62d7e8e6 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/repository/LocalLooseboardRepository.java @@ -0,0 +1,67 @@ +package com.baiyue.lastleg.looseboard.repository; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baiyue.lastleg.looseboard.entity.*; +import com.baiyue.lastleg.looseboard.dto.LocalLooseboardRequests; +import org.springframework.util.StringUtils; +import com.baiyue.lastleg.looseboard.mapper.*; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Repository; +import java.util.List; + +@Repository +public class LocalLooseboardRepository { + @Resource private LocalLooseboardOrderMapper orders; + @Resource private LocalLooseboardHistoryMapper histories; + @Resource private LocalLooseboardOperationMapper operations; + @Resource private LastlegOrderMapper lastlegOrders; + @Resource private LastlegOrderStatusHistoryMapper lastlegHistories; + /** 按客户和创建幂等键读取订单。 */ + public LocalLooseboardOrder findKey(String customer, String key) { return orders.findKey(customer, key); } + /** 按客户和业务单号读取订单。 */ + public LocalLooseboardOrder findNo(String customer, String number) { return orders.findNo(customer, number); } + /** 锁定同客户订单保护编辑及状态推进。 */ + public LocalLooseboardOrder lockNo(String customer, String number) { return orders.lockNo(customer, number); } + /** 在客户范围内组合路线、状态与取货日期筛选并返回分页需求。 */ + public Page page(LocalLooseboardRequests.Query query) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(StringUtils.hasText(query.customerId()), LocalLooseboardOrder::getCustomerId, trim(query.customerId())) + .like(StringUtils.hasText(query.orderNo()), LocalLooseboardOrder::getOrderNo, trim(query.orderNo())) + .and(StringUtils.hasText(query.routeKeyword()), value -> value + .like(LocalLooseboardOrder::getOrigin, trim(query.routeKeyword())) + .or().like(LocalLooseboardOrder::getDestination, trim(query.routeKeyword()))) + .eq(StringUtils.hasText(query.status()), LocalLooseboardOrder::getStatus, + StringUtils.hasText(query.status()) ? query.status().trim().toUpperCase() : null) + .ge(query.requestedFrom() != null, LocalLooseboardOrder::getPickupAt, query.requestedFrom() == null ? null : query.requestedFrom().atStartOfDay()) + .lt(query.requestedTo() != null, LocalLooseboardOrder::getPickupAt, query.requestedTo() == null ? null : query.requestedTo().plusDays(1).atStartOfDay()) + .orderByDesc(LocalLooseboardOrder::getCreatedAt).orderByDesc(LocalLooseboardOrder::getId); + return orders.selectPage(Page.of(query.page(), query.size()), wrapper); + } + /** 规范查询文字,缺失值不参与筛选。 */ + private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); } + /** 保存散板需求。 */ + public void insertOrder(LocalLooseboardOrder order) { orders.insert(order); } + /** 更新已锁定需求。 */ + public void updateOrder(LocalLooseboardOrder order) { orders.updateById(order); } + /** 读取同客户订单历史。 */ + public List histories(String customer, Long id) { return histories.listOrder(customer, id); } + /** 保存不可变需求历史。 */ + public void insertHistory(LocalLooseboardHistory history) { histories.insert(history); } + /** 读取状态命令幂等记录。 */ + public LocalLooseboardOperation findOperation(String customer, Long id, String key) { return operations.findKey(customer, id, key); } + /** 保存命令幂等记录。 */ + public void insertOperation(LocalLooseboardOperation operation) { operations.insert(operation); } + /** 保存唯一尾程来源。 */ + public void insertTailOrder(LastlegOrder order) { lastlegOrders.insert(order); } + /** 读取关联尾程来源。 */ + public LastlegOrder tailOrder(Long id) { return lastlegOrders.selectById(id); } + /** 更新关联尾程事实。 */ + public void updateTailOrder(LastlegOrder order) { lastlegOrders.updateById(order); } + /** 保存尾程状态历史。 */ + public void insertTailHistory(LastlegOrderStatusHistory history) { lastlegHistories.insert(history); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardService.java index 33a90ac0c..d2d5fa6ee 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardService.java @@ -1,23 +1,17 @@ package com.baiyue.lastleg.looseboard.service; -import com.baiyue.common.core.container.ContainerSpecificationCatalog; +import com.baiyue.lastleg.looseboard.repository.LocalLooseboardRepository; -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.lastleg.entity.LastlegOrder; import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; -import com.baiyue.lastleg.mapper.LastlegOrderMapper; -import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; import com.baiyue.lastleg.looseboard.dto.LocalLooseboardRequests; import com.baiyue.lastleg.looseboard.dto.LocalLooseboardResponses; import com.baiyue.lastleg.looseboard.entity.LocalLooseboardHistory; import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder; import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOperation; -import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardHistoryMapper; -import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardOrderMapper; -import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardOperationMapper; import com.baiyue.lastleg.service.LastlegOrderNoGenerator; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; @@ -48,36 +42,32 @@ public class LocalLooseboardService { public static final String COMPLETED = "COMPLETED"; public static final String CANCELLED = "CANCELLED"; private static final Set STATUSES = Set.of(PENDING_ACCEPTANCE, DISPATCHED, IN_TRANSIT, COMPLETED, CANCELLED, "WAITING_INBOUND", "RECEIVING", "WAITING_CONSOLIDATION", "PENDING_DISPATCH"); - private static final Set MODES = Set.of("SHARED_TRUCK", "PRIORITY_CONSOLIDATION"); private static final Map> TRANSITIONS = Map.of( PENDING_ACCEPTANCE, Set.of(DISPATCHED, CANCELLED), DISPATCHED, Set.of(IN_TRANSIT, CANCELLED), IN_TRANSIT, Set.of(COMPLETED)); - @Resource private LocalLooseboardOrderMapper orders; - @Resource private LocalLooseboardHistoryMapper histories; - @Resource private LocalLooseboardOperationMapper operations; - @Resource private LastlegOrderMapper lastlegOrders; - @Resource private LastlegOrderStatusHistoryMapper lastlegHistories; + @Resource private LocalLooseboardRepository repository; @Resource private LastlegOrderNoGenerator lastlegOrderNoGenerator; @Resource private ObjectMapper objectMapper; + /** 在同一事务保存客户需求、唯一尾程来源及创建历史,创建键重复时核对请求哈希。 */ @Transactional public LocalLooseboardResponses.Detail create(String customerId, String operatorId, LocalLooseboardRequests.Create request) { String key = request.idempotencyKey().trim(); String requestHash = hash(request); - LocalLooseboardOrder replay = orders.findKey(customerId, key); + LocalLooseboardOrder replay = repository.findKey(customerId, key); if (replay != null) return replay(customerId, replay, requestHash); LocalDateTime now = LocalDateTime.now(); LocalLooseboardOrder order = new LocalLooseboardOrder(); order.setId(IdWorker.getId()); order.setCustomerId(customerId); order.setOrderNo(lastlegOrderNoGenerator.next("LOOSEBOARD", request.destination(), order.getId())); - apply(order, request.transportMode(), request.palletRequired(), request.transportType(), request.origin(), - request.originAddress(), request.destination(), request.destinationAddress(), request.containerType(), + apply(order, request.origin(), + request.originAddress(), request.destination(), request.destinationAddress(), request.productName(), request.estimatedPalletCount(), request.packageCount(), request.totalWeightKg(), request.totalVolumeCbm(), request.budgetAmount(), - request.currency(), request.contactName(), request.contactInfo(), request.requestedDate(), request.remark()); + request.currency(), request.originContactName(), request.originContactPhone(), request.destinationContactName(), request.destinationContactPhone(), request.pickupAt(), request.deliveryAt(), request.remark()); order.setStatus(PENDING_ACCEPTANCE); order.setIdempotencyKey(key); order.setRequestHash(requestHash); @@ -88,9 +78,9 @@ public class LocalLooseboardService { LastlegOrder lastlegOrder = createLastlegOrder(order, requestHash, now); order.setLastlegOrderId(lastlegOrder.getId()); try { - orders.insert(order); + repository.insertOrder(order); } catch (DuplicateKeyException exception) { - LocalLooseboardOrder concurrent = orders.findKey(customerId, key); + LocalLooseboardOrder concurrent = repository.findKey(customerId, key); if (concurrent != null) return replay(customerId, concurrent, requestHash); throw exception; } @@ -101,44 +91,34 @@ public class LocalLooseboardService { return detail(customerId, order.getOrderNo()); } + /** 按客户、状态及取货日期分页查询需求,并批量装配承运摘要。 */ @Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.REPEATABLE_READ) public PageResult page(LocalLooseboardRequests.Query query) { validateQuery(query); - LambdaQueryWrapper wrapper = new LambdaQueryWrapper() - .eq(StringUtils.hasText(query.customerId()), LocalLooseboardOrder::getCustomerId, trim(query.customerId())) - .like(StringUtils.hasText(query.orderNo()), LocalLooseboardOrder::getOrderNo, trim(query.orderNo())) - .and(StringUtils.hasText(query.routeKeyword()), value -> value - .like(LocalLooseboardOrder::getOrigin, trim(query.routeKeyword())) - .or().like(LocalLooseboardOrder::getDestination, trim(query.routeKeyword()))) - .eq(StringUtils.hasText(query.status()), LocalLooseboardOrder::getStatus, - StringUtils.hasText(query.status()) ? norm(query.status()) : null) - .eq(StringUtils.hasText(query.transportMode()), LocalLooseboardOrder::getTransportMode, - StringUtils.hasText(query.transportMode()) ? norm(query.transportMode()) : null) - .ge(query.requestedFrom() != null, LocalLooseboardOrder::getRequestedDate, query.requestedFrom()) - .le(query.requestedTo() != null, LocalLooseboardOrder::getRequestedDate, query.requestedTo()) - .orderByDesc(LocalLooseboardOrder::getCreatedAt).orderByDesc(LocalLooseboardOrder::getId); - Page result = orders.selectPage(Page.of(query.page(), query.size()), wrapper); + Page result = repository.page(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()); } - /** Customer-scoped query that ignores any client-supplied customer filter. */ + /** 客户查询强制使用认证客户范围,沿同一需求筛选合同分页。 */ @Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.REPEATABLE_READ) public PageResult page(String customerId, LocalLooseboardRequests.MerchantQuery query) { return page(new LocalLooseboardRequests.Query(query.page(), query.size(), customerId, - query.orderNo(), query.routeKeyword(), query.status(), query.transportMode(), + query.orderNo(), query.routeKeyword(), query.status(), query.requestedFrom(), query.requestedTo())); } + /** 按客户与业务单号读取需求及历史。 */ @Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.REPEATABLE_READ) public LocalLooseboardResponses.Detail detail(String customerId, String orderNo) { - LocalLooseboardOrder order = orders.findNo(customerId, orderNo); + LocalLooseboardOrder order = repository.findNo(customerId, orderNo); if (order == null) throw notFound(); return response(order); } + /** 锁定待接单需求并校验版本,保存变更前后快照。 */ @Transactional public LocalLooseboardResponses.Detail update(String customerId, String orderNo, String operatorId, LocalLooseboardRequests.Update request) { @@ -148,27 +128,28 @@ public class LocalLooseboardService { throw conflict("Only pending-acceptance looseboard orders can be edited"); } Map before = snapshot(order); - apply(order, request.transportMode(), request.palletRequired(), request.transportType(), request.origin(), - request.originAddress(), request.destination(), request.destinationAddress(), request.containerType(), + apply(order, request.origin(), + request.originAddress(), request.destination(), request.destinationAddress(), request.productName(), request.estimatedPalletCount(), request.packageCount(), request.totalWeightKg(), request.totalVolumeCbm(), request.budgetAmount(), - request.currency(), request.contactName(), request.contactInfo(), request.requestedDate(), request.remark()); + request.currency(), request.originContactName(), request.originContactPhone(), request.destinationContactName(), request.destinationContactPhone(), request.pickupAt(), request.deliveryAt(), request.remark()); Map after = snapshot(order); if (before.equals(after)) return response(order); LocalDateTime now = LocalDateTime.now(); touch(order, now); - orders.updateById(order); + repository.updateOrder(order); history(order, "UPDATE", order.getStatus(), order.getStatus(), operatorId, null, changeDetail(before, after), null, now); return response(order); } + /** 按版本和独立幂等键推进允许的状态,同时记录不可变历史。 */ @Transactional public LocalLooseboardResponses.Detail changeStatus(String customerId, String orderNo, String operatorId, LocalLooseboardRequests.ChangeStatus request) { LocalLooseboardOrder order = lock(customerId, orderNo); String key = request.idempotencyKey().trim(); String requestHash = hash(request); - LocalLooseboardOperation existing = operations.findKey(customerId, order.getId(), key); + LocalLooseboardOperation existing = repository.findOperation(customerId, order.getId(), key); if (existing != null) { if (!"STATUS".equals(existing.getOperationType()) || !requestHash.equals(existing.getRequestHash())) { throw conflict("Idempotency key was reused with a different looseboard status payload"); @@ -186,81 +167,95 @@ public class LocalLooseboardService { LocalDateTime now = LocalDateTime.now(); order.setStatus(next); touch(order, now); - orders.updateById(order); + repository.updateOrder(order); syncLastlegOrder(order, next, now); LocalLooseboardOperation operation = new LocalLooseboardOperation(); operation.setId(IdWorker.getId()); operation.setOrderId(order.getId()); operation.setCustomerId(customerId); operation.setOperationType("STATUS"); operation.setIdempotencyKey(key); operation.setRequestHash(requestHash); operation.setResultVersion(order.getVersion()); operation.setCreatedBy(operatorId); - operation.setCreatedAt(now); operations.insert(operation); + operation.setCreatedAt(now); repository.insertOperation(operation); history(order, "STATUS", from, next, operatorId, key, null, request.remark(), now); return response(order); } - private void apply(LocalLooseboardOrder order, String mode, String palletRequired, String transportType, + /** 保存申报货物与当地取送时间,不预设内部安排或实际收货。 */ + private void apply(LocalLooseboardOrder order, String origin, String originAddress, String destination, String destinationAddress, - String containerType, Integer packageCount, java.math.BigDecimal totalWeightKg, + String productName, Integer estimatedPalletCount, Integer packageCount, java.math.BigDecimal totalWeightKg, java.math.BigDecimal totalVolumeCbm, java.math.BigDecimal budgetAmount, String currency, - String contactName, String contactInfo, java.time.LocalDate requestedDate, String remark) { - order.setTransportMode(norm(mode)); - order.setPalletRequired(norm(palletRequired)); - order.setTransportType(norm(transportType)); + String originContactName, String originContactPhone, String destinationContactName, String destinationContactPhone, LocalDateTime pickupAt, LocalDateTime deliveryAt, String remark) { + if (!StringUtils.hasText(originContactName) || !StringUtils.hasText(originContactPhone) + || !StringUtils.hasText(destinationContactName) || !StringUtils.hasText(destinationContactPhone)) { + throw badRequest("请填写起运地和目的地联系人及联系电话"); + } + if (!LocalLooseboardRequests.validSchedule(pickupAt, deliveryAt)) { + throw badRequest("取货和送货时间必填,送货时间不能早于取货时间"); + } + order.setProductName(trim(productName)); + order.setEstimatedPalletCount(estimatedPalletCount); order.setOrigin(origin.trim()); order.setOriginAddress(trim(originAddress)); order.setDestination(destination.trim()); order.setDestinationAddress(trim(destinationAddress)); - order.setContainerType(StringUtils.hasText(containerType) - ? ContainerSpecificationCatalog.require(containerType).containerType() - : ContainerSpecificationCatalog.DEFAULT_CONTAINER_TYPE); order.setPackageCount(packageCount); order.setTotalWeightKg(totalWeightKg); order.setTotalVolumeCbm(totalVolumeCbm); order.setBudgetAmount(budgetAmount); order.setCurrency(StringUtils.hasText(currency) ? norm(currency) : "USD"); - order.setContactName(trim(contactName)); - order.setContactInfo(trim(contactInfo)); - order.setRequestedDate(requestedDate); + order.setOriginContactName(originContactName.trim()); + order.setOriginContactPhone(originContactPhone.trim()); + order.setDestinationContactName(destinationContactName.trim()); + order.setDestinationContactPhone(destinationContactPhone.trim()); + order.setPickupAt(pickupAt); + order.setDeliveryAt(deliveryAt); order.setRemark(trim(remark)); } + /** 拒绝幂等键绑定不同内容,重复请求返回原需求。 */ private LocalLooseboardResponses.Detail replay(String customerId, LocalLooseboardOrder order, String hash) { if (!hash.equals(order.getRequestHash())) throw conflict("Idempotency key was reused with different looseboard data"); return detail(customerId, order.getOrderNo()); } + /** 锁定客户订单并拒绝池来源的普通编辑。 */ private LocalLooseboardOrder lock(String customerId, String orderNo) { - LocalLooseboardOrder order = orders.lockNo(customerId, orderNo); + LocalLooseboardOrder order = repository.lockNo(customerId, orderNo); if (order == null) throw notFound(); fleetPool.requireStandalone(customerId, "LOCAL_LOOSEBOARD", order.getId()); return order; } + /** 装配需求详情、真实承运安排与不可变历史。 */ private LocalLooseboardResponses.Detail response(LocalLooseboardOrder order) { return new LocalLooseboardResponses.Detail(summary(order), order.getOriginAddress(), order.getDestinationAddress(), order.getRemark(), order.getCreatedBy(), - histories.listOrder(order.getCustomerId(), order.getId()).stream() + repository.histories(order.getCustomerId(), order.getId()).stream() .map(value -> new LocalLooseboardResponses.History(value.getOperationType(), value.getFromStatus(), value.getToStatus(), value.getOperatorId(), value.getIdempotencyKey(), value.getChangeDetail(), value.getRemark(), value.getCreatedAt())).toList()); } + /** 构造同客户商业订单的承运查询身份。 */ private com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.OrderKey poolKey(LocalLooseboardOrder order) { return new com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.OrderKey(order.getCustomerId(), "LOCAL_LOOSEBOARD", order.getId()); } + /** 装配原值需求摘要,预计托数与实际履约保持独立。 */ private LocalLooseboardResponses.Summary summary(LocalLooseboardOrder order) { return summary(order, fleetPool.arrangement(poolKey(order))); } + /** 装配原值需求摘要,预计托数与实际履约保持独立。 */ private LocalLooseboardResponses.Summary summary(LocalLooseboardOrder order, com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.Arrangement arrangement) { return new LocalLooseboardResponses.Summary(order.getId(), order.getCustomerId(), order.getOrderNo(), - order.getTransportMode(), order.getPalletRequired(), order.getTransportType(), order.getOrigin(), - order.getDestination(), order.getContainerType(), Objects.requireNonNullElse(order.getPackageCount(), 0), + order.getPalletRequired(), order.getTransportType(), order.getOrigin(), + order.getDestination(), order.getProductName(), order.getEstimatedPalletCount(), Objects.requireNonNullElse(order.getPackageCount(), 0), order.getTotalWeightKg(), order.getTotalVolumeCbm(), order.getBudgetAmount(), order.getCurrency(), - order.getContactName(), order.getContactInfo(), order.getRequestedDate(), order.getStatus(), + order.getOriginContactName(), order.getOriginContactPhone(), order.getDestinationContactName(), order.getDestinationContactPhone(), order.getPickupAt(), order.getDeliveryAt(), order.getStatus(), value(order.getVersion()), order.getCreatedAt(), order.getUpdatedAt(), arrangement); } + /** 写入带操作者和变更快照的订单历史。 */ private void history(LocalLooseboardOrder order, String type, String from, String to, String operator, String idempotencyKey, String changeDetail, String remark, LocalDateTime now) { LocalLooseboardHistory history = new LocalLooseboardHistory(); @@ -275,9 +270,10 @@ public class LocalLooseboardService { history.setChangeDetail(changeDetail); history.setRemark(trim(remark)); history.setCreatedAt(now); - histories.insert(history); + repository.insertHistory(history); } + /** 为独立下单创建唯一尾程来源,不按预计托数生成实物托盘。 */ private LastlegOrder createLastlegOrder(LocalLooseboardOrder source, String requestHash, LocalDateTime now) { LastlegOrder order = new LastlegOrder(); order.setId(IdWorker.getId()); @@ -302,21 +298,23 @@ public class LocalLooseboardService { order.setSourcePayloadHash("sha256:" + requestHash); order.setCreatedAt(now); order.setUpdatedAt(now); - lastlegOrders.insert(order); + repository.insertTailOrder(order); return order; } + /** 在同客户边界内同步关联尾程的商业状态。 */ private void syncLastlegOrder(LocalLooseboardOrder source, String status, LocalDateTime now) { if (source.getLastlegOrderId() == null) return; - LastlegOrder order = lastlegOrders.selectById(source.getLastlegOrderId()); + LastlegOrder order = repository.tailOrder(source.getLastlegOrderId()); if (order == null || !source.getCustomerId().equals(order.getCustomerId())) { throw conflict("Linked tail-leg order is missing"); } order.setSubOrderStatus("LOCAL_LOOSEBOARD:" + status); order.setUpdatedAt(now); - lastlegOrders.updateById(order); + repository.updateTailOrder(order); } + /** 追加尾程状态历史。 */ private void lastlegHistory(String customerId, Long orderId, String from, String to, String remark, LocalDateTime now) { LastlegOrderStatusHistory history = new LastlegOrderStatusHistory(); @@ -327,44 +325,47 @@ public class LocalLooseboardService { history.setEventId("LOCAL_LOOSEBOARD"); history.setRemark(remark); history.setCreatedAt(now); - lastlegHistories.insert(history); + repository.insertTailHistory(history); } + /** 校验状态与取货日期筛选边界。 */ private void validateQuery(LocalLooseboardRequests.Query query) { if (StringUtils.hasText(query.status()) && !STATUSES.contains(norm(query.status()))) { throw badRequest("Unsupported looseboard order status"); } - if (StringUtils.hasText(query.transportMode()) && !MODES.contains(norm(query.transportMode()))) { - throw badRequest("Unsupported looseboard transport mode"); - } if (query.requestedFrom() != null && query.requestedTo() != null && query.requestedFrom().isAfter(query.requestedTo())) { throw badRequest("requestedFrom must not be after requestedTo"); } } + /** 冻结本次编辑的业务字段用于差异审计。 */ private Map snapshot(LocalLooseboardOrder order) { Map value = new LinkedHashMap<>(); - value.put("transportMode", order.getTransportMode()); value.put("palletRequired", order.getPalletRequired()); value.put("transportType", order.getTransportType()); value.put("origin", order.getOrigin()); value.put("originAddress", order.getOriginAddress()); value.put("destination", order.getDestination()); value.put("destinationAddress", order.getDestinationAddress()); - value.put("containerType", order.getContainerType()); + value.put("productName", order.getProductName()); + value.put("estimatedPalletCount", order.getEstimatedPalletCount()); value.put("packageCount", order.getPackageCount()); value.put("totalWeightKg", order.getTotalWeightKg()); value.put("totalVolumeCbm", order.getTotalVolumeCbm()); value.put("budgetAmount", order.getBudgetAmount()); value.put("currency", order.getCurrency()); - value.put("contactName", order.getContactName()); - value.put("contactInfo", order.getContactInfo()); - value.put("requestedDate", order.getRequestedDate()); + value.put("originContactName", order.getOriginContactName()); + value.put("originContactPhone", order.getOriginContactPhone()); + value.put("destinationContactName", order.getDestinationContactName()); + value.put("destinationContactPhone", order.getDestinationContactPhone()); + value.put("pickupAt", order.getPickupAt()); + value.put("deliveryAt", order.getDeliveryAt()); value.put("remark", order.getRemark()); return value; } + /** 生成字段前后值的不可变变更快照。 */ private String changeDetail(Map before, Map after) { Map changes = new LinkedHashMap<>(); before.forEach((field, oldValue) -> { @@ -383,6 +384,7 @@ public class LocalLooseboardService { } } + /** 计算规范请求哈希以约束创建和状态命令幂等。 */ private String hash(Object value) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") @@ -392,20 +394,27 @@ public class LocalLooseboardService { } } + /** 递增锁定订单的版本并更新时间。 */ private static void touch(LocalLooseboardOrder order, LocalDateTime now) { order.setVersion(value(order.getVersion()) + 1); order.setUpdatedAt(now); } + /** 将未初始化的版本视作初始版本。 */ private static long value(Long value) { return value == null ? 0 : value; } + /** 规范选填文字,空白保存为未知。 */ private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); } + /** 统一内部枚举和币种大小写。 */ private static String norm(String value) { return value.trim().toUpperCase(); } + /** 返回明确的输入校验失败。 */ private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); } + /** 返回版本、幂等或状态冲突。 */ private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } + /** 隐藏客户范围外订单并返回未找到。 */ private static ResponseStatusException notFound() { return new ResponseStatusException(HttpStatus.NOT_FOUND, "本土散板订单不存在或无权查看"); } diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 86b64ec2e..afd7a2b27 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -2865,16 +2865,17 @@ CREATE TABLE IF NOT EXISTS ll_us_truck_order_status_history ( CREATE TABLE IF NOT EXISTS ll_local_looseboard_order ( id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', lastleg_order_id BIGINT NULL COMMENT '关联尾程主订单ID', - order_no VARCHAR(64) NOT NULL COMMENT '本土散板订单号', transport_mode VARCHAR(32) NOT NULL COMMENT '运输方式:拼车或优先拼载', - pallet_required VARCHAR(8) NULL DEFAULT NULL COMMENT '是否需要打盘:YES或NO;拼车来源未提供时为空', transport_type VARCHAR(32) NOT NULL COMMENT '运输类型', + order_no VARCHAR(64) NOT NULL COMMENT '本土散板订单号', + pallet_required VARCHAR(8) NULL DEFAULT NULL COMMENT '是否需要打盘:YES或NO;拼车来源未提供时为空', transport_type VARCHAR(32) NULL DEFAULT NULL COMMENT '接单运输安排;下单不预设', 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 '柜型;拼车来源未提供时为空,不推断为整柜', package_count INT NOT NULL DEFAULT 0 COMMENT '货物件数', + product_name VARCHAR(255) NULL COMMENT '商品名称', estimated_pallet_count INT NULL COMMENT '客户申报预计托数,不代表实际收货', package_count INT NOT NULL DEFAULT 0 COMMENT '货物件数', total_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '总重量(千克)', total_volume_cbm DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '总体积(立方米)', budget_amount DECIMAL(18,2) NULL COMMENT '预算总价', currency CHAR(3) NOT NULL DEFAULT 'USD' COMMENT '预算币种', - contact_name VARCHAR(128) NULL COMMENT '联系人姓名', contact_info VARCHAR(128) NULL COMMENT '联系人联系方式', - requested_date DATE NULL COMMENT '要求服务日期', status VARCHAR(32) NOT NULL DEFAULT 'PENDING_ACCEPTANCE' 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(3) NULL COMMENT '取货时间(当地时间,不转换时区)', delivery_at DATETIME(3) NULL COMMENT '送货时间(当地时间,不转换时区)', status VARCHAR(32) NOT NULL DEFAULT 'PENDING_ACCEPTANCE' COMMENT '本土散板订单状态', remark VARCHAR(1000) NULL COMMENT '备注', idempotency_key VARCHAR(255) NOT NULL COMMENT '创建幂等键', request_hash CHAR(64) NOT NULL COMMENT '创建请求内容哈希', created_by VARCHAR(128) NULL COMMENT '创建人ID', version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', @@ -2883,7 +2884,7 @@ CREATE TABLE IF NOT EXISTS ll_local_looseboard_order ( UNIQUE KEY uk_ll_local_looseboard_lastleg(lastleg_order_id), UNIQUE KEY uk_ll_local_looseboard_key(customer_id,idempotency_key), KEY idx_ll_local_looseboard_list(customer_id,status,created_at), - KEY idx_ll_local_looseboard_date(customer_id,requested_date,created_at) + KEY idx_ll_local_looseboard_date(customer_id,pickup_at,created_at) ) ENGINE=InnoDB COMMENT='本土散板订单'; CREATE TABLE IF NOT EXISTS ll_local_looseboard_history ( 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 f0c18bbe1..75f671884 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -1455,7 +1455,6 @@ paths: - { name: orderNo, in: query, schema: { type: string } } - { name: routeKeyword, in: query, schema: { type: string } } - { name: status, in: query, schema: { $ref: '#/components/schemas/LooseboardStatus' } } - - { name: transportMode, in: query, schema: { type: string, enum: [SHARED_TRUCK, PRIORITY_CONSOLIDATION] } } - { name: requestedFrom, in: query, schema: { type: string, format: date } } - { name: requestedTo, in: query, schema: { type: string, format: date } } responses: { '200': { description: 当前客户本土散板订单分页, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardPageResponse' } } } } } @@ -3664,25 +3663,28 @@ components: LooseboardSummary: type: object description: 散板商业主单摘要;池来源附带同客户fleetArrangement,独立订单该字段为null。池来源不创建第二张占位尾程来源。 - required: [id, customerId, orderNo, transportMode, transportType, origin, destination, packageCount, totalWeightKg, totalVolumeCbm, status, version, createdAt, updatedAt] + required: [id, customerId, orderNo, origin, destination, estimatedPalletCount, packageCount, totalWeightKg, totalVolumeCbm, status, version, createdAt, updatedAt] properties: id: { type: string, pattern: '^[0-9]+$' } customerId: { type: string } orderNo: { type: string } - transportMode: { type: string, enum: [SHARED_TRUCK, PRIORITY_CONSOLIDATION] } palletRequired: { type: string, nullable: true, enum: [YES, NO], description: 是否委托打盘服务;池询价未提供时未知,不从实托数或托位推断YES } - transportType: { type: string } + transportType: { type: string, nullable: true } origin: { type: string } destination: { type: string } - containerType: { type: string, nullable: true, enum: [40GP, 40HQ, 45HQ], description: 已知时为平台统一柜型;池询价未提供时未知,不默认40HQ } + productName: { type: string, nullable: true, maxLength: 255 } + estimatedPalletCount: { type: integer, minimum: 0, description: 申报预计托数,独立于实际收货 } packageCount: { type: integer } totalWeightKg: { type: number } 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 } + originContactPhone: { type: string, nullable: true } + destinationContactName: { type: string, nullable: true } + destinationContactPhone: { type: string, nullable: true } + pickupAt: { type: string, format: date-time, nullable: true, description: 当地日期时间不带时区;按分钟输入 } + deliveryAt: { type: string, format: date-time, nullable: true, description: 当地日期时间,不能早于取货时间 } status: { $ref: '#/components/schemas/LooseboardStatus' } version: { type: integer, format: int64 } createdAt: { type: string, format: date-time } @@ -4086,47 +4088,49 @@ components: remark: { type: string, nullable: true, maxLength: 1000 } LooseboardCreate: type: object - required: [transportMode, palletRequired, transportType, origin, destination, packageCount, totalWeightKg, totalVolumeCbm, idempotencyKey] + required: [originContactName, originContactPhone, destinationContactName, destinationContactPhone, pickupAt, deliveryAt, origin, destination, estimatedPalletCount, packageCount, totalWeightKg, totalVolumeCbm, idempotencyKey] properties: - transportMode: { type: string, enum: [SHARED_TRUCK, PRIORITY_CONSOLIDATION] } - palletRequired: { type: string, enum: [YES, NO] } - transportType: { type: string, enum: [PORT_PICKUP, WAREHOUSE_TO_WAREHOUSE, WAREHOUSE_TO_DOOR] } 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 } + productName: { type: string, nullable: true, maxLength: 255 } + estimatedPalletCount: { type: integer, minimum: 0, description: 申报预计托数,独立于实际收货 } 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, default: USD } - contactName: { type: string, nullable: true, maxLength: 128 } - contactInfo: { type: string, nullable: true, maxLength: 128 } - requestedDate: { type: string, format: date, nullable: true } + originContactName: { type: string, minLength: 1, maxLength: 128 } + originContactPhone: { type: string, minLength: 1, maxLength: 128 } + destinationContactName: { type: string, minLength: 1, maxLength: 128 } + destinationContactPhone: { type: string, minLength: 1, 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 } LooseboardUpdate: type: object - required: [version, transportMode, palletRequired, transportType, origin, destination, packageCount, totalWeightKg, totalVolumeCbm] + required: [originContactName, originContactPhone, destinationContactName, destinationContactPhone, pickupAt, deliveryAt, version, origin, destination, estimatedPalletCount, packageCount, totalWeightKg, totalVolumeCbm] properties: version: { type: integer, format: int64, minimum: 0 } - transportMode: { type: string, enum: [SHARED_TRUCK, PRIORITY_CONSOLIDATION] } - palletRequired: { type: string, enum: [YES, NO] } - transportType: { type: string, enum: [PORT_PICKUP, WAREHOUSE_TO_WAREHOUSE, WAREHOUSE_TO_DOOR] } 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 } + productName: { type: string, nullable: true, maxLength: 255 } + estimatedPalletCount: { type: integer, minimum: 0, description: 申报预计托数,独立于实际收货 } 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, default: USD } - contactName: { type: string, nullable: true, maxLength: 128 } - contactInfo: { type: string, nullable: true, maxLength: 128 } - requestedDate: { type: string, format: date, nullable: true } + originContactName: { type: string, minLength: 1, maxLength: 128 } + originContactPhone: { type: string, minLength: 1, maxLength: 128 } + destinationContactName: { type: string, minLength: 1, maxLength: 128 } + destinationContactPhone: { type: string, minLength: 1, maxLength: 128 } + pickupAt: { type: string, format: date-time, description: 当地日期时间不带时区;按分钟输入 } + deliveryAt: { type: string, format: date-time, description: 当地日期时间,不能早于取货时间 } remark: { type: string, nullable: true, maxLength: 1000 } securitySchemes: bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT } 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 fb281fd2b..f1cd3e630 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 @@ -28,7 +28,7 @@ class FleetPoolOrderBridgeTest { FleetPoolOrderBridge service; FleetPoolOrderLink row; LocalLooseboardOrder looseOrder; UsTruckOrder truckOrder; @BeforeEach void setup(){ - service=new FleetPoolOrderBridge(links,loose,looseHistory,trucks,truckHistory,numbers,new ObjectMapper().findAndRegisterModules(),evidence,appointmentSources); + service=new FleetPoolOrderBridge(new FleetPoolOrderRepository(links,loose,looseHistory,trucks,truckHistory),numbers,new ObjectMapper().findAndRegisterModules(),evidence,appointmentSources); when(numbers.next(anyString(),any(),anyLong())).thenReturn("LOCAL-001"); when(links.lockBooking(7L)).thenAnswer(a->row);when(links.lockBookingNo("FB-7")).thenAnswer(a->row); @@ -86,15 +86,16 @@ class FleetPoolOrderBridgeTest { } @Test void looseboardInquiryDoesNotInventContainerOrPalletizingServiceFromForecastPallets(){ var source=input("LOCAL_LOOSEBOARD");service.confirm(source,"op"); - assertThat(looseOrder.getContainerType()).isNull();assertThat(looseOrder.getPalletRequired()).isNull(); - assertThat(looseOrder.getTransportMode()).isEqualTo("SHARED_TRUCK"); + assertThat(looseOrder.getEstimatedPalletCount()).isEqualTo(source.expectedPalletCount());assertThat(looseOrder.getPalletRequired()).isNull(); + assertThat(looseOrder.getOriginContactName()).isEqualTo(source.contactName()); + assertThat(looseOrder.getDestinationContactName()).isNull(); assertThat(looseOrder.getTransportType()).isEqualTo("WAREHOUSE_TO_WAREHOUSE"); assertThat(looseOrder.getOrigin()).isEqualTo(source.warehouseName());assertThat(looseOrder.getDestination()).isEqualTo(source.destinationLabel()); assertThat(looseOrder.getPackageCount()).isEqualTo(source.packageCount()); assertThat(looseOrder.getTotalWeightKg()).isEqualByComparingTo(source.weightKg());assertThat(looseOrder.getTotalVolumeCbm()).isEqualByComparingTo(source.volumeCbm()); assertThat(looseOrder.getOriginAddress()).isNull();assertThat(looseOrder.getDestinationAddress()).isNull();assertThat(looseOrder.getBudgetAmount()).isNull(); incoming();service.receiving("9","WT-7","pda");service.acceptedReceipt(7,receipt(),3,"op"); - assertThat(looseOrder.getPalletRequired()).isNull();assertThat(looseOrder.getContainerType()).isNull(); + assertThat(looseOrder.getPalletRequired()).isNull();assertThat(looseOrder.getEstimatedPalletCount()).isEqualTo(source.expectedPalletCount()); } @Test void truckInquiryPreservesUnknownVehicleWhileZeroActualCountsRemainRealUntilReceipt(){ var source=input("US_TRUCK");service.confirm(source,"op"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/LocalLooseboardPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/LocalLooseboardPersistenceTest.java new file mode 100644 index 000000000..8bb58d9f7 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/LocalLooseboardPersistenceTest.java @@ -0,0 +1,62 @@ +package com.baiyue.lastleg.looseboard; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder; +import com.baiyue.lastleg.looseboard.mapper.*; +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 LocalLooseboardPersistenceTest { + /** 使用模块基线和真实 Mapper 验证四项联系人、取送时间保存回读、更新及客户隔离。 */ + @Test + void contactsAndLocalMinuteTimesRoundTripThroughActualMapper() 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_local_looseboard_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(LocalLooseboardOrderMapper.class); + var factory = new MybatisSqlSessionFactoryBean(); + factory.setDataSource(ds); factory.setConfiguration(config); + var mapper = new SqlSessionTemplate(factory.getObject()).getMapper(LocalLooseboardOrderMapper.class); + var repository = new com.baiyue.lastleg.looseboard.repository.LocalLooseboardRepository(); + org.springframework.test.util.ReflectionTestUtils.setField(repository, "orders", mapper); + var order = new LocalLooseboardOrder(); + order.setId(1L); order.setCustomerId("C-1"); order.setOrderNo("TRUCK-TEST-1"); + 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_ACCEPTANCE"); order.setIdempotencyKey("roundtrip"); order.setRequestHash("0".repeat(64)); + repository.insertOrder(order); + var saved = repository.findNo("C-1", order.getOrderNo()); + 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()); + assertNull(repository.findNo("C-2", order.getOrderNo())); assertNull(repository.findKey("C-2", "roundtrip")); + saved.setDestinationContactPhone("+1-555-0102"); saved.setDeliveryAt(saved.getDeliveryAt().plusMinutes(15)); + repository.updateOrder(saved); + var updated = repository.findKey("C-1", "roundtrip"); + 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/looseboard/LocalLooseboardRequestsTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/LocalLooseboardRequestsTest.java new file mode 100644 index 000000000..69bdddd0a --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/LocalLooseboardRequestsTest.java @@ -0,0 +1,51 @@ +package com.baiyue.lastleg.looseboard; + +import com.baiyue.lastleg.looseboard.dto.LocalLooseboardRequests; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import jakarta.validation.Validation; +import org.junit.jupiter.api.Test; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; + +class LocalLooseboardRequestsTest { + /** 验证真实 Bean Validation:新增和编辑均要求四项联系人及两个时间。 */ + @Test void requiresContactsAndBothLocalTimes() throws Exception { + try (var factory = Validation.buildDefaultValidatorFactory()) { + var validator = factory.getValidator(); + var json = new ObjectMapper().findAndRegisterModules(); + ObjectNode valid = json.valueToTree(request()); + assertTrue(validator.validate(request()).isEmpty()); + for (String field : List.of("originContactName", "originContactPhone", "destinationContactName", "destinationContactPhone", "pickupAt", "deliveryAt")) { + ObjectNode missing = valid.deepCopy(); missing.remove(field); + assertFalse(validator.validate(json.treeToValue(missing, LocalLooseboardRequests.Create.class)).isEmpty(), field); + missing.remove("idempotencyKey"); missing.put("version", 0); + assertFalse(validator.validate(json.treeToValue(missing, LocalLooseboardRequests.Update.class)).isEmpty(), field); + } + for (String field : List.of("originContactName", "originContactPhone", "destinationContactName", "destinationContactPhone")) { + ObjectNode blank = valid.deepCopy(); blank.put(field, " "); + assertFalse(validator.validate(json.treeToValue(blank, LocalLooseboardRequests.Create.class)).isEmpty(), field); + } + ObjectNode reversed = valid.deepCopy(); reversed.put("deliveryAt", "2030-01-02T10:29:00"); + assertFalse(validator.validate(json.treeToValue(reversed, LocalLooseboardRequests.Create.class)).isEmpty()); + } + } + + /** JSON 往返保持当地分钟,不包含已删除字段。 */ + @Test void roundTripsLocalTimesAndSplitContacts() throws Exception { + var json = new ObjectMapper().findAndRegisterModules(); + var valid = request(); var tree = json.valueToTree(valid); + var restored = json.treeToValue(tree, LocalLooseboardRequests.Create.class); + assertEquals(valid, restored); + for (String field : List.of("transportMode", "containerType", "contactName", "contactInfo", "requestedDate", "scheduleValid")) assertFalse(tree.has(field)); + } + + /** 创建具有独立预计托数和件数的最小完整需求。 */ + private LocalLooseboardRequests.Create request() { + return new LocalLooseboardRequests.Create("LA", null, "ONT", null, "货物", 3, 8, + BigDecimal.ZERO, BigDecimal.ZERO, null, "USD", "发货人", "1", "收货人", "2", + LocalDateTime.of(2030, 1, 2, 10, 30), LocalDateTime.of(2030, 1, 2, 15, 45), null, "create-1"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/LocalLooseboardSchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/LocalLooseboardSchemaContractTest.java index a6785bff9..bef6ae5c0 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/LocalLooseboardSchemaContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/LocalLooseboardSchemaContractTest.java @@ -45,7 +45,7 @@ class LocalLooseboardSchemaContractTest { } @Test - void poolUnknownContainerAndPalletizingSurviveOmittedNullInsertColumnsInBothBaselines() throws Exception { + void demandFieldsAndUnknownPalletizingRemainSeparateInBothBaselines() throws Exception { String baseline; try (var input = getClass().getResourceAsStream("/db/lastleg.sql")) { baseline = new String(input.readAllBytes(), StandardCharsets.UTF_8); @@ -53,8 +53,13 @@ class LocalLooseboardSchemaContractTest { String unified = Files.readString(Path.of("..", "deploy", "sql", "海外区域统一增量-MySQL8.sql"), StandardCharsets.UTF_8); for (String sql : java.util.List.of(baseline, unified)) { String block = tableBlock(sql.replace("CREATE TABLE IF NOT EXISTS", "CREATE TABLE"), "ll_local_looseboard_order").toLowerCase(); - 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"); + assertTrue(block.contains("product_name varchar(255)")); + assertTrue(block.contains("estimated_pallet_count int")); + assertTrue(block.contains("pickup_at datetime(3)")); + assertTrue(block.contains("delivery_at datetime(3)")); + for (String field : java.util.List.of("origin_contact_name", "origin_contact_phone", "destination_contact_name", "destination_contact_phone")) assertTrue(block.contains(field)); + for (String field : java.util.List.of("transport_mode", "contact_name varchar", "contact_info", "requested_date")) org.junit.jupiter.api.Assertions.assertFalse(block.matches("(?s).*[,\\n]\\s*" + field + ".*")); + org.junit.jupiter.api.Assertions.assertFalse(block.contains("container_type")); assertTrue(java.util.regex.Pattern.compile("(?:^|,)\\s*pallet_required\\s+varchar\\(8\\)\\s+(?:collate\\s+\\w+\\s+)?(?:null\\s+)?default\\s+null\\b").matcher(block).find(), "Forecast pallets must not invent YES or NO palletizing service"); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/controller/PlatformLocalLooseboardControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/controller/PlatformLocalLooseboardControllerTest.java index ccd1af921..f153bae1c 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/controller/PlatformLocalLooseboardControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/controller/PlatformLocalLooseboardControllerTest.java @@ -40,18 +40,15 @@ class PlatformLocalLooseboardControllerTest { @Test void updateRequiresIndependentEditPermission() { - var request = new LocalLooseboardRequests.Update(0L, "SHARED_TRUCK", "NO", "PORT_PICKUP", - "LA", null, "Ontario", null, null, 1, BigDecimal.ZERO, BigDecimal.ZERO, - null, "USD", null, null, null, null); + var v = create(); + var request = new LocalLooseboardRequests.Update(0L, v.origin(), v.originAddress(), v.destination(), v.destinationAddress(), v.productName(), v.estimatedPalletCount(), v.packageCount(), v.totalWeightKg(), v.totalVolumeCbm(), v.budgetAmount(), v.currency(), v.originContactName(), v.originContactPhone(), v.destinationContactName(), v.destinationContactPhone(), v.pickupAt(), v.deliveryAt(), v.remark()); ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> controller.update(user(Set.of(OverseasPermissions.LOOSEBOARD_CREATE)), "100", "LB1", request)); assertEquals(403, error.getStatusCode().value()); } private LocalLooseboardRequests.Create create() { - return new LocalLooseboardRequests.Create("SHARED_TRUCK", "NO", "PORT_PICKUP", "LA", null, - "Ontario", null, null, 1, BigDecimal.ZERO, BigDecimal.ZERO, null, "USD", - null, null, LocalDate.now(), null, "key-1"); + return new LocalLooseboardRequests.Create("LA", null, "Ontario", null, "货物", 1, 1, BigDecimal.ZERO, BigDecimal.ZERO, null, "USD", "A", "1", "B", "2", java.time.LocalDateTime.of(2030, 1, 2, 9, 30), java.time.LocalDateTime.of(2030, 1, 2, 15, 30), null, "key-1"); } private AuthenticatedUser user(Set permissions) { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardServiceTest.java index 4b1d8a5f1..c89e42be6 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardServiceTest.java @@ -24,6 +24,7 @@ import org.springframework.web.server.ResponseStatusException; import java.math.BigDecimal; import java.time.LocalDate; +import com.baiyue.lastleg.looseboard.repository.LocalLooseboardRepository; import java.time.LocalDateTime; import java.util.List; import java.util.HashMap; @@ -54,11 +55,13 @@ class LocalLooseboardServiceTest { void setUp() { service = new LocalLooseboardService(); 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, "histories", histories); - ReflectionTestUtils.setField(service, "operations", operations); - ReflectionTestUtils.setField(service, "lastlegOrders", lastlegOrders); - ReflectionTestUtils.setField(service, "lastlegHistories", lastlegHistories); + var repository = new LocalLooseboardRepository(); + ReflectionTestUtils.setField(service, "repository", repository); + ReflectionTestUtils.setField(repository, "orders", orders); + ReflectionTestUtils.setField(repository, "histories", histories); + ReflectionTestUtils.setField(repository, "operations", operations); + ReflectionTestUtils.setField(repository, "lastlegOrders", lastlegOrders); + ReflectionTestUtils.setField(repository, "lastlegHistories", lastlegHistories); ReflectionTestUtils.setField(service, "lastlegOrderNoGenerator", lastlegOrderNoGenerator); ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules()); org.mockito.Mockito.lenient().when(lastlegOrderNoGenerator.next(anyString(), any(), anyLong())) @@ -86,31 +89,29 @@ class LocalLooseboardServiceTest { assertEquals("100", detail.summary().customerId()); assertEquals(LocalLooseboardService.PENDING_ACCEPTANCE, detail.summary().status()); - assertEquals("SHARED_TRUCK", detail.summary().transportMode()); - assertEquals("40HQ", detail.summary().containerType()); + assertEquals("Alice", detail.summary().originContactName()); + assertEquals("Bob", detail.summary().destinationContactName()); + assertEquals("家居用品", detail.summary().productName()); + assertEquals(3, detail.summary().estimatedPalletCount()); + assertEquals(LocalDateTime.of(2030, 1, 2, 9, 30), detail.summary().pickupAt()); verify(lastlegOrders).insert(org.mockito.ArgumentMatchers.argThat(value -> "LOCAL_LOOSEBOARD".equals(value.getOrderType()) && value.getSourceOrderNo().equals("LB-WH-20260901-000001") && value.getInboundCode() == null + && value.getPalletCount() == 0 && "LOCAL_LOOSEBOARD:PENDING_ACCEPTANCE".equals(value.getSubOrderStatus()))); verify(histories).insert(org.mockito.ArgumentMatchers.argThat(value -> "CREATE".equals(value.getOperationType()))); } @Test - void createDefaultsTheContainerTypeAndRejectsRemovedTypes() { - AtomicReference saved = new AtomicReference<>(); - when(orders.insert(any(LocalLooseboardOrder.class))).thenAnswer(call -> { - saved.set(call.getArgument(0)); - return 1; - }); - when(orders.findNo(anyString(), anyString())).thenAnswer(call -> saved.get()); - when(histories.listOrder(anyString(), any())).thenReturn(List.of()); - - var detail = service.create("100", "7", create("default-type", null)); - - assertEquals("40HQ", detail.summary().containerType()); - assertThrows(BizException.class, () -> service.create("100", "7", create("retired-type", "40HC"))); + void validatesRequiredTimesAndChronologyWithoutInventingActualPallets() { + var pickup = LocalDateTime.of(2030, 1, 2, 10, 30); + org.junit.jupiter.api.Assertions.assertFalse(LocalLooseboardRequests.validSchedule(null, null)); + org.junit.jupiter.api.Assertions.assertFalse(LocalLooseboardRequests.validSchedule(pickup, null)); + org.junit.jupiter.api.Assertions.assertFalse(LocalLooseboardRequests.validSchedule(null, null)); + org.junit.jupiter.api.Assertions.assertFalse(LocalLooseboardRequests.validSchedule(pickup, pickup.minusMinutes(1))); + org.junit.jupiter.api.Assertions.assertTrue(LocalLooseboardRequests.validSchedule(pickup, pickup.plusMinutes(1))); } @Test @@ -193,27 +194,22 @@ class LocalLooseboardServiceTest { } private LocalLooseboardRequests.Create create(String key) { - return create(key, "40HQ"); - } - - private LocalLooseboardRequests.Create create(String key, String containerType) { - return new LocalLooseboardRequests.Create("SHARED_TRUCK", "YES", "WAREHOUSE_TO_WAREHOUSE", - "Los Angeles", "Address A", "Ontario", "Address B", containerType, 12, - new BigDecimal("100.000"), new BigDecimal("2.500000"), new BigDecimal("500.00"), - "USD", "Alice", "+1-555-0100", LocalDate.now().plusDays(2), null, key); + return new LocalLooseboardRequests.Create("Los Angeles", "Address A", "Ontario", "Address B", + "家居用品", 3, 12, new BigDecimal("100.000"), new BigDecimal("2.500000"), new BigDecimal("500.00"), + "USD", "Alice", "+1-555-0100", "Bob", "+1-555-0101", LocalDateTime.of(2030, 1, 2, 9, 30), LocalDateTime.of(2030, 1, 2, 10, 30), null, key); } private LocalLooseboardRequests.Update update(long version) { - return new LocalLooseboardRequests.Update(version, "SHARED_TRUCK", "YES", "WAREHOUSE_TO_WAREHOUSE", - "Los Angeles", "Address A", "Ontario", "Address B", "40HQ", 12, - new BigDecimal("100.000"), new BigDecimal("2.500000"), new BigDecimal("500.00"), - "USD", "Alice", "+1-555-0100", LocalDate.now().plusDays(2), null); + var v = create("unused"); + return new LocalLooseboardRequests.Update(version, v.origin(), v.originAddress(), v.destination(), v.destinationAddress(), + v.productName(), v.estimatedPalletCount(), v.packageCount(), v.totalWeightKg(), v.totalVolumeCbm(), v.budgetAmount(), v.currency(), + v.originContactName(), v.originContactPhone(), v.destinationContactName(), v.destinationContactPhone(), v.pickupAt(), v.deliveryAt(), v.remark()); } private LocalLooseboardOrder order(String status, long version) { LocalLooseboardOrder order = new LocalLooseboardOrder(); order.setId(1L); order.setCustomerId("100"); order.setOrderNo("LB1"); - order.setTransportMode("SHARED_TRUCK"); order.setPalletRequired("YES"); + order.setPalletRequired("YES"); order.setTransportType("WAREHOUSE_TO_WAREHOUSE"); order.setOrigin("Los Angeles"); order.setDestination("Ontario"); order.setPackageCount(12); order.setCurrency("USD"); order.setStatus(status); order.setVersion(version); order.setCreatedAt(LocalDateTime.now()); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/merchant/MerchantOverseasCustomerApiContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/merchant/MerchantOverseasCustomerApiContractTest.java index c547cb562..0ecfb8737 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/merchant/MerchantOverseasCustomerApiContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/merchant/MerchantOverseasCustomerApiContractTest.java @@ -107,7 +107,7 @@ class MerchantOverseasCustomerApiContractTest { void looseboardAndInboundQueriesUseAuthenticatedCustomerScope() { AuthenticatedUser merchant = user(101L, "MERCHANT"); LocalLooseboardRequests.MerchantQuery looseboardQuery = new LocalLooseboardRequests.MerchantQuery(1, 20, - null, null, null, null, null, null); + null, null, null, null, null); InboundPlanRequests.Query inboundQuery = new InboundPlanRequests.Query(1, 20, null, null, null, null, null, null, null, null, null, null, null, null); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java index 99c59b069..b0f77f613 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java @@ -60,7 +60,7 @@ class TaillegRealConsumerAcceptanceTest { @EnableAutoConfiguration(excludeName = "com.baiyue.common.security.BaiyueSecurityAutoConfiguration") @Import({LoadingCompletedConsumer.class, LastlegOrderNoGenerator.class, DropshipOrderService.class, InboundPlanService.class, InventoryCommandService.class, RelabelApplicationService.class, - WarehouseTransferService.class, LocalLooseboardService.class}) + WarehouseTransferService.class, LocalLooseboardService.class, com.baiyue.lastleg.looseboard.repository.LocalLooseboardRepository.class}) @MapperScan({"com.baiyue.lastleg.mapper", "com.baiyue.lastleg.dropship.mapper", "com.baiyue.lastleg.inboundplan.mapper", "com.baiyue.lastleg.inboundplan.pda.mapper", "com.baiyue.lastleg.inventory.mapper", "com.baiyue.lastleg.inventory.command.mapper", "com.baiyue.lastleg.relabel.mapper", @@ -117,9 +117,9 @@ class TaillegRealConsumerAcceptanceTest { if (!exists("ll_local_looseboard_order", MARKER + "-LOOSEBOARD-KEY")) { looseboard.create(CUSTOMER, "real-acceptance", new LocalLooseboardRequests.Create( - "SHARED_TRUCK", "YES", "WAREHOUSE_TO_WAREHOUSE", "洛杉矶海外仓", "Los Angeles", - "ONT8", "FBA ONT8", "40HQ", 3, new BigDecimal("30.00"), new BigDecimal("0.40"), - new BigDecimal("120.00"), "USD", "验收联系人", "13800000000", REQUESTED_DATE, + "洛杉矶海外仓", "Los Angeles", + "ONT8", "FBA ONT8", "验收货物", 2, 3, new BigDecimal("30.00"), new BigDecimal("0.40"), + new BigDecimal("120.00"), "USD", "验收联系人", "13800000000", "验收收货人", "13800000001", REQUESTED_DATE.atTime(9, 30), REQUESTED_DATE.atTime(15, 30), MARKER, MARKER + "-LOOSEBOARD-KEY")); } diff --git a/scripts/admin-test-env-modules/phase4-overseas-business.mjs b/scripts/admin-test-env-modules/phase4-overseas-business.mjs index 2a78f8a75..9f00735d3 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-business.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-business.mjs @@ -282,11 +282,11 @@ function truckPayload(context) { function looseboardPayload(context) { const suffix = context.marker.replace(/[^A-Za-z0-9]/g, '').slice(-16) return { - transportMode: 'SHARED_TRUCK', palletRequired: 'YES', transportType: 'WAREHOUSE_TO_WAREHOUSE', + productName: '散板验收货物', estimatedPalletCount: 2, origin: `散板起运-${suffix}`, originAddress: 'Los Angeles', destination: `散板目的-${suffix}`, - destinationAddress: 'Ontario', containerType: '40GP', packageCount: 3, totalWeightKg: 80, - totalVolumeCbm: 1.8, budgetAmount: 66, currency: 'USD', contactName: '散板验收联系人', - contactInfo: '13800000001', requestedDate: new Date(Date.now() + 8 * 86400000).toISOString().slice(0, 10), + destinationAddress: 'Ontario', packageCount: 3, totalWeightKg: 80, + totalVolumeCbm: 1.8, budgetAmount: 66, currency: 'USD', originContactName: '散板验收联系人', originContactPhone: '13800000001', + destinationContactName: '散板验收收货人', destinationContactPhone: '13800000002', pickupAt: '2026-10-20T09:30:00', deliveryAt: '2026-10-20T15:30:00', remark: context.marker, idempotencyKey: `${context.marker}-phase4-looseboard`.slice(0, 255), } }