diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 37e2a607b..65f30529d 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -6710,6 +6710,8 @@ INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107280,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107281,0,'PLATFORM','BUTTON',5107280,'platform:appointment:delete','删除预约',NULL,'DELETE',741,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107281,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107282,0,'PLATFORM','BUTTON',5107280,'platform:appointment:followup','记录预约跟进',NULL,'POST',742,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107282,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107010,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107011,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107012,0,CURRENT_TIMESTAMP(3)); diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index e08c3b3f5..4d87ae203 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -1479,6 +1479,30 @@ CREATE TABLE ll_order ( KEY idx_ll_order_container_progress (source_container_booking_id,estimated_warehouse_at,created_at,id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程订单主表'; +CREATE TABLE ll_appointment_followup ( + id BIGINT NOT NULL COMMENT '预约筹划跟进记录ID', + source_key VARCHAR(160) NOT NULL COMMENT '稳定ORDER或BOOKING来源键,不因实收转换而变化', + customer_id VARCHAR(128) NOT NULL COMMENT '服务端核实的来源客户', + source_order_id BIGINT NULL COMMENT '已形成的真实尾程货源ID,尚未生成时为空', + stage VARCHAR(32) NOT NULL COMMENT '跟进对象:DRAYAGE_CONTACT/TERMINAL_APPOINTMENT/WAREHOUSE_RECEIVING/FBA_RECEIVING', + version INT NOT NULL COMMENT '同一货源及跟进对象的记录版本', + contacted_party VARCHAR(200) NULL COMMENT '本次联系对象,不替代承运主档', + result VARCHAR(1000) NOT NULL COMMENT '本次反馈事实,不自动确认预约或推进实物作业', + next_action VARCHAR(500) NULL COMMENT '下一步安排', + followup_date DATE NULL COMMENT '按业务地点当地日期记录的下次跟进日', + operator_id VARCHAR(128) NOT NULL COMMENT '登录平台操作者ID', + operator_name VARCHAR(200) NULL COMMENT '操作人姓名快照', + command_key VARCHAR(64) NOT NULL COMMENT '持久幂等键', + request_hash CHAR(64) NOT NULL COMMENT '含操作者身份的请求SHA256摘要', + created_at DATETIME(3) NOT NULL COMMENT 'UTC记录时间', + PRIMARY KEY (id), + UNIQUE KEY uk_appointment_followup_version (source_key,stage,version), + UNIQUE KEY uk_appointment_followup_command (source_key,stage,command_key), + KEY idx_appointment_followup_date (followup_date,source_order_id), + CONSTRAINT fk_appointment_followup_order FOREIGN KEY (source_order_id) REFERENCES ll_order(id), + CONSTRAINT chk_appointment_followup_stage CHECK (stage IN ('DRAYAGE_CONTACT','TERMINAL_APPOINTMENT','WAREHOUSE_RECEIVING','FBA_RECEIVING')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='真实在途货源的预约筹划联系记录,追加留痕'; + CREATE TABLE ll_order_command_record ( id bigint NOT NULL COMMENT '尾程订单命令幂等记录ID', customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键', @@ -3601,4 +3625,4 @@ CREATE TABLE ll_return_carton_count_session ( UNIQUE KEY uk_ll_return_count_session_receipt(receipt_id) ) ENGINE=InnoDB COMMENT='退货箱内清点单人单任务持久会话'; -SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 160 AS initialized_tables; +SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 161 AS initialized_tables; diff --git a/iam/src/main/resources/db/lastleg-permissions.sql b/iam/src/main/resources/db/lastleg-permissions.sql index d05c9c0a9..6b4c39d13 100644 --- a/iam/src/main/resources/db/lastleg-permissions.sql +++ b/iam/src/main/resources/db/lastleg-permissions.sql @@ -20,6 +20,7 @@ INSERT INTO iam_permission VALUES (5107280,0,'PLATFORM','MENU',0,'platform:appointment:list','预约列表','/appointment-calendar','GET',740,'ACTIVE',1,0), (5107281,0,'PLATFORM','BUTTON',5107280,'platform:appointment:delete','删除预约',NULL,'DELETE',741,'ACTIVE',1,0), + (5107282,0,'PLATFORM','BUTTON',5107280,'platform:appointment:followup','记录预约跟进',NULL,'POST',742,'ACTIVE',1,0), (5107000,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:last-mile-order:list','海外尾程订单','/overseas-warehouse/last-mile-orders','GET',700,'ACTIVE',1,0), (5107001,0,'PLATFORM','BUTTON',5107000,'platform:overseas-warehouse:last-mile-order:edit','编辑海外尾程订单',NULL,'PUT',701,'ACTIVE',1,0), (5107002,0,'PLATFORM','BUTTON',5107000,'platform:overseas-warehouse:last-mile-order:export','导出海外尾程订单',NULL,'GET',702,'ACTIVE',1,0), diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index ea8201011..332e16471 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -11,7 +11,7 @@ 业务包相对 `src/main/java/com/baiyue/lastleg/`;`src/`、`docs/`、`scripts/`、`../` 相对服务目录。裸类名在指定包内检索。 -拼车需求池:`fleet/trip/FleetCapacityOfferService.java`、`fleet/business/FleetPoolOrderBridge.java`;流程见 `../../docs/海外仓业务/内部拼车需求池首版开发契约.md`。 +拼车接货与车次准备:`fleet/trip/`、`fleet/business/`;合同`fleet-preparation-v1.yaml`,流程见 `../../docs/海外仓业务/内部拼车需求池首版开发契约.md`。 事件重试:`controller/EventOperationsController.java`,内部 `/internal/lastleg/event-operations/{entryId}` 查询、`/retry` 写入;仅卸柜、派送、暂扣、资金及费用事件,以 `EVENT_OPERATIONS_TOKEN` 保护。原事件不可改,DEAD→PENDING 与 `event_delivery_retry_audit` 同事务;脚本 `scripts/event-outbox-operations.mjs`。 @@ -36,7 +36,7 @@ | 入仓计划分页、柜级预览、转换与详情 | `inboundplan/controller/InboundPlanController.java` | | 自动生成托盘、资源生成失败与重试 | `inboundplan/service/ContainerInboundPlanConversionCoordinator.java` 的 `convertManualAndAutoMaterialize`;详细规则定向查 `docs/business-index.md` 的“人工计划自动生成托盘与资源重试” | | 托盘分配算法 | `inboundplan/service/PalletAllocator.java` | -| 跨客户同仓预约、日历、增删改查 | `InboundPlanController.java` | +| 跨客户同仓预约、日历 | `InboundPlanController.java`;在途/外接货跟进与覆盖见`appointmentplanning/` | | PDA 入仓、柜号签收、实收、上架与完成 | `inboundplan/pda/controller/ReceivingContainerController.java` → `inboundplan/pda/service/ReceivingContainerService.java`;门禁与状态定向查 `docs/business-index.md` 的“PDA 入仓、实收、上架与完成” | | PDA 卸柜照片上传、归属校验与预览 | `ReceivingContainerController.java` | | PDA 国内装柜照片对照 | `ReceivingContainerController.java` | diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargo.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargo.java new file mode 100644 index 000000000..74cbd9d78 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargo.java @@ -0,0 +1,37 @@ +package com.baiyue.lastleg.appointmentplanning; + +import lombok.Data; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** Read-only loaded cargo, independent of inbound-plan materialization. */ +@Data +public class AppointmentCargo { + private String sourceKey; + private Long orderId; + private String customerId; + private String customerName; + private String inboundCode; + private String containerBookingId; + private String containerNo; + private String originWarehouseId; + private String originWarehouseCode; + private String originWarehouseName; + private String destinationWarehouseCode; + private Integer packages; + private Integer receivedPackages; + private Integer shippedPackages; + private Integer sourcePalletCount; + private BigDecimal volumeCbm; + private LocalDateTime estimatedArrivalAt; + private LocalDateTime estimatedWarehouseAt; + private Integer orderVersion; + private String standardNode; + private Long shippingVersion; + private LocalDateTime shippingObservedAt; + private String shippingProvider; + private boolean hasInboundPlan; + private boolean hasActiveAppointment; + private boolean detained; + private boolean mixedDestinations; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoMapper.java new file mode 100644 index 000000000..d19787cd8 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoMapper.java @@ -0,0 +1,45 @@ +package com.baiyue.lastleg.appointmentplanning; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import java.util.List; + +@Mapper +public interface AppointmentCargoMapper { + String FILTER = """ + FROM ll_order o JOIN ll_container_shipping_gate g ON g.container_booking_id=o.source_container_booking_id + WHERE o.order_type='FIRST_LEG' AND o.destination_type='FBA' AND g.node_sequence >=40 + AND o.current_status NOT IN ('CANCELLED','COMPLETED') + AND o.overseas_warehouse_id IN + #{id} + AND o.fba_warehouse_code IN + #{code} + AND o.overseas_warehouse_id=#{warehouseId} + AND o.fba_warehouse_code=#{destination} + AND (o.inbound_code LIKE CONCAT('%',#{keyword},'%') OR o.container_no LIKE CONCAT('%',#{keyword},'%') OR o.customer_name LIKE CONCAT('%',#{keyword},'%')) + """; + + @Select("") + long count(@Param("origins") List origins, @Param("destinations") List destinations, + @Param("warehouseId") String warehouseId, @Param("destination") String destination, @Param("keyword") String keyword); + + @Select("") + List page(@Param("origins") List origins, @Param("destinations") List destinations, + @Param("warehouseId") String warehouseId, @Param("destination") String destination, + @Param("keyword") String keyword, @Param("size") int size, @Param("offset") long offset); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoService.java new file mode 100644 index 000000000..507af0b90 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoService.java @@ -0,0 +1,57 @@ +package com.baiyue.lastleg.appointmentplanning; + +import com.baiyue.common.core.api.PageResult; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; +import java.util.List; +import java.util.Set; + +@Service +public class AppointmentCargoService { + private final AppointmentCargoMapper mapper; + private final AppointmentWarehouseClient warehouses; + private final String token; + + public AppointmentCargoService(AppointmentCargoMapper mapper, AppointmentWarehouseClient warehouses, + @Value("${baiyue.security.internal-service-token:}") String token) { + this.mapper = mapper; this.warehouses = warehouses; this.token = token; + } + + public PageResult page(int page, int size, String warehouseId, String destination, String keyword) { + if (page < 1 || size < 1 || size > 100) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "每页数量必须为1至100"); + if (keyword != null && keyword.length() > 128) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "搜索词过长"); + var scope = scope(); + var origins = scope.origins(); + var destinations = scope.destinations(); + warehouseId = clean(warehouseId); destination = clean(destination); keyword = clean(keyword); + if (warehouseId != null && !origins.contains(warehouseId) || destination != null && !destinations.contains(destination)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "请选择已启用的美国接仓点及FBA目的仓"); + } + if (origins.isEmpty() || destinations.isEmpty()) return new PageResult<>(List.of(), page, size, 0, 0); + long total = mapper.count(origins, destinations, warehouseId, destination, keyword); + var rows = mapper.page(origins, destinations, warehouseId, destination, keyword, size, (long) (page - 1) * size); + return new PageResult<>(rows, page, size, total, (total + size - 1) / size); + } + + private record Scope(List origins, List destinations) { } + private Scope scope() { + final List catalog; + try { + var result = warehouses.catalog(token); + if (result == null || !result.successful() || result.data() == null) throw new IllegalStateException(); + catalog = result.data(); + } catch (RuntimeException error) { + throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "仓库目录暂不可用,无法核实美国货物范围", error); + } + var us = catalog.stream().filter(w -> w != null && w.id() != null && w.id() > 0 && "US".equals(w.countryCode())).toList(); + var origins = us.stream().filter(w -> Set.of("OVERSEAS", "OVERSEAS_PARTNER").contains(w.warehouseType() == null ? "" : w.warehouseType())) + .map(w -> w.id().toString()).distinct().toList(); + var destinations = us.stream().filter(w -> "FBA".equals(w.warehouseType()) && clean(w.warehouseCode()) != null) + .map(AppointmentWarehouseClient.Warehouse::warehouseCode).distinct().toList(); + return new Scope(origins, destinations); + } + + private static String clean(String value) { return value == null || value.isBlank() ? null : value.trim(); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCoverageMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCoverageMapper.java new file mode 100644 index 000000000..beaa43b28 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCoverageMapper.java @@ -0,0 +1,37 @@ +package com.baiyue.lastleg.appointmentplanning; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import java.time.LocalDateTime; +import java.util.List; + +@Mapper +public interface AppointmentCoverageMapper { + record OrderRef(String customerId, Long orderId) { } + record Source(Long orderId, String customerId, String destinationType, String fbaWarehouseCode, + Integer packages, Integer version, LocalDateTime updatedAt, boolean shipmentReferencesMissing) { } + record Fact(Long orderId, String customerId, String appointmentNo, String status, + LocalDateTime appointmentAt, LocalDateTime appointmentEndAt, LocalDateTime updatedAt, + Long lineId, Integer packages, String warehouseCode) { } + + @Select({""}) + List sources(@Param("sources") List sources); + + @Select({""}) + List appointments(@Param("sources") List sources); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCoverageService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCoverageService.java new file mode 100644 index 000000000..9f3feb3e6 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCoverageService.java @@ -0,0 +1,101 @@ +package com.baiyue.lastleg.appointmentplanning; + +import org.springframework.stereotype.Service; +import java.time.LocalDateTime; +import java.util.*; +import java.util.stream.Collectors; + +/** Shared read projection. Callers authorize the customer/source before invoking; never confirms bookings. */ +@Service +public class AppointmentCoverageService { + public record SourceRef(String customerId, String sourceKey, String destinationKey) { } + public record Appointment(String appointmentNo, String confirmationNo, String status, + LocalDateTime confirmedAt, LocalDateTime confirmedEndAt, String timezone) { } + public record Coverage(String customerId, String sourceKey, String destinationKey, String status, + List appointments, List missingFields, + String sourceVersion, boolean changeReviewRequired) { } + private final AppointmentCoverageMapper mapper; + private final AppointmentExternalSourceGateway external; + public AppointmentCoverageService(AppointmentCoverageMapper mapper, AppointmentExternalSourceGateway external) { + this.mapper=mapper; this.external=external; + } + + public List query(List input) { + if (input==null || input.isEmpty()) return List.of(); + if (input.size()>100 || input.stream().anyMatch(s -> s==null || blank(s.customerId()) || blank(s.sourceKey()) || blank(s.destinationKey()))) { + throw new IllegalArgumentException("每次最多核对100个完整的预约来源"); + } + var requested=input.stream().distinct().toList(); + var externalRefs=requested.stream().filter(s -> s.sourceKey().startsWith("BOOKING:")) + .map(s -> new AppointmentExternalSourceGateway.SourceRef(s.customerId(),s.sourceKey().substring(8))).toList(); + var bookings=externalRefs.isEmpty() ? List.of() : external.resolve(externalRefs); + var bookingMap=bookings.stream().collect(Collectors.toMap(s -> key(s.customerId(),"BOOKING:"+s.bookingNo()),s -> s)); + var refs=new LinkedHashMap(); + for (var source:requested) { + if (source.sourceKey().startsWith("ORDER:")) { + try { + long id=Long.parseLong(source.sourceKey().substring(6)); + if (id>0) refs.put(source,new AppointmentCoverageMapper.OrderRef(source.customerId(),id)); + } catch (NumberFormatException ignored) { /* Invalid source is returned explicitly below. */ } + } else if (source.sourceKey().startsWith("BOOKING:")) { + var booking=bookingMap.get(key(source.customerId(),source.sourceKey())); + if (booking!=null && booking.orderId()!=null && Objects.equals(source.destinationKey(),booking.destinationKey())) { + refs.put(source,new AppointmentCoverageMapper.OrderRef(source.customerId(),booking.orderId())); + } + } + } + var unique=refs.values().stream().distinct().toList(); + if (unique.size()!=refs.size()) throw new IllegalArgumentException("同一货物存在重复来源,请保留原外接登记来源后再核对"); + var orders=unique.isEmpty() ? List.of() : mapper.sources(unique); + var orderMap=orders.stream().collect(Collectors.toMap(s -> new AppointmentCoverageMapper.OrderRef(s.customerId(),s.orderId()),s -> s)); + var facts=unique.isEmpty() ? List.of() : mapper.appointments(unique); + var byOrder=facts.stream().collect(Collectors.groupingBy(f -> new AppointmentCoverageMapper.OrderRef(f.customerId(),f.orderId()))); + var output=new ArrayList(); + for (var source:requested) { + var booking=bookingMap.get(key(source.customerId(),source.sourceKey())); + if (booking!=null && !source.destinationKey().equals(booking.destinationKey())) { + output.add(empty(source,"DESTINATION_MISMATCH","DESTINATION",version(booking.recordVersion()),true)); continue; + } + if (booking!=null && !Set.of("CONFIRMED","RECEIVED","BOUND").contains(booking.bookingStatus()==null ? "" : booking.bookingStatus())) { + output.add(empty(source,"SOURCE_NOT_READY","CARRIER_CONFIRMATION",version(booking.recordVersion()),false)); continue; + } + var order=orderMap.get(refs.get(source)); + if (order==null) { + output.add(empty(source,booking==null ? "SOURCE_NOT_FOUND" : "SOURCE_NOT_MATERIALIZED","REAL_CARGO_MATERIALS",booking==null ? null : version(booking.recordVersion()),false)); continue; + } + if (!"FBA".equals(order.destinationType()) || !source.destinationKey().equals("FBA:"+order.fbaWarehouseCode())) { + output.add(empty(source,"DESTINATION_MISMATCH","DESTINATION",version(order.version()),true)); continue; + } + var relevant=byOrder.getOrDefault(refs.get(source),List.of()); + var distinct=new LinkedHashMap(); + var seen=new HashSet(); long confirmed=0, covered=0; boolean invalid=false, changed=false; + for (var fact:relevant) { + if (!Objects.equals(order.fbaWarehouseCode(),fact.warehouseCode()) || fact.packages()==null || fact.packages()<=0 || !seen.add(fact.lineId())) { invalid=true; continue; } + covered+=fact.packages(); + if ("SUCCESS".equals(fact.status()) && fact.appointmentAt()!=null) confirmed+=fact.packages(); + boolean success="SUCCESS".equals(fact.status()); + distinct.putIfAbsent(fact.appointmentNo(),new Appointment(fact.appointmentNo(),success ? fact.appointmentNo() : null,fact.status(),success ? fact.appointmentAt() : null,success ? fact.appointmentEndAt() : null,null)); + changed|=order.updatedAt()==null || fact.updatedAt()==null || order.updatedAt().isAfter(fact.updatedAt()); + } + var missing=new ArrayList(); + String status; + if (invalid || order.packages()==null || order.packages()<=0 || covered>order.packages()) { status="REVIEW_REQUIRED"; missing.add("CARGO_QUANTITY_REVIEW"); } + else if (covered==0) { status="UNASSIGNED"; missing.add("RECEIVING_APPOINTMENT"); } + else if (covered0) missing.add("APPOINTMENT_TIMEZONE_UNVERIFIED"); + if (order.shipmentReferencesMissing()) missing.add("FBA_SHIPMENT_REFERENCES"); + if (changed) missing.add("SOURCE_CHANGED_REVIEW"); + output.add(new Coverage(source.customerId(),source.sourceKey(),source.destinationKey(),status,List.copyOf(distinct.values()),List.copyOf(missing), + (booking==null ? "" : "BOOKING:"+version(booking.recordVersion())+"/")+"ORDER:"+version(order.version()),changed || invalid)); + } + return List.copyOf(output); + } + private static Coverage empty(SourceRef s,String status,String missing,String version,boolean review) { + return new Coverage(s.customerId(),s.sourceKey(),s.destinationKey(),status,List.of(),List.of(missing),version,review); + } + private static String key(String customerId,String sourceKey) { return customerId+"\n"+sourceKey; } + private static String version(Number version) { return version==null ? null : version.toString(); } + private static boolean blank(String value) { return value==null || value.isBlank(); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentExternalSourceGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentExternalSourceGateway.java new file mode 100644 index 000000000..2404ba580 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentExternalSourceGateway.java @@ -0,0 +1,11 @@ +package com.baiyue.lastleg.appointmentplanning; + +import java.util.List; + +/** Implemented by the external-booking owner; planning must not query Fleet's persistence directly. */ +public interface AppointmentExternalSourceGateway { + record SourceRef(String customerId, String bookingNo) { } + record Source(String customerId, String bookingNo, String tripNo, String bookingStatus, + String warehouseId, String destinationKey, Long orderId, Integer packageCount, Long recordVersion) { } + List resolve(List sources); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentFollowup.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentFollowup.java new file mode 100644 index 000000000..44110b2a8 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentFollowup.java @@ -0,0 +1,28 @@ +package com.baiyue.lastleg.appointmentplanning; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Data +@TableName("ll_appointment_followup") +public class AppointmentFollowup { + @TableId(type = IdType.INPUT) private Long id; + private Long sourceOrderId; + private String sourceKey; + private String customerId; + private String stage; + private Integer version; + private String contactedParty; + private String result; + private String nextAction; + private LocalDate followupDate; + private String operatorId; + private String operatorName; + private String commandKey; + private String requestHash; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentFollowupMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentFollowupMapper.java new file mode 100644 index 000000000..bc71648fa --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentFollowupMapper.java @@ -0,0 +1,19 @@ +package com.baiyue.lastleg.appointmentplanning; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import java.util.List; + +@Mapper +public interface AppointmentFollowupMapper extends BaseMapper { + @Select("SELECT * FROM ll_appointment_followup WHERE source_key=#{sourceKey} AND customer_id=#{customerId} AND stage=#{stage} AND command_key=#{key}") + AppointmentFollowup command(@Param("sourceKey") String sourceKey, @Param("customerId") String customerId, @Param("stage") String stage, @Param("key") String key); + @Select("SELECT COALESCE(MAX(version),0) FROM ll_appointment_followup WHERE source_key=#{sourceKey} AND customer_id=#{customerId} AND stage=#{stage}") + int version(@Param("sourceKey") String sourceKey, @Param("customerId") String customerId, @Param("stage") String stage); + @Select("SELECT * FROM ll_appointment_followup WHERE source_key=#{sourceKey} AND customer_id=#{customerId} ORDER BY created_at DESC,id DESC LIMIT #{size} OFFSET #{offset}") + List page(@Param("sourceKey") String sourceKey, @Param("customerId") String customerId, @Param("size") int size, @Param("offset") long offset); + @Select("SELECT COUNT(*) FROM ll_appointment_followup WHERE source_key=#{sourceKey} AND customer_id=#{customerId}") + long count(@Param("sourceKey") String sourceKey, @Param("customerId") String customerId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentFollowupService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentFollowupService.java new file mode 100644 index 000000000..053d082a9 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentFollowupService.java @@ -0,0 +1,83 @@ +package com.baiyue.lastleg.appointmentplanning; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.core.api.PageResult; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.validation.constraints.*; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.nio.charset.StandardCharsets; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.Instant; +import java.util.Set; + +@Service +public class AppointmentFollowupService { + private final AppointmentSourceService sources; + private final AppointmentFollowupMapper mapper; + private final ObjectMapper json; + public AppointmentFollowupService(AppointmentSourceService sources, AppointmentFollowupMapper mapper, ObjectMapper json) { + this.sources = sources; this.mapper = mapper; this.json = json; + } + public record Save(@NotBlank @Size(max=32) String stage, @Min(0) int expectedVersion, + @NotBlank @Size(max=64) String idempotencyKey, + @Size(max=200) String contactedParty, @NotBlank @Size(max=1000) String result, + @Size(max=500) String nextAction, LocalDate followupDate) { } + public record Record(String stage, int version, String contactedParty, String result, String nextAction, + LocalDate followupDate, String operatorName, Instant createdAt) { } + public record History(PageResult history, java.util.Map versions) { } + private static final Set STAGES = Set.of("DRAYAGE_CONTACT", "TERMINAL_APPOINTMENT", "WAREHOUSE_RECEIVING", "FBA_RECEIVING"); + + public History history(String sourceKey, String customerId, int page, int size) { + if (page<1 || size<1 || size>100) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"分页参数无效"); + long total = mapper.count(sourceKey,customerId); + if (total == 0) sources.resolve(sourceKey,customerId,null); + var records = mapper.page(sourceKey,customerId,size,(long)(page-1)*size).stream().map(AppointmentFollowupService::view).toList(); + var versions = new java.util.LinkedHashMap(); + STAGES.stream().sorted().forEach(stage -> versions.put(stage, mapper.version(sourceKey,customerId,stage))); + return new History(new PageResult<>(records,page,size,total,(total+size-1)/size),versions); + } + + @Transactional(isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED) + public Record save(String sourceKey, String customerId, Save request, String operatorId, String operatorName) { + if (!STAGES.contains(request.stage())) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"请选择有效跟进事项"); + if (request.followupDate()!=null && (request.nextAction()==null || request.nextAction().isBlank())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"设置跟进日期时请填写下一步安排"); + } + final String hash; + try { hash = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest((customerId+"\n"+operatorId+"\n"+json.writeValueAsString(request)).getBytes(StandardCharsets.UTF_8))); } + catch (JsonProcessingException | NoSuchAlgorithmException error) { throw new IllegalStateException("无法保存跟进请求",error); } + var existing = mapper.command(sourceKey,customerId,request.stage(),request.idempotencyKey()); + if (existing!=null) { + if (!hash.equals(existing.getRequestHash())) throw new ResponseStatusException(HttpStatus.CONFLICT,"重复请求内容不一致,请核对跟进记录"); + return view(existing); + } + var source = sources.resolve(sourceKey,customerId,null); + if (!source.canFollowup()) throw new ResponseStatusException(HttpStatus.CONFLICT,"当前来源仅可查看,请先核对承运状态"); + if (mapper.version(sourceKey,customerId,request.stage())!=request.expectedVersion()) throw new ResponseStatusException(HttpStatus.CONFLICT,"跟进记录已更新,请重新读取后提交"); + var value = new AppointmentFollowup(); value.setId(IdWorker.getId()); value.setSourceOrderId(source.orderId()); value.setSourceKey(source.sourceKey()); value.setCustomerId(source.customerId()); + value.setStage(request.stage()); value.setVersion(request.expectedVersion()+1); value.setContactedParty(clean(request.contactedParty())); + value.setResult(request.result().trim()); value.setNextAction(clean(request.nextAction())); value.setFollowupDate(request.followupDate()); + value.setOperatorId(operatorId); value.setOperatorName(clean(operatorName)); value.setCommandKey(request.idempotencyKey()); + value.setRequestHash(hash); value.setCreatedAt(LocalDateTime.now(ZoneOffset.UTC)); + try { mapper.insert(value); } + catch (org.springframework.dao.DuplicateKeyException conflict) { + var raced = mapper.command(sourceKey,customerId,request.stage(),request.idempotencyKey()); + if (raced!=null && hash.equals(raced.getRequestHash())) return view(raced); + throw new ResponseStatusException(HttpStatus.CONFLICT,"跟进记录已更新或请求内容冲突,请重新读取",conflict); + } + return view(value); + } + private static String clean(String value) { return value==null || value.isBlank() ? null : value.trim(); } + private static Record view(AppointmentFollowup row) { + return new Record(row.getStage(),row.getVersion(),row.getContactedParty(),row.getResult(),row.getNextAction(),row.getFollowupDate(),row.getOperatorName(),row.getCreatedAt().toInstant(ZoneOffset.UTC)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentPlanningController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentPlanningController.java new file mode 100644 index 000000000..4e71ef1e1 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentPlanningController.java @@ -0,0 +1,70 @@ +package com.baiyue.lastleg.appointmentplanning; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.security.AuthenticatedUser; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import jakarta.validation.Valid; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@RequestMapping("/api/platform/v1/last-leg/appointment-planning") +public class AppointmentPlanningController { + private final AppointmentCargoService service; + private final AppointmentFollowupService followups; + private final AppointmentSourceService sources; + private final AppointmentCoverageService coverage; + public AppointmentPlanningController(AppointmentCargoService service, AppointmentFollowupService followups, + AppointmentSourceService sources, AppointmentCoverageService coverage) { + this.service = service; this.followups = followups; this.sources = sources; this.coverage = coverage; + } + public record Resolved(AppointmentSourceService.Source source, AppointmentCoverageService.Coverage coverage) { } + + @GetMapping("/sources/resolve") + @PreAuthorize("hasAuthority('platform:appointment:list') and hasAuthority('platform:overseas-warehouse:last-mile-order:list')") + public ApiResponse resolve(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam String sourceKey, @RequestParam String customerId, @RequestParam(required=false) String destinationKey) { + requirePlatform(user); + var source = sources.resolve(sourceKey,customerId,destinationKey); + var result = coverage.query(java.util.List.of(new AppointmentCoverageService.SourceRef(source.customerId(),source.sourceKey(),source.destinationKey()))); + return ApiResponse.success(new Resolved(source,result.get(0))); + } + + @GetMapping("/cargo") + @PreAuthorize("hasAuthority('platform:appointment:list') and hasAuthority('platform:overseas-warehouse:last-mile-order:list')") + public ApiResponse> cargo(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size, + @RequestParam(required = false) String warehouseId, @RequestParam(required = false) String destination, + @RequestParam(required = false) String keyword) { + requirePlatform(user); + return ApiResponse.success(service.page(page, size, warehouseId, destination, keyword)); + } + + @GetMapping("/sources/{sourceKey}/follow-ups") + @PreAuthorize("hasAuthority('platform:appointment:list') and hasAuthority('platform:overseas-warehouse:last-mile-order:list')") + public ApiResponse history(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String sourceKey, @RequestParam String customerId, @RequestParam(defaultValue="1") int page, @RequestParam(defaultValue="20") int size) { + requirePlatform(user); return ApiResponse.success(followups.history(sourceKey,customerId,page,size)); + } + + @PostMapping("/sources/{sourceKey}/follow-ups") + @PreAuthorize("hasAuthority('platform:appointment:list') and hasAuthority('platform:overseas-warehouse:last-mile-order:list') and hasAuthority('platform:appointment:followup')") + public ApiResponse followup(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String sourceKey, @RequestParam String customerId, @Valid @RequestBody AppointmentFollowupService.Save request) { + requirePlatform(user); + return ApiResponse.success(followups.save(sourceKey,customerId,request,user.userId().toString(),user.displayName()==null ? user.username() : user.displayName())); + } + private static void requirePlatform(AuthenticatedUser user) { + if (user == null || user.userId()==null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + if (!"PLATFORM".equals(user.appCode()) || !Long.valueOf(0).equals(user.tenantId())) throw new ResponseStatusException(HttpStatus.FORBIDDEN); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentPlanningPolicy.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentPlanningPolicy.java new file mode 100644 index 000000000..009285889 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentPlanningPolicy.java @@ -0,0 +1,83 @@ +package com.baiyue.lastleg.appointmentplanning; + +import java.time.Instant; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +/** Stage-specific planning rules. Planning never creates stock, vehicles or carrier confirmations. */ +public final class AppointmentPlanningPolicy { + private AppointmentPlanningPolicy() { } + + public record Cargo(String sourceKey, String customerId, String originWarehouseId, + String destinationKey, String originCountry, String destinationCountry, + Integer packages, Instant readyFrom, Instant readyTo, + boolean eligibleForPlanning, String blockReason) { } + public record Group(String originWarehouseId, String destinationKey, long packages, + int sourceCount, int customerCount, Instant readyFrom, Instant readyTo, + boolean readinessIncomplete) { } + public record Forecast(String sourceKey, String sourceVersion, Instant eta, Instant readyAt) { } + public record Impact(String sourceKey, String reason, Instant previousEta, Instant latestEta, + Instant confirmedAt, boolean reviewRequired) { } + + public static Group group(List cargo) { + if (cargo == null || cargo.isEmpty()) throw new IllegalArgumentException("请选择需要安排的真实货物"); + if (cargo.size() > 500) throw new IllegalArgumentException("每批最多选择500条来源货物"); + Set sources = new HashSet<>(); + Set customers = new HashSet<>(); + String origin = null, destination = null; + long packages = 0; + Instant from = null, to = null; + boolean incomplete = false; + for (Cargo row : cargo) { + if (row == null || blank(row.sourceKey()) || blank(row.customerId())) { + throw new IllegalArgumentException("货物缺少稳定来源或客户归属"); + } + if (!sources.add(row.sourceKey())) throw new IllegalArgumentException("同一来源货物不能重复选择"); + if (!row.eligibleForPlanning()) throw new IllegalArgumentException( + blank(row.blockReason()) ? "所选货物暂不具备筹划资格" : row.blockReason()); + if (!"US".equals(row.originCountry()) || !"US".equals(row.destinationCountry())) { + throw new IllegalArgumentException("请选择已确认美国起运点及目的地的货物"); + } + if (blank(row.originWarehouseId()) || blank(row.destinationKey())) { + throw new IllegalArgumentException("货物缺少实际接仓点或目的地"); + } + if (origin == null) { origin = row.originWarehouseId(); destination = row.destinationKey(); } + if (!origin.equals(row.originWarehouseId()) || !destination.equals(row.destinationKey())) { + throw new IllegalArgumentException("同批货物必须具有相同实际起运仓和目的地"); + } + if (row.packages() == null || row.packages() <= 0) throw new IllegalArgumentException("货物箱数待核实,不能填默认数量"); + packages = Math.addExact(packages, row.packages().longValue()); + customers.add(row.customerId()); + if (row.readyFrom() == null || row.readyTo() == null) incomplete = true; + if (row.readyFrom() != null && row.readyTo() != null && row.readyTo().isBefore(row.readyFrom())) { + throw new IllegalArgumentException("货物预计备妥窗口无效"); + } + if (row.readyFrom() != null && (from == null || row.readyFrom().isAfter(from))) from = row.readyFrom(); + if (row.readyTo() != null && (to == null || row.readyTo().isBefore(to))) to = row.readyTo(); + } + if (from != null && to != null && to.isBefore(from)) throw new IllegalArgumentException("所选货物预计备妥窗口不相容,请分别安排"); + return new Group(origin, destination, packages, sources.size(), customers.size(), from, to, incomplete); + } + + /** A new forecast only requests review; it never changes an externally confirmed appointment. */ + public static Impact compare(Forecast acknowledged, Forecast latest, Instant confirmedAt) { + if (acknowledged == null || latest == null || blank(latest.sourceKey()) + || !Objects.equals(acknowledged.sourceKey(), latest.sourceKey())) { + throw new IllegalArgumentException("只能比较同一真实货源的预测"); + } + boolean changed = !Objects.equals(acknowledged.eta(), latest.eta()) + || !Objects.equals(acknowledged.readyAt(), latest.readyAt()); + String reason = "UNCHANGED"; + if (changed) { + reason = latest.eta() == null ? "FORECAST_MISSING" : "FORECAST_CHANGED"; + if (confirmedAt != null && latest.readyAt() != null && latest.readyAt().isAfter(confirmedAt)) { + reason = "READINESS_AFTER_APPOINTMENT"; + } + } + return new Impact(latest.sourceKey(), reason, acknowledged.eta(), latest.eta(), confirmedAt, changed); + } + + private static boolean blank(String value) { return value == null || value.isBlank(); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceService.java new file mode 100644 index 000000000..168ecc0a4 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceService.java @@ -0,0 +1,86 @@ +package com.baiyue.lastleg.appointmentplanning; + +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +@Service +public class AppointmentSourceService { + public record Source(String sourceKey, String customerId, Long orderId, String inboundCode, String bookingNo, + String containerNo, String originWarehouseId, String originWarehouseName, + String destinationKey, String destinationWarehouseCode, Integer packages, + String sourceVersion, boolean canFollowup, List blockReasons) { } + private final LastlegOrderMapper orders; + private final ContainerShippingGateMapper shipping; + private final AppointmentExternalSourceGateway external; + private final AppointmentWarehouseClient warehouses; + private final String token; + public AppointmentSourceService(LastlegOrderMapper orders, ContainerShippingGateMapper shipping, + AppointmentExternalSourceGateway external, AppointmentWarehouseClient warehouses, + @Value("${baiyue.security.internal-service-token:}") String token) { + this.orders=orders; this.shipping=shipping; this.external=external; this.warehouses=warehouses; this.token=token; + } + public Source resolve(String sourceKey,String customerId,String destinationKey) { + if (blank(sourceKey) || sourceKey.length()>160 || blank(customerId) || customerId.length()>128) throw bad("请选择完整的真实货源"); + com.baiyue.lastleg.entity.LastlegOrder order=null; + String bookingNo=null,origin,destination,version; + Integer packages; + boolean followup=true; + List reasons=List.of(); + if (sourceKey.startsWith("ORDER:")) { + long id; + try { id=Long.parseLong(sourceKey.substring(6)); if (id<=0 || !sourceKey.equals("ORDER:"+id)) throw new NumberFormatException(); } + catch (NumberFormatException error) { throw bad("货源标识无效"); } + order=orders.selectById(id); + if (order==null || !customerId.equals(order.getCustomerId())) throw missing(); + if (!"FIRST_LEG".equals(order.getOrderType())) throw bad("请从原外接登记继续跟进,避免重复建立货源"); + var gate=order.getSourceContainerBookingId()==null ? null : shipping.selectById(order.getSourceContainerBookingId()); + if (gate==null || gate.getNodeSequence()==null || gate.getNodeSequence()<40) throw bad("货物尚无已开船或后续实际船节点"); + origin=order.getOverseasWarehouseId(); destination="FBA:"+order.getFbaWarehouseCode(); + if (!"FBA".equals(order.getDestinationType())) throw bad("当前货源不是FBA送仓货物"); + packages=order.getPackageCount(); version=order.getVersion()==null ? null : "ORDER:"+order.getVersion(); + } else if (sourceKey.startsWith("BOOKING:")) { + bookingNo=sourceKey.substring(8); + if (blank(bookingNo)) throw bad("外接登记号不能为空"); + var matches=external.resolve(List.of(new AppointmentExternalSourceGateway.SourceRef(customerId,bookingNo))); + if (matches.size()!=1) throw missing(); + var source=matches.get(0); + if (!Objects.equals(source.customerId(),customerId) || !Objects.equals(source.bookingNo(),bookingNo)) throw missing(); + origin=source.warehouseId(); destination=source.destinationKey(); packages=source.packageCount(); + version=source.recordVersion()==null ? null : "BOOKING:"+source.recordVersion(); + followup=Set.of("CONFIRMED","RECEIVED","BOUND").contains(source.bookingStatus()==null ? "" : source.bookingStatus()); + if (!followup) reasons=List.of("当前登记尚未确认承运或已取消,仅可查看跟进记录"); + if (source.orderId()!=null) { + order=orders.selectById(source.orderId()); + if (order==null || !customerId.equals(order.getCustomerId())) throw missing(); + if (!Objects.equals(order.getOverseasWarehouseId(),origin)) throw bad("来货的接仓点与原登记不一致,请先核对"); + if (!"FBA".equals(order.getDestinationType()) || !Objects.equals(destination,"FBA:"+order.getFbaWarehouseCode())) throw bad("来货目的仓与原登记不一致,请先核对"); + packages=order.getPackageCount(); + } + } else throw bad("不支持的货源类型"); + if (order!=null && Set.of("CANCELLED","COMPLETED").contains(order.getCurrentStatus()==null ? "" : order.getCurrentStatus())) { + followup=false; reasons=List.of("该货物已取消或完成,仅可查看跟进记录"); + } + if (!blank(destinationKey) && !Objects.equals(destination,destinationKey)) throw bad("目的地已变化,请返回原业务重新核对"); + if (destination==null || !destination.startsWith("FBA:")) throw bad("当前仅支持FBA收货跟进来源"); + var catalog=catalog(); final String originId=origin; final String code=destination.substring(4); + var warehouse=catalog.stream().filter(w -> w.id()!=null && w.id().toString().equals(originId) && "US".equals(w.countryCode()) + && Set.of("OVERSEAS","OVERSEAS_PARTNER").contains(w.warehouseType()==null ? "" : w.warehouseType())).findFirst().orElseThrow(() -> bad("接仓点不在启用的美国仓目录中")); + if (catalog.stream().noneMatch(w -> "FBA".equals(w.warehouseType()) && "US".equals(w.countryCode()) && code.equals(w.warehouseCode()))) throw bad("目的仓不在启用的美国FBA目录中"); + return new Source(sourceKey,customerId,order==null ? null : order.getId(),order==null ? null : order.getInboundCode(),bookingNo, + order==null ? null : order.getContainerNo(),origin,warehouse.warehouseName(),destination,code,packages,version,followup,reasons); + } + private List catalog() { + try { var result=warehouses.catalog(token); if (result==null || !result.successful() || result.data()==null) throw new IllegalStateException(); return result.data().stream().filter(Objects::nonNull).toList(); } + catch (RuntimeException error) { throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"仓库目录暂不可用,请稍后重新核对",error); } + } + private static boolean blank(String value) { return value==null || value.isBlank(); } + private static ResponseStatusException bad(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST,message); } + private static ResponseStatusException missing() { return new ResponseStatusException(HttpStatus.NOT_FOUND,"未找到当前客户的货物来源"); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentTimePolicy.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentTimePolicy.java new file mode 100644 index 000000000..b41cfbaa7 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentTimePolicy.java @@ -0,0 +1,42 @@ +package com.baiyue.lastleg.appointmentplanning; + +import java.time.DateTimeException; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.util.List; + +/** Resolves a location's appointment window without using the operator's system time zone. */ +public final class AppointmentTimePolicy { + private AppointmentTimePolicy() { } + + public record Window(Instant start, Instant end, String timezone) { } + + public static Window resolve(LocalDateTime start, LocalDateTime end, String timezone, + ZoneOffset startOffset, ZoneOffset endOffset) { + if (start == null) throw new IllegalArgumentException("请填写预约开始时间"); + if (timezone == null || timezone.isBlank()) throw new IllegalArgumentException("请先补齐预约地点时区"); + final ZoneId zone; + try { zone = ZoneId.of(timezone); } + catch (DateTimeException error) { throw new IllegalArgumentException("预约地点时区无效", error); } + if (zone instanceof ZoneOffset) throw new IllegalArgumentException("预约地点需要地区时区,不能仅填写固定时差"); + Instant first = instant(start, zone, startOffset); + Instant last = end == null ? null : instant(end, zone, endOffset); + if (end == null && endOffset != null) throw new IllegalArgumentException("未填写结束时间,不能指定结束时差"); + if (last != null && !last.isAfter(first)) throw new IllegalArgumentException("预约结束时间必须晚于开始时间"); + return new Window(first, last, zone.getId()); + } + + private static Instant instant(LocalDateTime value, ZoneId zone, ZoneOffset selected) { + List offsets = zone.getRules().getValidOffsets(value); + if (offsets.isEmpty()) throw new IllegalArgumentException("该当地时间因夏令时切换不存在,请重新选择"); + if (selected == null && offsets.size() > 1) { + throw new IllegalArgumentException("该当地时间在夏令时切换期间出现两次,请明确时差"); + } + if (selected != null && !offsets.contains(selected)) { + throw new IllegalArgumentException("所选时差与预约地点及日期不符"); + } + return value.toInstant(selected == null ? offsets.get(0) : selected); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentWarehouseClient.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentWarehouseClient.java new file mode 100644 index 000000000..af5039153 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentWarehouseClient.java @@ -0,0 +1,14 @@ +package com.baiyue.lastleg.appointmentplanning; + +import com.baiyue.common.core.api.ApiResponse; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import java.util.List; + +@FeignClient(name = "warehouse-service", contextId = "appointmentWarehouseClient", url = "${baiyue.clients.domestic-warehouse.url:}") +public interface AppointmentWarehouseClient { + @GetMapping("/internal/warehouses/order-catalog") + ApiResponse> catalog(@RequestHeader("X-Internal-Service-Token") String token); + record Warehouse(Long id, String warehouseCode, String warehouseName, String warehouseType, String countryCode) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/FleetDispatchPlanningAdapter.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/FleetDispatchPlanningAdapter.java new file mode 100644 index 000000000..61dcbe5ed --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/FleetDispatchPlanningAdapter.java @@ -0,0 +1,58 @@ +package com.baiyue.lastleg.dispatch.fleet; + +import com.baiyue.common.core.api.PageResult; +import com.baiyue.lastleg.dispatch.dto.DispatchRequests; +import com.baiyue.lastleg.dispatch.service.DispatchService; +import com.baiyue.lastleg.fleet.trip.FleetBookingIncomingGateway; +import com.baiyue.lastleg.fleet.trip.FleetDispatchGateway; +import com.baiyue.lastleg.fleet.trip.FleetDispatchPlanningGateway; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.http.HttpStatus; +import java.util.*; + +@Component +@Transactional(propagation=Propagation.MANDATORY) +public class FleetDispatchPlanningAdapter implements FleetDispatchPlanningGateway { + private final FleetDispatchPlanningMapper mapper; + private final DispatchService dispatches; + private final FleetDispatchGateway cargo; + public FleetDispatchPlanningAdapter(FleetDispatchPlanningMapper mapper,@Lazy DispatchService dispatches,FleetDispatchGateway cargo) { + this.mapper=mapper;this.dispatches=dispatches;this.cargo=cargo; + } + public PageResult candidates(String tripNo,String warehouseId,String keyword,int page,int size) { + var result=mapper.candidates(new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(page,size),tripNo,warehouseId,keyword); + return new PageResult<>(result.getRecords(),result.getCurrent(),result.getSize(),result.getTotal(),result.getPages()); + } + public String resolveIncoming(String customerId,FleetBookingIncomingGateway.Evidence receipt,String operator) { + if(receipt==null||receipt.inboundCode()==null||!"READY_FOR_DISPATCH".equals(receipt.status())||receipt.pallets().isEmpty()) + throw conflict("请先完成真实收货和实收核对"); + var existing=mapper.sourceDispatches(customerId,receipt.inboundCode()); + if(existing.size()>1)throw conflict("本次来货已有多张排车安排,请调度核查:"+String.join("、",existing)); + String number; + if(existing.isEmpty()) { + var ids=mapper.receivedPlanPallets(customerId,receipt.inboundCode()); + if(ids.isEmpty())throw conflict("实收来源没有可用于排车的真实托盘"); + number=dispatches.preArrange(customerId,operator,new DispatchRequests.PreArrange(ids,null,null,null,null)).dispatchNo(); + } else number=existing.get(0); + validateIncomingDispatch(number,customerId,receipt); + return number; + } + public void validateIncomingDispatch(String number,String customerId,FleetBookingIncomingGateway.Evidence receipt) { + var snapshot=cargo.lockForPlanning(number); + if(!customerId.equals(snapshot.customerId())||!receipt.warehouseId().equals(snapshot.warehouseId()))throw conflict("排车客户或起运仓与来货不一致"); + var expected=new TreeMap(); + for(var p:receipt.pallets())expected.merge(p.warehousePalletId(),p.packages(),Math::addExact); + var selected=new TreeMap(); + for(var item:cargo.sourceCargo(number)) { + if(!Objects.equals(receipt.inboundCode(),item.inboundCode())||!Objects.equals(receipt.destinationKey(),item.destinationKey())||item.warehousePalletId()==null) + throw conflict("候选排车包含其他来源或目的地,请调度核查 "+number); + selected.merge(item.warehousePalletId(),item.packages(),Math::addExact); + } + if(!expected.equals(selected))throw conflict("候选排车未完整覆盖本票全部实收货物,请调度核查 "+number); + } + private static ResponseStatusException conflict(String message){return new ResponseStatusException(HttpStatus.CONFLICT,message);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/FleetDispatchPlanningMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/FleetDispatchPlanningMapper.java new file mode 100644 index 000000000..60f01d757 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/FleetDispatchPlanningMapper.java @@ -0,0 +1,30 @@ +package com.baiyue.lastleg.dispatch.fleet; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baiyue.lastleg.fleet.trip.FleetDispatchPlanningGateway; +import org.apache.ibatis.annotations.*; +import java.util.List; + +@Mapper +public interface FleetDispatchPlanningMapper { + @Select({""}) + IPage candidates(IPage page,@Param("tripNo") String tripNo,@Param("warehouseId") String warehouseId,@Param("keyword") String keyword); + + @Select("SELECT DISTINCT d.dispatch_no FROM ll_dispatch_order d JOIN ll_dispatch_item i ON i.dispatch_order_id=d.id AND i.customer_id=d.customer_id " + + "WHERE d.customer_id=#{customerId} AND i.inbound_code=#{inboundCode} AND d.status!='CANCELLED' ORDER BY d.dispatch_no") + List sourceDispatches(@Param("customerId") String customerId,@Param("inboundCode") String inboundCode); + + @Select("SELECT DISTINCT p.id FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id " + + "JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id JOIN ll_inbound_plan_line l ON l.id=a.line_id " + + "WHERE ip.customer_id=#{customerId} AND l.inbound_code=#{inboundCode} AND p.warehouse_pallet_id IS NOT NULL ORDER BY p.id") + List receivedPlanPallets(@Param("customerId") String customerId,@Param("inboundCode") String inboundCode); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetAppointmentSourceAdapter.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetAppointmentSourceAdapter.java new file mode 100644 index 000000000..21ffd2bc5 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetAppointmentSourceAdapter.java @@ -0,0 +1,17 @@ +package com.baiyue.lastleg.fleet.business; + +import com.baiyue.lastleg.appointmentplanning.AppointmentExternalSourceGateway; +import org.springframework.stereotype.Component; +import java.util.List; + +/** Appointment resolves aliases through Fleet's read boundary, never by copying cargo. */ +@Component +public class FleetAppointmentSourceAdapter implements AppointmentExternalSourceGateway { + private final FleetPoolSourceResolver sources; + public FleetAppointmentSourceAdapter(FleetPoolSourceResolver sources){this.sources=sources;} + @Override + public List resolve(List refs) { + return sources.resolve(refs.stream().map(r->new FleetPoolSourceResolver.SourceRef(r.customerId(),r.bookingNo())).toList()) + .stream().map(s->new Source(s.customerId(),s.bookingNo(),s.tripNo(),s.bookingStatus(),s.warehouseId(),s.destinationKey(),s.lastlegOrderId(),s.packageCount(),s.recordVersion())).toList(); + } +} 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 7256d4e75..b76c8fed8 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 @@ -39,6 +39,13 @@ public class FleetPoolOrderBridge { this.links=links;this.loose=loose;this.looseHistory=looseHistory;this.trucks=trucks; this.truckHistory=truckHistory;this.numbers=numbers;this.json=json;this.deliveryEvidence=deliveryEvidence; } + @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()); + return Map.copyOf(result); + } @Transactional(propagation=Propagation.MANDATORY) public BusinessOrderRef confirm(ConfirmedBooking in,String operator) { validate(in); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderMapper.java index e20de43bc..e4519b73c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderMapper.java @@ -7,6 +7,15 @@ import java.util.List; @Mapper public interface FleetPoolOrderMapper extends BaseMapper { + @Select({""}) + List resolveSources(@Param("sources") List sources); @Select("SELECT * FROM ll_fleet_pool_order_link WHERE booking_id=#{id} FOR UPDATE") FleetPoolOrderLink lockBooking(long id); @Select("SELECT * FROM ll_fleet_pool_order_link WHERE booking_no=#{no} FOR UPDATE") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolSourceResolver.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolSourceResolver.java new file mode 100644 index 000000000..cba1be01f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolSourceResolver.java @@ -0,0 +1,37 @@ +package com.baiyue.lastleg.fleet.business; + +import java.util.List; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** Internal read boundary for appointment planning; caller must authorize every customer/source. */ +@Service +public class FleetPoolSourceResolver { + public record SourceRef(String customerId, String bookingNo) { } + public record Source(String customerId, String bookingNo, String tripNo, String bookingStatus, + String warehouseId, String destinationKey, String destinationLabel, String incomingNo, + Long lastlegOrderId, String businessOrderNo, String fulfillmentStage, Long recordVersion, Integer packageCount, Integer expectedPalletCount, java.time.LocalDateTime estimatedArrivalAt) { } + public record SourceRow(String customerId, String bookingNo, String tripNo, String bookingStatus, + String warehouseId, String destinationKey, String destinationLabel, String incomingNo, + Long lastlegOrderId, String businessOrderNo, String fulfillmentStage, Long recordVersion, + String cargoJson, Integer expectedPalletCount, java.time.LocalDateTime estimatedArrivalAt) { } + private final com.fasterxml.jackson.databind.ObjectMapper json; + private final FleetPoolOrderMapper links; + public FleetPoolSourceResolver(FleetPoolOrderMapper links,com.fasterxml.jackson.databind.ObjectMapper json) { this.links = links; this.json=json; } + + @Transactional(readOnly = true) + public List resolve(List sources) { + if (sources == null || sources.isEmpty()) return List.of(); + if (sources.size() > 100 || sources.stream().anyMatch(s -> s == null || s.customerId() == null || s.customerId().isBlank() || s.bookingNo() == null || s.bookingNo().isBlank())) + throw new IllegalArgumentException("来源须包含客户及登记业务号,每次最多100条"); + return links.resolveSources(sources.stream().distinct().toList()).stream().map(row -> { + Integer packages; + try { + var value=json.readTree(row.cargoJson()).get("packageCount"); + if(value==null||value.isNull()) packages=null; + else { if(!value.isIntegralNumber()||!value.canConvertToInt())throw new IllegalStateException("预报箱数格式无效"); packages=value.intValue(); } + } catch(java.io.IOException e){throw new IllegalStateException("货物预报读取失败",e);} + return new Source(row.customerId(),row.bookingNo(),row.tripNo(),row.bookingStatus(),row.warehouseId(),row.destinationKey(),row.destinationLabel(),row.incomingNo(),row.lastlegOrderId(),row.businessOrderNo(),row.fulfillmentStage(),row.recordVersion(),packages,row.expectedPalletCount(),row.estimatedArrivalAt()); + }).toList(); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingIncomingGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingIncomingGateway.java index 35b41658f..daf79585a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingIncomingGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingIncomingGateway.java @@ -11,6 +11,8 @@ public interface FleetBookingIncomingGateway { record Pallet(String warehousePalletId,String palletNo,int packages,BigDecimal weightKg,BigDecimal volumeCbm) { } record Evidence(String incomingNo,String inboundCode,String warehouseId,String destinationKey,String status, String receiptHash,List pallets,int receivedPackages) { } + record Source(String customerId,String incomingNo) { } + java.util.Map statuses(List sources); Evidence create(String operator, Incoming incoming); Evidence inspect(String customerId,String incomingNo,boolean lock); Evidence confirm(String customerId,String incomingNo,String operator,String key); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingService.java index aa1294cb3..01e8144a0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingService.java @@ -35,12 +35,17 @@ public class FleetBookingService { private final FleetBookingQuoteContext quoteContext; private final FleetQuoteCandidateService quotes; @jakarta.annotation.Resource private FleetPoolOrderBridge poolOrders; + @jakarta.annotation.Resource private FleetDispatchPlanningGateway planning; + @jakarta.annotation.Resource private FleetCapacityOfferCommandMapper commands; public FleetBookingService(FleetTripMapper trips,FleetBookingMapper bookings,FleetBookingReservations reservations, FleetTripService history,FleetTripExecutionService execution,FleetCustomerDirectory customers,ObjectMapper json,FleetBookingIncomingGateway incoming, FleetBookingQuoteContext quoteContext,FleetQuoteCandidateService quotes){ this.trips=trips;this.bookings=bookings;this.reservations=reservations;this.history=history;this.execution=execution;this.customers=customers;this.json=json;this.incoming=incoming; this.quoteContext=quoteContext;this.quotes=quotes; } + public java.util.Map incomingStatuses(java.util.List rows) { + return incoming.statuses(rows.stream().filter(r->r.getIncomingNo()!=null).map(r->new FleetBookingIncomingGateway.Source(r.getCustomerId(),r.getIncomingNo())).distinct().toList()); + } @Transactional(readOnly=true,isolation=org.springframework.transaction.annotation.Isolation.REPEATABLE_READ) public FleetBookingModels.ListResult list(String tripNo){var trip=trip(tripNo,false);return new FleetBookingModels.ListResult(trip.getRecordVersion(),reservations.responses(trip.getId()));} @Transactional(readOnly=true,isolation=org.springframework.transaction.annotation.Isolation.REPEATABLE_READ) @@ -125,9 +130,12 @@ public class FleetBookingService { requireTrip(trip,request.tripRecordVersion());requireVersion(row,request.recordVersion()); if(!"CONFIRMED".equals(row.getStatus())||row.getIncomingNo()!=null)throw conflict("仅已确认且尚未登记来货的预订允许此操作"); customers.required(Long.parseLong(row.getCustomerId())); - var arrival=request.estimatedArrivalAt()==null?trip.getPlannedDepartureAt().toInstant(ZoneOffset.UTC).minusSeconds(7200):request.estimatedArrivalAt(); + var arrival=request.estimatedArrivalAt()!=null?request.estimatedArrivalAt():row.getEstimatedArrivalAt()==null?null:row.getEstimatedArrivalAt().toInstant(ZoneOffset.UTC); + if(arrival==null)throw conflict("请确认预计到仓时间,不自动推算到仓安排"); + if(arrival.isAfter(trip.getPlannedDepartureAt().toInstant(ZoneOffset.UTC)))throw conflict("预计到仓不得晚于计划发车时间"); + var instructions=trim(request.deliveryInstructions())!=null?request.deliveryInstructions():reservations.cargo(row).remark(); var evidence=incoming.create(operator,new FleetBookingIncomingGateway.Incoming(row.getCustomerId(),trip.getWarehouseId().toString(),trip.getWarehouseName(),row.getBookingNo(),reservations.cargo(row), - LocalDateTime.ofInstant(arrival,java.time.ZoneId.systemDefault()),request.deliveryInstructions(),request.privateAddress(),request.postalCode(),request.recipientName(),request.recipientPhone())); + LocalDateTime.ofInstant(arrival,java.time.ZoneId.systemDefault()),instructions,request.privateAddress(),request.postalCode(),request.recipientName(),request.recipientPhone())); requireEvidence(trip,row,evidence); row.setIncomingNo(evidence.incomingNo());row.setIncomingKey(request.idempotencyKey());row.setIncomingHash(hash); save(trip,row,operator,"BOOKING_INCOMING","登记外接来货 "+evidence.incomingNo());return result(trip,row,false); @@ -169,12 +177,48 @@ public class FleetBookingService { if(Objects.equals(row.getBindingKey(),request.idempotencyKey())){if(!hash.equals(row.getBindingHash()))throw conflict("关联发运请求内容不一致");return result(trip,row,true);} requireTrip(trip,request.tripRecordVersion());requireVersion(row,request.recordVersion()); if(!"RECEIVED".equals(row.getStatus()))throw conflict("请先确认外接实收货量和运费"); + if(row.getOfferId()!=null) { + var accepted=reservations.receipt(row); + var evidence=incoming.inspect(row.getCustomerId(),row.getIncomingNo(),true);requireEvidence(trip,row,evidence); + if(accepted==null||!Objects.equals(accepted.source().receiptHash(),evidence.receiptHash()))throw conflict("实收来源已变化,请重新核对"); + planning.validateIncomingDispatch(request.dispatchNo(),row.getCustomerId(),evidence); + } row.setBindingKey(request.idempotencyKey());row.setBindingHash(hash);bookings.updateById(row); var linked=history.bindDispatch(tripNo,operator,new FleetTripModels.BindingCommand(request.tripRecordVersion(),request.dispatchNo(),"关联外接预订 "+bookingNo)); var result=booking(trip,bookingNo); if(!"BOUND".equals(result.getStatus())||!Objects.equals(result.getDispatchNo(),request.dispatchNo().trim()))throw conflict("所选排车未包含此外接来货单的实际货物"); return new FleetBookingModels.Result(linked.trip().recordVersion(),reservations.response(result),false); } + @Transactional + public FleetBookingModels.Result arrangeDispatch(String tripNo,String bookingNo,String operator,FleetBookingModels.Command request) { + var trip=trip(tripNo,true);String hash=hash(request); + var previous=commands.find(trip.getId(),operator,"BOOKING_ARRANGE",bookingNo,request.idempotencyKey()); + if(previous!=null) { + if(!hash.equals(previous.getRequestHash()))throw conflict("排入本车请求内容不一致"); + try {var saved=json.readValue(previous.getResultJson(),FleetBookingModels.Result.class);return new FleetBookingModels.Result(saved.tripRecordVersion(),saved.booking(),true);} + catch(java.io.IOException e){throw new IllegalStateException("原排入结果读取失败",e);} + } + String resourceBlock=execution.currentVehicleBlockReason(trip,true);var row=booking(trip,bookingNo); + requireTrip(trip,request.tripRecordVersion());requireVersion(row,request.recordVersion()); + if(!"RECEIVED".equals(row.getStatus()))throw conflict("请先确认全部实收货量和运费"); + var accepted=reservations.receipt(row); + var evidence=incoming.inspect(row.getCustomerId(),row.getIncomingNo(),true);requireEvidence(trip,row,evidence); + if(accepted==null||!Objects.equals(accepted.source().receiptHash(),evidence.receiptHash()))throw conflict("实收来源已变化,请重新核对"); + if(resourceBlock!=null)throw conflict(resourceBlock); + var plan=execution.capacityPlan(tripNo); + if(!plan.planValid()||plan.blockReason()!=null)throw conflict("本车计划容量待核对,暂不能排入"); + reservations.segments(trip,plan.capacityPlan(),row.getId(),row); + String dispatchNo=planning.resolveIncoming(row.getCustomerId(),evidence,operator); + var linked=history.bindDispatch(tripNo,operator,new FleetTripModels.BindingCommand(trip.getRecordVersion(),dispatchNo,"实收核对后排入本车 "+bookingNo)); + row=booking(trip,bookingNo); + if(!"BOUND".equals(row.getStatus())||!Objects.equals(row.getDispatchNo(),dispatchNo))throw conflict("未完整关联本票实收来源"); + row.setBindingKey(request.idempotencyKey());row.setBindingHash(hash);bookings.updateById(row); + var result=new FleetBookingModels.Result(linked.trip().recordVersion(),reservations.response(row),false); + var command=new FleetCapacityOfferCommand();command.setId(IdWorker.getId());command.setTripId(trip.getId());command.setOperatorId(operator); + command.setAction("BOOKING_ARRANGE");command.setScopeNo(bookingNo);command.setIdempotencyKey(request.idempotencyKey());command.setRequestHash(hash); + command.setResultJson(write(result));command.setCreatedAt(now());commands.insert(command); + return result; + } private static void requireEvidence(FleetTripEntity trip,FleetBookingEntity row,FleetBookingIncomingGateway.Evidence evidence){ if(evidence==null||evidence.incomingNo()==null||!Objects.equals(trip.getWarehouseId().toString(),evidence.warehouseId())||!Objects.equals(row.getDestinationKey(),evidence.destinationKey()))throw conflict("来货单仓库或目的地与外接承运约定不一致"); if(row.getIncomingNo()!=null&&!Objects.equals(row.getIncomingNo(),evidence.incomingNo()))throw conflict("来货单引用不一致"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferModels.java index 51d53dd31..b5f54bde1 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferModels.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferModels.java @@ -21,7 +21,7 @@ public final class FleetCapacityOfferModels { } public record InquiryQuery(@Min(1) Integer page,@Min(1) @Max(100) Integer size, @Size(max=128) String keyword,@Size(max=64) String offerNo,@Size(max=32) String status, - @Pattern(regexp="MINE|ALL") String scope) { + @Pattern(regexp="MINE|ALL|TODO") String scope) { public InquiryQuery {page=page==null?1:page;size=size==null?20:size;scope=scope==null?"MINE":scope;} } public record Write(@NotBlank @Size(max=64) String tripNo,@NotNull @Min(0) Long tripRecordVersion, @@ -50,12 +50,15 @@ public final class FleetCapacityOfferModels { String contactName,String contactPhone,String remark,List destinations,List segments, boolean capacityKnown,boolean canCreateInquiry,boolean canConfirm,String blockReason,Actions actions) { } public record InquiryActions(boolean canEdit,boolean canConfirm,boolean canCancel,String blockReason) { } + public record Progress(String stage,String label,String reservation,String owner,String primaryAction, + String message,List missingFields) { } public record Inquiry(String bookingNo,String offerNo,String tripNo,String status,long recordVersion, long offerRecordVersion,long tripRecordVersion,String salesOwnerUserId,String customerId,String customerCode, String customerName,String businessType,String deliveryMode,Instant estimatedArrivalAt,Integer expectedPalletCount, FleetBookingModels.Cargo cargo,String destinationLabel,BigDecimal agreedAmount, FleetQuoteCandidateModels.SourceSnapshot quoteSource,String businessOrderNo,String incomingNo,String dispatchNo, - Instant updatedAt,InquiryActions actions) { } + Instant updatedAt,InquiryActions actions,Progress progress,com.baiyue.lastleg.appointmentplanning.AppointmentCoverageService.Coverage appointmentCoverage) { } + public record Fulfillment(FleetBookingModels.Response booking,FleetBookingModels.IncomingPreview preview,Instant plannedDepartureAt) { } public record Detail(Offer offer,PageResult inquiries) { } public record OfferResult(Offer offer,boolean replayed) { } public record InquiryResult(Inquiry inquiry,Offer offer,boolean replayed) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferService.java index 0d2ac1b1e..becab1ea6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferService.java @@ -37,6 +37,7 @@ public class FleetCapacityOfferService { private final FleetPoolOrderBridge orders; private final ObjectMapper json; private final Clock clock; + @jakarta.annotation.Resource private com.baiyue.lastleg.appointmentplanning.AppointmentCoverageService appointmentCoverage; @Autowired public FleetCapacityOfferService(FleetCapacityOfferMapper offers,FleetCapacityOfferCommandMapper commands, FleetTripMapper trips,FleetBookingMapper bookings,FleetBookingService bookingService,FleetBookingReservations reservations, @@ -67,23 +68,52 @@ public class FleetCapacityOfferService { } @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) public PageResult inquiries(FleetCapacityOfferModels.InquiryQuery query){ - readPermission();if("ALL".equals(query.scope())&&!manage())throw forbidden(); + readPermission();if(!"MINE".equals(query.scope())&&!manage())throw forbidden(); var filter=new LambdaQueryWrapper().isNotNull(FleetBookingEntity::getOfferId); - if(!"ALL".equals(query.scope()))filter.eq(FleetBookingEntity::getSalesOwnerUserId,operator()); + if("MINE".equals(query.scope()))filter.eq(FleetBookingEntity::getSalesOwnerUserId,operator()); if(query.offerNo()!=null){var row=offer(query.offerNo(),false);requireVisible(row);filter.eq(FleetBookingEntity::getOfferId,row.getId());} if(nonblank(query.status()))filter.eq(FleetBookingEntity::getStatus,query.status()); - if(nonblank(query.keyword()))filter.and(q->q.like(FleetBookingEntity::getBookingNo,query.keyword()).or().like(FleetBookingEntity::getCustomerName,query.keyword()).or().like(FleetBookingEntity::getCustomerCode,query.keyword())); + if(nonblank(query.keyword()))filter.and(q->q.like(FleetBookingEntity::getBookingNo,query.keyword()).or().like(FleetBookingEntity::getCustomerName,query.keyword()).or().like(FleetBookingEntity::getCustomerCode,query.keyword()).or().like(FleetBookingEntity::getTripNo,query.keyword())); + boolean todo="TODO".equals(query.scope()); + if(todo)filter.in(FleetBookingEntity::getStatus,"DRAFT","CONFIRMED","RECEIVED"); filter.orderByDesc(FleetBookingEntity::getUpdatedAt,FleetBookingEntity::getId); - var result=bookings.selectPage(new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(query.page(),query.size()),filter); + var result=bookings.selectPage(new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(todo?1:query.page(),todo?100:query.size()),filter); + if(todo&&result.getTotal()>100)throw bad("待办实时核对最多100条,请按客户或登记号缩小范围"); + var incomingStatuses=bookingService.incomingStatuses(result.getRecords()); + var coverage=coverageByBooking(result.getRecords()); + var stages=orders.fulfillmentStages(result.getRecords().stream().map(FleetBookingEntity::getId).toList()); var numbers=orders.businessOrderNumbers(result.getRecords().stream().map(FleetBookingEntity::getId).toList()); var projections=new HashMap(); - var values=result.getRecords().stream().map(row->inquiry(row,numbers.get(row.getId()),projections.computeIfAbsent(row.getOfferId(),key->{var offer=offers.selectById(key);return project(offer,trip(offer.getTripNo(),false));}))).toList(); + var values=result.getRecords().stream().map(row->inquiry(row,numbers.get(row.getId()),projections.computeIfAbsent(row.getOfferId(),key->{var offer=offers.selectById(key);return project(offer,trip(offer.getTripNo(),false));}),incomingStatuses.get(new FleetBookingIncomingGateway.Source(row.getCustomerId(),row.getIncomingNo())),stages.get(row.getId()),coverage.get(row.getBookingNo()))).filter(v->!todo||"调度".equals(v.progress().owner())).toList(); + if(todo)return page(values,query.page(),query.size()); return new PageResult<>(values,result.getCurrent(),result.getSize(),result.getTotal(),result.getPages()); } @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) public FleetCapacityOfferModels.Inquiry inquiry(String number){ readPermission();var row=inquiryRow(number);own(row);return inquiry(row,orders.businessOrderNumbers(List.of(row.getId())).get(row.getId())); } + @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) + public FleetCapacityOfferModels.Fulfillment fulfillment(String number) { + readPermission();var row=inquiryRow(number);own(row); + var trip=trip(row.getTripNo(),false); + return new FleetCapacityOfferModels.Fulfillment(reservations.response(row), + bookingService.incoming(row.getTripNo(),number),instant(trip.getPlannedDepartureAt())); + } + @Transactional + public FleetBookingModels.Result arrangeIncoming(String number,FleetBookingModels.StartIncoming request) { + requireManage();var row=inquiryRow(number); + return bookingService.startIncoming(row.getTripNo(),number,operator(),request); + } + @Transactional + public FleetBookingModels.Result acceptReceipt(String number,FleetBookingModels.AcceptReceipt request) { + requireManage();var row=inquiryRow(number); + return bookingService.acceptReceipt(row.getTripNo(),number,operator(),request); + } + @Transactional + public FleetBookingModels.Result arrangeDispatch(String number,FleetBookingModels.Command request) { + requireManage();var row=inquiryRow(number); + return bookingService.arrangeDispatch(row.getTripNo(),number,operator(),request); + } @Transactional(readOnly=true) public List customerOptions(String keyword){ writePermission("inquiry-create"); @@ -165,6 +195,16 @@ public class FleetCapacityOfferService { var result=inquiryResult(row,booking);saveCommand(trip,"INQUIRY_EDIT",number,request.idempotencyKey(),request,result);return result; } @Transactional + public FleetCapacityOfferModels.InquiryResult saveAndConfirm(String number,FleetCapacityOfferModels.InquiryWrite request) { + requireManage();var initial=inquiryRow(number);var trip=trip(initial.getTripNo(),true); + var replay=replay(trip,"SAVE_CONFIRM",number,request.idempotencyKey(),request,FleetCapacityOfferModels.InquiryResult.class); + if(replay!=null)return replayed(replay); + var saved=updateInquiry(number,request); + var result=confirm(number,new FleetCapacityOfferModels.InquiryCommand(saved.offer().tripRecordVersion(),saved.offer().recordVersion(),saved.inquiry().recordVersion(),request.idempotencyKey(),"核对资料并确认承运")); + saveCommand(trip,"SAVE_CONFIRM",number,request.idempotencyKey(),request,result); + return result; + } + @Transactional public FleetCapacityOfferModels.InquiryResult confirm(String number,FleetCapacityOfferModels.InquiryCommand request){ requireManage();var initial=inquiryRow(number);var trip=trip(initial.getTripNo(),true); String vehicleBlock=execution.currentVehicleBlockReason(trip,true);var row=offers.lock(offers.selectById(initial.getOfferId()).getOfferNo());var booking=bookings.lock(trip.getId(),number); @@ -172,7 +212,10 @@ public class FleetCapacityOfferService { versions(trip,row,request.tripRecordVersion(),request.offerRecordVersion());requireInquiryOpen(row,trip); if(vehicleBlock!=null)throw conflict(vehicleBlock); var offer=project(row,trip);if(!offer.canConfirm())throw conflict(offer.blockReason()); - var cargo=reservations.cargo(booking);FleetBookingService.requireCompleteCargo(cargo); + var cargo=reservations.cargo(booking); + var missing=FleetInquiryProgress.missing(booking,cargo,offer); + if(!missing.isEmpty())throw conflict("确认前还需:"+String.join("、",missing)); + FleetBookingService.requireCompleteCargo(cargo); if(!nonblank(cargo.contactName())||!nonblank(cargo.contactPhone())||!nonblank(cargo.cargoName()))throw conflict("成交前请补齐货名和联系信息"); if("US_TRUCK".equals(booking.getBusinessType())&&(booking.getExpectedPalletCount()==null||booking.getExpectedPalletCount()<1))throw conflict("本土卡车成交前请填写预计实托数,不能用托位替代"); if(booking.getEstimatedArrivalAt()==null||booking.getEstimatedArrivalAt().isAfter(row.getCutoffAt()))throw conflict("请填写不晚于交货截止的预计到仓时间"); @@ -197,18 +240,32 @@ public class FleetCapacityOfferService { var result=inquiryResult(row,bookings.lock(trip.getId(),number));saveCommand(trip,"CANCEL",number,request.idempotencyKey(),request,result);return result; } + private Map coverageByBooking(List rows) { + var refs=rows.stream().filter(r->r.getDestinationKey()!=null&&r.getDestinationKey().startsWith("FBA:")) + .map(r->new com.baiyue.lastleg.appointmentplanning.AppointmentCoverageService.SourceRef(r.getCustomerId(),"BOOKING:"+r.getBookingNo(),r.getDestinationKey())).toList(); + var result=new HashMap(); + if(refs.isEmpty())return result; + for(var c:appointmentCoverage.query(refs))result.put(c.sourceKey().substring("BOOKING:".length()),c); + return result; + } private FleetCapacityOfferModels.InquiryResult inquiryResult(FleetCapacityOfferEntity row,FleetBookingEntity booking){ return new FleetCapacityOfferModels.InquiryResult(inquiry(booking,orders.businessOrderNumbers(List.of(booking.getId())).get(booking.getId())),project(row,trip(row.getTripNo(),false)),false); } private FleetCapacityOfferModels.InquiryResult replayed(FleetCapacityOfferModels.InquiryResult value){return new FleetCapacityOfferModels.InquiryResult(value.inquiry(),value.offer(),true);} private FleetCapacityOfferModels.Inquiry inquiry(FleetBookingEntity booking,String businessNo){ - var row=offers.selectById(booking.getOfferId());return inquiry(booking,businessNo,project(row,trip(booking.getTripNo(),false))); + var row=offers.selectById(booking.getOfferId());return inquiry(booking,businessNo,project(row,trip(booking.getTripNo(),false)),bookingService.incomingStatuses(List.of(booking)).get(new FleetBookingIncomingGateway.Source(booking.getCustomerId(),booking.getIncomingNo())),orders.fulfillmentStages(List.of(booking.getId())).get(booking.getId()),coverageByBooking(List.of(booking)).get(booking.getBookingNo())); } - private FleetCapacityOfferModels.Inquiry inquiry(FleetBookingEntity booking,String businessNo,FleetCapacityOfferModels.Offer offer){ + private FleetCapacityOfferModels.Inquiry inquiry(FleetBookingEntity booking,String businessNo,FleetCapacityOfferModels.Offer offer,String incomingStatus,String fulfillmentStage,com.baiyue.lastleg.appointmentplanning.AppointmentCoverageService.Coverage coverage){ boolean owned=manage()||Objects.equals(operator(),booking.getSalesOwnerUserId());boolean draft="DRAFT".equals(booking.getStatus()); boolean canCancel=owned&&(draft||manage()&&"CONFIRMED".equals(booking.getStatus())&&booking.getIncomingNo()==null); + var cargo=reservations.cargo(booking); + var missing=FleetInquiryProgress.missing(booking,cargo,offer); + String block=draft?FleetInquiryProgress.capacityBlock(cargo,offer):null; + boolean canEdit=owned&&draft&&offer.canCreateInquiry()&&(manage()||has(LIST)&&has(LIST.replace(":list",":inquiry-edit"))); + boolean canConfirm=manage()&&draft&&offer.canConfirm()&&missing.isEmpty()&&block==null; return new FleetCapacityOfferModels.Inquiry(booking.getBookingNo(),offer.offerNo(),booking.getTripNo(),booking.getStatus(),booking.getRecordVersion(),offer.recordVersion(),offer.tripRecordVersion(),booking.getSalesOwnerUserId(),booking.getCustomerId(),booking.getCustomerCode(),booking.getCustomerName(),booking.getBusinessType(),booking.getDeliveryMode(),instant(booking.getEstimatedArrivalAt()),booking.getExpectedPalletCount(),reservations.cargo(booking),booking.getDestinationLabel(),booking.getAgreedAmount(),reservations.quoteSource(booking),businessNo,booking.getIncomingNo(),booking.getDispatchNo(),instant(booking.getUpdatedAt()), - new FleetCapacityOfferModels.InquiryActions(owned&&draft&&offer.canCreateInquiry()&&(manage()||has(LIST)&&has(LIST.replace(":list",":inquiry-edit"))),manage()&&draft&&offer.canConfirm(),canCancel&&(manage()||has(LIST)&&has(LIST.replace(":list",":inquiry-cancel"))),offer.blockReason())); + new FleetCapacityOfferModels.InquiryActions(canEdit,canConfirm,canCancel&&(manage()||has(LIST)&&has(LIST.replace(":list",":inquiry-cancel"))),block), + FleetInquiryProgress.project(booking,missing,block,canEdit,canConfirm,incomingStatus,fulfillmentStage),coverage); } private FleetCapacityOfferModels.Offer project(FleetCapacityOfferEntity row,FleetTripEntity trip){ String block=openBlock(row,trip,clock.instant());var planning=execution.capacityPlan(trip.getTripNo()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetDispatchPlanningGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetDispatchPlanningGateway.java new file mode 100644 index 000000000..56a5d1fc3 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetDispatchPlanningGateway.java @@ -0,0 +1,11 @@ +package com.baiyue.lastleg.fleet.trip; + +import com.baiyue.common.core.api.PageResult; + +/** Dispatch owns candidate eligibility and creation; fleet orchestrates the trip transaction. */ +public interface FleetDispatchPlanningGateway { + record Candidate(String dispatchNo,String customerId,String warehouseId,int packageCount,String route) { } + PageResult candidates(String tripNo,String warehouseId,String keyword,int page,int size); + void validateIncomingDispatch(String dispatchNo,String customerId,FleetBookingIncomingGateway.Evidence receipt); + String resolveIncoming(String customerId,FleetBookingIncomingGateway.Evidence receipt,String operator); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetInquiryProgress.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetInquiryProgress.java new file mode 100644 index 000000000..34c7746a3 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetInquiryProgress.java @@ -0,0 +1,72 @@ +package com.baiyue.lastleg.fleet.trip; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; + +/** Derived handling guidance, never a second writable booking state machine. */ +final class FleetInquiryProgress { + private FleetInquiryProgress() { } + + static List missing(FleetBookingEntity booking, FleetBookingModels.Cargo cargo, + FleetCapacityOfferModels.Offer offer) { + var fields = new ArrayList(); + if (cargo.packageCount() == null || cargo.packageCount() < 1) fields.add("预计箱数"); + if (cargo.palletSpaces() == null || cargo.palletSpaces() < 1) fields.add("占用标准托位"); + if (!positive(cargo.weightKg())) fields.add("预计重量"); + if (!positive(cargo.volumeCbm())) fields.add("预计体积"); + if ("US_TRUCK".equals(booking.getBusinessType()) && + (booking.getExpectedPalletCount() == null || booking.getExpectedPalletCount() < 1)) fields.add("预计实托数"); + if (cargo.unitPrice() == null || cargo.unitPrice().signum() < 0) fields.add("约定价格"); + if (!List.of("CNY", "USD").contains(cargo.currency() == null ? "" : cargo.currency())) fields.add("币种(CNY或USD)"); + if (blank(cargo.cargoName())) fields.add("货物名称"); + if (blank(cargo.contactName())) fields.add("联系人"); + if (blank(cargo.contactPhone())) fields.add("联系方式"); + if (booking.getEstimatedArrivalAt() == null) fields.add("预计到仓时间"); + else if (booking.getEstimatedArrivalAt().toInstant(java.time.ZoneOffset.UTC).isAfter(offer.cutoffAt())) fields.add("预计到仓时间(不得晚于最晚送仓)"); + return List.copyOf(fields); + } + + static String capacityBlock(FleetBookingModels.Cargo cargo, FleetCapacityOfferModels.Offer offer) { + if (!offer.canConfirm()) return offer.blockReason(); + var destination = offer.destinations().stream().filter(d -> d.destinationKey().equals(cargo.destinationKey())).findFirst(); + if (destination.isEmpty()) return "卸货站已变化,请调度核对路线"; + var remaining = destination.get().remaining(); + if (remaining == null) return "余位待调度核对"; + if (cargo.palletSpaces() != null && cargo.palletSpaces() > remaining.palletSpaces() + || cargo.weightKg() != null && cargo.weightKg().compareTo(remaining.weightKg()) > 0 + || cargo.volumeCbm() != null && cargo.volumeCbm().compareTo(remaining.volumeCbm()) > 0) + return "本票预报超过所选目的地余位,请调度核对"; + return null; + } + + static FleetCapacityOfferModels.Progress project(FleetBookingEntity booking, List missing, + String block, boolean canEdit, boolean canConfirm, String incomingStatus, String fulfillmentStage) { + if ("IN_TRANSIT".equals(fulfillmentStage)) return progress("IN_TRANSIT", "已发车", "运输中", "派送", "VIEW", "按实际派送进度跟进", List.of()); + if ("COMPLETED".equals(fulfillmentStage)) return progress("COMPLETED", "已完成", "运输已完成", "无待办", "VIEW", "实际货物已全部签收", List.of()); + return switch (booking.getStatus()) { + case "DRAFT" -> { + if (!canEdit && !canConfirm && block != null) + yield progress("BLOCKED", "登记暂不可处理", "尚未保位", "调度", "VIEW", block, missing); + if (!missing.isEmpty()) yield progress("NEEDS_DETAILS", "待补资料", "尚未保位", "销售", canEdit ? "EDIT" : "VIEW", "确认前还需:" + String.join("、", missing), missing); + if (block != null) yield progress("RECHECK", "待调度核对", "尚未保位", "调度", "VIEW", block, missing); + yield progress("AWAITING_CONFIRMATION", "待调度确认", "尚未保位", "调度", canConfirm ? "CONFIRM" : "VIEW", "保存后调度可见,确认承运成功后才保位", missing); + } + case "CONFIRMED" -> booking.getIncomingNo() == null + ? progress("AWAITING_INCOMING", "尚未安排来货", "已保留货位", "调度", "INCOMING", "确定来货安排后再生成来货单", List.of()) + : incomingStatus != null && List.of("WAIT_CUSTOMER_CONFIRMATION","READY_FOR_DISPATCH").contains(incomingStatus) + ? progress("AWAITING_RECEIPT_CHECK", "实收完成待核量", "已保留货位", "调度", "RECEIPT", "核对预报与实收差异、容量及约定运费", List.of()) + : progress("RECEIVING", "已安排来货", "已保留货位", "仓库", "VIEW", incomingStatus == null ? "收货进度暂不可确认,请打开本票进度核对" : "等待仓库实际收货,销售跟进客户送仓", List.of()); + case "RECEIVED" -> progress("READY_TO_BIND", "实收已核对", "已保留货位", "调度", "BIND", "确认排入原车次,仍需后续审核和实际作业", List.of()); + case "BOUND" -> progress("BOUND", "已排入本车", "已转为本车货物", "审核 / 仓库", "VIEW", "排入不等于已装车或发车,请查看关联排车作业", List.of()); + default -> progress("CANCELLED", "已取消", "未保留货位", "无待办", "VIEW", "原登记保留供追溯", List.of()); + }; + } + + private static FleetCapacityOfferModels.Progress progress(String stage, String label, String reservation, + String owner, String action, String message, List missing) { + return new FleetCapacityOfferModels.Progress(stage, label, reservation, owner, action, message, missing); + } + private static boolean blank(String value) { return value == null || value.isBlank(); } + private static boolean positive(BigDecimal value) { return value != null && value.signum() > 0; } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripPreparationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripPreparationService.java new file mode 100644 index 000000000..7e5848ca2 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripPreparationService.java @@ -0,0 +1,81 @@ +package com.baiyue.lastleg.fleet.trip; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.security.core.context.SecurityContextHolder; +import java.util.*; +import java.time.LocalDateTime; +import java.time.ZoneOffset; + +/** Shared by dispatch and appointment consumers; all business writes commit together. */ +@Service +public class FleetTripPreparationService { + public record Prepare(@Size(max=64) String tripNo,@Min(0) Long recordVersion, + @Valid FleetTripModels.Create plan,@Positive Long vehicleId,@Positive Long driverId, + @NotEmpty @Size(max=100) List<@NotBlank @Size(max=64) String> dispatchNos, + @NotBlank @Size(min=8,max=128) String idempotencyKey) { } + private final FleetTripService service; + private final FleetTripMapper trips; + private final FleetCapacityOfferCommandMapper commands; + private final ObjectMapper json; + public FleetTripPreparationService(FleetTripService service,FleetTripMapper trips,FleetCapacityOfferCommandMapper commands,ObjectMapper json) { + this.service=service;this.trips=trips;this.commands=commands;this.json=json; + } + @Transactional + public FleetTripModels.Detail prepare(String operator,Prepare request) { + boolean create=request.tripNo()==null||request.tripNo().isBlank(); + if(create) { permission("platform:fleet:trip:create");permission("platform:fleet:trip:assign"); } + permission("platform:fleet:trip:edit"); + if(new HashSet<>(request.dispatchNos()).size()!=request.dispatchNos().size())throw conflict("排车单不能重复选择"); + if(create && (request.plan()==null||request.vehicleId()==null||request.driverId()==null))throw conflict("新建车次请填写计划、车辆和司机"); + if(!create && (request.plan()!=null||request.vehicleId()!=null||request.driverId()!=null||request.recordVersion()==null))throw conflict("加入已有车次请提交车次版本,不重复修改资源或计划"); + var initial=create?trips.findCreated(operator,request.idempotencyKey()):trips.findByNo(request.tripNo()); + if(!create&&initial==null)throw conflict("所选车次不存在"); + String payload=write(request); + if(initial!=null) { + var locked=trips.lockByNo(initial.getTripNo()); + var previous=commands.find(locked.getId(),operator,"PREPARE_TRIP",locked.getTripNo(),request.idempotencyKey()); + if(previous!=null) { + if(!Objects.equals(previous.getRequestHash(),hash(payload)))throw conflict("同一安排请求不能更换内容"); + try{return json.readValue(previous.getResultJson(),FleetTripModels.Detail.class);}catch(Exception e){throw new IllegalStateException(e);} + } + if(create)throw conflict("该新建请求已被其他操作使用,请核对原车次"); + } + FleetTripModels.Detail detail; + if(create) { + var p=request.plan(); + detail=service.create(operator,new FleetTripModels.Create(p.warehouseId(),p.plannedDepartureAt(),p.expectedDriverEndAt(),p.stops(),p.remark(),request.idempotencyKey())); + // Concurrent same-key create may have committed while waiting for its unique constraint. + var locked=trips.lockByNo(detail.trip().tripNo()); + var previous=commands.find(locked.getId(),operator,"PREPARE_TRIP",locked.getTripNo(),request.idempotencyKey()); + if(previous!=null) { + if(!Objects.equals(previous.getRequestHash(),hash(payload)))throw conflict("同一安排请求不能更换内容"); + try{return json.readValue(previous.getResultJson(),FleetTripModels.Detail.class);}catch(Exception e){throw new IllegalStateException(e);} + } + detail=service.assign(detail.trip().tripNo(),operator,new FleetTripModels.Assign(detail.trip().recordVersion(),request.vehicleId(),request.driverId(),"安排车次时分配资源")); + } else { + detail=service.detail(request.tripNo()); + if(detail.trip().recordVersion()!=request.recordVersion())throw conflict("车次已变化,请重新核对"); + } + for(String number:request.dispatchNos().stream().sorted().toList()) + detail=service.bindDispatch(detail.trip().tripNo(),operator,new FleetTripModels.BindingCommand(detail.trip().recordVersion(),number,"勾选待运货物安排车次")); + var row=trips.findByNo(detail.trip().tripNo()); + var command=new FleetCapacityOfferCommand();command.setId(IdWorker.getId());command.setTripId(row.getId());command.setOperatorId(operator); + command.setAction("PREPARE_TRIP");command.setScopeNo(row.getTripNo());command.setIdempotencyKey(request.idempotencyKey());command.setRequestHash(hash(payload)); + command.setResultJson(write(detail));command.setCreatedAt(LocalDateTime.now(ZoneOffset.UTC));commands.insert(command); + return detail; + } + private static void permission(String name) { + var auth=SecurityContextHolder.getContext().getAuthentication(); + if(auth==null||auth.getAuthorities().stream().noneMatch(a->name.equals(a.getAuthority())))throw new ResponseStatusException(HttpStatus.FORBIDDEN,"缺少安排车次所需权限"); + } + private String write(Object value){try{return json.writeValueAsString(value);}catch(Exception e){throw new IllegalStateException(e);}} + private static String hash(String value){try{return HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(value.getBytes(java.nio.charset.StandardCharsets.UTF_8)));}catch(Exception e){throw new IllegalStateException(e);}} + private static ResponseStatusException conflict(String message){return new ResponseStatusException(HttpStatus.CONFLICT,message);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripService.java index d3cb48671..00df73c67 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripService.java @@ -190,6 +190,15 @@ public class FleetTripService { return detail(trip); } + @jakarta.annotation.Resource private FleetDispatchPlanningGateway planning; + @Transactional(readOnly=true,isolation=org.springframework.transaction.annotation.Isolation.REPEATABLE_READ) + public com.baiyue.common.core.api.PageResult dispatchCandidates(String tripNo,String keyword,int page,int size) { + var trip=trips.findByNo(tripNo); + if(trip==null)throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.NOT_FOUND,"车次不存在"); + if(!"PLANNED".equals(trip.getStatus()))throw conflict("仅计划中的车次可以添加待运货物"); + return planning.candidates(tripNo,trip.getWarehouseId().toString(),keyword,page,size); + } + @Transactional public FleetTripModels.Detail bindDispatch(String tripNo, String operator, FleetTripModels.BindingCommand request) { var trip = planned(tripNo, request.recordVersion()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetCapacityOfferController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetCapacityOfferController.java index f7e36a100..d7fbcd061 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetCapacityOfferController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetCapacityOfferController.java @@ -45,4 +45,16 @@ public class PlatformFleetCapacityOfferController { public ApiResponse> customers(@RequestParam(required=false) String keyword){return ApiResponse.success(service.customerOptions(keyword));} @GetMapping("/{offerNo}/quote-candidates") @PreAuthorize("hasAuthority('platform:fleet:quote:list') and hasAnyAuthority('platform:fleet:capacity-offer:list','platform:fleet:capacity-offer:manage')") public ApiResponse> quoteCandidates(@PathVariable String offerNo,@Valid FleetBookingModels.QuoteCandidatesQuery query){return ApiResponse.success(service.quoteCandidates(offerNo,query));} + @GetMapping("/inquiries/{bookingNo}/fulfillment") @PreAuthorize("hasAnyAuthority('platform:fleet:capacity-offer:list','platform:fleet:capacity-offer:manage')") + public ApiResponse fulfillment(@PathVariable String bookingNo){return ApiResponse.success(service.fulfillment(bookingNo));} + @PostMapping("/inquiries/{bookingNo}/incoming") @PreAuthorize("hasAuthority('platform:fleet:capacity-offer:manage') and hasAuthority('platform:fleet:trip:booking-edit')") + public ApiResponse incoming(@PathVariable String bookingNo,@Valid @RequestBody FleetBookingModels.StartIncoming request){return ApiResponse.success(service.arrangeIncoming(bookingNo,request));} + @PostMapping("/inquiries/{bookingNo}/receipt-confirmation") @PreAuthorize("hasAuthority('platform:fleet:capacity-offer:manage') and hasAuthority('platform:fleet:trip:booking-confirm')") + public ApiResponse receipt(@PathVariable String bookingNo,@Valid @RequestBody FleetBookingModels.AcceptReceipt request){return ApiResponse.success(service.acceptReceipt(bookingNo,request));} + @PostMapping("/inquiries/{bookingNo}/arrange-dispatch") @PreAuthorize("hasAuthority('platform:fleet:capacity-offer:manage') and hasAuthority('platform:fleet:trip:booking-edit') and hasAuthority('platform:fleet:trip:edit') and hasAuthority('platform:overseas-warehouse:inbound-order:smart-dispatch')") + public ApiResponse arrange(@PathVariable String bookingNo,@Valid @RequestBody FleetBookingModels.Command request){return ApiResponse.success(service.arrangeDispatch(bookingNo,request));} + + @PostMapping("/inquiries/{bookingNo}/save-and-confirm") @PreAuthorize("hasAuthority('platform:fleet:capacity-offer:manage')") + public ApiResponse saveAndConfirm(@PathVariable String bookingNo,@Valid @RequestBody FleetCapacityOfferModels.InquiryWrite request){return ApiResponse.success(service.saveAndConfirm(bookingNo,request));} + } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetTripController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetTripController.java index 18259b501..e21203694 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetTripController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetTripController.java @@ -17,6 +17,13 @@ import org.springframework.web.server.ResponseStatusException; @Validated @RequestMapping("/api/platform/v1/last-leg/fleet/trips") public class PlatformFleetTripController { + @jakarta.annotation.Resource private FleetTripPreparationService preparation; + @PostMapping("/prepare") + @PreAuthorize("hasAuthority('platform:fleet:trip:edit')") + public ApiResponse prepare(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody FleetTripPreparationService.Prepare request) { + return ApiResponse.success(preparation.prepare(operator(user),request)); + } private final FleetTripService service; private final FleetTripExecutionService execution; private final FleetTripCompletionService completion; @@ -50,6 +57,13 @@ public class PlatformFleetTripController { public ApiResponse cancel(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo, @Valid @RequestBody FleetTripModels.Cancel request) { return ApiResponse.success(service.cancel(tripNo, operator(user), request)); } + @GetMapping("/{tripNo}/dispatch-candidates") + @PreAuthorize("hasAuthority('platform:fleet:trip:list')") + public ApiResponse> dispatchCandidates(@PathVariable String tripNo, + @RequestParam(required=false) String keyword,@RequestParam(defaultValue="1") @Min(1) int page, + @RequestParam(defaultValue="20") @Min(1) @Max(100) int size) { + return ApiResponse.success(service.dispatchCandidates(tripNo,keyword,page,size)); + } @PostMapping("/{tripNo}/dispatches/bind") @PreAuthorize("hasAuthority('platform:fleet:trip:edit')") public ApiResponse bind(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/fleet/FleetIncomingAdapter.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/fleet/FleetIncomingAdapter.java index 43b5a96d6..8999ddc0c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/fleet/FleetIncomingAdapter.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/fleet/FleetIncomingAdapter.java @@ -24,6 +24,16 @@ public class FleetIncomingAdapter implements FleetBookingIncomingGateway { private final ObjectMapper json; public FleetIncomingAdapter(WarehouseTransferService service,WarehouseTransferOrderMapper orders,ObjectMapper json){this.service=service;this.orders=orders;this.json=json;} @Override @Transactional(propagation=Propagation.MANDATORY) + public Map statuses(List sources) { + if(sources.isEmpty())return Map.of(); + if(sources.size()>100)throw conflict("每次最多读取100条来货进度"); + var query=new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper(); + query.and(q->{for(var source:sources)q.or(c->c.eq(com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder::getCustomerId,source.customerId()).eq(com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder::getTransferNo,source.incomingNo()));}); + var result=new HashMap(); + for(var row:orders.selectList(query))result.put(new Source(row.getCustomerId(),row.getTransferNo()),row.getStatus()); + return Map.copyOf(result); + } + @Override @Transactional(propagation=Propagation.MANDATORY) public Evidence create(String operator,Incoming input){ boolean fba=input.cargo().destinationKey().startsWith("FBA:"); String code=fba?input.cargo().destinationKey().substring(4):null; diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 78a58d645..855d8538d 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -1624,6 +1624,30 @@ CREATE TABLE IF NOT EXISTS ll_pda_inventory_move ( KEY idx_ll_pda_inventory_move_pallet(overseas_warehouse_id,pallet_id,created_at) ) ENGINE=InnoDB COMMENT='海外PDA同仓整托移库记录'; +CREATE TABLE IF NOT EXISTS ll_appointment_followup ( + id BIGINT NOT NULL COMMENT '预约筹划跟进记录ID', + source_key VARCHAR(160) NOT NULL COMMENT '稳定ORDER或BOOKING来源键,不因实收转换而变化', + customer_id VARCHAR(128) NOT NULL COMMENT '服务端核实的来源客户', + source_order_id BIGINT NULL COMMENT '已形成的真实尾程货源ID,尚未生成时为空', + stage VARCHAR(32) NOT NULL COMMENT '跟进对象:DRAYAGE_CONTACT/TERMINAL_APPOINTMENT/WAREHOUSE_RECEIVING/FBA_RECEIVING', + version INT NOT NULL COMMENT '同一货源及跟进对象的记录版本', + contacted_party VARCHAR(200) NULL COMMENT '本次联系对象,不替代承运主档', + result VARCHAR(1000) NOT NULL COMMENT '本次反馈事实,不自动确认预约或推进实物作业', + next_action VARCHAR(500) NULL COMMENT '下一步安排', + followup_date DATE NULL COMMENT '按业务地点当地日期记录的下次跟进日', + operator_id VARCHAR(128) NOT NULL COMMENT '登录平台操作者ID', + operator_name VARCHAR(200) NULL COMMENT '操作人姓名快照', + command_key VARCHAR(64) NOT NULL COMMENT '持久幂等键', + request_hash CHAR(64) NOT NULL COMMENT '含操作者身份的请求SHA256摘要', + created_at DATETIME(3) NOT NULL COMMENT 'UTC记录时间', + PRIMARY KEY (id), + UNIQUE KEY uk_appointment_followup_version (source_key,stage,version), + UNIQUE KEY uk_appointment_followup_command (source_key,stage,command_key), + KEY idx_appointment_followup_date (followup_date,source_order_id), + CONSTRAINT fk_appointment_followup_order FOREIGN KEY (source_order_id) REFERENCES ll_order(id), + CONSTRAINT chk_appointment_followup_stage CHECK (stage IN ('DRAYAGE_CONTACT','TERMINAL_APPOINTMENT','WAREHOUSE_RECEIVING','FBA_RECEIVING')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='真实在途货源的预约筹划联系记录,追加留痕'; + CREATE TABLE IF NOT EXISTS ll_inbound_fba_appointment ( id BIGINT NOT NULL COMMENT '主键ID', appointment_no VARCHAR(64) NOT NULL COMMENT '预约单号', diff --git a/lastleg-service/src/main/resources/openapi/appointment-planning-v1.yaml b/lastleg-service/src/main/resources/openapi/appointment-planning-v1.yaml new file mode 100644 index 000000000..6a3b3ca95 --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/appointment-planning-v1.yaml @@ -0,0 +1,230 @@ +openapi: 3.0.3 +info: + title: 美国预约筹划货源 + version: 1.0.0 + description: 读取真实已开船头程货物,不要求预先创建入仓计划,不生成库存、车次或对外确认。 +security: + - bearerAuth: [] +paths: + /api/platform/v1/last-leg/appointment-planning/cargo: + get: + operationId: appointmentPlanningCargo + summary: 分页读取美国FBA筹划货源 + description: >- + 仅平台身份;同时要求platform:appointment:list与platform:overseas-warehouse:last-mile-order:list。 + 从公共仓库目录核对美国接仓点与美国FBA目的仓;目录失败返回错误,不冒充空结果。 + 当前读取FIRST_LEG货物与已确认开船或更晚的实际船节点,未建计划可见;已有计划不重复计数。 + 预计时间来自尾程订单保存的来源快照,不代表实时船司ETA;shippingObservedAt仅是实际节点观察时间。 + sourcePalletCount是头程预报托数,不是海外实测托位。其他货源由后续正式合同接入,不能从本接口推断已支持。 + parameters: + - {name: page, in: query, schema: {type: integer, minimum: 1, default: 1}} + - {name: size, in: query, schema: {type: integer, minimum: 1, maximum: 100, default: 20}} + - {name: warehouseId, in: query, schema: {type: string}, description: 美国接仓点主档ID,仅用于关联} + - {name: destination, in: query, schema: {type: string}, description: 美国FBA仓编码} + - {name: keyword, in: query, schema: {type: string, maxLength: 128}, description: 入仓码、柜号或客户名称} + responses: + '200': + description: 业务响应,必须核对code + content: + application/json: + schema: + type: object + properties: + code: {type: string} + message: {type: string} + data: + type: object + properties: + records: {type: array, items: {$ref: '#/components/schemas/Cargo'}} + page: {type: integer} + size: {type: integer} + total: {type: integer} + pages: {type: integer} + '400': {description: 查询参数或美国范围无效} + '403': {description: 无来源读取权限或非平台身份} + '503': {description: 仓库目录暂不可用,不能证明美国货源范围} + /api/platform/v1/last-leg/appointment-planning/sources/resolve: + get: + operationId: resolveAppointmentPlanningSource + summary: 核对深链来源并读取预约覆盖 + description: >- + 仅平台;同时要求platform:appointment:list与platform:overseas-warehouse:last-mile-order:list。 + 依据真实客户、货源与美国仓目录核对来源,URL参数不能授予预约资格。 + ORDER仅接受已开船头程FBA货;BOOKING保留原登记身份,确认承运后尚未生成入仓单也能跟进。 + 此查询不建单、不确认预约。当前覆盖读取已有FBA预约,尚未生成货物时返回SOURCE_NOT_MATERIALIZED。 + parameters: + - {name: sourceKey, in: query, required: true, schema: {type: string, maxLength: 160}, description: 'ORDER:<内部关联ID>或BOOKING:<登记号>,非用户展示编号'} + - {name: customerId, in: query, required: true, schema: {type: string, maxLength: 128}} + - {name: destinationKey, in: query, schema: {type: string}, description: 'FBA:<仓编码>,传入后必须与真实来源一致'} + responses: + '200': + description: code=200时data包含已核实来源和预约覆盖 + content: + application/json: + schema: + type: object + properties: + code: {type: string} + message: {type: string} + data: + type: object + properties: + source: {$ref: '#/components/schemas/PlanningSource'} + coverage: {$ref: '#/components/schemas/Coverage'} + '400': {description: 非美国FBA来源、尚未开船或目的地变化} + '403': {description: 无读取权限或非平台身份} + '404': {description: 客户与来源不匹配或来源不存在} + '503': {description: 仓库目录不可用} + /api/platform/v1/last-leg/appointment-planning/sources/{sourceKey}/follow-ups: + parameters: + - {name: sourceKey, in: path, required: true, schema: {type: string, maxLength: 160}, description: 'ORDER或BOOKING稳定来源键,路径须URL编码'} + - {name: customerId, in: query, required: true, schema: {type: string, maxLength: 128}} + get: + summary: 分页读取分事项跟进历史与最新版本 + description: 仅平台;同时要求platform:appointment:list与platform:overseas-warehouse:last-mile-order:list。已有跟进历史在货物完成后仍可读取。 + parameters: + - {name: page, in: query, schema: {type: integer, minimum: 1, default: 1}} + - {name: size, in: query, schema: {type: integer, minimum: 1, maximum: 100, default: 20}} + responses: + '200': + description: code=200时data含history分页和按stage排列的versions;createdAt为UTC时间点,followupDate为业务地点当地日期。 + content: + application/json: + schema: + type: object + properties: + code: {type: string} + message: {type: string} + data: + type: object + properties: + history: + type: object + properties: + records: {type: array, items: {$ref: '#/components/schemas/FollowupRecord'}} + page: {type: integer} + size: {type: integer} + total: {type: integer} + pages: {type: integer} + versions: {type: object, additionalProperties: {type: integer, minimum: 0}, description: 四个事项各自的最新版本} + '403': {description: 无读取权限或非平台身份} + '404': {description: 无历史且来源不具备美国筹划资格} + post: + summary: 追加人工联系结果与下次跟进 + description: >- + 仅平台;除读取权限外要求platform:appointment:followup。 + 跟进人来自登录主体,不接受客户端操作者ID。核实来源后按stage版本追加,以持久化唯一约束防止并发覆盖;幂等键持久化,同键同操作者同内容返回原记录,异内容拒绝。 + 不要求已创建入仓计划、外部预约号或司机,不更新正式预约、库存和车次。设置followupDate时nextAction必填。 + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [stage, expectedVersion, idempotencyKey, result] + properties: + stage: {type: string, enum: [DRAYAGE_CONTACT, TERMINAL_APPOINTMENT, WAREHOUSE_RECEIVING, FBA_RECEIVING]} + expectedVersion: {type: integer, minimum: 0, description: 初次为0,后续取history.versions中对应事项版本} + idempotencyKey: {type: string, maxLength: 64} + contactedParty: {type: string, maxLength: 200, nullable: true} + result: {type: string, minLength: 1, maxLength: 1000} + nextAction: {type: string, maxLength: 500, nullable: true} + followupDate: {type: string, format: date, nullable: true} + responses: + '200': + description: data为原记录或新增记录 + content: + application/json: + schema: + type: object + properties: + code: {type: string} + message: {type: string} + data: {$ref: '#/components/schemas/FollowupRecord'} + '400': {description: 无效事项或缺少下一步安排} + '403': {description: 无写权限或非平台身份} + '404': {description: 来源不存在或不可筹划} + '409': {description: 版本冲突或同键异内容} +components: + securitySchemes: + bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT} + schemas: + FollowupRecord: + type: object + properties: + stage: {type: string, enum: [DRAYAGE_CONTACT, TERMINAL_APPOINTMENT, WAREHOUSE_RECEIVING, FBA_RECEIVING]} + version: {type: integer, minimum: 1} + contactedParty: {type: string, nullable: true} + result: {type: string} + nextAction: {type: string, nullable: true} + followupDate: {type: string, format: date, nullable: true} + operatorName: {type: string, nullable: true} + createdAt: {type: string, format: date-time, description: UTC时间点} + PlanningSource: + type: object + properties: + sourceKey: {type: string} + customerId: {type: string} + orderId: {type: string, nullable: true} + inboundCode: {type: string, nullable: true} + bookingNo: {type: string, nullable: true} + containerNo: {type: string, nullable: true} + originWarehouseId: {type: string} + originWarehouseName: {type: string} + destinationKey: {type: string} + destinationWarehouseCode: {type: string} + packages: {type: integer, nullable: true, description: 未知数量保持空值,不能替换为0} + sourceVersion: {type: string, nullable: true} + canFollowup: {type: boolean} + blockReasons: {type: array, items: {type: string}} + Coverage: + type: object + properties: + customerId: {type: string} + sourceKey: {type: string} + destinationKey: {type: string} + status: {type: string, enum: [SOURCE_NOT_FOUND, SOURCE_NOT_MATERIALIZED, SOURCE_NOT_READY, DESTINATION_MISMATCH, UNASSIGNED, PARTIAL, PENDING, CONFIRMED, REVIEW_REQUIRED]} + sourceVersion: {type: string, nullable: true} + changeReviewRequired: {type: boolean} + missingFields: {type: array, items: {type: string}, description: 缺项或待核实原因代码,客户端需显示业务文案} + appointments: + type: array + items: + type: object + properties: + appointmentNo: {type: string} + confirmationNo: {type: string, nullable: true} + status: {type: string} + confirmedAt: {type: string, nullable: true} + confirmedEndAt: {type: string, nullable: true} + timezone: {type: string, nullable: true, description: 旧预约未记录时区时为空并返回缺项,不能按浏览器时区推定} + Cargo: + type: object + properties: + sourceKey: {type: string, description: 稳定ORDER来源键,不因生成入仓计划而变化;非展示编号} + orderId: {type: string} + customerId: {type: string} + customerName: {type: string, nullable: true} + inboundCode: {type: string, nullable: true} + containerBookingId: {type: string} + containerNo: {type: string, nullable: true} + originWarehouseId: {type: string} + originWarehouseCode: {type: string} + originWarehouseName: {type: string} + destinationWarehouseCode: {type: string} + packages: {type: integer, nullable: true} + receivedPackages: {type: integer, nullable: true} + shippedPackages: {type: integer, nullable: true} + sourcePalletCount: {type: integer, nullable: true} + volumeCbm: {type: number, nullable: true} + estimatedArrivalAt: {type: string, nullable: true, description: 来源预计到港快照,保留原业务当地时间;不得按浏览器时区转换} + estimatedWarehouseAt: {type: string, nullable: true, description: 已保存预计到仓,不因本查询改变} + orderVersion: {type: integer} + standardNode: {type: string} + shippingVersion: {type: string, nullable: true} + shippingObservedAt: {type: string, nullable: true} + shippingProvider: {type: string, nullable: true} + hasInboundPlan: {type: boolean} + hasActiveAppointment: {type: boolean} + detained: {type: boolean} + mixedDestinations: {type: boolean, description: 存在其他目的地明细时,不能按整票默认为同仓货物} diff --git a/lastleg-service/src/main/resources/openapi/fleet-capacity-offers-v1.yaml b/lastleg-service/src/main/resources/openapi/fleet-capacity-offers-v1.yaml index 96ff377c3..52163e41b 100644 --- a/lastleg-service/src/main/resources/openapi/fleet-capacity-offers-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/fleet-capacity-offers-v1.yaml @@ -376,7 +376,7 @@ paths: /api/platform/v1/last-leg/fleet/capacity-offers/inquiries: get: summary: 分页查询本人或获准的全部意向 - description: 权限:platform:fleet:capacity-offer:list 或 platform:fleet:capacity-offer:manage。ALL只允许manage;销售负责人由登录身份过滤,历史不依赖发布仍OPEN。 + description: 权限:platform:fleet:capacity-offer:list 或 platform:fleet:capacity-offer:manage。ALL和TODO只允许manage;TODO按服务端负责角色筛选调度待办,基础候选超过100条要求按客户或登记号缩小范围,过滤后精确分页,不按当前页伪造总数;销售负责人由登录身份过滤,历史不依赖发布仍OPEN。 responses: '200': description: 统一业务响应;code=200成功,冲突或资格拒绝不得当成功 @@ -440,6 +440,7 @@ paths: enum: - MINE - ALL + - TODO default: MINE /api/platform/v1/last-leg/fleet/capacity-offers/inquiries/{bookingNo}: get: @@ -985,8 +986,41 @@ components: actions: $ref: '#/components/schemas/OfferActions' Inquiry: + description: 登记返回服务端资料缺项与处理进度;canConfirm同时核对本票完整性和目的地三维余位,最终写命令仍复验。 type: object properties: + appointmentCoverage: + type: object + nullable: true + description: FBA来源通过AppointmentCoverageService按客户和BOOKING稳定来源批量读取;无订单保留待形成货件资料,私人目的地为null。不把保位当收货预约,不推算未知时区。 + properties: + status: {type: string} + sourceVersion: {type: string, nullable: true} + changeReviewRequired: {type: boolean} + missingFields: {type: array, items: {type: string}} + appointments: + type: array + items: + type: object + properties: + appointmentNo: {type: string} + confirmationNo: {type: string, nullable: true} + status: {type: string} + confirmedAt: {type: string, nullable: true} + confirmedEndAt: {type: string, nullable: true} + timezone: {type: string, nullable: true} + progress: + type: object + description: 只读派生处理提示,不是可写状态;负责人及动作按资料完整性、承运/来货/实收事实返回;保存不保位。 + required: [stage, label, reservation, owner, primaryAction, message, missingFields] + properties: + stage: {type: string} + label: {type: string} + reservation: {type: string} + owner: {type: string} + primaryAction: {type: string, enum: [VIEW, EDIT, CONFIRM, INCOMING, RECEIPT, BIND]} + message: {type: string} + missingFields: {type: array, items: {type: string}} bookingNo: type: string offerNo: diff --git a/lastleg-service/src/main/resources/openapi/fleet-preparation-v1.yaml b/lastleg-service/src/main/resources/openapi/fleet-preparation-v1.yaml new file mode 100644 index 000000000..8488475c3 --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/fleet-preparation-v1.yaml @@ -0,0 +1,103 @@ +openapi: 3.0.3 +info: + title: 共用车次准备与拼车连续处理 + version: 1.0.0 +security: + - bearerAuth: [] +paths: + /api/platform/v1/last-leg/fleet/trips/prepare: + post: + summary: 原子新建或加入车次并关联所选排车单 + description: '权限:platform:fleet:trip:edit;新建另需platform:fleet:trip:create和platform:fleet:trip:assign。新建、资源分配和全部关联在同一事务,失败回滚;幂等键同载荷重放原结果,异载荷拒绝。同仓、未执行、未绑定其他车次;不确认容量、不审批、不发车。' + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/Prepare'} + responses: + '200': {description: '统一ApiResponse,data为FleetTripModels.Detail;code=200才成功'} + /api/platform/v1/last-leg/fleet/trips/{tripNo}/dispatch-candidates: + parameters: [{$ref: './fleet-trips-v1.yaml#/components/parameters/TripNo'}] + get: + summary: 本仓未关联且未开始执行的排车候选 + description: '权限:platform:fleet:trip:list。数据库筛选后分页,绑定时重新锁定校验。候选不代表预约、装载或审核已完成。' + parameters: + - {name: keyword, in: query, schema: {type: string}} + - {name: page, in: query, schema: {type: integer, minimum: 1, default: 1}} + - {name: size, in: query, schema: {type: integer, minimum: 1, maximum: 100, default: 20}} + responses: + '200': {description: '统一分页data:records含dispatchNo、customerId、warehouseId、packageCount、route及total/page/size/pages;内部身份不作为用户业务编号'} + /api/platform/v1/last-leg/fleet/capacity-offers/inquiries/{bookingNo}/save-and-confirm: + parameters: [{$ref: './fleet-trips-v1.yaml#/components/parameters/BookingNo'}] + post: + summary: 同窗补齐登记资料并原子确认承运 + description: '权限:platform:fleet:capacity-offer:manage。复用InquiryWrite,更新资料、创建唯一商业订单及占位同事务;任何阶段失败全部回滚,不把保存当成交。同键同载荷返回原结果。' + requestBody: + required: true + content: + application/json: + schema: {$ref: './fleet-capacity-offers-v1.yaml#/components/schemas/InquiryWrite'} + responses: + '200': {description: '统一ApiResponse,data为InquiryResult'} + /api/platform/v1/last-leg/fleet/capacity-offers/inquiries/{bookingNo}/fulfillment: + parameters: [{$ref: './fleet-trips-v1.yaml#/components/parameters/BookingNo'}] + get: + summary: 从原登记查看本票履约进度 + description: '权限:platform:fleet:capacity-offer:list或platform:fleet:capacity-offer:manage。销售仅本人;返回本票booking、preview及plannedDepartureAt,不返回整车司机或他人货物。preview含tripRecordVersion、recordVersion、incoming,未安排时incoming为null。' + responses: + '200': {description: '统一ApiResponse,data为Fulfillment'} + /api/platform/v1/last-leg/fleet/capacity-offers/inquiries/{bookingNo}/incoming: + parameters: [{$ref: './fleet-trips-v1.yaml#/components/parameters/BookingNo'}] + post: + summary: 从原登记明确安排来货 + description: '权限:platform:fleet:capacity-offer:manage和platform:fleet:trip:booking-edit。复用版本及幂等门禁,未填时间时沿用登记预计到仓,仍缺失则拒绝,不自动减两小时猜测。承运确认不自动调用此命令。' + requestBody: + required: true + content: + application/json: + schema: {$ref: './fleet-trips-v1.yaml#/components/schemas/BookingIncoming'} + responses: + '200': {$ref: './fleet-trips-v1.yaml#/components/responses/BookingResult'} + /api/platform/v1/last-leg/fleet/capacity-offers/inquiries/{bookingNo}/receipt-confirmation: + parameters: [{$ref: './fleet-trips-v1.yaml#/components/parameters/BookingNo'}] + post: + summary: 原登记核对真实实收与约定运费 + description: '权限:platform:fleet:capacity-offer:manage和platform:fleet:trip:booking-confirm。沿实际receiptHash/托盘和三维容量校验;不以预报覆盖实收。' + requestBody: + required: true + content: + application/json: + schema: {$ref: './fleet-trips-v1.yaml#/components/schemas/BookingAcceptReceipt'} + responses: + '200': {$ref: './fleet-trips-v1.yaml#/components/responses/BookingResult'} + /api/platform/v1/last-leg/fleet/capacity-offers/inquiries/{bookingNo}/arrange-dispatch: + parameters: [{$ref: './fleet-trips-v1.yaml#/components/parameters/BookingNo'}] + post: + summary: 实收后复用或生成排车并原子排入本车 + description: '权限:platform:fleet:capacity-offer:manage、platform:fleet:trip:booking-edit、platform:fleet:trip:edit和platform:overseas-warehouse:inbound-order:smart-dispatch。锁原车次和本票来货,先校验实收快照、车辆、有效容量;来源有多张排车或不完整时拒绝,不另建。无排车时复用真实预排命令;任何校验或绑定失败整体回滚。同键重放不重复建单。成功将预留替换为BOUND,旧计划失效待核对,仍需审核和现场作业。' + requestBody: + required: true + content: + application/json: + schema: {$ref: './fleet-trips-v1.yaml#/components/schemas/BookingCommand'} + responses: + '200': {$ref: './fleet-trips-v1.yaml#/components/responses/BookingResult'} +components: + securitySchemes: + bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT} + schemas: + Prepare: + type: object + required: [dispatchNos, idempotencyKey] + properties: + tripNo: {type: string, nullable: true, maxLength: 64} + recordVersion: {type: integer, format: int64, nullable: true, minimum: 0} + plan: + allOf: + - {$ref: './fleet-trips-v1.yaml#/components/schemas/Create'} + nullable: true + description: 仅新建提交,实际创建幂等键使用外层idempotencyKey。 + vehicleId: {type: string, nullable: true} + driverId: {type: string, nullable: true} + dispatchNos: {type: array, minItems: 1, maxItems: 100, uniqueItems: true, items: {type: string, maxLength: 64}} + idempotencyKey: {type: string, minLength: 8, maxLength: 128} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoMapperTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoMapperTest.java new file mode 100644 index 000000000..7f7f18b11 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoMapperTest.java @@ -0,0 +1,45 @@ +package com.baiyue.lastleg.appointmentplanning; + +import org.apache.ibatis.mapping.Environment; +import org.apache.ibatis.session.Configuration; +import org.apache.ibatis.session.SqlSessionFactoryBuilder; +import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.Test; +import java.util.List; +import java.util.UUID; +import static org.junit.jupiter.api.Assertions.*; + +class AppointmentCargoMapperTest { + @Test void unplannedSailedCargoIsVisibleAndCreatingPlansDoesNotDuplicateTheSource() throws Exception { + var ds = new JdbcDataSource(); ds.setURL("jdbc:h2:mem:appointment-"+ UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1"); + try (var c=ds.getConnection(); var s=c.createStatement()) { + s.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR,customer_name VARCHAR,inbound_code VARCHAR,source_container_booking_id VARCHAR,container_no VARCHAR,overseas_warehouse_id VARCHAR,overseas_warehouse_code VARCHAR,overseas_warehouse_name VARCHAR,fba_warehouse_code VARCHAR,package_count INT,received_package_count INT,shipped_package_count INT,pallet_count INT,volume_cbm DECIMAL,estimated_arrival_at TIMESTAMP,estimated_warehouse_at TIMESTAMP,version INT,detained BOOLEAN,order_type VARCHAR,destination_type VARCHAR,current_status VARCHAR,created_at TIMESTAMP)"); + s.execute("CREATE TABLE ll_container_shipping_gate(container_booking_id VARCHAR,node_sequence INT,standard_node VARCHAR,aggregate_version BIGINT,observed_at TIMESTAMP,provider_code VARCHAR)"); + s.execute("CREATE TABLE ll_inbound_plan(id BIGINT,source_order_id BIGINT,customer_id VARCHAR)"); + s.execute("CREATE TABLE ll_inbound_fba_appointment_item(order_id BIGINT,customer_id VARCHAR,appointment_id BIGINT)"); + s.execute("CREATE TABLE ll_inbound_fba_appointment(id BIGINT,appointment_status VARCHAR)"); + s.execute("CREATE TABLE ll_order_detail(order_id BIGINT,customer_id VARCHAR,destination_warehouse_code VARCHAR)"); + s.execute("INSERT INTO ll_order(id,customer_id,inbound_code,source_container_booking_id,overseas_warehouse_id,fba_warehouse_code,package_count,order_type,destination_type,current_status,created_at) VALUES(1,'A','IN-A','C1','100','ONT8',10,'FIRST_LEG','FBA','WAIT_INBOUND',CURRENT_TIMESTAMP),(2,'B','IN-B','C2','100','ONT8',8,'FIRST_LEG','FBA','WAIT_INBOUND',CURRENT_TIMESTAMP)"); + s.execute("INSERT INTO ll_container_shipping_gate(container_booking_id,node_sequence) VALUES('C1',40),('C2',30)"); + } + var config=new Configuration(new Environment("test",new JdbcTransactionFactory(),ds)); + config.setMapUnderscoreToCamelCase(true); config.addMapper(AppointmentCargoMapper.class); + try (var session=new SqlSessionFactoryBuilder().build(config).openSession(true)) { + var mapper=session.getMapper(AppointmentCargoMapper.class); + assertEquals(1,mapper.count(List.of("100"),List.of("ONT8"),null,null,null)); + var first=mapper.page(List.of("100"),List.of("ONT8"),null,null,null,20,0); + assertEquals("ORDER:1",first.get(0).getSourceKey()); assertFalse(first.get(0).isHasInboundPlan()); + try (var s=session.getConnection().createStatement()) { + s.execute("INSERT INTO ll_inbound_plan VALUES(11,1,'A'),(12,1,'A')"); + s.execute("INSERT INTO ll_inbound_fba_appointment VALUES(20,'SUCCESS')"); + s.execute("INSERT INTO ll_inbound_fba_appointment_item VALUES(1,'A',20)"); + } + session.clearCache(); + var after=mapper.page(List.of("100"),List.of("ONT8"),null,null,null,20,0); + assertEquals(1,after.size()); assertTrue(after.get(0).isHasInboundPlan()); assertTrue(after.get(0).isHasActiveAppointment()); + assertEquals(10,after.get(0).getPackages()); assertEquals(1,mapper.count(List.of("100"),List.of("ONT8"),null,null,null)); + assertEquals(0,mapper.count(List.of("200"),List.of("ONT8"),null,null,null)); + } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoServiceTest.java new file mode 100644 index 000000000..280e1dc8a --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoServiceTest.java @@ -0,0 +1,44 @@ +package com.baiyue.lastleg.appointmentplanning; + +import com.baiyue.common.core.api.ApiResponse; +import org.junit.jupiter.api.Test; +import org.springframework.web.server.ResponseStatusException; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class AppointmentCargoServiceTest { + @Test void authenticUsDirectoryScopesTheDatabaseQueryAndPreservesServerPagination() { + var mapper = mock(AppointmentCargoMapper.class); + var warehouses = mock(AppointmentWarehouseClient.class); + when(warehouses.catalog("token")).thenReturn(ApiResponse.success(List.of( + new AppointmentWarehouseClient.Warehouse(1L,"US-WH","美国仓","OVERSEAS","US"), + new AppointmentWarehouseClient.Warehouse(2L,"CA-WH","加拿大仓","OVERSEAS","CA"), + new AppointmentWarehouseClient.Warehouse(3L,"ONT8","ONT8","FBA","US"), + new AppointmentWarehouseClient.Warehouse(4L,"YYZ1","YYZ1","FBA","CA")))); + var row = new AppointmentCargo(); row.setOrderId(99L); row.setSourceKey("ORDER:99"); row.setHasInboundPlan(false); + when(mapper.count(List.of("1"),List.of("ONT8"),null,null,"WT")).thenReturn(21L); + when(mapper.page(List.of("1"),List.of("ONT8"),null,null,"WT",20,20)).thenReturn(List.of(row)); + var result = new AppointmentCargoService(mapper,warehouses,"token").page(2,20,null,null," WT "); + assertEquals(21,result.total()); assertEquals(2,result.pages()); assertEquals(1,result.records().size()); + assertFalse(result.records().get(0).isHasInboundPlan()); + verify(warehouses,times(1)).catalog("token"); + } + + @Test void directoryFailureIsNotReportedAsAnEmptyAmericanCargoList() { + var mapper = mock(AppointmentCargoMapper.class); var warehouses = mock(AppointmentWarehouseClient.class); + when(warehouses.catalog("token")).thenThrow(new IllegalStateException("offline")); + var error = assertThrows(ResponseStatusException.class, () -> new AppointmentCargoService(mapper,warehouses,"token").page(1,20,null,null,null)); + assertEquals(503,error.getStatusCode().value()); verifyNoInteractions(mapper); + } + + @Test void invalidPaginationAndNonUsFiltersNeverQueryCargo() { + var mapper = mock(AppointmentCargoMapper.class); var warehouses = mock(AppointmentWarehouseClient.class); + var service = new AppointmentCargoService(mapper,warehouses,"token"); + assertThrows(ResponseStatusException.class, () -> service.page(0,20,null,null,null)); + verifyNoInteractions(warehouses); + when(warehouses.catalog("token")).thenReturn(ApiResponse.success(List.of())); + assertThrows(ResponseStatusException.class, () -> service.page(1,20,"unknown",null,null)); + verifyNoInteractions(mapper); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentCoverageMapperTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentCoverageMapperTest.java new file mode 100644 index 000000000..736bfd28d --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentCoverageMapperTest.java @@ -0,0 +1,41 @@ +package com.baiyue.lastleg.appointmentplanning; + +import org.apache.ibatis.mapping.Environment; +import org.apache.ibatis.session.Configuration; +import org.apache.ibatis.session.SqlSessionFactoryBuilder; +import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.Test; +import java.util.List; +import java.util.UUID; +import static org.junit.jupiter.api.Assertions.*; + +class AppointmentCoverageMapperTest { + @Test void readsOnlyMatchingCustomerAndPlanLinesAndExcludesCancelledAppointments() throws Exception { + var ds=new JdbcDataSource(); ds.setURL("jdbc:h2:mem:coverage-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1"); + try (var c=ds.getConnection();var s=c.createStatement()) { + s.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR(128),destination_type VARCHAR(32),fba_warehouse_code VARCHAR(128),package_count INT,version INT,updated_at TIMESTAMP)"); + s.execute("CREATE TABLE ll_order_detail(order_id BIGINT,customer_id VARCHAR(128),shipment_no VARCHAR(128),fba_tracking_no VARCHAR(128))"); + s.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT,plan_id BIGINT,order_id BIGINT,customer_id VARCHAR(128),required_package_count INT,fba_warehouse_code VARCHAR(128))"); + s.execute("CREATE TABLE ll_inbound_fba_appointment(id BIGINT,appointment_no VARCHAR(128),appointment_status VARCHAR(32),appointment_at TIMESTAMP,appointment_end_at TIMESTAMP,updated_at TIMESTAMP)"); + s.execute("CREATE TABLE ll_inbound_fba_appointment_item(id BIGINT,appointment_id BIGINT,order_id BIGINT,customer_id VARCHAR(128),line_id BIGINT,plan_id BIGINT)"); + s.execute("INSERT INTO ll_order VALUES(1,'A','FBA','ONT8',20,1,CURRENT_TIMESTAMP),(2,'B','FBA','ONT8',8,1,CURRENT_TIMESTAMP)"); + s.execute("INSERT INTO ll_order_detail VALUES(1,'A','SHIP-A',NULL),(2,'B',NULL,NULL)"); + s.execute("INSERT INTO ll_inbound_plan_line VALUES(10,100,1,'A',20,'ONT8'),(20,200,2,'B',8,'ONT8')"); + s.execute("INSERT INTO ll_inbound_fba_appointment VALUES(1000,'APP-1','SUCCESS',CURRENT_TIMESTAMP,NULL,CURRENT_TIMESTAMP),(2000,'CANCELLED-1','CANCELLED',CURRENT_TIMESTAMP,NULL,CURRENT_TIMESTAMP)"); + s.execute("INSERT INTO ll_inbound_fba_appointment_item VALUES(1,1000,1,'A',10,100),(2,1000,2,'B',20,200),(3,2000,1,'A',10,100),(4,1000,1,'A',20,200)"); + } + var config=new Configuration(new Environment("test",new JdbcTransactionFactory(),ds)); + config.setMapUnderscoreToCamelCase(true); config.addMapper(AppointmentCoverageMapper.class); + try (var session=new SqlSessionFactoryBuilder().build(config).openSession(true)) { + var mapper=session.getMapper(AppointmentCoverageMapper.class); + var refs=List.of(new AppointmentCoverageMapper.OrderRef("A",1L)); + var source=mapper.sources(refs).get(0); assertFalse(source.shipmentReferencesMissing()); assertEquals(20,source.packages()); + var facts=mapper.appointments(refs); assertEquals(1,facts.size()); assertEquals("APP-1",facts.get(0).appointmentNo()); assertEquals(20,facts.get(0).packages()); + assertTrue(mapper.sources(List.of(new AppointmentCoverageMapper.OrderRef("B",1L))).isEmpty()); + assertTrue(mapper.sources(List.of(new AppointmentCoverageMapper.OrderRef("B",2L))).get(0).shipmentReferencesMissing()); + try (var s=session.getConnection().createStatement()) { s.execute("INSERT INTO ll_order_detail VALUES(1,'A',' ',NULL)"); } + session.clearCache(); assertTrue(mapper.sources(refs).get(0).shipmentReferencesMissing()); + } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentCoverageServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentCoverageServiceTest.java new file mode 100644 index 000000000..02b3794b6 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentCoverageServiceTest.java @@ -0,0 +1,65 @@ +package com.baiyue.lastleg.appointmentplanning; + +import org.junit.jupiter.api.Test; +import java.time.LocalDateTime; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class AppointmentCoverageServiceTest { + private static final LocalDateTime BEFORE=LocalDateTime.of(2026,10,9,1,0), AFTER=BEFORE.plusHours(1); + private static AppointmentCoverageMapper.Source order() { + return new AppointmentCoverageMapper.Source(1L,"A","FBA","ONT8",20,2,BEFORE,false); + } + private static AppointmentCoverageMapper.Fact fact(long line,int packages,String status) { + return new AppointmentCoverageMapper.Fact(1L,"A","FBA-CONFIRM-1",status,AFTER.plusDays(1),null,AFTER,line,packages,"ONT8"); + } + @Test void completeQuantitySharesOneAppointmentButDoesNotInventItsTimezone() { + var mapper=mock(AppointmentCoverageMapper.class); var gateway=mock(AppointmentExternalSourceGateway.class); + when(mapper.sources(anyList())).thenReturn(List.of(order())); + when(mapper.appointments(anyList())).thenReturn(List.of(fact(10,10,"SUCCESS"),fact(11,10,"SUCCESS"))); + var result=new AppointmentCoverageService(mapper,gateway).query(List.of(new AppointmentCoverageService.SourceRef("A","ORDER:1","FBA:ONT8"))).get(0); + assertEquals("CONFIRMED",result.status()); assertEquals(1,result.appointments().size()); + assertNull(result.appointments().get(0).timezone()); assertTrue(result.missingFields().contains("APPOINTMENT_TIMEZONE_UNVERIFIED")); + assertFalse(result.changeReviewRequired()); verifyNoInteractions(gateway); + } + @Test void partialPendingAndDuplicateClaimsNeverAppearAsCompleteCoverage() { + var mapper=mock(AppointmentCoverageMapper.class); when(mapper.sources(anyList())).thenReturn(List.of(order())); + var service=new AppointmentCoverageService(mapper,mock(AppointmentExternalSourceGateway.class)); + var refs=List.of(new AppointmentCoverageService.SourceRef("A","ORDER:1","FBA:ONT8")); + when(mapper.appointments(anyList())).thenReturn(List.of(fact(10,10,"SUCCESS"))); + assertEquals("PARTIAL",service.query(refs).get(0).status()); + when(mapper.appointments(anyList())).thenReturn(List.of(fact(10,20,"PENDING"))); + assertEquals("PENDING",service.query(refs).get(0).status()); + when(mapper.appointments(anyList())).thenReturn(List.of(fact(10,10,"SUCCESS"),fact(10,10,"SUCCESS"))); + assertEquals("REVIEW_REQUIRED",service.query(refs).get(0).status()); + } + @Test void externalBookingKeepsItsIdentityBeforeAndAfterRealCargoExists() { + var mapper=mock(AppointmentCoverageMapper.class); var gateway=mock(AppointmentExternalSourceGateway.class); + var refs=List.of(new AppointmentCoverageService.SourceRef("A","BOOKING:B-1","FBA:ONT8")); + var service=new AppointmentCoverageService(mapper,gateway); + when(gateway.resolve(anyList())).thenReturn(List.of(new AppointmentExternalSourceGateway.Source("A","B-1","TR-1","CONFIRMED","100","FBA:ONT8",null,20,1L))); + assertEquals("SOURCE_NOT_MATERIALIZED",service.query(refs).get(0).status()); verifyNoInteractions(mapper); + when(gateway.resolve(anyList())).thenReturn(List.of(new AppointmentExternalSourceGateway.Source("A","B-1","TR-1","BOUND","100","FBA:ONT8",1L,20,2L))); + when(mapper.sources(anyList())).thenReturn(List.of(order())); when(mapper.appointments(anyList())).thenReturn(List.of(fact(10,20,"SUCCESS"))); + var result=service.query(refs).get(0); assertEquals("BOOKING:B-1",result.sourceKey()); assertEquals("CONFIRMED",result.status()); + assertThrows(IllegalArgumentException.class, () -> service.query(List.of(refs.get(0),new AppointmentCoverageService.SourceRef("A","ORDER:1","FBA:ONT8")))); + } + @Test void anotherCustomerAndChangedDestinationCannotBorrowAppointmentCoverage() { + var mapper=mock(AppointmentCoverageMapper.class); var gateway=mock(AppointmentExternalSourceGateway.class); + when(mapper.sources(anyList())).thenReturn(List.of(order())); when(mapper.appointments(anyList())).thenReturn(List.of(fact(10,20,"SUCCESS"))); + var service=new AppointmentCoverageService(mapper,gateway); + assertEquals("SOURCE_NOT_FOUND",service.query(List.of(new AppointmentCoverageService.SourceRef("B","ORDER:1","FBA:ONT8"))).get(0).status()); + assertEquals("DESTINATION_MISMATCH",service.query(List.of(new AppointmentCoverageService.SourceRef("A","ORDER:1","FBA:LAX9"))).get(0).status()); + } + @Test void changedSourcesAndMissingShipmentReferencesAreExplicitWithoutChangingConfirmedTime() { + var mapper=mock(AppointmentCoverageMapper.class); + when(mapper.sources(anyList())).thenReturn(List.of(new AppointmentCoverageMapper.Source(1L,"A","FBA","ONT8",20,3,AFTER.plusHours(1),true))); + when(mapper.appointments(anyList())).thenReturn(List.of(fact(10,20,"SUCCESS"))); + var result=new AppointmentCoverageService(mapper,mock(AppointmentExternalSourceGateway.class)).query(List.of(new AppointmentCoverageService.SourceRef("A","ORDER:1","FBA:ONT8"))).get(0); + assertTrue(result.changeReviewRequired()); assertTrue(result.missingFields().containsAll(List.of("SOURCE_CHANGED_REVIEW","FBA_SHIPMENT_REFERENCES"))); + assertEquals(AFTER.plusDays(1),result.appointments().get(0).confirmedAt()); + verify(mapper,times(1)).sources(anyList()); verify(mapper,times(1)).appointments(anyList()); verifyNoMoreInteractions(mapper); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentFollowupPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentFollowupPersistenceTest.java new file mode 100644 index 000000000..885ccc328 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentFollowupPersistenceTest.java @@ -0,0 +1,45 @@ +package com.baiyue.lastleg.appointmentplanning; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder; +import org.apache.ibatis.mapping.Environment; +import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.Test; +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.UUID; +import static org.junit.jupiter.api.Assertions.*; + +class AppointmentFollowupPersistenceTest { + @Test void baselineAndMapperPersistSeparateStagesAndEnforceCommandAndVersionUniqueness() throws Exception { + String sql; + try (var input=getClass().getResourceAsStream("/db/lastleg.sql")) { + assertNotNull(input); var baseline=new String(input.readAllBytes(),StandardCharsets.UTF_8); + int start=baseline.indexOf("CREATE TABLE IF NOT EXISTS ll_appointment_followup ("); + assertTrue(start>=0); sql=baseline.substring(start,baseline.indexOf(';',start)+1); + } + var ds=new JdbcDataSource(); ds.setURL("jdbc:h2:mem:followup-"+ UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1"); + try (var c=ds.getConnection();var s=c.createStatement()) { + s.execute("CREATE TABLE ll_order(id BIGINT PRIMARY KEY)"); s.execute("INSERT INTO ll_order VALUES(1)"); s.execute(sql); + } + var config=new MybatisConfiguration(); config.setEnvironment(new Environment("test",new JdbcTransactionFactory(),ds)); + config.setMapUnderscoreToCamelCase(true); config.addMapper(AppointmentFollowupMapper.class); + try (var session=new MybatisSqlSessionFactoryBuilder().build(config).openSession(true)) { + var mapper=session.getMapper(AppointmentFollowupMapper.class); + var first=record(11L,"DRAYAGE_CONTACT",1,"key-a"); mapper.insert(first); + mapper.insert(record(12L,"FBA_RECEIVING",1,"key-a")); + assertEquals(1,mapper.version("ORDER:1","A","DRAYAGE_CONTACT")); assertEquals(1,mapper.version("ORDER:1","A","FBA_RECEIVING")); + assertEquals(2,mapper.count("ORDER:1","A")); assertEquals("联系结果",mapper.command("ORDER:1","A","DRAYAGE_CONTACT","key-a").getResult()); + assertEquals(1,mapper.page("ORDER:1","A",1,0).size()); + assertThrows(RuntimeException.class, () -> mapper.insert(record(13L,"DRAYAGE_CONTACT",2,"key-a"))); + assertThrows(RuntimeException.class, () -> mapper.insert(record(14L,"DRAYAGE_CONTACT",1,"key-b"))); + assertEquals(2,mapper.count("ORDER:1","A")); + } + } + private static AppointmentFollowup record(long id,String stage,int version,String key) { + var row=new AppointmentFollowup(); row.setId(id); row.setSourceOrderId(1L); row.setSourceKey("ORDER:1"); row.setCustomerId("A"); row.setStage(stage); row.setVersion(version); + row.setResult("联系结果"); row.setOperatorId("operator"); row.setCommandKey(key); row.setRequestHash("a".repeat(64)); + row.setCreatedAt(LocalDateTime.of(2026,10,9,12,0)); return row; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentFollowupServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentFollowupServiceTest.java new file mode 100644 index 000000000..dcc6a2825 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentFollowupServiceTest.java @@ -0,0 +1,59 @@ +package com.baiyue.lastleg.appointmentplanning; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.web.server.ResponseStatusException; +import java.time.LocalDate; +import java.util.HashMap; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class AppointmentFollowupServiceTest { + @Test void contactsBeforePlanningAreAppendOnlyAndOldCommandsReplayTheirOriginalResult() { + var cargo=mock(AppointmentSourceService.class); var mapper=mock(AppointmentFollowupMapper.class); + var saved=new HashMap(); var version=new int[]{0}; + when(cargo.resolve("ORDER:1","A",null)).thenReturn(new AppointmentSourceService.Source("ORDER:1","A",1L,"IN-A",null,"C1","100","美国仓","FBA:ONT8","ONT8",10,"ORDER:0",true,List.of())); + when(mapper.command(eq("ORDER:1"),eq("A"),eq("DRAYAGE_CONTACT"),anyString())).thenAnswer(call -> saved.get(call.getArgument(3))); + when(mapper.version("ORDER:1","A","DRAYAGE_CONTACT")).thenAnswer(call -> version[0]); + when(mapper.insert(any(AppointmentFollowup.class))).thenAnswer(call -> { + AppointmentFollowup row=call.getArgument(0); saved.put(row.getCommandKey(),row); version[0]=row.getVersion(); return 1; + }); + var service=new AppointmentFollowupService(cargo,mapper,new ObjectMapper().findAndRegisterModules()); + var first=new AppointmentFollowupService.Save("DRAYAGE_CONTACT",0,"first","车队A","已联系,等待回复","明日再联系", LocalDate.of(2026,10,15)); + var result=service.save("ORDER:1","A",first,"operator","运营甲"); + service.save("ORDER:1","A",new AppointmentFollowupService.Save("DRAYAGE_CONTACT",1,"second","车队A","尚未给出正式时段",null,null),"operator","运营甲"); + var replay=service.save("ORDER:1","A",first,"operator","运营甲"); + assertEquals(result,replay); assertEquals(1,replay.version()); assertEquals(2,saved.size()); + assertEquals("运营甲",result.operatorName()); assertEquals("已联系,等待回复",result.result()); + verify(mapper,times(2)).insert(any(AppointmentFollowup.class)); + verify(cargo,times(2)).resolve("ORDER:1","A",null); + var conflict=assertThrows(ResponseStatusException.class, () -> service.save("ORDER:1","A", + new AppointmentFollowupService.Save("DRAYAGE_CONTACT",0,"first",null,"不同结果",null,null),"operator","运营甲")); + assertEquals(409,conflict.getStatusCode().value()); + assertThrows(ResponseStatusException.class, () -> service.save("ORDER:1","A",first,"another-operator","运营乙")); + assertThrows(ResponseStatusException.class, () -> service.save("ORDER:1","A", + new AppointmentFollowupService.Save("DRAYAGE_CONTACT",0,"stale",null,"并发旧版本",null,null),"operator","运营甲")); + assertEquals(2,saved.size()); + } + + @Test void followupDateRequiresAnActionAndInvalidStagesDoNotWrite() { + var cargo=mock(AppointmentSourceService.class); var mapper=mock(AppointmentFollowupMapper.class); + var service=new AppointmentFollowupService(cargo,mapper,new ObjectMapper()); + assertThrows(ResponseStatusException.class, () -> service.save("ORDER:1","A", + new AppointmentFollowupService.Save("FBA_RECEIVING",0,"key",null,"等待材料",null,LocalDate.of(2026,10,15)),"operator","运营")); + assertThrows(ResponseStatusException.class, () -> service.save("ORDER:1","A", + new AppointmentFollowupService.Save("CONFIRM_ALL",0,"key",null,"不能混用",null,null),"operator","运营")); + verifyNoInteractions(cargo,mapper); + } + + @Test void existingHistoryRemainsReadableWhenTheCargoHasLeftTheActivePlanningList() { + var cargo=mock(AppointmentSourceService.class); var mapper=mock(AppointmentFollowupMapper.class); + when(mapper.count("ORDER:1","A")).thenReturn(21L); when(mapper.page("ORDER:1","A",20,20)).thenReturn(List.of()); + var service=new AppointmentFollowupService(cargo,mapper,new ObjectMapper()); + var history=service.history("ORDER:1","A",2,20); + assertEquals(21,history.history().total()); assertEquals(2,history.history().pages()); + verifyNoInteractions(cargo); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentPlanningAuthorizationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentPlanningAuthorizationTest.java new file mode 100644 index 000000000..99777fa8f --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentPlanningAuthorizationTest.java @@ -0,0 +1,56 @@ +package com.baiyue.lastleg.appointmentplanning; + +import com.baiyue.common.security.AuthenticatedUser; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.*; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.server.ResponseStatusException; +import java.util.List; +import java.util.Set; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class AppointmentPlanningAuthorizationTest { + private static final String LIST="platform:appointment:list", CARGO="platform:overseas-warehouse:last-mile-order:list", WRITE="platform:appointment:followup"; + @Test void readRequiresBothSourcePermissionsAndCannotWrite() { + try (var context=new AnnotationConfigApplicationContext(Config.class)) { + var controller=context.getBean(AppointmentPlanningController.class); + var user=user(0L,"PLATFORM"); + for (String permission:List.of(LIST,CARGO)) { + login(permission); + assertThrows(AccessDeniedException.class,() -> controller.cargo(user,1,20,null,null,null)); + assertThrows(AccessDeniedException.class,() -> controller.history(user,"ORDER:1","A",1,20)); + } + login(LIST,CARGO); + controller.cargo(user,1,20,null,null,null); + verify(context.getBean(AppointmentCargoService.class)).page(1,20,null,null,null); + assertThrows(AccessDeniedException.class,() -> controller.followup(user,"ORDER:1","A",null)); + verifyNoInteractions(context.getBean(AppointmentFollowupService.class)); + } finally { SecurityContextHolder.clearContext(); } + } + @Test void permissionDoesNotAllowClientIdentityAndOperatorComesFromLogin() { + try (var context=new AnnotationConfigApplicationContext(Config.class)) { + var controller=context.getBean(AppointmentPlanningController.class); + login(LIST,CARGO,WRITE); + var request=new AppointmentFollowupService.Save("FBA_RECEIVING",0,"key",null,"已联系",null,null); + assertThrows(ResponseStatusException.class,() -> controller.followup(user(1L,"CLIENT"),"BOOKING:B-1","A",request)); + assertThrows(ResponseStatusException.class,() -> controller.cargo(null,1,20,null,null,null)); + controller.followup(user(0L,"PLATFORM"),"BOOKING:B-1","A",request); + verify(context.getBean(AppointmentFollowupService.class)).save("BOOKING:B-1","A",request,"7","operator"); + } finally { SecurityContextHolder.clearContext(); } + } + private static AuthenticatedUser user(Long customer,String app) { return new AuthenticatedUser(7L,customer,0L,8L,"operator",app,"WEB",Set.of()); } + private static void login(String... permissions) { SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("operator","unused",java.util.Arrays.stream(permissions).map(SimpleGrantedAuthority::new).toList())); } + @Configuration @EnableMethodSecurity + static class Config { + @Bean AppointmentCargoService cargo() { return mock(AppointmentCargoService.class); } + @Bean AppointmentFollowupService followups() { return mock(AppointmentFollowupService.class); } + @Bean AppointmentSourceService source() { return mock(AppointmentSourceService.class); } + @Bean AppointmentCoverageService coverage() { return mock(AppointmentCoverageService.class); } + @Bean AppointmentPlanningController controller(AppointmentCargoService cargo,AppointmentFollowupService followups,AppointmentSourceService source,AppointmentCoverageService coverage) { return new AppointmentPlanningController(cargo,followups,source,coverage); } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentPlanningPolicyTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentPlanningPolicyTest.java new file mode 100644 index 000000000..2cf46a7ce --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentPlanningPolicyTest.java @@ -0,0 +1,86 @@ +package com.baiyue.lastleg.appointmentplanning; + +import org.junit.jupiter.api.Test; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; + +class AppointmentPlanningPolicyTest { + private static Instant at(int hour) { return Instant.parse("2026-10-15T" + String.format("%02d", hour) + ":00:00Z"); } + private static AppointmentPlanningPolicy.Cargo cargo(String source, String customer, String origin, String destination, + Integer packages, Instant from, Instant to) { + return new AppointmentPlanningPolicy.Cargo(source, customer, origin, destination, "US", "US", packages, from, to, true, null); + } + + @Test void twoCustomersCanPlanOneDestinationWithoutAPlanNumberDriverOrExternalConfirmation() { + var result = AppointmentPlanningPolicy.group(List.of( + cargo("ORDER:1", "A", "WH-1", "FBA:ONT8", 10, at(1), at(5)), + cargo("BOOKING:B-1", "B", "WH-1", "FBA:ONT8", 8, at(3), at(8)))); + assertEquals(18, result.packages()); assertEquals(2, result.customerCount()); + assertEquals(at(3), result.readyFrom()); assertEquals(at(5), result.readyTo()); + assertFalse(result.readinessIncomplete()); + } + + @Test void unknownReadinessIsPreservedInsteadOfInventingAnArrivalOffset() { + var result = AppointmentPlanningPolicy.group(List.of(cargo("ORDER:1", "A", "WH-1", "FBA:ONT8", 10, null, null))); + assertTrue(result.readinessIncomplete()); assertNull(result.readyFrom()); assertNull(result.readyTo()); + } + + @Test void differentOriginsDestinationsAndDisjointWindowsAreNotSilentlyMerged() { + var first = cargo("ORDER:1", "A", "WH-1", "FBA:ONT8", 10, at(1), at(3)); + assertThrows(IllegalArgumentException.class, () -> AppointmentPlanningPolicy.group(List.of(first, + cargo("ORDER:2", "B", "WH-2", "FBA:ONT8", 8, at(1), at(3))))); + assertThrows(IllegalArgumentException.class, () -> AppointmentPlanningPolicy.group(List.of(first, + cargo("ORDER:2", "B", "WH-1", "FBA:LAX9", 8, at(1), at(3))))); + assertThrows(IllegalArgumentException.class, () -> AppointmentPlanningPolicy.group(List.of(first, + cargo("ORDER:2", "B", "WH-1", "FBA:ONT8", 8, at(5), at(8))))); + } + + @Test void sourceIdentityMustNotBeCountedAgainAfterMaterialization() { + var first = cargo("BOOKING:B-1", "A", "WH-1", "FBA:ONT8", 10, null, null); + assertThrows(IllegalArgumentException.class, () -> AppointmentPlanningPolicy.group(List.of(first, first))); + assertThrows(IllegalArgumentException.class, () -> AppointmentPlanningPolicy.group(List.of( + cargo("ORDER:1", "A", "WH-1", "FBA:ONT8", null, null, null)))); + } + + @Test void nonUsAndBlockedSourcesCannotBeTreatedAsEligibleAmericanCargo() { + assertThrows(IllegalArgumentException.class, () -> AppointmentPlanningPolicy.group(List.of( + new AppointmentPlanningPolicy.Cargo("ORDER:1", "A", "WH-1", "FBA:YYZ", "US", "CA", 1, null, null, true, null)))); + var error = assertThrows(IllegalArgumentException.class, () -> AppointmentPlanningPolicy.group(List.of( + new AppointmentPlanningPolicy.Cargo("ORDER:1", "A", "WH-1", "FBA:ONT8", "US", "US", 1, null, null, false, "货物已被有效安排占用")))); + assertEquals("货物已被有效安排占用", error.getMessage()); + } + + @Test void changedReadinessIdentifiesImpactWithoutOverwritingTheConfirmedTime() { + var before = new AppointmentPlanningPolicy.Forecast("ORDER:1", "1", at(1), at(3)); + var after = new AppointmentPlanningPolicy.Forecast("ORDER:1", "2", at(4), at(8)); + var impact = AppointmentPlanningPolicy.compare(before, after, at(6)); + assertTrue(impact.reviewRequired()); assertEquals("READINESS_AFTER_APPOINTMENT", impact.reason()); + assertEquals(at(6), impact.confirmedAt()); assertEquals(at(1), impact.previousEta()); assertEquals(at(4), impact.latestEta()); + assertFalse(AppointmentPlanningPolicy.compare(after, after, at(6)).reviewRequired()); + } + + @Test void lostForecastNeedsReviewAndUnrelatedSourcesCannotBeCompared() { + var before = new AppointmentPlanningPolicy.Forecast("ORDER:1", "1", at(1), at(3)); + assertEquals("FORECAST_MISSING", AppointmentPlanningPolicy.compare(before, + new AppointmentPlanningPolicy.Forecast("ORDER:1", "2", null, null), at(6)).reason()); + assertThrows(IllegalArgumentException.class, () -> AppointmentPlanningPolicy.compare(before, + new AppointmentPlanningPolicy.Forecast("ORDER:2", "2", at(2), at(3)), at(6))); + } + + @Test void losAngelesTimesResolveToTheLocationZoneAndKeepAnUnknownEndUnknown() { + var result = AppointmentTimePolicy.resolve(LocalDateTime.parse("2026-10-15T09:00:00"), null, "America/Los_Angeles", null, null); + assertEquals(Instant.parse("2026-10-15T16:00:00Z"), result.start()); assertNull(result.end()); + assertThrows(IllegalArgumentException.class, () -> AppointmentTimePolicy.resolve(LocalDateTime.parse("2026-10-15T09:00:00"), null, null, null, null)); + } + + @Test void daylightSavingGapIsRejectedAndOverlapRequiresAnExplicitValidOffset() { + assertThrows(IllegalArgumentException.class, () -> AppointmentTimePolicy.resolve(LocalDateTime.parse("2026-03-08T02:30:00"), null, "America/Los_Angeles", null, null)); + var overlap = LocalDateTime.parse("2026-11-01T01:30:00"); + assertThrows(IllegalArgumentException.class, () -> AppointmentTimePolicy.resolve(overlap, null, "America/Los_Angeles", null, null)); + assertEquals(Instant.parse("2026-11-01T09:30:00Z"), AppointmentTimePolicy.resolve(overlap, null, "America/Los_Angeles", ZoneOffset.ofHours(-8), null).start()); + assertThrows(IllegalArgumentException.class, () -> AppointmentTimePolicy.resolve(overlap, null, "America/Los_Angeles", ZoneOffset.ofHours(8), null)); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceServiceTest.java new file mode 100644 index 000000000..34b269222 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceServiceTest.java @@ -0,0 +1,40 @@ +package com.baiyue.lastleg.appointmentplanning; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper; +import org.junit.jupiter.api.Test; +import org.springframework.web.server.ResponseStatusException; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class AppointmentSourceServiceTest { + @Test void confirmedExternalBookingCanBeFollowedWithoutCreatingAnOrderAndKeepsItsKeyAfterMaterialization() { + var orders=mock(LastlegOrderMapper.class); var gateway=mock(AppointmentExternalSourceGateway.class); var warehouse=mock(AppointmentWarehouseClient.class); + when(warehouse.catalog("token")).thenReturn(ApiResponse.success(List.of( + new AppointmentWarehouseClient.Warehouse(100L,"US-WH","美国仓","OVERSEAS","US"), + new AppointmentWarehouseClient.Warehouse(200L,"ONT8","ONT8","FBA","US")))); + var service=new AppointmentSourceService(orders,mock(ContainerShippingGateMapper.class),gateway,warehouse,"token"); + when(gateway.resolve(anyList())).thenReturn(List.of(new AppointmentExternalSourceGateway.Source("A","B-1","TR-1","CONFIRMED","100","FBA:ONT8",null,null,1L))); + var source=service.resolve("BOOKING:B-1","A","FBA:ONT8"); assertTrue(source.canFollowup()); assertNull(source.orderId()); assertNull(source.packages()); verifyNoInteractions(orders); + var order=new LastlegOrder(); order.setId(1L); order.setCustomerId("A"); order.setOverseasWarehouseId("100"); order.setPackageCount(12); order.setInboundCode("IN-A"); order.setDestinationType("FBA"); order.setFbaWarehouseCode("ONT8"); + when(orders.selectById(1L)).thenReturn(order); + when(gateway.resolve(anyList())).thenReturn(List.of(new AppointmentExternalSourceGateway.Source("A","B-1","TR-1","RECEIVED","100","FBA:ONT8",1L,10,2L))); + var received=service.resolve("BOOKING:B-1","A","FBA:ONT8"); assertEquals("BOOKING:B-1",received.sourceKey()); assertEquals(12,received.packages()); assertEquals("IN-A",received.inboundCode()); + assertThrows(ResponseStatusException.class, () -> service.resolve("BOOKING:B-1","A","FBA:LAX9")); + assertThrows(ResponseStatusException.class, () -> service.resolve("BOOKING:B-1","OTHER","FBA:ONT8")); + order.setCurrentStatus("CANCELLED"); assertFalse(service.resolve("BOOKING:B-1","A","FBA:ONT8").canFollowup()); + order.setFbaWarehouseCode("LAX9"); assertThrows(ResponseStatusException.class, () -> service.resolve("BOOKING:B-1","A","FBA:ONT8")); + } + @Test void draftBookingCannotBePresentedAsConfirmedCarrierCapacity() { + var gateway=mock(AppointmentExternalSourceGateway.class); var warehouse=mock(AppointmentWarehouseClient.class); + when(warehouse.catalog("token")).thenReturn(ApiResponse.success(List.of( + new AppointmentWarehouseClient.Warehouse(100L,"US-WH","美国仓","OVERSEAS","US"),new AppointmentWarehouseClient.Warehouse(200L,"ONT8","ONT8","FBA","US")))); + when(gateway.resolve(anyList())).thenReturn(List.of(new AppointmentExternalSourceGateway.Source("A","B-1","TR-1","DRAFT","100","FBA:ONT8",null,null,0L))); + var source=new AppointmentSourceService(mock(LastlegOrderMapper.class),mock(ContainerShippingGateMapper.class),gateway,warehouse,"token").resolve("BOOKING:B-1","A","FBA:ONT8"); + assertFalse(source.canFollowup()); assertFalse(source.blockReasons().isEmpty()); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/fleet/FleetDispatchPlanningAdapterTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/fleet/FleetDispatchPlanningAdapterTest.java new file mode 100644 index 000000000..3034312e9 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/fleet/FleetDispatchPlanningAdapterTest.java @@ -0,0 +1,32 @@ +package com.baiyue.lastleg.dispatch.fleet; + +import com.baiyue.lastleg.dispatch.service.DispatchService; +import com.baiyue.lastleg.fleet.trip.*; +import org.junit.jupiter.api.Test; +import java.math.BigDecimal; +import java.util.List; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class FleetDispatchPlanningAdapterTest { + private final FleetDispatchPlanningMapper mapper=mock(FleetDispatchPlanningMapper.class); + private final DispatchService service=mock(DispatchService.class); + private final FleetDispatchGateway gateway=mock(FleetDispatchGateway.class); + private final FleetDispatchPlanningAdapter adapter=new FleetDispatchPlanningAdapter(mapper,service,gateway); + private FleetBookingIncomingGateway.Evidence receipt(){return new FleetBookingIncomingGateway.Evidence("WT-1","IN-1","92","FBA:ACY2","READY_FOR_DISPATCH","hash",List.of(new FleetBookingIncomingGateway.Pallet("1","P-1",10,BigDecimal.TEN,BigDecimal.ONE)),10);} + @Test void refusesAmbiguousSourcesWithoutCreatingAnotherDispatch() { + when(mapper.sourceDispatches("9","IN-1")).thenReturn(List.of("DS-1","DS-2")); + assertThatThrownBy(()->adapter.resolveIncoming("9",receipt(),"1")).hasMessageContaining("多张排车"); + verifyNoInteractions(service); + } + @Test void reusesOnlyAnExactExistingShipment() { + when(mapper.sourceDispatches("9","IN-1")).thenReturn(List.of("DS-1")); + when(gateway.lockForPlanning("DS-1")).thenReturn(new FleetDispatchGateway.Snapshot(1,"DS-1","9","92",10)); + when(gateway.sourceCargo("DS-1")).thenReturn(List.of(new FleetDispatchGateway.SourceCargo("IN-1","1","P-1",10,"FBA:ACY2"))); + assertThat(adapter.resolveIncoming("9",receipt(),"1")).isEqualTo("DS-1");verifyNoInteractions(service); + when(gateway.sourceCargo("DS-1")).thenReturn(List.of(new FleetDispatchGateway.SourceCargo("IN-1","1","P-1",9,"FBA:ACY2"))); + assertThatThrownBy(()->adapter.resolveIncoming("9",receipt(),"1")).hasMessageContaining("全部实收"); + when(gateway.sourceCargo("DS-1")).thenReturn(List.of(new FleetDispatchGateway.SourceCargo("IN-other","1","P-1",10,"FBA:ACY2"))); + assertThatThrownBy(()->adapter.resolveIncoming("9",receipt(),"1")).hasMessageContaining("其他来源"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/fleet/FleetPlanningReadPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/fleet/FleetPlanningReadPersistenceTest.java new file mode 100644 index 000000000..732e9c9cf --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/fleet/FleetPlanningReadPersistenceTest.java @@ -0,0 +1,51 @@ +package com.baiyue.lastleg.dispatch.fleet; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baiyue.lastleg.fleet.business.*; +import com.fasterxml.jackson.databind.ObjectMapper; +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.util.List; +import java.util.UUID; +import static org.assertj.core.api.Assertions.*; + +class FleetPlanningReadPersistenceTest { + @Test void candidatesPaginateOnlyEligibleSameWarehouseUnboundCargoAndSourcesPreserveUnknowns() throws Exception { + var ds=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa",""); + var jdbc=new JdbcTemplate(ds); + jdbc.execute("CREATE TABLE ll_dispatch_order(id BIGINT PRIMARY KEY,dispatch_no VARCHAR(64),customer_id VARCHAR(64),overseas_warehouse_id VARCHAR(64),package_count INT,status VARCHAR(32),picked_package_count INT,shipped_package_count INT,updated_at TIMESTAMP,route VARCHAR(500))"); + jdbc.execute("CREATE TABLE ll_dispatch_item(dispatch_order_id BIGINT,customer_id VARCHAR(64),inbound_code VARCHAR(64))"); + jdbc.execute("CREATE TABLE ll_fleet_trip_dispatch(dispatch_id BIGINT)"); + jdbc.execute("CREATE TABLE ll_dispatch_pda_task(dispatch_order_id BIGINT,status VARCHAR(32))"); + jdbc.execute("CREATE TABLE ll_outbound_order(dispatch_order_id BIGINT,status VARCHAR(32))"); + jdbc.execute("CREATE TABLE ll_fleet_trip(id BIGINT PRIMARY KEY,warehouse_id BIGINT)"); + jdbc.execute("CREATE TABLE ll_fleet_external_booking(id BIGINT,trip_id BIGINT,customer_id VARCHAR(64),booking_no VARCHAR(64),trip_no VARCHAR(64),status VARCHAR(32),offer_id BIGINT,destination_key VARCHAR(160),destination_label VARCHAR(100),incoming_no VARCHAR(64),record_version BIGINT,cargo_json VARCHAR(2000),expected_pallet_count INT,estimated_arrival_at TIMESTAMP)"); + jdbc.execute("CREATE TABLE ll_fleet_pool_order_link(booking_id BIGINT,customer_id VARCHAR(64),lastleg_order_id BIGINT,business_order_no VARCHAR(64),fulfillment_stage VARCHAR(32))"); + for(int i=1;i<=6;i++)jdbc.update("INSERT INTO ll_dispatch_order(id,dispatch_no,customer_id,overseas_warehouse_id,package_count,status,picked_package_count,shipped_package_count,updated_at) VALUES(?,?,?,'92',10,'PENDING_DEPARTURE',0,0,CURRENT_TIMESTAMP)",i,"DS-"+i,"9"); + jdbc.update("INSERT INTO ll_fleet_trip_dispatch VALUES(2)"); + jdbc.update("UPDATE ll_dispatch_order SET overseas_warehouse_id='93' WHERE id=3"); + jdbc.update("INSERT INTO ll_dispatch_pda_task VALUES(4,'PICKING')"); + jdbc.update("INSERT INTO ll_dispatch_item VALUES(5,'9','IN-5'),(6,'9','IN-6')"); + jdbc.update("INSERT INTO ll_fleet_trip VALUES(1,92)"); + jdbc.update("INSERT INTO ll_fleet_external_booking(id,trip_id,customer_id,booking_no,trip_no,status,offer_id,destination_key,destination_label,incoming_no,record_version,cargo_json) VALUES(5,1,'9','FB-5','TR-OTHER','RECEIVED',1,'FBA:ACY2','ACY2','IN-5',1,'{\"packageCount\":null}'),(6,1,'9','FB-6','TR-1','RECEIVED',1,'FBA:ACY2','ACY2','IN-6',2,'{\"packageCount\":10}')"); + var configuration=new MybatisConfiguration();configuration.addMapper(FleetDispatchPlanningMapper.class);configuration.addMapper(FleetPoolOrderMapper.class); + var interceptor=new MybatisPlusInterceptor();interceptor.addInnerInterceptor(new PaginationInnerInterceptor(com.baomidou.mybatisplus.annotation.DbType.H2));configuration.addInterceptor(interceptor); + var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(configuration); + var session=new SqlSessionTemplate(factory.getObject());var mapper=session.getMapper(FleetDispatchPlanningMapper.class); + var page=mapper.candidates(new Page<>(1,1),"TR-1","92",null); + assertThat(page.getTotal()).isEqualTo(2);assertThat(page.getRecords()).hasSize(1); + assertThat(mapper.candidates(new Page<>(1,100),"TR-1","92","DS-1").getRecords()).extracting(r->r.dispatchNo()).containsExactly("DS-1"); + var resolver=new FleetPoolSourceResolver(session.getMapper(FleetPoolOrderMapper.class),new ObjectMapper()); + var sources=resolver.resolve(List.of(new FleetPoolSourceResolver.SourceRef("9","FB-5"),new FleetPoolSourceResolver.SourceRef("9","FB-6"),new FleetPoolSourceResolver.SourceRef("OTHER","FB-6"))); + assertThat(sources).hasSize(2); + assertThat(sources.stream().filter(s->s.bookingNo().equals("FB-5")).findFirst().orElseThrow().packageCount()).isNull(); + assertThat(sources.stream().filter(s->s.bookingNo().equals("FB-6")).findFirst().orElseThrow().packageCount()).isEqualTo(10); + assertThat(sources).allMatch(s->s.lastlegOrderId()==null&&s.expectedPalletCount()==null); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetInquiryProgressTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetInquiryProgressTest.java new file mode 100644 index 000000000..c3d6b1820 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetInquiryProgressTest.java @@ -0,0 +1,47 @@ +package com.baiyue.lastleg.fleet.trip; + +import com.baiyue.lastleg.fleet.domain.TripCapacityPolicy; +import org.junit.jupiter.api.Test; +import java.math.BigDecimal; +import java.time.*; +import java.util.List; +import static org.assertj.core.api.Assertions.*; + +class FleetInquiryProgressTest { + private final Instant cutoff=Instant.parse("2026-10-20T10:00:00Z"); + private FleetCapacityOfferModels.Offer offer() { + return new FleetCapacityOfferModels.Offer("FO-1","TR-1","OPEN",1,1,"1","仓库",cutoff,cutoff,null,null,null, + List.of(new FleetCapacityOfferModels.Destination("FBA:ACY2","ACY2",1,new TripCapacityPolicy.Load(4,new BigDecimal("1000"),new BigDecimal("10")))),List.of(),true,true,true,null,null); + } + private FleetBookingModels.Cargo cargo(Integer spaces) { + return new FleetBookingModels.Cargo("FBA:ACY2","货物",1,spaces,new BigDecimal("100"),BigDecimal.ONE,"PALLET",BigDecimal.ZERO,"USD","客户联系人","联系信息",null); + } + private FleetBookingEntity booking(String status) { + var row=new FleetBookingEntity();row.setStatus(status);row.setBusinessType("LOCAL_LOOSEBOARD");row.setEstimatedArrivalAt(LocalDateTime.ofInstant(cutoff.minusSeconds(60),ZoneOffset.UTC));return row; + } + @Test void unknownQuantityIsMissingAndTruckNeedsPhysicalPalletCount() { + var row=booking("DRAFT");row.setBusinessType("US_TRUCK"); + assertThat(FleetInquiryProgress.missing(row,cargo(null),offer())).containsExactly("占用标准托位","预计实托数"); + row.setExpectedPalletCount(1); + assertThat(FleetInquiryProgress.missing(row,cargo(1),offer())).isEmpty(); + } + @Test void capacityIsCheckedAgainstThisDestinationAndThisShipment() { + assertThat(FleetInquiryProgress.capacityBlock(cargo(5),offer())).contains("超过"); + assertThat(FleetInquiryProgress.capacityBlock(cargo(4),offer())).isNull(); + } + @Test void expiryDoesNotTellSalesToEditAnUnsavableDraft() { + var p=FleetInquiryProgress.project(booking("DRAFT"),List.of("预计重量"),"已截止",false,false,null,null); + assertThat(p.primaryAction()).isEqualTo("VIEW");assertThat(p.reservation()).isEqualTo("尚未保位"); + } + @Test void receivingIsNotDispatchWorkUntilActualMeasurementCompletes() { + var row=booking("CONFIRMED");row.setIncomingNo("WT-1"); + var waiting=FleetInquiryProgress.project(row,List.of(),null,false,false,"WAIT_PDA_RECEIVING",null); + assertThat(waiting.owner()).isEqualTo("仓库");assertThat(waiting.primaryAction()).isEqualTo("VIEW"); + var received=FleetInquiryProgress.project(row,List.of(),null,false,false,"WAIT_CUSTOMER_CONFIRMATION",null); + assertThat(received.owner()).isEqualTo("调度");assertThat(received.primaryAction()).isEqualTo("RECEIPT"); + } + @Test void bindingIsNotDepartureAndOnlyActualFulfillmentAdvancesTracking() { + assertThat(FleetInquiryProgress.project(booking("BOUND"),List.of(),null,false,false,null,"PENDING_DISPATCH").label()).isEqualTo("已排入本车"); + assertThat(FleetInquiryProgress.project(booking("BOUND"),List.of(),null,false,false,null,"IN_TRANSIT").label()).isEqualTo("已发车"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripApiContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripApiContractTest.java index 74ab60280..faddeed17 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripApiContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripApiContractTest.java @@ -13,11 +13,15 @@ import static org.assertj.core.api.Assertions.*; class FleetTripApiContractTest { @Test void allTripOperationsHaveOpenApiAndRealPermissions() throws Exception { Map document = new Yaml().load(Files.readString(Path.of("src/main/resources/openapi/fleet-trips-v1.yaml"))); - Map paths = (Map) document.get("paths"); + Map preparation = new Yaml().load(Files.readString(Path.of("src/main/resources/openapi/fleet-preparation-v1.yaml"))); + Map pool = new Yaml().load(Files.readString(Path.of("src/main/resources/openapi/fleet-capacity-offers-v1.yaml"))); + var paths = new java.util.HashMap(); + for(var spec:java.util.List.of(document,preparation,pool))paths.putAll((Map)spec.get("paths")); String module = Files.readString(Path.of("../iam/src/main/resources/db/lastleg-permissions.sql")); + module += Files.readString(Path.of("../iam/src/main/resources/db/fleet-capacity-offer-permissions.sql")); String deployed = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql")); int count = 0; - for (var controller : java.util.List.of(PlatformFleetTripController.class, PlatformFleetBookingController.class)) { + for (var controller : java.util.List.of(PlatformFleetTripController.class, PlatformFleetBookingController.class, PlatformFleetCapacityOfferController.class)) { String base = controller.getAnnotation(RequestMapping.class).value()[0]; for (var method : controller.getDeclaredMethods()) { String verb = null; String[] suffix = null; @@ -42,8 +46,10 @@ class FleetTripApiContractTest { count++; } } - assertThat(count).isEqualTo(29); + assertThat(count).isEqualTo(52); verifyRefs(document, document); + verifyRefs(preparation, preparation); + verifyRefs(pool, pool); } @Test void schemasMatchAndUseUniqueResourceClaimsAndVersionedHistory() throws Exception { @@ -68,7 +74,7 @@ class FleetTripApiContractTest { if(!reference.startsWith("#/")) { String[] parts=reference.split("#",2); assertThat(parts).hasSize(2); - assertThat(parts[0]).isEqualTo("fleet-quote-candidates-v1.yaml"); + assertThat(Path.of(parts[0]).getFileName().toString()).isIn("fleet-quote-candidates-v1.yaml","fleet-trips-v1.yaml","fleet-capacity-offers-v1.yaml"); try { value=new Yaml().load(Files.readString(Path.of("src/main/resources/openapi",parts[0]))); } catch(java.io.IOException error) { throw new java.io.UncheckedIOException(error); } pointer="#"+parts[1]; diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripAuthorizationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripAuthorizationTest.java index e2dc143b7..a7e68abb8 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripAuthorizationTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripAuthorizationTest.java @@ -36,6 +36,7 @@ class FleetTripAuthorizationTest { assertThatThrownBy(() -> controller.cancel(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class); assertThatThrownBy(() -> controller.plan(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class); assertThatThrownBy(() -> controller.bind(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(() -> controller.prepare(null,null)).isInstanceOf(AccessDeniedException.class); assertThatThrownBy(() -> controller.unbind(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class); assertThatThrownBy(() -> controller.confirmLoading(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class); assertThatThrownBy(() -> controller.reviewDispatch(null, "TR-TEST", "DP-1", null)).isInstanceOf(AccessDeniedException.class); @@ -55,8 +56,25 @@ class FleetTripAuthorizationTest { verifyNoMoreInteractions(service); } finally { SecurityContextHolder.clearContext(); } } + @Test void poolReadDoesNotGrantIncomingReceiptOrDispatchCommands() { + try(var context=new AnnotationConfigApplicationContext(Config.class)) { + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("sales","unused",List.of(new SimpleGrantedAuthority(FleetCapacityOfferService.LIST)))); + var pool=context.getBean(PlatformFleetCapacityOfferController.class); + pool.fulfillment("FB-1");verify(context.getBean(FleetCapacityOfferService.class)).fulfillment("FB-1"); + assertThatThrownBy(()->pool.incoming("FB-1",null)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(()->pool.receipt("FB-1",null)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(()->pool.arrange("FB-1",null)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(()->pool.saveAndConfirm("FB-1",null)).isInstanceOf(AccessDeniedException.class); + } finally {SecurityContextHolder.clearContext();} + } @Configuration @EnableMethodSecurity static class Config { + @Bean FleetCapacityOfferCommandMapper commands() { return mock(FleetCapacityOfferCommandMapper.class); } + @Bean FleetDispatchPlanningGateway planning() { return mock(FleetDispatchPlanningGateway.class); } + @Bean FleetTripPreparationService preparation() { return mock(FleetTripPreparationService.class); } + @Bean com.baiyue.lastleg.appointmentplanning.AppointmentCoverageService appointmentCoverage() { return mock(com.baiyue.lastleg.appointmentplanning.AppointmentCoverageService.class); } + @Bean FleetCapacityOfferService capacityOffers() { return mock(FleetCapacityOfferService.class); } + @Bean PlatformFleetCapacityOfferController poolController(FleetCapacityOfferService service) { return new PlatformFleetCapacityOfferController(service); } @Bean com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge poolOrders() { return mock(com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge.class); } @Bean FleetBookingService bookingService() { return mock(FleetBookingService.class); } @Bean com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory customers() { return mock(com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory.class); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripPersistenceTest.java index 615002116..2c5d7d03b 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripPersistenceTest.java @@ -56,6 +56,8 @@ import static org.mockito.Mockito.*; class FleetTripPersistenceTest { private JdbcTemplate jdbc; private FleetTripService service; + private FleetTripPreparationService preparation; + private FleetDispatchPlanningGateway planning; private DriverService drivers; private VehicleRepository vehicles; private VehicleApplicationService vehicleService; @@ -141,6 +143,9 @@ class FleetTripPersistenceTest { transactional(workflow, DispatchPdaWorkflowService.class), session.getMapper(DeliveryOperationMapper.class)), DispatchFleetAdapter.class); service = transactional(new FleetTripService(session.getMapper(FleetTripMapper.class), resources, session.getMapper(FleetTripHistoryMapper.class), vehicles, drivers, directory, json, bindingMapper, dispatchGateway, reservations), FleetTripService.class); + preparation = transactional(new FleetTripPreparationService(service,session.getMapper(FleetTripMapper.class),session.getMapper(FleetCapacityOfferCommandMapper.class),json),FleetTripPreparationService.class); + planning=mock(FleetDispatchPlanningGateway.class); + ReflectionTestUtils.setField(service,"planning",planning); execution = transactional(new FleetTripExecutionService(session.getMapper(FleetTripMapper.class), bindingMapper, resources, dispatchGateway, service, drivers, vehicles, json, reservations), FleetTripExecutionService.class); var customerDirectory = mock(com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory.class); when(customerDirectory.required(anyLong())).thenAnswer(call -> new com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory.Customer( @@ -150,11 +155,47 @@ class FleetTripPersistenceTest { quoteCandidates=mock(com.baiyue.lastleg.fleet.quote.FleetQuoteCandidateService.class); bookingService = transactional(new FleetBookingService(session.getMapper(FleetTripMapper.class), bookingMapper, reservations, service, execution, customerDirectory, json, incoming, quoteContext, quoteCandidates), FleetBookingService.class); ReflectionTestUtils.setField(bookingService,"poolOrders",poolOrders); + ReflectionTestUtils.setField(bookingService,"planning",planning); + ReflectionTestUtils.setField(bookingService,"commands",session.getMapper(FleetCapacityOfferCommandMapper.class)); capacityOffers=transactional(new FleetCapacityOfferService(session.getMapper(FleetCapacityOfferMapper.class),session.getMapper(FleetCapacityOfferCommandMapper.class), session.getMapper(FleetTripMapper.class),bookingMapper,bookingService,reservations,execution,customerDirectory,poolOrders,json),FleetCapacityOfferService.class); + ReflectionTestUtils.setField(capacityOffers,"appointmentCoverage",mock(com.baiyue.lastleg.appointmentplanning.AppointmentCoverageService.class)); completion = transactional(new FleetTripCompletionService(session.getMapper(FleetTripMapper.class), bindingMapper, resources, dispatchGateway, service, vehicles, vehicleService, json), FleetTripCompletionService.class); } + @Test void preparationRollsBackNewTripAndResourcesIfAnyBindingFailsThenReplaysSuccess() { + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("1","unused",List.of("platform:fleet:trip:create","platform:fleet:trip:assign","platform:fleet:trip:edit").stream().map(SimpleGrantedAuthority::new).toList())); + long vehicle=vehicle(true), driver=driver(true);dispatch(21,"A","92");dispatch(22,"B","93"); + var request=new FleetTripPreparationService.Prepare(null,null,createRequest("prepare-atomic"),vehicle,driver,List.of("DP-21","DP-22"),"prepare-atomic"); + assertThatThrownBy(()->preparation.prepare("1",request)).hasMessageContaining("同一起运仓"); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip",Integer.class)).isZero(); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_dispatch",Integer.class)).isZero(); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment",Integer.class)).isZero(); + jdbc.update("UPDATE ll_dispatch_order SET overseas_warehouse_id='92' WHERE id=22"); + jdbc.update("UPDATE ll_dispatch_pda_task SET warehouse_id='92' WHERE dispatch_order_id=22"); + var saved=preparation.prepare("1",request); + assertThat(saved.dispatches()).hasSize(2); + assertThat(preparation.prepare("1",request)).isEqualTo(saved); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip",Integer.class)).isEqualTo(1); + assertThatThrownBy(()->preparation.prepare("1",new FleetTripPreparationService.Prepare(null,null,request.plan(),vehicle,driver,List.of("DP-21"),request.idempotencyKey()))).hasMessageContaining("不能更换内容"); + } + + @Test void sameWindowSaveAndConfirmIsAtomicAndReplaysOriginalResult() { + var offer=publishedOffer();var draft=capacityOffers.createInquiry(offer.offerNo(),poolDraft(offer,2,"prepare-register")); + var input=poolDraft(draft.offer(),2,"same-window-confirm"); + var update=new FleetCapacityOfferModels.InquiryWrite(input.tripRecordVersion(),input.offerRecordVersion(),draft.inquiry().recordVersion(),input.customerId(),input.businessType(),input.deliveryMode(),input.estimatedArrivalAt(),input.expectedPalletCount(),input.cargo(),input.quoteRef(),input.idempotencyKey()); + doThrow(new IllegalStateException("bridge failed")).when(poolOrders).confirm(any(),anyString()); + assertThatThrownBy(()->capacityOffers.saveAndConfirm(draft.inquiry().bookingNo(),update)).hasMessageContaining("bridge failed"); + assertThat(capacityOffers.inquiry(draft.inquiry().bookingNo()).recordVersion()).isEqualTo(draft.inquiry().recordVersion()); + assertThat(capacityOffers.inquiry(draft.inquiry().bookingNo()).status()).isEqualTo("DRAFT"); + reset(poolOrders); + var confirmed=capacityOffers.saveAndConfirm(draft.inquiry().bookingNo(),update); + assertThat(confirmed.inquiry().status()).isEqualTo("CONFIRMED"); + assertThat(confirmed.inquiry().progress().reservation()).isEqualTo("已保留货位"); + assertThat(capacityOffers.saveAndConfirm(draft.inquiry().bookingNo(),update).replayed()).isTrue(); + verify(poolOrders,times(1)).confirm(any(),anyString()); + } + @Test void poolSalesAreIsolatedByUserIdAndCannotConfirmOrReadAllInquiries() { var offer=publishedOffer();poolLogin(101,false); var first=capacityOffers.createInquiry(offer.offerNo(),poolDraft(offer,2,"owner-first")); @@ -443,7 +484,8 @@ class FleetTripPersistenceTest { } finally { executor.shutdownNow(); } } - @Test void externalReceiptChangesReservationOnlyAfterExplicitActualQuantityAndPriceConfirmation() { + @org.junit.jupiter.params.ParameterizedTest @org.junit.jupiter.params.provider.ValueSource(booleans={false,true}) + void externalReceiptChangesReservationOnlyAfterExplicitActualQuantityAndPriceConfirmation(boolean automatic) { var source=enableQuote(); String number=readyTrip();var basis=execution.capacityPlan(number); execution.confirmCapacityPlan(number,"1",new FleetExecutionModels.Confirm(basis.recordVersion(),basis.manifestHash(),List.of(measurement()),"计划")); @@ -451,7 +493,7 @@ class FleetTripPersistenceTest { var confirmed=bookingService.confirm(number,draft.booking().bookingNo(),"1",new FleetBookingModels.Command(draft.tripRecordVersion(),0L,"receipt-reserve",null)); var awaiting=new FleetBookingIncomingGateway.Evidence("WT-EXT","WT-EXT","92","FBA:ONT8","WAIT_PDA_RECEIVING","empty",List.of(),0); when(incoming.create(eq("1"),any())).thenReturn(awaiting); - var request=new FleetBookingModels.StartIncoming(confirmed.tripRecordVersion(),confirmed.booking().recordVersion(),null,null,null,null,null,null,"incoming-start"); + var request=new FleetBookingModels.StartIncoming(confirmed.tripRecordVersion(),confirmed.booking().recordVersion(),departure.minusSeconds(120),null,null,null,null,null,"incoming-start"); var started=bookingService.startIncoming(number,draft.booking().bookingNo(),"1",request); assertThat(bookingService.startIncoming(number,draft.booking().bookingNo(),"1",request).replayed()).isTrue(); verify(incoming,times(1)).create(eq("1"),any()); @@ -473,11 +515,20 @@ class FleetTripPersistenceTest { assertThat(execution.capacityPlan(number).availableSegments().get(0).remaining().palletSpaces()).isEqualTo(28); dispatch(13,"9","92"); jdbc.update("INSERT INTO ll_dispatch_item(id,dispatch_order_id,customer_id,warehouse_pallet_id,pallet_no,package_count,source_stage,destination_warehouse_code,overseas_warehouse_id,inbound_code) VALUES(3013,13,'9',902,'PALLET-902',22,'RECEIVED','ONT8','92','WT-EXT')"); - var linked=bookingService.bindDispatch(number,draft.booking().bookingNo(),"1",new FleetBookingModels.BindDispatch(accepted.tripRecordVersion(),accepted.booking().recordVersion(),"DP-13","link-external")); + when(incoming.inspect("9","WT-EXT",true)).thenReturn(new FleetBookingIncomingGateway.Evidence(actual.incomingNo(),actual.inboundCode(),actual.warehouseId(),actual.destinationKey(),"READY_FOR_DISPATCH",actual.receiptHash(),actual.pallets(),actual.receivedPackages())); + when(planning.resolveIncoming(eq("9"),any(),eq("1"))).thenReturn("DP-13"); + var arrange=new FleetBookingModels.Command(accepted.tripRecordVersion(),accepted.booking().recordVersion(),"auto-link-external",null); + var linked=automatic?bookingService.arrangeDispatch(number,draft.booking().bookingNo(),"1",arrange):bookingService.bindDispatch(number,draft.booking().bookingNo(),"1",new FleetBookingModels.BindDispatch(accepted.tripRecordVersion(),accepted.booking().recordVersion(),"DP-13","link-external")); + if(automatic){assertThat(bookingService.arrangeDispatch(number,draft.booking().bookingNo(),"1",arrange).replayed()).isTrue();verify(planning,times(1)).resolveIncoming(eq("9"),any(),eq("1"));} assertThat(linked.booking().status()).isEqualTo("BOUND");assertThat(linked.booking().dispatchNo()).isEqualTo("DP-13"); assertThat(execution.capacityPlan(number).planValid()).isFalse(); assertThat(service.detail(number).dispatches()).hasSize(3); - service.unbindDispatch(number,"1",new FleetTripModels.BindingCommand(linked.tripRecordVersion(),"DP-13","撤销未开工关联")); + var recheck=execution.capacityPlan(number); + var planned=execution.confirmCapacityPlan(number,"1",new FleetExecutionModels.Confirm(recheck.recordVersion(),recheck.manifestHash(),List.of(measurement(),new FleetExecutionModels.Measurement("902",1,new BigDecimal("150"),new BigDecimal("1.5"),1)),"关联实收后核对")); + assertThat(planned.capacityPlan().segments().get(0).occupied().palletSpaces()).isEqualTo(2); + assertThat(planned.availableSegments().get(0).remaining().palletSpaces()).isEqualTo(28); + if(automatic){var replay=bookingService.arrangeDispatch(number,draft.booking().bookingNo(),"1",arrange);assertThat(replay.tripRecordVersion()).isEqualTo(linked.tripRecordVersion());assertThat(replay.booking()).isEqualTo(linked.booking());} + service.unbindDispatch(number,"1",new FleetTripModels.BindingCommand(planned.recordVersion(),"DP-13","撤销未开工关联")); assertThat(bookingService.list(number).records().get(0).status()).isEqualTo("RECEIVED"); assertThat(bookingService.list(number).records().get(0).dispatchNo()).isNull(); }