diff --git a/config/nacos/cn/order-service.yml b/config/nacos/cn/order-service.yml index ffcc1335b..49e6cb174 100644 --- a/config/nacos/cn/order-service.yml +++ b/config/nacos/cn/order-service.yml @@ -8,3 +8,7 @@ spring: baiyue.clients: # 容器环境默认通过 Nacos 的 warehouse-service 实例调用;仅端口回归时显式设置直连 URL。 warehouse.url: ${WAREHOUSE_SERVICE_URL:} + +baiyue.order: + # 两套系统并跑期间统一使用旧系统入仓码;正式切换后改为 AUTO。 + inbound-code-mode: ${ORDER_INBOUND_CODE_MODE:LEGACY_MANUAL} diff --git a/config/nacos/local/order-service.yml b/config/nacos/local/order-service.yml index f46a32771..b6f9ec3b5 100644 --- a/config/nacos/local/order-service.yml +++ b/config/nacos/local/order-service.yml @@ -5,3 +5,7 @@ spring.datasource: password: ${ORDER_DB_PASSWORD:123456} baiyue.clients: warehouse.url: ${WAREHOUSE_SERVICE_URL:} + +baiyue.order: + # 两套系统并跑期间统一使用旧系统入仓码;正式切换后改为 AUTO。 + inbound-code-mode: ${ORDER_INBOUND_CODE_MODE:LEGACY_MANUAL} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java index e4f361d90..b367abcc9 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java @@ -31,6 +31,15 @@ public interface OrderCargoClient { @PathVariable long tenantId, @PathVariable long orderId); + @GetMapping("/{tenantId}/{orderId}/measurement-declaration") + ApiResponse measurementDeclaration( + @RequestHeader("X-Internal-Service-Token") String token, + @PathVariable long tenantId, @PathVariable long orderId); + + record MeasurementDeclarationSnapshot( + String referenceId, BigDecimal originalGrossWeightKg, + BigDecimal originalVolumeCbm, String defaultActualGoodsName) {} + record CargoBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName, Integer originalPackageCount, BigDecimal originalGrossWeightKg, BigDecimal originalVolumeCbm, String skuNo, String cargoNameEn, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java index f95135f66..dd369b08e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java @@ -190,7 +190,7 @@ public class PlatformInboundMeasurementController { resourceId = "#resultId", description = "确认 OCR 重量并累加实际总毛重") @Operation(summary = "确认 OCR 重量", description = "可在同一事务中携带人工毛重修改值并确认;统一换算 KG、扣除托盘皮重后," - + "按所有已确认净重重算入库单 actualGrossWeightKg。") + + "按所有已确认结果分别重算入库单毛重与扣皮后的货物净重。") @PostMapping("/weight-results/{resultId}/confirm") @PreAuthorize("hasAuthority('platform:firstleg:inbound:weight:confirm')" + " and (!#request.hasAdjustment() or hasAuthority('platform:firstleg:inbound:weight:edit'))") diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementReviewController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementReviewController.java index de04be97c..c96ac1791 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementReviewController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementReviewController.java @@ -6,7 +6,6 @@ import com.baiyue.common.audit.BusinessAudit; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.PageResult; import com.baiyue.common.security.AuthenticatedUser; -import com.baiyue.firstleg.dto.ExceptionReleaseRequest; import com.baiyue.firstleg.dto.MeasurementReviewRequest; import com.baiyue.firstleg.dto.MeasurementApprovalResponse; import com.baiyue.firstleg.entity.MeasureReviewRecordEntity; @@ -57,41 +56,6 @@ public class PlatformInboundMeasurementReviewController { request.measurementId(), request.expectedVersion() + 1)); } - @BusinessAudit(event = "INBOUND_MEASURE_REJECT", resourceType = "INBOUND", - resourceId = "#inboundId", description = "测量审核驳回") - @Operation(summary = "测量审核驳回", - description = "驳回原因必填,只退回测量数据修改;已确认的托盘和库位占用保持不动。") - @PostMapping("/{inboundId}/measurements/reject") - @PreAuthorize("hasAuthority('firstleg:inbound:measurement-review')") - public ApiResponse reject( - @AuthenticationPrincipal AuthenticatedUser user, - @RequestParam long tenantId, - @PathVariable long inboundId, - @RequestHeader("Idempotency-Key") String idempotencyKey, - @Valid @RequestBody MeasurementReviewRequest request) { - validateScope(user, tenantId); - service.reject(tenantId, user.userId(), user.username(), inboundId, idempotencyKey, request); - return ApiResponse.success(null); - } - - @BusinessAudit(event = "INBOUND_MEASURE_EXCEPTION_RELEASE", resourceType = "INBOUND", - resourceId = "#inboundId", description = "测量驳回异常放行") - @Operation(summary = "测量驳回异常放行", - description = "Nacos 开关开启、有异常放行权限且原因非空时,才可从 MEASURE_REJECTED 推进至 WAIT_SPLIT。") - @PostMapping("/{inboundId}/measurements/exception-release") - @PreAuthorize("hasAuthority('firstleg:inbound:exception-release')") - public ApiResponse exceptionRelease( - @AuthenticationPrincipal AuthenticatedUser user, - @RequestParam long tenantId, - @PathVariable long inboundId, - @RequestHeader("Idempotency-Key") String idempotencyKey, - @Valid @RequestBody ExceptionReleaseRequest request) { - validateScope(user, tenantId); - service.exceptionRelease( - tenantId, user.userId(), user.username(), inboundId, idempotencyKey, request); - return ApiResponse.success(null); - } - @Operation(summary = "查询测量审核历史") @GetMapping("/{inboundId}/measurement-review-history") @PreAuthorize("hasAuthority('firstleg:inbound:measurement-review-history')") diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/CurrentContainerTransport.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/CurrentContainerTransport.java new file mode 100644 index 000000000..3e571b147 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/CurrentContainerTransport.java @@ -0,0 +1,11 @@ +package com.baiyue.firstleg.dto; + +import java.time.LocalDateTime; + +/** 当前业务单元所关联柜次的正式运输事实。 */ +public record CurrentContainerTransport( + Long inboundId, Long accountId, Long containerBookingId, + String containerNo, String cabinetNo, String vesselName, String voyageNo, + LocalDateTime estimatedSailingAt, LocalDateTime actualSailingAt, + LocalDateTime estimatedPortArrivalAt, LocalDateTime actualPortArrivalAt +) {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementRequests.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementRequests.java index b6ab938c4..2590623f5 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementRequests.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementRequests.java @@ -1,5 +1,6 @@ package com.baiyue.firstleg.dto; +import com.fasterxml.jackson.annotation.JsonAnySetter; import jakarta.validation.Valid; import jakarta.validation.constraints.DecimalMin; import jakarta.validation.constraints.DecimalMax; @@ -26,12 +27,13 @@ public final class InboundMeasurementRequests { public record SaveDraftRequest( Long measurementId, @NotNull @PositiveOrZero Integer version, - @Size(max = 64) String originalDocumentNo, - @DecimalMin("0.000") @Digits(integer = 15, fraction = 3) BigDecimal originalGrossWeightKg, - @DecimalMin("0.0000") @Digits(integer = 14, fraction = 4) BigDecimal originalVolumeCbm, @Size(max = 255) String actualGoodsName, @NotEmpty @Size(max = 500) List<@Valid ItemRequest> items ) { + @JsonAnySetter + public void rejectUnknownField(String fieldName, Object ignored) { + throw new IllegalArgumentException("测量草稿不接受字段: " + fieldName); + } } public record ItemRequest( diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementResponses.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementResponses.java index 0f74b7382..3c608af80 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementResponses.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementResponses.java @@ -40,6 +40,7 @@ public final class InboundMeasurementResponses { Integer originalPackageCount, Integer actualPackageCount, BigDecimal actualGrossWeightKg, + BigDecimal actualNetWeightKg, BigDecimal actualVolumeCbm, BigDecimal volumeWeightKg, String actualGoodsName, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundOrderSummary.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundOrderSummary.java index 0770077a4..f5a98f09a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundOrderSummary.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundOrderSummary.java @@ -28,12 +28,25 @@ public record InboundOrderSummary( LocalDateTime measureCompletedAt, LocalDateTime inboundedAt, String expressCompanyCode, String expressCompanyName, String expressTrackingNo, String containerNo, String cabinetNo, - String vesselName, String voyageNo, LocalDateTime sailingAt, LocalDateTime portArrivalAt, + String vesselName, String voyageNo, + LocalDateTime estimatedSailingAt, LocalDateTime actualSailingAt, + LocalDateTime estimatedPortArrivalAt, LocalDateTime actualPortArrivalAt, LocalDateTime createdAt, LocalDateTime updatedAt ) { + /** 非统一入仓接口保留既有快照适配;统一订单不得调用此重载。 */ public static InboundOrderSummary from( InboundOrderEntity order, InboundTransportSnapshotEntity transport, InboundPickupSnapshotEntity pickup, String locationCodes) { + CurrentContainerTransport adapted = transport == null ? null : new CurrentContainerTransport( + order.getId(), order.getTenantId(), null, transport.getContainerNo(), transport.getCabinetNo(), + transport.getVesselName(), transport.getVoyageNo(), transport.getSailingAt(), null, + transport.getPortArrivalAt(), null); + return from(order, adapted, pickup, locationCodes); + } + + public static InboundOrderSummary from( + InboundOrderEntity order, CurrentContainerTransport transport, + InboundPickupSnapshotEntity pickup, String locationCodes) { return new InboundOrderSummary( order.getId(), order.getTenantId(), order.getOrderId(), order.getOrderNo(), order.getInboundCode(), order.getProcessStatus(), order.getOrderStatusSnapshot(), order.getMeasureReviewStatus(), @@ -53,12 +66,14 @@ public record InboundOrderSummary( locationCodes, order.getMeasureCompletedAt(), order.getInboundedAt(), order.getExpressCompanyCode(), order.getExpressCompanyName(), order.getExpressTrackingNo(), - transport == null ? null : transport.getContainerNo(), - transport == null ? null : transport.getCabinetNo(), - transport == null ? null : transport.getVesselName(), - transport == null ? null : transport.getVoyageNo(), - transport == null ? null : transport.getSailingAt(), - transport == null ? null : transport.getPortArrivalAt(), + transport == null ? null : transport.containerNo(), + transport == null ? null : transport.cabinetNo(), + transport == null ? null : transport.vesselName(), + transport == null ? null : transport.voyageNo(), + transport == null ? null : transport.estimatedSailingAt(), + transport == null ? null : transport.actualSailingAt(), + transport == null ? null : transport.estimatedPortArrivalAt(), + transport == null ? null : transport.actualPortArrivalAt(), order.getCreatedAt(), order.getUpdatedAt() ); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/MerchantInboundBillingResponse.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/MerchantInboundBillingResponse.java index 22dd6a734..c0e3e2cca 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/MerchantInboundBillingResponse.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/MerchantInboundBillingResponse.java @@ -19,7 +19,7 @@ public record MerchantInboundBillingResponse(InboundOrderDetail inbound, public record MeasurementSummary( Long measurementId, String sourceType, String measurementStatus, - Integer actualPackageCount, BigDecimal actualGrossWeightKg, + Integer actualPackageCount, BigDecimal actualGrossWeightKg, BigDecimal actualNetWeightKg, BigDecimal actualVolumeCbm, BigDecimal volumeWeightKg, String actualGoodsName) { } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundPricingEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundPricingEntity.java index 3796af551..a8429d117 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundPricingEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundPricingEntity.java @@ -36,6 +36,7 @@ public class InboundPricingEntity extends BaseEntity { private Long destinationWarehouseId; private String destinationName; private BigDecimal actualGrossWeightKg; + private BigDecimal actualNetWeightKg; private BigDecimal actualVolumeCbm; private BigDecimal chargeableWeightKg; private BigDecimal chargeableVolumeCbm; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundOrderMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundOrderMapper.java index 7765e6957..c5663cf46 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundOrderMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundOrderMapper.java @@ -12,19 +12,14 @@ public interface InboundOrderMapper extends BaseMapper { @Update(""" UPDATE fl_inbound_order SET process_status = 'WAIT_SPLIT', measure_review_status = 'APPROVED', - actual_gross_weight_kg = CASE - WHEN EXISTS (SELECT 1 - FROM fl_inbound_weight_result weight - WHERE weight.tenant_id = #{tenantId} AND weight.inbound_id = #{inboundId} - AND weight.deleted = 0 AND weight.recognition_status = 'CONFIRMED') - THEN (SELECT COALESCE(SUM(weight.net_weight_kg), 0) - FROM fl_inbound_weight_result weight - WHERE weight.tenant_id = #{tenantId} AND weight.inbound_id = #{inboundId} - AND weight.deleted = 0 AND weight.recognition_status = 'CONFIRMED') - ELSE (SELECT COALESCE(SUM(item.total_weight_kg), 0) - FROM fl_inbound_measurement_item item - WHERE item.tenant_id = #{tenantId} AND item.measurement_id = #{measurementId}) - END, + actual_gross_weight_kg = (SELECT SUM(weight.gross_weight_kg) + FROM fl_inbound_weight_result weight + WHERE weight.tenant_id = #{tenantId} AND weight.inbound_id = #{inboundId} + AND weight.deleted = 0 AND weight.recognition_status = 'CONFIRMED'), + actual_net_weight_kg = (SELECT SUM(weight.net_weight_kg) + FROM fl_inbound_weight_result weight + WHERE weight.tenant_id = #{tenantId} AND weight.inbound_id = #{inboundId} + AND weight.deleted = 0 AND weight.recognition_status = 'CONFIRMED'), actual_volume_cbm = (SELECT COALESCE(SUM(item.total_volume_cbm), 0) FROM fl_inbound_measurement_item item WHERE item.tenant_id = #{tenantId} AND item.measurement_id = #{measurementId}), diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/UnifiedInboundTransportMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/UnifiedInboundTransportMapper.java new file mode 100644 index 000000000..b27dc0ed5 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/UnifiedInboundTransportMapper.java @@ -0,0 +1,29 @@ +package com.baiyue.firstleg.mapper; + +import com.baiyue.firstleg.dto.CurrentContainerTransport; +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 UnifiedInboundTransportMapper { + @Select(""" + + """) + List findCurrentByInboundIds(@Param("inboundIds") List inboundIds); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundMeasurementRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundMeasurementRepository.java index d367442c7..8bc8cf9f3 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundMeasurementRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundMeasurementRepository.java @@ -59,7 +59,8 @@ public class InboundMeasurementRepository { return inboundOrders.update(inbound, new LambdaUpdateWrapper() .eq(InboundOrderEntity::getTenantId, inbound.getTenantId()) .eq(InboundOrderEntity::getId, inbound.getId()) - .set(InboundOrderEntity::getActualGrossWeightKg, inbound.getActualGrossWeightKg())) == 1; + .set(InboundOrderEntity::getActualGrossWeightKg, inbound.getActualGrossWeightKg()) + .set(InboundOrderEntity::getActualNetWeightKg, inbound.getActualNetWeightKg())) == 1; } public InboundMeasurementEntity findMeasurement(long tenantId, long inboundId, long measurementId) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundOrderRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundOrderRepository.java index 6c0027f98..3d9de0415 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundOrderRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundOrderRepository.java @@ -154,29 +154,31 @@ public class InboundOrderRepository { + "OR (newer.fee_version = f.fee_version AND newer.id > f.id))))", query.feeReviewStatus()) .apply(StringUtils.hasText(query.containerNo()), - "EXISTS (SELECT 1 FROM fl_inbound_transport_snapshot t " - + "WHERE t.tenant_id = fl_inbound_order.tenant_id AND t.inbound_id = fl_inbound_order.id " - + "AND t.container_no LIKE CONCAT('%', {0}, '%'))", query.containerNo()) + "EXISTS (SELECT 1 FROM fl_loading_plan_item i JOIN fl_loading_plan p ON p.id=i.loading_plan_id " + + "JOIN fl_container_booking b ON b.id=p.container_booking_id WHERE i.deleted=0 " + + "AND p.plan_status IN ('LOADING','COMPLETED') AND i.account_id=fl_inbound_order.tenant_id " + + "AND i.inbound_id=fl_inbound_order.id AND b.container_no LIKE CONCAT('%', {0}, '%'))", query.containerNo()) .apply(StringUtils.hasText(query.cabinetNo()), - "EXISTS (SELECT 1 FROM fl_inbound_transport_snapshot t " - + "WHERE t.tenant_id = fl_inbound_order.tenant_id AND t.inbound_id = fl_inbound_order.id " - + "AND t.cabinet_no LIKE CONCAT('%', {0}, '%'))", query.cabinetNo()) + "EXISTS (SELECT 1 FROM fl_loading_plan_item i JOIN fl_loading_plan p ON p.id=i.loading_plan_id " + + "JOIN fl_container_booking b ON b.id=p.container_booking_id WHERE i.deleted=0 " + + "AND p.plan_status IN ('LOADING','COMPLETED') AND i.account_id=fl_inbound_order.tenant_id " + + "AND i.inbound_id=fl_inbound_order.id AND b.cabinet_no LIKE CONCAT('%', {0}, '%'))", query.cabinetNo()) .apply(query.sailingDate() != null, - "EXISTS (SELECT 1 FROM fl_inbound_transport_snapshot t " - + "WHERE t.tenant_id = fl_inbound_order.tenant_id AND t.inbound_id = fl_inbound_order.id " - + "AND t.sailing_at >= {0} AND t.sailing_at < {1})", + "EXISTS (SELECT 1 FROM fl_loading_plan_item i JOIN fl_loading_plan p ON p.id=i.loading_plan_id " + + "JOIN fl_container_booking b ON b.id=p.container_booking_id WHERE i.deleted=0 " + + "AND p.plan_status IN ('LOADING','COMPLETED') AND i.account_id=fl_inbound_order.tenant_id " + + "AND i.inbound_id=fl_inbound_order.id AND COALESCE(b.actual_sailing_at,b.estimated_sailing_at) " + + ">= {0} AND COALESCE(b.actual_sailing_at,b.estimated_sailing_at) < {1})", query.sailingDate() == null ? null : query.sailingDate().atStartOfDay(), query.sailingDate() == null ? null : query.sailingDate().plusDays(1).atStartOfDay()) .apply(Boolean.TRUE.equals(query.loaded()), - "EXISTS (SELECT 1 FROM fl_inbound_transport_snapshot t " - + "WHERE t.tenant_id = fl_inbound_order.tenant_id AND t.inbound_id = fl_inbound_order.id " - + "AND COALESCE(TRIM(t.loading_status), '') <> '' " - + "AND t.loading_status NOT IN ('UNLOADED', 'PENDING'))") + "EXISTS (SELECT 1 FROM fl_loading_plan_item i JOIN fl_loading_plan p ON p.id=i.loading_plan_id " + + "WHERE i.deleted=0 AND p.plan_status='COMPLETED' " + + "AND i.account_id=fl_inbound_order.tenant_id AND i.inbound_id=fl_inbound_order.id)") .apply(Boolean.FALSE.equals(query.loaded()), - "NOT EXISTS (SELECT 1 FROM fl_inbound_transport_snapshot t " - + "WHERE t.tenant_id = fl_inbound_order.tenant_id AND t.inbound_id = fl_inbound_order.id " - + "AND COALESCE(TRIM(t.loading_status), '') <> '' " - + "AND t.loading_status NOT IN ('UNLOADED', 'PENDING'))") + "NOT EXISTS (SELECT 1 FROM fl_loading_plan_item i JOIN fl_loading_plan p ON p.id=i.loading_plan_id " + + "WHERE i.deleted=0 AND p.plan_status='COMPLETED' " + + "AND i.account_id=fl_inbound_order.tenant_id AND i.inbound_id=fl_inbound_order.id)") .ge(query.createdFrom() != null, InboundOrderEntity::getCreatedAt, query.createdFrom()) .le(query.createdTo() != null, InboundOrderEntity::getCreatedAt, query.createdTo()) .orderByDesc(InboundOrderEntity::getCreatedAt).orderByDesc(InboundOrderEntity::getId); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/UnifiedInboundReadRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/UnifiedInboundReadRepository.java index 2ca7baca5..5a8fbe592 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/UnifiedInboundReadRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/UnifiedInboundReadRepository.java @@ -8,9 +8,11 @@ import com.baiyue.firstleg.loading.mapper.InboundLoadingPickTaskMapper; import com.baiyue.firstleg.split.entity.PreloadPlanEntity; import com.baiyue.firstleg.split.mapper.PreloadPlanMapper; import com.baiyue.firstleg.entity.InboundStatusHistoryEntity; -import com.baiyue.firstleg.entity.InboundTransportSnapshotEntity; +import com.baiyue.firstleg.dto.CurrentContainerTransport; import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper; -import com.baiyue.firstleg.mapper.InboundTransportSnapshotMapper; +import com.baiyue.firstleg.mapper.UnifiedInboundTransportMapper; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; import jakarta.annotation.Resource; import org.springframework.stereotype.Repository; @@ -26,7 +28,7 @@ public class UnifiedInboundReadRepository { @Resource private InboundLoadingPickTaskMapper picks; @Resource private LoadingEvidenceMapper evidences; @Resource private InboundStatusHistoryMapper histories; - @Resource private InboundTransportSnapshotMapper transports; + @Resource private UnifiedInboundTransportMapper transports; public PreloadPlanEntity findPreload(long tenantId, long inboundId) { return preloads.selectOne(new LambdaQueryWrapper() @@ -84,19 +86,23 @@ public class UnifiedInboundReadRepository { Function.identity(), (latest, ignored) -> latest)); } - public InboundTransportSnapshotEntity findTransport(long tenantId, long inboundId) { - return transports.selectOne(new LambdaQueryWrapper() - .eq(InboundTransportSnapshotEntity::getTenantId, tenantId) - .eq(InboundTransportSnapshotEntity::getInboundId, inboundId)); + public CurrentContainerTransport findTransport(long tenantId, long inboundId) { + return findTransports(Map.of(inboundId, tenantId)).get(inboundId); } - public Map findTransports(Map tenantByInbound) { + public Map findTransports(Map tenantByInbound) { if (tenantByInbound.isEmpty()) return Map.of(); - return transports.selectList(new LambdaQueryWrapper() - .in(InboundTransportSnapshotEntity::getInboundId, tenantByInbound.keySet())) - .stream().filter(item -> owns(tenantByInbound, item.getInboundId(), item.getTenantId())) - .collect(Collectors.toMap(InboundTransportSnapshotEntity::getInboundId, - Function.identity(), (left, right) -> left)); + Map> grouped = transports + .findCurrentByInboundIds(List.copyOf(tenantByInbound.keySet())).stream() + .filter(item -> owns(tenantByInbound, item.inboundId(), item.accountId())) + .collect(Collectors.groupingBy(CurrentContainerTransport::inboundId)); + grouped.forEach((inboundId, rows) -> { + long bookingCount = rows.stream().map(CurrentContainerTransport::containerBookingId).distinct().count(); + if (bookingCount > 1) throw new BizException(ResultCode.CONFLICT, + "同一入库业务单元关联多个有效柜次: inboundId=" + inboundId); + }); + return grouped.entrySet().stream().collect(Collectors.toMap(Map.Entry::getKey, + entry -> entry.getValue().get(0))); } private static boolean owns(Map tenantByInbound, Long inboundId, Long tenantId) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/CartonMeasurementService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/CartonMeasurementService.java index 6fc197536..f33086aa6 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/CartonMeasurementService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/CartonMeasurementService.java @@ -150,21 +150,24 @@ public class CartonMeasurementService { .filter(weight -> "CONFIRMED".equals(weight.getRecognitionStatus())) .toList(); if (!confirmedWeights.isEmpty()) { + if (confirmedWeights.stream().anyMatch(weight -> !positive(weight.getGrossWeightKg()) + || !positive(weight.getNetWeightKg()))) { + throw new BizException(ResultCode.VALIDATION_FAILED, + "已确认称重结果缺少有效毛重或净重,请重新确认后再修改箱数"); + } + BigDecimal confirmedGrossWeight = confirmedWeights.stream() + .map(InboundWeightResultEntity::getGrossWeightKg) + .reduce(BigDecimal.ZERO, BigDecimal::add) + .setScale(3, RoundingMode.HALF_UP); BigDecimal confirmedNetWeight = confirmedWeights.stream() .map(InboundWeightResultEntity::getNetWeightKg) - .filter(java.util.Objects::nonNull) .reduce(BigDecimal.ZERO, BigDecimal::add) - .setScale(3, RoundingMode.CEILING); - inbound.setActualGrossWeightKg(confirmedNetWeight); + .setScale(3, RoundingMode.HALF_UP); + inbound.setActualGrossWeightKg(confirmedGrossWeight); + inbound.setActualNetWeightKg(confirmedNetWeight); } else { - BigDecimal totalWeight = items.stream() - .map(InboundMeasurementItemEntity::getTotalWeightKg) - .filter(java.util.Objects::nonNull) - .reduce(BigDecimal.ZERO, BigDecimal::add) - .setScale(3, RoundingMode.CEILING); - if (positive(totalWeight)) { - inbound.setActualGrossWeightKg(totalWeight); - } + inbound.setActualGrossWeightKg(null); + inbound.setActualNetWeightKg(null); } inbound.setActualVolumeCbm(measurement.getTotalVolumeCbm()); inbound.setVolumeWeightKg(measurement.getVolumeWeightKg()); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java index 5fbc5760f..a5f92a869 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java @@ -58,9 +58,9 @@ public class FbaPricingService { ActivePriceBookEstimator.Estimate estimate = priceBookEstimator.estimate(new EstimateRequest( inbound.getWarehouseId(), inbound.getChannelCode(), inbound.getDestinationCode(), destinationType(inbound), inbound.getDestinationWarehouseId(), inbound.getDestinationName(), null, - pricingTaxMode(inbound), inbound.getActualGrossWeightKg(), inbound.getActualVolumeCbm())); + pricingTaxMode(inbound), inbound.getActualNetWeightKg(), inbound.getActualVolumeCbm())); RateRow rate = estimate.rate(); - BigDecimal weight = inbound.getActualGrossWeightKg().setScale(3, RoundingMode.HALF_UP); + BigDecimal weight = inbound.getActualNetWeightKg().setScale(3, RoundingMode.HALF_UP); BigDecimal volume = inbound.getActualVolumeCbm().setScale(4, RoundingMode.HALF_UP); BigDecimal chargeable = estimate.chargeableQuantity(); BigDecimal converted = chargeable; @@ -240,9 +240,9 @@ public class FbaPricingService { throw conflict("当前目的地类型不支持自动计费"); } if (!"APPROVED".equals(inbound.getMeasureReviewStatus())) throw conflict("只能消费审核通过的测量快照"); - if (inbound.getActualGrossWeightKg() == null || inbound.getActualGrossWeightKg().signum() <= 0 + if (inbound.getActualNetWeightKg() == null || inbound.getActualNetWeightKg().signum() <= 0 || inbound.getActualVolumeCbm() == null || inbound.getActualVolumeCbm().signum() <= 0) { - throw conflict("审核通过的测量快照必须包含大于零的实重和体积"); + throw conflict("审核通过的测量快照必须包含大于零的货物净重和体积"); } if (inbound.getCustomerId() == null || inbound.getCustomerId() <= 0) { throw conflict("入库单缺少订单客户归属,不能生成对客费用"); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundFeeDetailService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundFeeDetailService.java index 84cf4c698..0662f143b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundFeeDetailService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundFeeDetailService.java @@ -390,7 +390,8 @@ public class InboundFeeDetailService { value.setDestinationCode(inbound.getDestinationCode()); value.setDestinationName(inbound.getDestinationName()); value.setPackageCount(inbound.getTotalPackageCount()); - value.setActualGrossWeightKg(inbound.getActualGrossWeightKg()); + // 财务历史字段名保留,但计费事实统一使用扣除托盘皮重后的货物净重。 + value.setActualGrossWeightKg(inbound.getActualNetWeightKg()); value.setActualVolumeCbm(inbound.getActualVolumeCbm()); value.setVolumeWeightKg(inbound.getVolumeWeightKg()); value.setChargeableWeightKg(inbound.getChargeableWeightKg()); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java index ce2c9a87e..c280a3bea 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java @@ -68,7 +68,9 @@ public class InboundMeasurementService { ? List.of() : repository.listItems(tenantId, measurement.getId()); List weights = repository.listWeights(tenantId, inboundId); MeasureReviewRecordEntity review = repository.findLatestReview(tenantId, inboundId); - return toDetail(inbound, measurement, items, weights, review); + OrderCargoClient.MeasurementDeclarationSnapshot declaration = needsDeclarationFallback(measurement) + ? loadDeclaration(inbound) : null; + return toDetail(inbound, measurement, items, weights, review, declaration); } public List weightPalletOptions( @@ -87,11 +89,23 @@ public class InboundMeasurementService { InboundMeasurementRequests.SaveDraftRequest request) { InboundOrderEntity inbound = requireInbound(tenantId, inboundId); validateInboundDetails(tenantId, inbound, request.items()); + InboundMeasurementEntity existing = request.measurementId() == null ? null + : repository.findMeasurement(tenantId, inboundId, request.measurementId()); + OrderCargoClient.MeasurementDeclarationSnapshot declaration = loadDeclaration(inbound); + String actualGoodsName = StringUtils.hasText(request.actualGoodsName()) ? request.actualGoodsName().trim() + : existing != null && StringUtils.hasText(existing.getActualGoodsName()) + ? existing.getActualGoodsName() : defaultGoodsName(declaration); MeasurementBatchCommand command = new MeasurementBatchCommand( tenantId, inboundId, request.measurementId(), request.version(), MeasurementBatchService.SOURCE_MANUAL, "PLATFORM", null, null, null, - LocalDateTime.now(), null, request.originalDocumentNo(), - request.originalGrossWeightKg(), request.originalVolumeCbm(), request.actualGoodsName(), + LocalDateTime.now(), null, + existing != null && StringUtils.hasText(existing.getOriginalDocumentNo()) + ? existing.getOriginalDocumentNo() : declaration.referenceId(), + existing != null && existing.getOriginalGrossWeightKg() != null + ? existing.getOriginalGrossWeightKg() : declaration.originalGrossWeightKg(), + existing != null && existing.getOriginalVolumeCbm() != null + ? existing.getOriginalVolumeCbm() : declaration.originalVolumeCbm(), + actualGoodsName, operatorId, request.items().stream().map(item -> new MeasurementBatchCommand.Item( item.inboundDetailId(), item.itemNo(), item.boxSpecification(), item.packageCount(), item.lengthCm(), item.widthCm(), item.heightCm(), item.unitWeightKg(), item.intelligentImageFileId(), @@ -116,10 +130,7 @@ public class InboundMeasurementService { } List items = repository.listItems(tenantId, measurement.getId()); List weights = repository.listWeights(tenantId, inboundId); - BigDecimal confirmedWeight = confirmedWeightForSubmission(weights); - int confirmedPalletCount = Math.toIntExact(weights.stream() - .filter(weight -> WEIGHT_CONFIRMED.equals(weight.getRecognitionStatus())) - .count()); + ConfirmedWeightTotals confirmedTotals = confirmedWeightTotals(weights); if (!alreadyAdvanced) { requireConfirmedPackageTotal(inbound, items); @@ -137,10 +148,9 @@ public class InboundMeasurementService { if (measurement.getTotalPackageCount() != null) { inbound.setTotalPackageCount(measurement.getTotalPackageCount()); } - if (confirmedWeight != null) { - inbound.setActualGrossWeightKg(confirmedWeight); - } - inbound.setPalletCount(confirmedPalletCount); + inbound.setActualGrossWeightKg(confirmedTotals.grossWeightKg()); + inbound.setActualNetWeightKg(confirmedTotals.netWeightKg()); + inbound.setPalletCount(confirmedTotals.palletCount()); if (measurement.getTotalVolumeCbm() != null) { inbound.setActualVolumeCbm(measurement.getTotalVolumeCbm()); } @@ -161,6 +171,7 @@ public class InboundMeasurementService { long tenantId, long inboundId, long operatorId, InboundMeasurementRequests.CreateWeightResultRequest request) { InboundOrderEntity inbound = requireInbound(tenantId, inboundId); + requireWeightEditableInbound(inbound); if (repository.findWeightByFile(tenantId, inboundId, request.fileId()) != null) { throw new BizException(ResultCode.CONFLICT, "同一称重图片不能重复计重"); } @@ -192,6 +203,7 @@ public class InboundMeasurementService { weight.setDeleted(false); weight.setVersion(0); repository.insertWeight(weight); + recalculateConfirmedWeight(inbound, operatorId); return toWeight(weight); } @@ -200,14 +212,12 @@ public class InboundMeasurementService { long tenantId, long inboundId, long resultId, long operatorId, String operatorName, InboundMeasurementRequests.CorrectWeightResultRequest request) { InboundOrderEntity inbound = requireInbound(tenantId, inboundId); + requireWeightEditableInbound(inbound); InboundWeightResultEntity weight = requireWeight(tenantId, inboundId, resultId); requireVersion(request.version(), weight.getVersion()); - boolean wasConfirmed = WEIGHT_CONFIRMED.equals(weight.getRecognitionStatus()); applyWeightCorrection(weight, request.version(), request.adjustedWeightValue(), request.adjustedWeightUnit(), request.adjustmentReason(), operatorId, operatorName); - if (wasConfirmed) { - recalculateConfirmedWeight(inbound, operatorId); - } + recalculateConfirmedWeight(inbound, operatorId); return toWeight(weight); } @@ -246,6 +256,7 @@ public class InboundMeasurementService { long tenantId, long inboundId, long resultId, long operatorId, String operatorName, InboundMeasurementRequests.ConfirmWeightResultRequest request) { InboundOrderEntity inbound = requireInbound(tenantId, inboundId); + requireWeightConfirmableInbound(inbound, request.hasAdjustment()); InboundWeightResultEntity weight = requireWeight(tenantId, inboundId, resultId); requireVersion(request.version(), weight.getVersion()); int currentVersion = request.version(); @@ -291,19 +302,15 @@ public class InboundMeasurementService { @Transactional public void deleteWeightResult(long tenantId, long inboundId, long resultId, - int version, long operatorId) { + int version, long operatorId) { InboundOrderEntity inbound = requireInbound(tenantId, inboundId); - if (!WEIGHT_EDITABLE_STATUSES.contains(inbound.getProcessStatus())) { - throw new BizException(ResultCode.CONFLICT, "当前入库状态不允许删除称重图片"); - } + requireWeightEditableInbound(inbound); InboundWeightResultEntity weight = requireWeight(tenantId, inboundId, resultId); requireVersion(version, weight.getVersion()); if (!repository.deleteWeight(tenantId, inboundId, resultId, version)) { throw concurrentModification(); } - if (WEIGHT_CONFIRMED.equals(weight.getRecognitionStatus())) { - recalculateConfirmedWeight(inbound, operatorId); - } + recalculateConfirmedWeight(inbound, operatorId); try { ApiResponse response = imageClient.delete( internalToken, operatorId, tenantId, inboundId, weight.getFileId()); @@ -319,15 +326,10 @@ public class InboundMeasurementService { } private void recalculateConfirmedWeight(InboundOrderEntity inbound, long operatorId) { - List confirmedWeights = repository - .listWeights(inbound.getTenantId(), inbound.getId()).stream() - .filter(weight -> WEIGHT_CONFIRMED.equals(weight.getRecognitionStatus())) - .toList(); - BigDecimal total = confirmedWeights.stream() - .map(InboundWeightResultEntity::getNetWeightKg) - .reduce(BigDecimal.ZERO, BigDecimal::add) - .setScale(3, RoundingMode.HALF_UP); - inbound.setActualGrossWeightKg(confirmedWeights.isEmpty() ? null : total); + ConfirmedWeightTotals totals = confirmedWeightTotals( + repository.listWeights(inbound.getTenantId(), inbound.getId())); + inbound.setActualGrossWeightKg(totals.grossWeightKg()); + inbound.setActualNetWeightKg(totals.netWeightKg()); if ("WAIT_MEASURE".equals(inbound.getProcessStatus())) { inbound.setProcessStatus("MEASURING"); } @@ -337,13 +339,31 @@ public class InboundMeasurementService { } } - private BigDecimal confirmedWeightForSubmission(List weights) { - BigDecimal total = weights.stream() + private ConfirmedWeightTotals confirmedWeightTotals(List weights) { + List confirmed = weights.stream() .filter(weight -> WEIGHT_CONFIRMED.equals(weight.getRecognitionStatus())) + .toList(); + if (confirmed.isEmpty()) { + return new ConfirmedWeightTotals(null, null, 0); + } + if (confirmed.stream().anyMatch(weight -> !isPositive(weight.getGrossWeightKg()) + || !isPositive(weight.getNetWeightKg()))) { + throw new BizException(ResultCode.VALIDATION_FAILED, + "已确认称重结果缺少有效毛重或净重,请重新确认后再继续"); + } + BigDecimal grossWeightKg = confirmed.stream() + .map(InboundWeightResultEntity::getGrossWeightKg) + .reduce(BigDecimal.ZERO, BigDecimal::add) + .setScale(3, RoundingMode.HALF_UP); + BigDecimal netWeightKg = confirmed.stream() .map(InboundWeightResultEntity::getNetWeightKg) - .filter(java.util.Objects::nonNull) - .reduce(BigDecimal.ZERO, BigDecimal::add).setScale(3, RoundingMode.HALF_UP); - return isPositive(total) ? total : null; + .reduce(BigDecimal.ZERO, BigDecimal::add) + .setScale(3, RoundingMode.HALF_UP); + return new ConfirmedWeightTotals(grossWeightKg, netWeightKg, confirmed.size()); + } + + private record ConfirmedWeightTotals( + BigDecimal grossWeightKg, BigDecimal netWeightKg, int palletCount) { } private void requireCompleteAdjustment(BigDecimal value, String unit, String reason) { @@ -353,6 +373,23 @@ public class InboundMeasurementService { } } + private void requireWeightEditableInbound(InboundOrderEntity inbound) { + if (!WEIGHT_EDITABLE_STATUSES.contains(inbound.getProcessStatus())) { + throw new BizException(ResultCode.CONFLICT, "当前入库状态不允许修改称重结果"); + } + } + + private void requireWeightConfirmableInbound(InboundOrderEntity inbound, boolean hasAdjustment) { + if (WEIGHT_EDITABLE_STATUSES.contains(inbound.getProcessStatus())) { + return; + } + if ("MEASURE_PENDING".equals(inbound.getProcessStatus()) && hasAdjustment) { + return; + } + throw new BizException(ResultCode.CONFLICT, + "当前入库状态不允许确认称重结果,待审核状态仅允许人工修正并确认"); + } + private void insertRevision(InboundWeightResultEntity weight, int revisionNo, String action, BigDecimal beforeValue, String beforeUnit, BigDecimal afterValue, String afterUnit, @@ -380,7 +417,7 @@ public class InboundMeasurementService { private InboundMeasurementResponses.Detail toDetail( InboundOrderEntity inbound, InboundMeasurementEntity measurement, List items, List weights, - MeasureReviewRecordEntity review) { + MeasureReviewRecordEntity review, OrderCargoClient.MeasurementDeclarationSnapshot declaration) { return new InboundMeasurementResponses.Detail( inbound.getId(), inbound.getInboundCode(), inbound.getCompanyName(), inbound.getChannelCode(), inbound.getChannelName(), inbound.getProcessStatus(), inbound.getMeasureReviewStatus(), @@ -391,18 +428,58 @@ public class InboundMeasurementService { measurement == null ? null : measurement.getStatus(), inbound.getVersion(), measurement == null ? null : measurement.getVersion(), - measurement == null ? null : measurement.getOriginalDocumentNo(), - measurement == null ? null : measurement.getOriginalGrossWeightKg(), - measurement == null ? null : measurement.getOriginalVolumeCbm(), + measurement != null && StringUtils.hasText(measurement.getOriginalDocumentNo()) + ? measurement.getOriginalDocumentNo() : declaration == null ? null : declaration.referenceId(), + measurement != null && measurement.getOriginalGrossWeightKg() != null + ? measurement.getOriginalGrossWeightKg() : declaration == null ? null : declaration.originalGrossWeightKg(), + measurement != null && measurement.getOriginalVolumeCbm() != null + ? measurement.getOriginalVolumeCbm() : declaration == null ? null : declaration.originalVolumeCbm(), inbound.getTotalPackageCount(), measurement == null ? inbound.getTotalPackageCount() : measurement.getTotalPackageCount(), inbound.getActualGrossWeightKg(), + inbound.getActualNetWeightKg(), measurement == null ? inbound.getActualVolumeCbm() : measurement.getTotalVolumeCbm(), measurement == null ? inbound.getVolumeWeightKg() : measurement.getVolumeWeightKg(), - measurement == null ? null : measurement.getActualGoodsName(), + measurement != null && StringUtils.hasText(measurement.getActualGoodsName()) + ? measurement.getActualGoodsName() : declaration == null ? null : declaration.defaultActualGoodsName(), items.stream().map(this::toItem).toList(), weights.stream().map(this::toWeight).toList()); } + private boolean needsDeclarationFallback(InboundMeasurementEntity measurement) { + return measurement == null || !StringUtils.hasText(measurement.getOriginalDocumentNo()) + || measurement.getOriginalGrossWeightKg() == null || measurement.getOriginalVolumeCbm() == null + || !StringUtils.hasText(measurement.getActualGoodsName()); + } + + private OrderCargoClient.MeasurementDeclarationSnapshot loadDeclaration(InboundOrderEntity inbound) { + if (inbound.getOrderId() == null || inbound.getOrderId() <= 0) { + throw new BizException(ResultCode.CONFLICT, "入库单未关联有效订单,无法读取客户申报数据"); + } + try { + ApiResponse response = orderCargoClient + .measurementDeclaration(internalToken, inbound.getTenantId(), inbound.getOrderId()); + if (response == null || !response.successful() || response.data() == null) { + String message = response == null || !StringUtils.hasText(response.message()) + ? "客户订单申报数据查询失败" : response.message(); + throw new BizException(response == null ? ResultCode.SERVICE_UNAVAILABLE + : ResultCode.of(response.code()), message); + } + return response.data(); + } catch (FeignException exception) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "订单服务暂不可用,无法读取客户申报数据"); + } + } + + private String defaultGoodsName(OrderCargoClient.MeasurementDeclarationSnapshot declaration) { + String value = declaration.defaultActualGoodsName(); + if (!StringUtils.hasText(value)) return null; + if (value.length() > 255) { + throw new BizException(ResultCode.VALIDATION_FAILED, + "客户货物品名汇总超过255字符,请填写精简后的实际品名"); + } + return value; + } + private InboundMeasurementResponses.Item toItem(InboundMeasurementItemEntity item) { return new InboundMeasurementResponses.Item( item.getId(), item.getInboundDetailId(), item.getItemNo(), item.getBoxSpecification(), item.getPackageCount(), diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementReviewService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementReviewService.java index a4c271877..6700292b4 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementReviewService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementReviewService.java @@ -28,6 +28,8 @@ public class MeasurementReviewService { @Resource private InboundWorkflowRepository repository; @Resource + private com.baiyue.firstleg.repository.InboundMeasurementRepository measurements; + @Resource private InboundIdempotencyService idempotency; @Resource private InboundProperties properties; @@ -78,12 +80,30 @@ public class MeasurementReviewService { throw new BizException(ResultCode.VALIDATION_FAILED, "人工确认箱数合计与入库单总箱数不一致,不能审核通过"); } - BigDecimal weight = order.getActualGrossWeightKg(); + var weights = measurements.listWeights(tenantId, inboundId); + if (weights.isEmpty() || weights.stream().anyMatch(weight -> + !"CONFIRMED".equals(weight.getRecognitionStatus()) + || weight.getGrossWeightKg() == null + || weight.getGrossWeightKg().signum() <= 0 + || weight.getNetWeightKg() == null + || weight.getNetWeightKg().signum() <= 0)) { + throw new BizException(ResultCode.VALIDATION_FAILED, + "所有称重结果必须已确认且包含大于零的毛重和货物净重,才能审核通过"); + } + BigDecimal grossWeight = weights.stream() + .map(com.baiyue.firstleg.entity.InboundWeightResultEntity::getGrossWeightKg) + .reduce(BigDecimal.ZERO, BigDecimal::add); + BigDecimal netWeight = weights.stream() + .map(com.baiyue.firstleg.entity.InboundWeightResultEntity::getNetWeightKg) + .reduce(BigDecimal.ZERO, BigDecimal::add); BigDecimal volume = items.stream().map(com.baiyue.firstleg.entity.InboundMeasurementItemEntity::getTotalVolumeCbm) .filter(java.util.Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add); - if (weight == null || weight.signum() <= 0 || volume.signum() <= 0) { + if (order.getActualGrossWeightKg() == null || order.getActualNetWeightKg() == null + || order.getActualGrossWeightKg().compareTo(grossWeight) != 0 + || order.getActualNetWeightKg().compareTo(netWeight) != 0 + || volume.signum() <= 0) { throw new BizException(ResultCode.VALIDATION_FAILED, - "审核通过的测量快照必须包含大于零的货物总重量和体积"); + "审核通过前称重毛重、货物净重汇总和体积必须完整且一致"); } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantInboundBillingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantInboundBillingService.java index 30af43bd4..41bc8ad66 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantInboundBillingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantInboundBillingService.java @@ -53,6 +53,7 @@ public class MerchantInboundBillingService { var summary = new MerchantInboundBillingResponse.MeasurementSummary( measurement.measurementId(), measurement.sourceType(), measurement.measurementStatus(), measurement.actualPackageCount(), measurement.actualGrossWeightKg(), + measurement.actualNetWeightKg(), measurement.actualVolumeCbm(), measurement.volumeWeightKg(), measurement.actualGoodsName()); var measurementImages = measurement.items().stream() .filter(item -> item.intelligentImageFileId() != null) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/UnifiedInboundQueryService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/UnifiedInboundQueryService.java index 3ddbc22be..219b72682 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/UnifiedInboundQueryService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/UnifiedInboundQueryService.java @@ -7,7 +7,7 @@ import com.baiyue.firstleg.dto.UnifiedInboundDetail; import com.baiyue.firstleg.dto.UnifiedInboundQuery; import com.baiyue.firstleg.dto.UnifiedInboundSummary; import com.baiyue.firstleg.entity.InboundOrderEntity; -import com.baiyue.firstleg.entity.InboundTransportSnapshotEntity; +import com.baiyue.firstleg.dto.CurrentContainerTransport; import com.baiyue.firstleg.loading.entity.LoadingEvidenceEntity; import com.baiyue.firstleg.repository.InboundOrderRepository; import com.baiyue.firstleg.repository.UnifiedInboundReadRepository; @@ -75,7 +75,7 @@ public class UnifiedInboundQueryService { evidence = operations.findEvidence(tenantId, inbound.getId()); deliveryAt = deliveryAt(detail.statusHistory()); } - InboundTransportSnapshotEntity transport = includePhaseThree + CurrentContainerTransport transport = includePhaseThree ? operations.findTransport(tenantId, inbound.getId()) : null; return new UnifiedInboundDetail(summary(inbound, detail, preload, pick, evidence, deliveryAt, transport, null, null), timeline); @@ -105,7 +105,7 @@ public class UnifiedInboundQueryService { com.baiyue.firstleg.split.entity.PreloadPlanEntity preload, com.baiyue.firstleg.loading.entity.InboundLoadingPickTaskEntity pick, List evidence, LocalDateTime deliveryAt, - InboundTransportSnapshotEntity transport, String locationCodes, + CurrentContainerTransport transport, String locationCodes, com.baiyue.firstleg.entity.InboundPickupSnapshotEntity pickup) { String mainStatus = mainStatus(inbound.getProcessStatus()); List activeEvidence = evidence.stream() @@ -124,13 +124,22 @@ public class UnifiedInboundQueryService { ? new UnifiedInboundSummary.DeliverySummary( "DELIVERED".equals(mainStatus) ? "COMPLETED" : "PENDING", deliveryAt, inbound.getExpressTrackingNo()) : null; - var inboundSummary = detail == null - ? com.baiyue.firstleg.dto.InboundOrderSummary.from(inbound, transport, pickup, locationCodes) : detail.summary(); + var baseSummary = detail == null ? null : detail.summary(); + var inboundSummary = com.baiyue.firstleg.dto.InboundOrderSummary.from(inbound, transport, + pickup == null && baseSummary != null && baseSummary.requestedPickupAt() != null + ? pickupSnapshot(baseSummary.requestedPickupAt()) : pickup, + locationCodes == null && baseSummary != null ? baseSummary.locationCodes() : locationCodes); return new UnifiedInboundSummary(inboundSummary, mainStatus, nextAction(mainStatus, inbound.getProcessStatus(), preload == null ? null : preload.getStatus()), preloadSummary, loadingSummary, deliverySummary); } + private com.baiyue.firstleg.entity.InboundPickupSnapshotEntity pickupSnapshot(LocalDateTime pickupAt) { + var snapshot = new com.baiyue.firstleg.entity.InboundPickupSnapshotEntity(); + snapshot.setPickupAt(pickupAt); + return snapshot; + } + private LocalDateTime deliveryAt(List history) { return history.stream() .filter(item -> "COMPLETE_DELIVERY".equals(item.action()) || "DELIVERED".equals(item.toStatus())) diff --git a/firstleg/src/main/resources/db/phase2-inbound-schema.sql b/firstleg/src/main/resources/db/phase2-inbound-schema.sql index 7962a0d2f..8225c2372 100644 --- a/firstleg/src/main/resources/db/phase2-inbound-schema.sql +++ b/firstleg/src/main/resources/db/phase2-inbound-schema.sql @@ -42,13 +42,13 @@ CREATE TABLE IF NOT EXISTS fl_inbound_order ( hold_flag TINYINT(1) NOT NULL DEFAULT 0 COMMENT '当前是否扣货', current_hold_reason VARCHAR(500) NULL COMMENT '当前扣货理由快照', total_package_count INT UNSIGNED NULL COMMENT '确认后的总箱数', - actual_gross_weight_kg DECIMAL(18,3) NULL COMMENT '下游货物实际毛重kg,汇总已确认称重净重', + actual_gross_weight_kg DECIMAL(18,3) NULL COMMENT '已确认称重图片含托盘毛重汇总kg', chargeable_weight_kg DECIMAL(18,3) NULL COMMENT '当前费用快照的计费重量kg', actual_volume_cbm DECIMAL(18,4) NULL COMMENT '确认后的实际总体积m3', chargeable_volume_cbm DECIMAL(18,4) NULL COMMENT '当前费用快照的计费体积m3', pallet_count INT UNSIGNED NULL COMMENT '已分配总托盘数快照', volume_weight_kg DECIMAL(18,3) NULL COMMENT '总材积重kg', - actual_net_weight_kg DECIMAL(18,3) NULL COMMENT '总净重kg', + actual_net_weight_kg DECIMAL(18,3) NULL COMMENT '已确认称重图片扣除托盘皮重后的货物净重汇总kg', measure_completed_at DATETIME(3) NULL COMMENT '测量完成时间', inbounded_at DATETIME(3) NULL COMMENT '入库完成时间', express_company_code VARCHAR(64) NULL COMMENT '快递公司编码快照', diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index 7422fd893..7685acb03 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -566,8 +566,8 @@ paths: 由稳定的 item.id(measurementItemId)与 intelligentImageFileId 关联;称重图片只随 weightResults 返回,由稳定的 weightResult.id 与 fileId 关联,并携带确认状态及服务端确认的 grossWeightKg/netWeightKg。两类证据不得合并为无明细归属的统一图片列表。 - actualGrossWeightKg 是服务端权威的货物总重量;存在已确认称重结果时为所有已确认 - netWeightKg 之和,消费者不得根据图片或 OCR 值自行重算。 + actualGrossWeightKg 是服务端确认后的实测毛重,actualNetWeightKg 是扣除托盘皮重后的货物净重; + 两者语义独立,消费者不得互相回退,也不得根据图片或 OCR 值自行重算。 parameters: - {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} - {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} @@ -578,6 +578,23 @@ paths: application/json: schema: {$ref: '#/components/schemas/InboundMeasurementDetailEnvelope'} '403': {description: 无入库测量查看权限} + /api/platform/v1/first-leg/inbounds/{inboundId}/measurement/draft: + put: + summary: 保存测量草稿 + description: >- + 原单据号码、申报毛重和申报体积由服务端从客户订单生成快照,客户端不得提交覆盖; + actualGoodsName 默认使用客户货物品名汇总,但允许测量人员修正。 + parameters: + - {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/InboundMeasurementSaveDraftRequest'} + responses: + '200': {description: 成功或业务失败,按响应体 code 判断} + '403': {description: 无测量编辑权限} /api/platform/v1/first-leg/inbounds/{inboundId}/measurement/carton-scans: post: summary: 平台模拟单箱设备扫描 @@ -697,7 +714,7 @@ paths: platform:firstleg:inbound:weight:edit 权限。修改与确认在同一事务内完成,保留 OCR 原值, 分别写入 CORRECT 和 CONFIRM 修订记录。输入表示含托盘毛重;服务端统一换算为 KG, 使用创建时固化的托盘皮重计算 netWeightKg,且毛重必须大于皮重。 - 入库单 actualGrossWeightKg 明确保留为下游货物实际毛重字段,值汇总所有已确认 netWeightKg。 + 入库单 actualGrossWeightKg 为实测毛重;actualNetWeightKg 为扣除托盘皮重后的货物净重,两者不得混用。 parameters: - {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} - {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} @@ -749,7 +766,7 @@ paths: summary: 删除称重图片及重量结果 description: >- 需要 platform:firstleg:inbound:weight:edit 权限;仅允许在可编辑的测量状态删除。 - 删除已确认图片后重新汇总剩余已确认图片的净重,删除最后一张时 actualGrossWeightKg 恢复为空。 + 删除已确认图片后由生产者分别重算剩余确认称重的毛重、净重;消费端不得用净重填充毛重。 parameters: - {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} - {name: version, in: query, required: true, schema: {type: integer, format: int32, minimum: 0}} @@ -779,15 +796,6 @@ paths: reason: {type: string, maxLength: 1000} responses: '200': {description: 成功或业务失败,按响应体 code 判断;审核成功 data.processStatus 固定为 WAIT_SPLIT,initialPricingSucceeded 表示是否同时生成初算费用,初算失败时 feeStatus=UNPRICED 且主流程仍成功} - /api/platform/v1/inbounds/{inboundId}/measurements/reject: - post: - summary: 驳回测量 - description: 需要 firstleg:inbound:measurement-review 权限;只退回测量数据修改,已确认托盘和库位占用保持不动。 - parameters: - - {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} - - {name: Idempotency-Key, in: header, required: true, schema: {type: string}} - responses: - '200': {description: 成功或业务失败} /api/platform/v1/inbounds/{inboundId}/measurements/submit: post: summary: 提交测量完成 @@ -811,15 +819,6 @@ paths: application/json: schema: {$ref: '#/components/schemas/MerchantInboundBillingEnvelope'} '403': {description: 非当前客户或无订单查看权限} - /api/platform/v1/inbounds/{inboundId}/measurements/exception-release: - post: - summary: 测量异常放行 - description: 需要 firstleg:inbound:exception-release 权限 - parameters: - - {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} - - {name: Idempotency-Key, in: header, required: true, schema: {type: string}} - responses: - '200': {description: 成功或业务失败} components: securitySchemes: bearerAuth: @@ -1035,8 +1034,8 @@ components: currentHoldReason: {type: string, nullable: true} totalPackageCount: {type: integer, nullable: true} locationCodes: {type: string, nullable: true} - actualGrossWeightKg: {type: number, nullable: true, multipleOf: 0.001, description: 服务端权威货物总重量 KG;存在已确认称重图片时为全部 CONFIRMED netWeightKg 之和,消费者不得从图片重算} - actualNetWeightKg: {type: number, nullable: true} + actualGrossWeightKg: {type: number, nullable: true, multipleOf: 0.001, description: 服务端确认后的实测毛重 KG;只读取 fl_inbound_order.actual_gross_weight_kg,不以净重回退} + actualNetWeightKg: {type: number, nullable: true, multipleOf: 0.001, description: 服务端确认后的货物净重 KG;只读取 fl_inbound_order.actual_net_weight_kg,不以毛重回退} actualVolumeCbm: {type: number, nullable: true} volumeWeightKg: {type: number, nullable: true} chargeableWeightKg: {type: number, nullable: true} @@ -1050,8 +1049,10 @@ components: cabinetNo: {type: string, nullable: true} vesselName: {type: string, nullable: true} voyageNo: {type: string, nullable: true} - sailingAt: {type: string, format: date-time, nullable: true} - portArrivalAt: {type: string, format: date-time, nullable: true} + estimatedSailingAt: {type: string, format: date-time, nullable: true, description: 当前关联柜次预计开船时间} + actualSailingAt: {type: string, format: date-time, nullable: true, description: 当前关联柜次实际开船时间} + estimatedPortArrivalAt: {type: string, format: date-time, nullable: true, description: 当前关联柜次预计到港时间} + actualPortArrivalAt: {type: string, format: date-time, nullable: true, description: 当前关联柜次实际到港时间} expressTrackingNo: {type: string, nullable: true} pickupMethod: {type: string, enum: [SELF_EXPRESS, PICKUP, SELF_TRUCK], nullable: true} requestedPickupAt: {type: string, format: date-time, nullable: true, description: 客户要求平台上门提货的预约时间} @@ -1286,9 +1287,9 @@ components: measurementStatus: {type: string, nullable: true} inboundVersion: {type: integer, minimum: 0} version: {type: integer, minimum: 0, nullable: true} - originalDocumentNo: {type: string, nullable: true} - originalGrossWeightKg: {type: number, format: double, nullable: true} - originalVolumeCbm: {type: number, format: double, nullable: true} + originalDocumentNo: {type: string, nullable: true, description: 客户订单 referenceId 的服务端快照;旧空快照按订单事实回退} + originalGrossWeightKg: {type: number, format: double, nullable: true, description: 客户订单有效货物行 grossWeightKg 合计的服务端快照} + originalVolumeCbm: {type: number, format: double, nullable: true, description: 客户订单有效货物行 volumeCbm 合计的服务端快照} originalPackageCount: {type: integer, nullable: true} actualPackageCount: {type: integer, nullable: true} actualGrossWeightKg: @@ -1296,10 +1297,16 @@ components: format: double nullable: true multipleOf: 0.001 - description: 服务端权威货物总重量 KG;存在已确认称重结果时为所有 CONFIRMED netWeightKg 之和 + description: 全部 CONFIRMED 称重结果 grossWeightKg 之和,含托盘毛重 KG;无确认结果时为空 + actualNetWeightKg: + type: number + format: double + nullable: true + multipleOf: 0.001 + description: 全部 CONFIRMED 称重结果 netWeightKg 之和,扣除托盘皮重后的货物净重 KG;无确认结果时为空,审核与计费以此为准 actualVolumeCbm: {type: number, format: double, nullable: true} volumeWeightKg: {type: number, format: double, nullable: true} - actualGoodsName: {type: string, nullable: true} + actualGoodsName: {type: string, nullable: true, description: 默认由客户货物品名去重汇总,测量阶段允许人工修正} items: type: array description: DIMENSION 尺寸测量明细及其图片证据 @@ -1308,6 +1315,38 @@ components: type: array description: WEIGHT 称重结果及其图片、确认状态和服务端重量事实 items: {$ref: '#/components/schemas/InboundWeightResult'} + InboundMeasurementSaveDraftRequest: + type: object + required: [version, items] + additionalProperties: false + properties: + measurementId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true} + version: {type: integer, minimum: 0} + actualGoodsName: {type: string, maxLength: 255, nullable: true} + items: + type: array + minItems: 1 + maxItems: 500 + items: {$ref: '#/components/schemas/InboundMeasurementItemRequest'} + InboundMeasurementItemRequest: + type: object + required: [inboundDetailId, itemNo, boxSpecification, packageCount, lengthCm, widthCm, heightCm, unitWeightKg] + additionalProperties: false + properties: + inboundDetailId: {type: string, pattern: '^[1-9][0-9]*$'} + itemNo: {type: string, minLength: 1, maxLength: 32} + boxSpecification: {type: string, minLength: 1, maxLength: 64} + packageCount: {type: integer, minimum: 1, maximum: 1000000} + lengthCm: {type: number, minimum: 0.001} + widthCm: {type: number, minimum: 0.001} + heightCm: {type: number, minimum: 0.001} + unitWeightKg: {type: number, minimum: 0.001} + intelligentImageFileId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true} + overLimitWarning: {type: string, maxLength: 500, nullable: true} + outerLengthCm: {type: number, minimum: 0, nullable: true} + outerWidthCm: {type: number, minimum: 0, nullable: true} + outerHeightCm: {type: number, minimum: 0, nullable: true} + remark: {type: string, maxLength: 500, nullable: true} InboundMeasurementDetailEnvelope: type: object required: [code, message] diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementEvidenceOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementEvidenceOpenApiContractTest.java index 58a1d08cc..3d8067414 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementEvidenceOpenApiContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementEvidenceOpenApiContractTest.java @@ -23,21 +23,33 @@ class InboundMeasurementEvidenceOpenApiContractTest { Map schemas = stringMap(stringMap(document.get("components")).get("schemas")); Map detailProperties = properties(schemas, "InboundMeasurementDetail"); - assertThat(detailProperties).containsKeys("actualGrossWeightKg", "items", "weightResults"); + assertThat(detailProperties).containsKeys( + "actualGrossWeightKg", "actualNetWeightKg", "items", "weightResults"); assertThat(stringMap(detailProperties.get("items"))) .containsEntry("description", "DIMENSION 尺寸测量明细及其图片证据"); assertThat(stringMap(detailProperties.get("weightResults"))) .containsEntry("description", "WEIGHT 称重结果及其图片、确认状态和服务端重量事实"); assertThat(detailProperties).doesNotContainKey("images"); + Map draftProperties = properties(schemas, "InboundMeasurementSaveDraftRequest"); + assertThat(draftProperties).containsKeys("measurementId", "version", "actualGoodsName", "items") + .doesNotContainKeys("originalDocumentNo", "originalGrossWeightKg", "originalVolumeCbm"); + assertThat(paths).containsKey( + "/api/platform/v1/first-leg/inbounds/{inboundId}/measurement/draft"); + assertThat(stringMap(stringMap(draftProperties.get("items")).get("items"))) + .containsEntry("$ref", "#/components/schemas/InboundMeasurementItemRequest"); + assertThat(properties(schemas, "InboundMeasurementItemRequest")).containsKeys( + "inboundDetailId", "itemNo", "boxSpecification", "packageCount", "lengthCm", + "widthCm", "heightCm", "unitWeightKg", "intelligentImageFileId", "remark"); Map itemProperties = properties(schemas, "InboundMeasurementItem"); assertThat(itemProperties).containsKeys("id", "intelligentImageFileId"); Map weightProperties = properties(schemas, "InboundWeightResult"); assertThat(weightProperties).containsKeys( "id", "fileId", "recognitionStatus", "grossWeightKg", "netWeightKg", "confirmedAt"); - assertThat(stringMap(properties(schemas, "UnifiedInboundOrderSummary") - .get("actualGrossWeightKg")).get("description").toString()) - .contains("服务端权威货物总重量", "CONFIRMED netWeightKg", "不得从图片重算"); + assertThat(stringMap(detailProperties.get("actualGrossWeightKg")).get("description").toString()) + .contains("CONFIRMED", "grossWeightKg", "含托盘毛重"); + assertThat(stringMap(detailProperties.get("actualNetWeightKg")).get("description").toString()) + .contains("CONFIRMED", "netWeightKg", "审核与计费以此为准"); } private static Map properties(Map schemas, String schemaName) { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementReviewContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementReviewContractTest.java index 1347ad239..9e0ac4d84 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementReviewContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementReviewContractTest.java @@ -1,7 +1,6 @@ package com.baiyue.firstleg.controller; import com.baiyue.common.security.AuthenticatedUser; -import com.baiyue.firstleg.dto.ExceptionReleaseRequest; import com.baiyue.firstleg.dto.MeasurementApprovalResponse; import com.baiyue.firstleg.dto.MeasurementReviewRequest; import com.baiyue.firstleg.service.InitialInboundPricingService; @@ -24,14 +23,10 @@ import static org.mockito.Mockito.when; @DisplayName("入库测量审核控制器契约") class InboundMeasurementReviewContractTest { @Test - @DisplayName("审核、放行和历史接口声明独立按钮权限") + @DisplayName("审核和历史接口声明独立按钮权限") void shouldDeclareButtonPermissions() throws Exception { assertAuthority("approve", "hasAuthority('firstleg:inbound:measurement-review')", AuthenticatedUser.class, long.class, long.class, String.class, MeasurementReviewRequest.class); - assertAuthority("reject", "hasAuthority('firstleg:inbound:measurement-review')", - AuthenticatedUser.class, long.class, long.class, String.class, MeasurementReviewRequest.class); - assertAuthority("exceptionRelease", "hasAuthority('firstleg:inbound:exception-release')", - AuthenticatedUser.class, long.class, long.class, String.class, ExceptionReleaseRequest.class); assertAuthority("history", "hasAuthority('firstleg:inbound:measurement-review-history')", AuthenticatedUser.class, long.class, long.class, long.class, long.class); } @@ -43,14 +38,14 @@ class InboundMeasurementReviewContractTest { .getContentAsString(StandardCharsets.UTF_8); assertThat(openapi) .contains("/api/platform/v1/inbounds/{inboundId}/measurements/approve:") - .contains("/api/platform/v1/inbounds/{inboundId}/measurements/reject:") - .contains("firstleg:inbound:exception-release") .contains("Idempotency-Key") .contains("初算异常只返回非阻断提示") .contains("data.processStatus 固定为 WAIT_SPLIT") .contains("feeStatus=UNPRICED 且主流程仍成功") .contains("name: inboundId") .contains("schema: {type: string, pattern:") + .doesNotContain("/api/platform/v1/inbounds/{inboundId}/measurements/reject:") + .doesNotContain("/api/platform/v1/inbounds/{inboundId}/measurements/exception-release:") .doesNotContain("待客户确认"); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundOrderControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundOrderControllerContractTest.java index 39bef690c..6307b20a9 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundOrderControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundOrderControllerContractTest.java @@ -51,7 +51,9 @@ class InboundOrderControllerContractTest { .contains("required: false") .contains("InboundOrderSummary:") .contains("required: [id, tenantId]") - .contains("actualNetWeightKg: {type: number, nullable: true}") + .contains("actualNetWeightKg: {type: number, nullable: true") + .contains("只读取 fl_inbound_order.actual_gross_weight_kg,不以净重回退") + .contains("只读取 fl_inbound_order.actual_net_weight_kg,不以毛重回退") .contains("chargeableVolumeCbm: {type: number, nullable: true}") .contains("measureCompletedAt: {type: string, format: date-time, nullable: true}") .contains("pricingTaxMode: {type: string, enum: [TAX_INCLUDED, TAX_EXCLUDED]") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementControllerContractTest.java index 2a1d81c82..3299c4100 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementControllerContractTest.java @@ -2,17 +2,38 @@ package com.baiyue.firstleg.controller; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.firstleg.dto.InboundMeasurementRequests; +import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.multipart.MultipartFile; import java.lang.reflect.Method; +import java.lang.reflect.RecordComponent; +import java.util.Arrays; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; @DisplayName("平台入库测量接口权限契约") class PlatformInboundMeasurementControllerContractTest { + @Test + void draftRequestOnlyAcceptsEditableGoodsNameAndMeasurementItems() { + assertThat(Arrays.stream(InboundMeasurementRequests.SaveDraftRequest.class.getRecordComponents()) + .map(RecordComponent::getName)).containsExactly( + "measurementId", "version", "actualGoodsName", "items"); + } + + @Test + void draftRequestRejectsRemovedDeclarationFieldsAtDeserialization() { + String json = """ + {"version":0,"originalGrossWeightKg":2000,"actualGoodsName":"货物","items":[]} + """; + assertThatThrownBy(() -> new ObjectMapper().readValue( + json, InboundMeasurementRequests.SaveDraftRequest.class)) + .hasMessageContaining("originalGrossWeightKg"); + } + @Test void everyMeasurementOperationUsesDedicatedButtonPermission() throws Exception { assertAuthority("detail", "hasAuthority('platform:firstleg:inbound:measure:view')", diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/UnifiedInboundOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/UnifiedInboundOpenApiContractTest.java index d8b7d0959..d18b7a6e3 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/UnifiedInboundOpenApiContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/UnifiedInboundOpenApiContractTest.java @@ -41,7 +41,8 @@ class UnifiedInboundOpenApiContractTest { "holdFlag", "actualGrossWeightKg", "actualNetWeightKg", "actualVolumeCbm", "volumeWeightKg", "chargeableWeightKg", "chargeableVolumeCbm", "palletCount", "inboundedAt", "requestedPickupAt", "expressCompanyCode", "expressCompanyName", "containerNo", - "cabinetNo", "sailingAt", "portArrivalAt", "createdByName", "createdAt", "updatedAt"); + "cabinetNo", "estimatedSailingAt", "actualSailingAt", "estimatedPortArrivalAt", + "actualPortArrivalAt", "createdByName", "createdAt", "updatedAt"); } @SuppressWarnings("unchecked") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/dto/InboundOrderWeightSemanticsTest.java b/firstleg/src/test/java/com/baiyue/firstleg/dto/InboundOrderWeightSemanticsTest.java new file mode 100644 index 000000000..b4d42db61 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/dto/InboundOrderWeightSemanticsTest.java @@ -0,0 +1,35 @@ +package com.baiyue.firstleg.dto; + +import com.baiyue.firstleg.entity.InboundOrderEntity; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; + +import static org.assertj.core.api.Assertions.assertThat; + +class InboundOrderWeightSemanticsTest { + @Test + void summaryKeepsGrossAndNetWeightAsIndependentDatabaseFacts() { + InboundOrderEntity inbound = new InboundOrderEntity(); + inbound.setActualGrossWeightKg(new BigDecimal("1787.500")); + inbound.setActualNetWeightKg(new BigDecimal("1747.500")); + + InboundOrderSummary result = InboundOrderSummary.from( + inbound, (CurrentContainerTransport) null, null, null); + + assertThat(result.actualGrossWeightKg()).isEqualByComparingTo("1787.500"); + assertThat(result.actualNetWeightKg()).isEqualByComparingTo("1747.500"); + } + + @Test + void summaryDoesNotFallbackBetweenGrossAndNetWeight() { + InboundOrderEntity inbound = new InboundOrderEntity(); + inbound.setActualGrossWeightKg(new BigDecimal("1787.500")); + + InboundOrderSummary result = InboundOrderSummary.from( + inbound, (CurrentContainerTransport) null, null, null); + + assertThat(result.actualGrossWeightKg()).isEqualByComparingTo("1787.500"); + assertThat(result.actualNetWeightKg()).isNull(); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundOrderStatusSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundOrderStatusSqlContractTest.java index 80f0cc74e..bc33cd856 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundOrderStatusSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundOrderStatusSqlContractTest.java @@ -23,13 +23,14 @@ class InboundOrderStatusSqlContractTest { } @Test - void keepsConfirmedWeightNetSumAuthoritativeWhenMeasurementIsApproved() { + void keepsConfirmedGrossAndNetSumsSeparateWhenMeasurementIsApproved() { assertThat(sql("approveMeasurement")) .contains("FROM fl_inbound_weight_result weight") .contains("weight.recognition_status = 'CONFIRMED'") .contains("weight.deleted = 0") - .contains("SUM(weight.net_weight_kg)") - .contains("ELSE (SELECT COALESCE(SUM(item.total_weight_kg), 0)"); + .contains("actual_gross_weight_kg = (SELECT SUM(weight.gross_weight_kg)") + .contains("actual_net_weight_kg = (SELECT SUM(weight.net_weight_kg)") + .doesNotContain("item.total_weight_kg"); } private static String sql(String methodName) { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/UnifiedInboundTransportRepositoryTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/UnifiedInboundTransportRepositoryTest.java new file mode 100644 index 000000000..0252bdeea --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/UnifiedInboundTransportRepositoryTest.java @@ -0,0 +1,71 @@ +package com.baiyue.firstleg.repository; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.dto.CurrentContainerTransport; +import com.baiyue.firstleg.mapper.UnifiedInboundTransportMapper; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.ArgumentMatchers.anyList; + +class UnifiedInboundTransportRepositoryTest { + private final UnifiedInboundTransportMapper mapper = mock(UnifiedInboundTransportMapper.class); + private final UnifiedInboundReadRepository repository = repository(); + + @Test + void batchKeepsParentChildAndDepartedUnitOnTheirOwnContainer() { + var oldDeparted = fact(11L, 101L, 501L, "OLD", null, LocalDateTime.parse("2026-08-01T08:00")); + var splitChild = fact(12L, 101L, 502L, "NEW", LocalDateTime.parse("2026-08-20T08:00"), null); + when(mapper.findCurrentByInboundIds(anyList())).thenReturn(List.of(oldDeparted, splitChild)); + + var result = repository.findTransports(Map.of(11L, 101L, 12L, 101L)); + + assertThat(result.get(11L).containerBookingId()).isEqualTo(501L); + assertThat(result.get(11L).actualSailingAt()).isEqualTo(LocalDateTime.parse("2026-08-01T08:00")); + assertThat(result.get(12L).containerBookingId()).isEqualTo(502L); + assertThat(result.get(12L).estimatedSailingAt()).isEqualTo(LocalDateTime.parse("2026-08-20T08:00")); + verify(mapper).findCurrentByInboundIds(anyList()); + } + + @Test + void noContainerReturnsNoFact() { + when(mapper.findCurrentByInboundIds(List.of(11L))).thenReturn(List.of()); + assertThat(repository.findTransports(Map.of(11L, 101L))).isEmpty(); + } + + @Test + void multipleEffectiveContainersAreAnInvariantConflict() { + when(mapper.findCurrentByInboundIds(List.of(11L))).thenReturn(List.of( + fact(11L, 101L, 501L, "A", null, null), fact(11L, 101L, 502L, "B", null, null))); + assertThatThrownBy(() -> repository.findTransports(Map.of(11L, 101L))) + .isInstanceOf(BizException.class).hasMessageContaining("多个有效柜次"); + } + + @Test + void filtersRowsOutsideInboundTenantOwnership() { + when(mapper.findCurrentByInboundIds(List.of(11L))).thenReturn(List.of( + fact(11L, 999L, 501L, "OTHER", null, null))); + assertThat(repository.findTransports(Map.of(11L, 101L))).isEmpty(); + } + + private UnifiedInboundReadRepository repository() { + var value = new UnifiedInboundReadRepository(); + ReflectionTestUtils.setField(value, "transports", mapper); + return value; + } + + private CurrentContainerTransport fact(Long inboundId, Long accountId, Long bookingId, String containerNo, + LocalDateTime estimatedSailingAt, LocalDateTime actualSailingAt) { + return new CurrentContainerTransport(inboundId, accountId, bookingId, containerNo, "CAB", "VESSEL", "V1", + estimatedSailingAt, actualSailingAt, null, null); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/UnifiedInboundTransportSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/UnifiedInboundTransportSqlContractTest.java new file mode 100644 index 000000000..b3fbc7bca --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/UnifiedInboundTransportSqlContractTest.java @@ -0,0 +1,20 @@ +package com.baiyue.firstleg.repository; + +import com.baiyue.firstleg.mapper.UnifiedInboundTransportMapper; +import org.apache.ibatis.annotations.Select; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class UnifiedInboundTransportSqlContractTest { + @Test + void queriesCurrentBusinessUnitContainersInOneBatchFromAuthoritativeBooking() throws Exception { + String sql = String.join(" ", UnifiedInboundTransportMapper.class + .getMethod("findCurrentByInboundIds", java.util.List.class).getAnnotation(Select.class).value()); + assertThat(sql).contains("fl_loading_plan_item", "i.deleted = 0", + "p.plan_status IN ('LOADING', 'COMPLETED')", "fl_container_booking", + "estimated_sailing_at", "actual_sailing_at", "estimated_port_arrival_at", + "actual_port_arrival_at", " { assertThat(dimension.id()).isEqualTo(40L); assertThat(dimension.intelligentImageFileId()).isEqualTo(140L); @@ -232,6 +244,7 @@ class InboundMeasurementServiceTest { InboundOrderEntity inbound = inbound("WAIT_MEASURE"); InboundWeightResultEntity current = weight(20L, "10.000", "LB", "PENDING_CONFIRM", 0); InboundWeightResultEntity previous = weight(21L, "2.000", "KG", "CONFIRMED", 1); + previous.setGrossWeightKg(new BigDecimal("2.000")); previous.setNetWeightKg(new BigDecimal("2.000")); when(repository.findInbound(1L, 10L)).thenReturn(inbound); when(repository.findWeight(1L, 10L, 20L)).thenReturn(current); @@ -246,6 +259,7 @@ class InboundMeasurementServiceTest { assertThat(response.grossWeightKg()).isEqualByComparingTo("4.536"); assertThat(response.netWeightKg()).isEqualByComparingTo("4.536"); assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("6.536"); + assertThat(inbound.getActualNetWeightKg()).isEqualByComparingTo("6.536"); assertThat(inbound.getProcessStatus()).isEqualTo("MEASURING"); verify(repository).insertRevision(any(InboundWeightRevisionEntity.class)); } @@ -268,7 +282,8 @@ class InboundMeasurementServiceTest { assertThat(response.grossWeightKg()).isEqualByComparingTo("30.000"); assertThat(response.netWeightKg()).isEqualByComparingTo("11.500"); - assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("11.500"); + assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("30.000"); + assertThat(inbound.getActualNetWeightKg()).isEqualByComparingTo("11.500"); ArgumentCaptor revision = ArgumentCaptor.forClass(InboundWeightRevisionEntity.class); verify(repository).insertRevision(revision.capture()); @@ -276,6 +291,31 @@ class InboundMeasurementServiceTest { assertThat(revision.getValue().getNetWeightKg()).isEqualByComparingTo("11.500"); } + @Test + void confirmingFifthPalletSeparatelyAggregatesGrossAndCargoNetWeight() { + InboundOrderEntity inbound = inbound("WAIT_MEASURE"); + InboundWeightResultEntity current = weight(20L, "357.500", "KG", "PENDING_CONFIRM", 0); + current.setPalletTareWeightKg(new BigDecimal("8.000")); + List weights = new java.util.ArrayList<>(); + weights.add(current); + for (long id = 21L; id <= 24L; id++) { + InboundWeightResultEntity confirmed = weight(id, "357.500", "KG", "CONFIRMED", 1); + confirmed.setGrossWeightKg(new BigDecimal("357.500")); + confirmed.setNetWeightKg(new BigDecimal("349.500")); + weights.add(confirmed); + } + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.findWeight(1L, 10L, 20L)).thenReturn(current); + when(repository.updateWeight(current)).thenReturn(true); + when(repository.listWeights(1L, 10L)).thenReturn(weights); + + service.confirmWeightResult(1L, 10L, 20L, 9L, "操作员", + new InboundMeasurementRequests.ConfirmWeightResultRequest(0, null, null, null)); + + assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("1787.500"); + assertThat(inbound.getActualNetWeightKg()).isEqualByComparingTo("1747.500"); + } + @Test void confirmingWeightRejectsGrossWeightNotAboveFrozenTare() { InboundOrderEntity inbound = inbound("WAIT_MEASURE"); @@ -545,7 +585,7 @@ class InboundMeasurementServiceTest { verify(repository).deleteWeight(1L, 10L, 20L, 1); verify(imageClient).delete("internal-token", 9L, 1L, 10L, 120L); - verify(repository, never()).updateInboundWeightSummary(any()); + verify(repository).updateInboundWeightSummary(inbound); } @Test @@ -566,6 +606,7 @@ class InboundMeasurementServiceTest { service.deleteWeightResult(1L, 10L, 20L, 2, 9L); assertThat(inbound.getActualGrossWeightKg()).isNull(); + assertThat(inbound.getActualNetWeightKg()).isNull(); verify(repository).updateInboundWeightSummary(inbound); } @@ -574,8 +615,10 @@ class InboundMeasurementServiceTest { InboundOrderEntity inbound = inbound("MEASURING"); inbound.setActualGrossWeightKg(new BigDecimal("15.000")); InboundWeightResultEntity deleted = weight(20L, "10.000", "KG", "CONFIRMED", 2); + deleted.setGrossWeightKg(new BigDecimal("10.000")); deleted.setNetWeightKg(new BigDecimal("10.000")); InboundWeightResultEntity remaining = weight(21L, "5.000", "KG", "CONFIRMED", 1); + remaining.setGrossWeightKg(new BigDecimal("5.000")); remaining.setNetWeightKg(new BigDecimal("5.000")); when(repository.findInbound(1L, 10L)).thenReturn(inbound); when(repository.findWeight(1L, 10L, 20L)).thenReturn(deleted); @@ -588,6 +631,7 @@ class InboundMeasurementServiceTest { service.deleteWeightResult(1L, 10L, 20L, 2, 9L); assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("5.000"); + assertThat(inbound.getActualNetWeightKg()).isEqualByComparingTo("5.000"); verify(repository).updateInboundWeightSummary(inbound); } @@ -622,6 +666,7 @@ class InboundMeasurementServiceTest { item.setPackageCount(3); item.setSourceImageFileId(500L); InboundWeightResultEntity weight = weight(20L, "20.000", "KG", "CONFIRMED", 1); + weight.setGrossWeightKg(new BigDecimal("20.000")); weight.setNetWeightKg(new BigDecimal("20.000")); when(repository.findInbound(1L, 10L)).thenReturn(inbound); when(repository.findMeasurement(1L, 10L, 30L)).thenReturn(measurement); @@ -638,6 +683,7 @@ class InboundMeasurementServiceTest { assertThat(inbound.getMeasureReviewStatus()).isEqualTo("PENDING"); assertThat(measurement.getStatus()).isEqualTo("SUBMITTED"); assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("20.000"); + assertThat(inbound.getActualNetWeightKg()).isEqualByComparingTo("20.000"); assertThat(inbound.getPalletCount()).isEqualTo(1); assertThat(inbound.getActualVolumeCbm()).isEqualByComparingTo("1.2345"); } @@ -711,7 +757,7 @@ class InboundMeasurementServiceTest { when(batchService.saveBatch(any())).thenReturn(saved); InboundMeasurementResponses.DraftSaved result = service.saveDraft(1L, 10L, 9L, - new InboundMeasurementRequests.SaveDraftRequest(null, 0, null, null, null, null, + new InboundMeasurementRequests.SaveDraftRequest(null, 0, null, List.of(new InboundMeasurementRequests.ItemRequest(80001L, "001", "A", 2, new BigDecimal("40"), new BigDecimal("30"), new BigDecimal("20"), new BigDecimal("3.250"), null, null, null, null, null, null)))); @@ -720,13 +766,38 @@ class InboundMeasurementServiceTest { ArgumentCaptor command = ArgumentCaptor.forClass(MeasurementBatchCommand.class); verify(batchService).saveBatch(command.capture()); assertThat(command.getValue().items().get(0).inboundDetailId()).isEqualTo(80001L); + assertThat(command.getValue().originalDocumentNo()).isEqualTo("REF-50"); + assertThat(command.getValue().originalGrossWeightKg()).isEqualByComparingTo("2000"); + assertThat(command.getValue().originalVolumeCbm()).isEqualByComparingTo("20"); + assertThat(command.getValue().actualGoodsName()).isEqualTo("123123"); verify(orderCargoClient).detail("internal-token", 1L, 80L, 80001L); } + @Test + void savingDraftRejectsOverlongDefaultGoodsNameBeforeDatabaseWrite() { + InboundOrderEntity inbound = inbound("WAIT_MEASURE"); + InboundMeasurementRequests.ItemRequest item = new InboundMeasurementRequests.ItemRequest( + 80001L, "001", "A", 2, new BigDecimal("40"), new BigDecimal("30"), + new BigDecimal("20"), new BigDecimal("3.250"), null, null, null, null, null, null); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(orderCargoClient.detail("internal-token", 1L, 80L, 80001L)).thenReturn( + ApiResponse.success(new OrderCargoClient.CargoBinding( + 80001L, 80L, 1, "Test", 2, new BigDecimal("6.5"), new BigDecimal("0.1")))); + when(orderCargoClient.measurementDeclaration("internal-token", 1L, 80L)).thenReturn( + ApiResponse.success(new OrderCargoClient.MeasurementDeclarationSnapshot( + "REF", new BigDecimal("6.5"), new BigDecimal("0.1"), "A".repeat(256)))); + + assertThatThrownBy(() -> service.saveDraft(1L, 10L, 9L, + new InboundMeasurementRequests.SaveDraftRequest(null, 0, null, List.of(item)))) + .isInstanceOf(BizException.class).hasMessageContaining("超过255字符"); + verify(batchService, never()).saveBatch(any()); + } + private InboundOrderEntity inbound(String status) { InboundOrderEntity inbound = new InboundOrderEntity(); inbound.setId(10L); inbound.setTenantId(1L); + inbound.setOrderId(80L); inbound.setWarehouseId(100L); inbound.setProcessStatus(status); inbound.setTotalPackageCount(3); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java index 8089bf324..e1376f691 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java @@ -9,6 +9,8 @@ import com.baiyue.firstleg.entity.InboundMeasurementEntity; import com.baiyue.firstleg.entity.InboundMeasurementItemEntity; import com.baiyue.firstleg.entity.InboundOrderEntity; import com.baiyue.firstleg.entity.InboundStatusHistoryEntity; +import com.baiyue.firstleg.entity.InboundWeightResultEntity; +import com.baiyue.firstleg.repository.InboundMeasurementRepository; import com.baiyue.firstleg.repository.InboundWorkflowRepository; import jakarta.annotation.Resource; import org.junit.jupiter.api.DisplayName; @@ -41,6 +43,7 @@ import static org.mockito.Mockito.when; @SpringJUnitConfig(MeasurementReviewService.class) class MeasurementReviewServiceTest { @MockBean private InboundWorkflowRepository repository; + @MockBean private InboundMeasurementRepository measurements; @MockBean private InboundIdempotencyService idempotency; @MockBean private InboundProperties properties; @Resource private MeasurementReviewService service; @@ -212,7 +215,8 @@ class MeasurementReviewServiceTest { order.setProcessStatus("MEASURE_PENDING"); order.setMeasureReviewStatus("PENDING"); order.setTotalPackageCount(5); - order.setActualGrossWeightKg(new BigDecimal("11.500")); + order.setActualGrossWeightKg(new BigDecimal("1787.500")); + order.setActualNetWeightKg(new BigDecimal("1747.500")); InboundMeasurementEntity measurement = new InboundMeasurementEntity(); measurement.setId(20L); measurement.setStatus("SUBMITTED"); @@ -224,9 +228,23 @@ class MeasurementReviewServiceTest { when(repository.findOwned(10001L, 10L)).thenReturn(order); when(repository.findMeasurement(10001L, 10L, 20L)).thenReturn(measurement); when(repository.listMeasurementItems(10001L, 20L)).thenReturn(List.of(item)); + when(measurements.listWeights(10001L, 10L)).thenReturn(List.of( + confirmedWeight("357.500", "349.500"), + confirmedWeight("357.500", "349.500"), + confirmedWeight("357.500", "349.500"), + confirmedWeight("357.500", "349.500"), + confirmedWeight("357.500", "349.500"))); return new Fixture(service, repository, idempotency, order, measurement); } + private InboundWeightResultEntity confirmedWeight(String grossWeightKg, String netWeightKg) { + InboundWeightResultEntity weight = new InboundWeightResultEntity(); + weight.setRecognitionStatus("CONFIRMED"); + weight.setGrossWeightKg(new BigDecimal(grossWeightKg)); + weight.setNetWeightKg(new BigDecimal(netWeightKg)); + return weight; + } + private record Fixture(MeasurementReviewService service, InboundWorkflowRepository repository, InboundIdempotencyService idempotency, InboundOrderEntity order, InboundMeasurementEntity measurement) { diff --git a/order/src/main/java/com/baiyue/order/client/FirstlegUnifiedOrderClient.java b/order/src/main/java/com/baiyue/order/client/FirstlegUnifiedOrderClient.java index 8f06809ce..c8bb829bd 100644 --- a/order/src/main/java/com/baiyue/order/client/FirstlegUnifiedOrderClient.java +++ b/order/src/main/java/com/baiyue/order/client/FirstlegUnifiedOrderClient.java @@ -55,7 +55,8 @@ public interface FirstlegUnifiedOrderClient { BigDecimal chargeableWeightKg, LocalDateTime measureCompletedAt, LocalDateTime inboundedAt, String expressCompanyName, String createdByName, String containerNo, String cabinetNo, String vesselName, String voyageNo, - LocalDateTime sailingAt, LocalDateTime portArrivalAt, + LocalDateTime estimatedSailingAt, LocalDateTime actualSailingAt, + LocalDateTime estimatedPortArrivalAt, LocalDateTime actualPortArrivalAt, String expressTrackingNo, LocalDateTime createdAt, LocalDateTime updatedAt) {} record Preload(String status, Integer version, Integer itemCount, LocalDateTime updatedAt) {} record Loading(String status, Integer loadingVersion, Integer pickingVersion, diff --git a/order/src/main/java/com/baiyue/order/config/InboundCodeModeProvider.java b/order/src/main/java/com/baiyue/order/config/InboundCodeModeProvider.java new file mode 100644 index 000000000..581bed483 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/config/InboundCodeModeProvider.java @@ -0,0 +1,37 @@ +package com.baiyue.order.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.cloud.context.config.annotation.RefreshScope; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import java.util.Locale; + +/** 从共享配置读取全系统唯一的入仓码运行模式,并拒绝任何未知配置。 */ +@Component +@RefreshScope +public class InboundCodeModeProvider { + public enum Mode { AUTO, LEGACY_MANUAL } + + private final Mode mode; + + public InboundCodeModeProvider(@Value("${baiyue.order.inbound-code-mode}") String configuredMode) { + if (!StringUtils.hasText(configuredMode)) { + throw new IllegalStateException("baiyue.order.inbound-code-mode 必须显式配置"); + } + try { + mode = Mode.valueOf(configuredMode.trim().toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException exception) { + throw new IllegalStateException( + "baiyue.order.inbound-code-mode 仅支持 AUTO 或 LEGACY_MANUAL", exception); + } + } + + public Mode mode() { + return mode; + } + + public boolean legacyManual() { + return mode == Mode.LEGACY_MANUAL; + } +} diff --git a/order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java b/order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java index 6fc2e3701..5b8fa436b 100644 --- a/order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java +++ b/order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java @@ -20,6 +20,9 @@ import org.springframework.web.bind.annotation.RestController; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.util.List; +import java.math.BigDecimal; +import java.util.Objects; +import java.util.stream.Collectors; /** 供头程履约服务校验入仓单所关联订单货物明细。 */ @RestController @@ -89,6 +92,32 @@ public class InternalOrderCargoController { return ApiResponse.success(OrderApplicationService.inboundCommand(order, packageCount)); } + @GetMapping("/{tenantId}/{orderId}/measurement-declaration") + public ApiResponse measurementDeclaration( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @PathVariable long tenantId, @PathVariable long orderId) { + requireToken(token); + if (tenantId <= 0 || orderId <= 0) { + throw new BizException(ResultCode.BAD_REQUEST, "租户和订单ID必须大于0"); + } + var order = orders.findOwned(tenantId, orderId); + if (order == null) throw new BizException(ResultCode.NOT_FOUND, "订单不存在或不属于当前客户"); + // 申报重量/体积必须属于当前订单。拆分子单未物化独立货物事实时不得复制根单整单总量。 + List cargoes = orders.findCargo(tenantId, orderId); + return ApiResponse.success(new MeasurementDeclarationSnapshot( + order.getReferenceId(), sum(cargoes, OrderCargoEntity::getGrossWeightKg), + sum(cargoes, OrderCargoEntity::getVolumeCbm), + cargoes.stream().map(OrderCargoEntity::getCargoName).filter(Objects::nonNull) + .map(String::trim).filter(value -> !value.isEmpty()).distinct() + .collect(Collectors.joining("、")))); + } + + private BigDecimal sum(List cargoes, + java.util.function.Function getter) { + List values = cargoes.stream().map(getter).filter(Objects::nonNull).toList(); + return values.isEmpty() ? null : values.stream().reduce(BigDecimal.ZERO, BigDecimal::add); + } + private void requireToken(String token) { if (token == null || !MessageDigest.isEqual(expectedToken, token.getBytes(StandardCharsets.UTF_8))) { throw new BizException(ResultCode.FORBIDDEN, "内部服务凭证无效"); @@ -113,4 +142,8 @@ public class InternalOrderCargoController { Integer packageCount, java.math.BigDecimal lengthCm, java.math.BigDecimal widthCm, java.math.BigDecimal heightCm) { } + + public record MeasurementDeclarationSnapshot( + String referenceId, BigDecimal originalGrossWeightKg, + BigDecimal originalVolumeCbm, String defaultActualGoodsName) {} } diff --git a/order/src/main/java/com/baiyue/order/controller/MerchantFirstLegOrderController.java b/order/src/main/java/com/baiyue/order/controller/MerchantFirstLegOrderController.java index f072f41e7..5968f9d0c 100644 --- a/order/src/main/java/com/baiyue/order/controller/MerchantFirstLegOrderController.java +++ b/order/src/main/java/com/baiyue/order/controller/MerchantFirstLegOrderController.java @@ -9,6 +9,7 @@ import com.baiyue.order.dto.OrderRequests; import com.baiyue.order.dto.MerchantBatchDraftDtos; import com.baiyue.order.dto.MerchantInboundOrderQuery; import com.baiyue.order.dto.MerchantInboundOrderRow; +import com.baiyue.order.dto.InboundCodePolicyResponse; import com.baiyue.order.entity.OrderStatus; import com.baiyue.order.service.OrderApplicationService; import com.baiyue.order.service.OrderScopeGuard; @@ -50,6 +51,12 @@ public class MerchantFirstLegOrderController { @Resource private MerchantBatchDraftService batchDraftService; + @GetMapping("/inbound-code-policy") + @PreAuthorize("hasAnyAuthority('merchant:order:list', 'merchant:order:create', 'merchant:order:edit')") + public ApiResponse inboundCodePolicy() { + return ApiResponse.success(service.inboundCodePolicy()); + } + @BusinessAudit(event = "ORDER_CREATE", resourceType = "FIRST_LEG_ORDER", description = "Create draft / 新建草稿") @Operation(summary = "Create draft / 新建草稿", description = "Creates a first-leg draft order in the authenticated merchant tenant. / 在当前认证商户租户下创建头程订单草稿。") @ApiResponses({ @@ -69,7 +76,7 @@ public class MerchantFirstLegOrderController { @BusinessAudit(event = "ORDER_BATCH_CREATE", resourceType = "FIRST_LEG_ORDER", description = "Batch create drafts / 批量创建草稿") @Operation(summary = "Batch create drafts / 批量创建草稿", - description = "每个数组元素代表一张草稿;单行失败不影响其它行,响应直接返回订单号和入仓码。") + description = "每个数组元素代表一张草稿;单行失败不影响其它行。AUTO 模式的入仓码在提交订单时生成,草稿结果允许为空。") @PostMapping("/batch") @PreAuthorize("hasAuthority('merchant:order:create')") public ApiResponse batchCreate( diff --git a/order/src/main/java/com/baiyue/order/dto/InboundCodePolicyResponse.java b/order/src/main/java/com/baiyue/order/dto/InboundCodePolicyResponse.java new file mode 100644 index 000000000..d5e941742 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/dto/InboundCodePolicyResponse.java @@ -0,0 +1,5 @@ +package com.baiyue.order.dto; + +/** 全系统统一的入仓码录入策略。 */ +public record InboundCodePolicyResponse(String mode, boolean manualEntryRequired) { +} diff --git a/order/src/main/java/com/baiyue/order/dto/OrderRequests.java b/order/src/main/java/com/baiyue/order/dto/OrderRequests.java index 3af3ba9cb..28db86533 100644 --- a/order/src/main/java/com/baiyue/order/dto/OrderRequests.java +++ b/order/src/main/java/com/baiyue/order/dto/OrderRequests.java @@ -71,8 +71,27 @@ public final class OrderRequests { @NotBlank @Pattern(regexp = "FBA|OVERSEAS|PRIVATE") String destinationType, @Size(max = 32) String amazonWarehouseCode, @Size(max = 64) String shipmentId, - @Size(max = 64) String referenceId + @Size(max = 64) String referenceId, + @Schema(description = "旧系统入仓码;仅全局 LEGACY_MANUAL 模式接受,AUTO 模式必须留空", example = "NYEXX2608140001", maxLength = 40) + @Size(max = 40) @Pattern(regexp = "[A-Za-z0-9._:-]+") String inboundCode ) { + public SaveRequest(Long customerId, String customerName, String transportMode, String tradeTerm, + String destinationCountryCode, Long destinationWarehouseId, + String destinationWarehouseName, LocalDate expectedShipDate, String customerRemark, + Party sender, Party receiver, List cargoes, String pickupMethod, + LocalDateTime requestedPickupAt, Long receivingWarehouseId, + String receivingWarehouseName, String channelCode, String channelName, + Long carrierId, String carrierName, String taxMode, String pricingTaxMode, + String containerMode, Boolean customsInspection, String destinationType, + String amazonWarehouseCode, String shipmentId, String referenceId) { + this(customerId, customerName, transportMode, tradeTerm, destinationCountryCode, + destinationWarehouseId, destinationWarehouseName, expectedShipDate, customerRemark, + sender, receiver, cargoes, pickupMethod, requestedPickupAt, receivingWarehouseId, + receivingWarehouseName, channelCode, channelName, carrierId, carrierName, taxMode, + pricingTaxMode, containerMode, customsInspection, destinationType, amazonWarehouseCode, + shipmentId, referenceId, null); + } + public SaveRequest(Long customerId, String customerName, String transportMode, String tradeTerm, String destinationCountryCode, Long destinationWarehouseId, String destinationWarehouseName, LocalDate expectedShipDate, String customerRemark, @@ -87,7 +106,7 @@ public final class OrderRequests { sender, receiver, cargoes, pickupMethod, null, receivingWarehouseId, receivingWarehouseName, channelCode, channelName, carrierId, carrierName, taxMode, pricingTaxMode, containerMode, customsInspection, destinationType, amazonWarehouseCode, - shipmentId, referenceId); + shipmentId, referenceId, null); } @AssertTrue(message = "承运商 ID 和名称必须同时填写或同时留空") diff --git a/order/src/main/java/com/baiyue/order/dto/UnifiedOrderResponse.java b/order/src/main/java/com/baiyue/order/dto/UnifiedOrderResponse.java index cb207220b..d1784d4cd 100644 --- a/order/src/main/java/com/baiyue/order/dto/UnifiedOrderResponse.java +++ b/order/src/main/java/com/baiyue/order/dto/UnifiedOrderResponse.java @@ -25,7 +25,7 @@ public final class UnifiedOrderResponse { String companyName, String businessType, String pickupMethod, String taxMode, String containerMode, Boolean customsInspection, String destinationType, String destinationCode, - Integer totalPalletCount, BigDecimal chargeableVolumeCbm, + Integer totalPalletCount, BigDecimal chargeableWeightKg, BigDecimal chargeableVolumeCbm, BigDecimal volumetricWeightKg, BigDecimal totalNetWeightKg, LocalDateTime measurementCompletedAt, LocalDateTime inboundCompletedAt, String expressCompany, String expressTrackingNo, String createdBy, @@ -52,8 +52,11 @@ public final class UnifiedOrderResponse { String sealNo, LocalDateTime updatedAt) {} public record DeliverySummary(String status, LocalDateTime completedAt, String trackingNo) {} public record TransportSummary(String containerNo, String cabinetNo, - String vesselName, String voyageNo, LocalDateTime sailingAt, - LocalDateTime portArrivalAt) {} + String vesselName, String voyageNo, + LocalDateTime estimatedSailingAt, LocalDateTime actualSailingAt, + LocalDateTime effectiveSailingAt, Boolean effectiveSailingAtEstimated, + LocalDateTime estimatedPortArrivalAt, LocalDateTime actualPortArrivalAt, + LocalDateTime effectivePortArrivalAt, Boolean effectivePortArrivalAtEstimated) {} public record TimelineEvent(String source, String eventType, String mainStatus, String action, String remark, Long operatorId, String operatorName, LocalDateTime occurredAt) {} diff --git a/order/src/main/java/com/baiyue/order/repository/OrderRepository.java b/order/src/main/java/com/baiyue/order/repository/OrderRepository.java index f2bd8d847..dc95a32da 100644 --- a/order/src/main/java/com/baiyue/order/repository/OrderRepository.java +++ b/order/src/main/java/com/baiyue/order/repository/OrderRepository.java @@ -121,6 +121,14 @@ public class OrderRepository { .eq(OrderEntity::getId, orderId)); } + public boolean inboundCodeExists(long tenantId, String inboundCode, Long excludedOrderId) { + if (!StringUtils.hasText(inboundCode)) return false; + return orderMapper.selectCount(new LambdaQueryWrapper() + .eq(OrderEntity::getTenantId, tenantId) + .eq(OrderEntity::getInboundCode, inboundCode) + .ne(excludedOrderId != null, OrderEntity::getId, excludedOrderId)) > 0; + } + public PageResult page(Long tenantId, OrderRequests.QueryRequest query) { LambdaQueryWrapper wrapper = new LambdaQueryWrapper() .eq(tenantId != null, OrderEntity::getTenantId, tenantId) diff --git a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java index a57065c0f..613eefd5a 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java +++ b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java @@ -6,9 +6,11 @@ import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.order.dto.OrderDetailResponse; import com.baiyue.order.dto.OrderRequests; +import com.baiyue.order.dto.InboundCodePolicyResponse; import com.baiyue.order.dto.TenantOrderCountResponse; import com.baiyue.order.dto.TenantOrderStatisticsResponse; import com.baiyue.order.client.FirstlegInboundClient; +import com.baiyue.order.config.InboundCodeModeProvider; import com.baiyue.order.entity.*; import com.baiyue.order.repository.OrderRepository; import com.baomidou.mybatisplus.core.toolkit.IdWorker; @@ -45,9 +47,15 @@ public class OrderApplicationService { @Resource private OrderMasterDataGuard masterDataGuard; @Resource private OrderPricingChannelGuard pricingChannelGuard; @Resource private ProductImageGuard productImageGuard; + @Resource private InboundCodeModeProvider inboundCodeModeProvider; @Resource private ObjectProvider firstlegProvider; @Value("${baiyue.security.internal-service-token:}") private String internalToken; + public InboundCodePolicyResponse inboundCodePolicy() { + boolean manual = legacyManualMode(); + return new InboundCodePolicyResponse(inboundCodeModeProvider.mode().name(), manual); + } + /** 创建草稿时使用数据库唯一键保证请求幂等。 */ @Transactional public Long createDraft(long tenantId, long userId, OrderRequests.SaveRequest request, String idempotencyKey) { @@ -64,8 +72,8 @@ public class OrderApplicationService { private Long createDraftInternal(long tenantId, long userId, OrderRequests.SaveRequest request) { productImageGuard.requireOwned(tenantId, request.cargoes()); var snapshots = masterDataGuard.validate(tenantId, request); - pricingChannelGuard.requireAvailable(tenantId, request.receivingWarehouseId(), - request.channelCode(), request.destinationType()); + requirePricingChannel(tenantId, request.receivingWarehouseId(), request.channelCode(), + request.destinationType()); OrderEntity order = new OrderEntity(); long orderId = IdWorker.getId(); order.setId(orderId); order.setTenantId(tenantId); order.setOrderNo(nextNo(orderId)); @@ -104,7 +112,7 @@ public class OrderApplicationService { && !(allowSubmitted && before == OrderStatus.SUBMITTED)) { throw new BizException(ResultCode.CONFLICT, "当前订单状态不允许编辑订单资料"); } - pricingChannelGuard.requireAvailable(tenantId, request.receivingWarehouseId(), request.channelCode(), + requirePricingChannel(tenantId, request.receivingWarehouseId(), request.channelCode(), request.destinationType()); apply(order, tenantId, request, snapshots); order.setStatus(OrderStatus.DRAFT); order.setUpdatedBy(userId); if (before == OrderStatus.SUBMITTED) { @@ -129,7 +137,7 @@ public class OrderApplicationService { validatePersistedPrototypeFields(tenantId, order); masterDataGuard.validatePersisted(tenantId, order.getCustomerId(), order.getReceivingWarehouseId(), order.getDestinationWarehouseId(), order.getDestinationType()); - inboundCodePrefix = pricingChannelGuard.requireAvailable(tenantId, order.getReceivingWarehouseId(), + inboundCodePrefix = requirePricingChannel(tenantId, order.getReceivingWarehouseId(), order.getChannelCode(), order.getDestinationType()); } if ((target == OrderStatus.REJECTED || target == OrderStatus.CANCELLED) && !StringUtils.hasText(reason)) { @@ -137,7 +145,13 @@ public class OrderApplicationService { } order.setStatus(target); order.setUpdatedBy(userId); if (target == OrderStatus.SUBMITTED) { - if (!StringUtils.hasText(order.getInboundCode())) order.setInboundCode(nextInboundCode(inboundCodePrefix)); + if (!StringUtils.hasText(order.getInboundCode())) { + if (legacyManualMode()) { + throw new BizException(ResultCode.VALIDATION_FAILED, "当前系统使用旧系统入仓码,请先填写入仓码"); + } + order.setInboundCode(nextInboundCode(inboundCodePrefix)); + } + requirePrintableInboundCode(order); order.setSubmittedAt(LocalDateTime.now()); } if (target == OrderStatus.COMPLETED) order.setCompletedAt(LocalDateTime.now()); @@ -414,6 +428,7 @@ public class OrderApplicationService { } private void apply(OrderEntity order, long tenantId, OrderRequests.SaveRequest request, OrderMasterDataGuard.Snapshots snapshots) { order.setCustomerId(tenantId); + applyInboundCode(order, request.inboundCode()); order.setCustomerName(request.customerName().trim()); order.setTransportMode(request.transportMode()); order.setTradeTerm(request.tradeTerm()); order.setDestinationCountryCode(snapshots.destination() == null ? request.destinationCountryCode() @@ -443,6 +458,48 @@ public class OrderApplicationService { ? snapshots.destination().warehouse().warehouseCode() : request.amazonWarehouseCode()); order.setShipmentId(request.shipmentId()); order.setReferenceId(request.referenceId()); } + private void applyInboundCode(OrderEntity order, String requestedCode) { + String normalized = StringUtils.hasText(requestedCode) + ? requestedCode.trim().toUpperCase(Locale.ROOT) : null; + if (!legacyManualMode()) { + if (normalized != null && !normalized.equals(order.getInboundCode())) { + throw new BizException(ResultCode.VALIDATION_FAILED, "当前系统由新系统自动生成入仓码,请勿人工填写"); + } + return; + } + if (order.getStatus() != OrderStatus.DRAFT && StringUtils.hasText(order.getInboundCode())) { + if (normalized != null && !normalized.equals(order.getInboundCode())) { + throw new BizException(ResultCode.CONFLICT, "入仓码提交后不可修改"); + } + return; + } + if (normalized != null && repository.inboundCodeExists(order.getTenantId(), normalized, order.getId())) { + throw new BizException(ResultCode.CONFLICT, "入仓码已存在,请核对旧系统数据"); + } + order.setInboundCode(normalized); + } + private boolean legacyManualMode() { + return inboundCodeModeProvider.legacyManual(); + } + private String requirePricingChannel(long tenantId, long receivingWarehouseId, String channelCode, + String destinationType) { + if (legacyManualMode()) { + pricingChannelGuard.requireActive(tenantId, receivingWarehouseId, channelCode, destinationType); + return null; + } + return pricingChannelGuard.requireAvailable(tenantId, receivingWarehouseId, channelCode, destinationType); + } + private void requirePrintableInboundCode(OrderEntity order) { + String inboundCode = order.getInboundCode(); + if (!StringUtils.hasText(inboundCode) || !inboundCode.matches("[A-Za-z0-9._:-]+")) { + throw new BizException(ResultCode.VALIDATION_FAILED, "入仓码格式不正确"); + } + int total = totalPackageCount(order); + String longestCartonCode = inboundCode + "#" + total + "-" + total; + if (longestCartonCode.codePointCount(0, longestCartonCode.length()) > 34) { + throw new BizException(ResultCode.VALIDATION_FAILED, "入仓码过长,无法生成箱唛二维码"); + } + } private String formatAddress(com.baiyue.order.client.WarehouseClient.Detail detail) { if (detail.addresses() == null) return null; var a = detail.addresses().stream().filter(java.util.Objects::nonNull).findFirst().orElse(null); diff --git a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java index 611fb8834..5e3401d97 100644 --- a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java +++ b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java @@ -20,6 +20,7 @@ import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; import java.util.ArrayList; +import java.time.LocalDateTime; import java.util.Comparator; import java.util.EnumSet; import java.util.List; @@ -180,10 +181,10 @@ public class UnifiedOrderQueryService { order.getChannelName(), order.getReceivingWarehouseId(), order.getReceivingWarehouseName(), order.getDestinationWarehouseId(), order.getDestinationWarehouseName(), null, null, order.getInboundCode(), - order.getExpectedShipDate(), null, null, null, null, + order.getExpectedShipDate(), totalPackageCount(cargo), null, null, null, null, null, null, null, null, order.getPickupMethod(), order.getTaxMode(), order.getContainerMode(), order.getCustomsInspection(), order.getDestinationType(), order.getAmazonWarehouseCode(), - null, null, null, null, null, null, order.getExpressCompany(), order.getExpressTrackingNo(), + null, null, null, null, null, null, null, order.getExpressCompany(), order.getExpressTrackingNo(), string(order.getCreatedBy()), skuNos(cargo), totalQuantity(cargo), order.getShipmentId(), order.getReferenceId(), null, null, null, null, null, order.getCustomerRemark(), latestInternalNote, @@ -199,7 +200,7 @@ public class UnifiedOrderQueryService { inbound.actualGrossWeightKg(), inbound.actualVolumeCbm(), inbound.expectedArrivalAt(), inbound.feeReviewStatus(), inbound.companyName(), inbound.businessType(), order.getPickupMethod(), inbound.taxMode(), inbound.containerMode(), inbound.customsInspection(), inbound.destinationType(), inbound.destinationCode(), inbound.palletCount(), - inbound.chargeableVolumeCbm(), inbound.volumeWeightKg(), inbound.actualNetWeightKg(), + inbound.chargeableWeightKg(), inbound.chargeableVolumeCbm(), inbound.volumeWeightKg(), inbound.actualNetWeightKg(), inbound.measureCompletedAt(), inbound.inboundedAt(), inbound.expressCompanyName(), inbound.expressTrackingNo(), inbound.createdByName(), skuNos(cargo), totalQuantity(cargo), order.getShipmentId(), order.getReferenceId(), measurement(inbound), preload(summary.preload()), loading(summary.loading()), @@ -221,7 +222,7 @@ public class UnifiedOrderQueryService { inbound.actualVolumeCbm(), inbound.expectedArrivalAt(), inbound.feeReviewStatus(), inbound.companyName(), inbound.businessType(), order == null ? null : order.getPickupMethod(), inbound.taxMode(), inbound.containerMode(), inbound.customsInspection(), inbound.destinationType(), inbound.destinationCode(), inbound.palletCount(), - inbound.chargeableVolumeCbm(), inbound.volumeWeightKg(), inbound.actualNetWeightKg(), + inbound.chargeableWeightKg(), inbound.chargeableVolumeCbm(), inbound.volumeWeightKg(), inbound.actualNetWeightKg(), inbound.measureCompletedAt(), inbound.inboundedAt(), inbound.expressCompanyName(), inbound.expressTrackingNo(), inbound.createdByName(), skuNos(cargo), totalQuantity(cargo), order == null ? null : order.getShipmentId(), order == null ? null : order.getReferenceId(), measurement(inbound), preload(summary.preload()), loading(summary.loading()), @@ -248,8 +249,15 @@ public class UnifiedOrderQueryService { value.status(), value.completedAt(), value.trackingNo()); } private UnifiedOrderResponse.TransportSummary transport(FirstlegUnifiedOrderClient.Inbound inbound) { + LocalDateTime sailingAt = inbound.actualSailingAt() != null + ? inbound.actualSailingAt() : inbound.estimatedSailingAt(); + LocalDateTime arrivalAt = inbound.actualPortArrivalAt() != null + ? inbound.actualPortArrivalAt() : inbound.estimatedPortArrivalAt(); return new UnifiedOrderResponse.TransportSummary(inbound.containerNo(), inbound.cabinetNo(), - inbound.vesselName(), inbound.voyageNo(), inbound.sailingAt(), inbound.portArrivalAt()); + inbound.vesselName(), inbound.voyageNo(), inbound.estimatedSailingAt(), inbound.actualSailingAt(), + sailingAt, sailingAt == null ? null : inbound.actualSailingAt() == null, + inbound.estimatedPortArrivalAt(), inbound.actualPortArrivalAt(), arrivalAt, + arrivalAt == null ? null : inbound.actualPortArrivalAt() == null); } private String orderMainStatus(OrderStatus status) { @@ -270,13 +278,21 @@ public class UnifiedOrderQueryService { return cargo == null ? null : cargo.totalQuantity(); } - private record CargoSummary(List skuNos, Integer totalQuantity) { + private Integer totalPackageCount(CargoSummary cargo) { + return cargo == null ? null : cargo.totalPackageCount(); + } + + private record CargoSummary(List skuNos, Integer totalQuantity, Integer totalPackageCount) { private static CargoSummary from(List cargoes) { List skuNos = cargoes.stream().map(OrderCargoEntity::getSkuNo) .filter(Objects::nonNull).map(String::trim).filter(value -> !value.isEmpty()).distinct().toList(); int totalQuantity = cargoes.stream().map(OrderCargoEntity::getTotalQuantity) .filter(Objects::nonNull).mapToInt(Integer::intValue).sum(); - return new CargoSummary(skuNos, totalQuantity); + List packageCounts = cargoes.stream().map(OrderCargoEntity::getPackageCount) + .filter(Objects::nonNull).toList(); + Integer totalPackageCount = packageCounts.isEmpty() ? null + : Math.toIntExact(packageCounts.stream().mapToLong(Integer::longValue).sum()); + return new CargoSummary(skuNos, totalQuantity, totalPackageCount); } } private String inboundMainStatus(String status) { diff --git a/order/src/main/resources/application-test.yml b/order/src/main/resources/application-test.yml index 5f6be57fc..8e0bc7e3b 100644 --- a/order/src/main/resources/application-test.yml +++ b/order/src/main/resources/application-test.yml @@ -13,3 +13,5 @@ spring: url: ${ORDER_TEST_DB_URL:jdbc:mysql://${TEST_DB_HOST:8.163.30.128}:${TEST_DB_PORT:3306}/baiyue_order?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true} username: ${ORDER_TEST_DB_USERNAME:${TEST_DB_USERNAME:root}} password: ${ORDER_TEST_DB_PASSWORD:${TEST_DB_PASSWORD}} + +baiyue.order.inbound-code-mode: AUTO diff --git a/order/src/main/resources/application.yml b/order/src/main/resources/application.yml index 19677f6c6..9070e6c3e 100644 --- a/order/src/main/resources/application.yml +++ b/order/src/main/resources/application.yml @@ -35,7 +35,6 @@ baiyue: url: ${WAREHOUSE_SERVICE_URL:} file: url: ${FILE_SERVICE_URL:} - logging: file: name: "${LOGGING_FILE_NAME:${LOG_PATH:./logs}/${spring.application.name}.log}" diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index e5a26b2fc..cf5f10217 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -11,7 +11,8 @@ paths: summary: 平台统一订单列表 description: >- 统一返回七态产品主状态、下一步动作及测量/预装/装柜/派送摘要,需要 platform:order:list。 - 订单创建时按物流渠道生成固定 inboundCode,格式为物流前缀+YYMMDD+当天全平台票序;每日按上海日期从0001重新开始;有入库单时同时返回 + 入仓码来源由全系统统一模式控制:AUTO 在订单提交时按物流渠道生成,格式为物流前缀+YYMMDD+当天全平台票序; + LEGACY_MANUAL 在草稿中录入旧系统入仓码。两种模式下入仓码一经提交均固定;有入库单时同时返回 inboundId、inboundVersion、loadingVersion、pickingVersion;inboundVersion 才是 C04 loading/complete 的 expectedVersion,loadingVersion 当前等于 pickingVersion(兼容别名)。 tenantId 省略时跨全部客户;mainStatus 省略时按订单创建时间分页,指定 PENDING_REVIEW 时由 Order 查询, @@ -197,6 +198,23 @@ paths: schema: {$ref: '#/components/schemas/FirstLegBatchDraftResponse'} '400': {description: 请求数组为空、超过 50 项或幂等键缺失} '403': {description: 需要 merchant:order:create 权限} + /api/merchant/v1/orders/first-leg/inbound-code-policy: + get: + summary: 查询全系统入仓码模式 + description: >- + 返回订单服务当前统一运行模式。AUTO 表示提交时由新系统生成;LEGACY_MANUAL + 表示两套系统并跑期间必须录入旧系统生成的入仓码。该策略不按客户区分。 + responses: + '200': + description: 查询成功 + content: + application/json: + schema: + type: object + properties: + code: {type: string, example: '200'} + data: {$ref: '#/components/schemas/InboundCodePolicy'} + '403': {description: 需要 merchant:order:list、merchant:order:create 或 merchant:order:edit 权限之一} /api/merchant/v1/orders/first-leg/{id}/submit: post: summary: 提交订单 @@ -423,10 +441,10 @@ components: inboundVersion: {type: integer, nullable: true, description: C04 loading/complete 的 expectedVersion} inboundCode: {type: string, nullable: true} expectedShipDate: {type: string, format: date, nullable: true} - totalPackageCount: {type: integer, nullable: true} + totalPackageCount: {type: integer, nullable: true, description: 无入仓事实时为当前订单有效货物箱数合计;存在入仓业务单元后为该单元履约总箱数,拆分后不以父单原始箱数覆盖} recommendedPalletType: {type: string, nullable: true, description: 当前无列表级权威事实时返回 null,不得返回固定托盘类型} locationCodes: {type: string, nullable: true} - actualGrossWeightKg: {type: number, nullable: true} + actualGrossWeightKg: {type: number, nullable: true, description: Firstleg 实测毛重;来源 actualGrossWeightKg,不以净重回退} actualVolumeCbm: {type: number, nullable: true} expectedArrivalAt: {type: string, format: date-time, nullable: true} feeReviewStatus: {type: string, nullable: true, description: Firstleg 入仓费用审核快照;财务状态不在本字段表达} @@ -440,9 +458,10 @@ components: destinationType: {type: string, nullable: true} destinationCode: {type: string, nullable: true} totalPalletCount: {type: integer, nullable: true} + chargeableWeightKg: {type: number, nullable: true, description: Firstleg confirmed billing snapshot chargeable weight in kilograms} chargeableVolumeCbm: {type: number, nullable: true} volumetricWeightKg: {type: number, nullable: true} - totalNetWeightKg: {type: number, nullable: true, description: Firstleg 确认后的实际净重;未确认时返回 null} + totalNetWeightKg: {type: number, nullable: true, description: 统一订单货物净重展示字段;严格来源 Firstleg actualNetWeightKg,不以毛重回退} measurementCompletedAt: {type: string, format: date-time, nullable: true} inboundCompletedAt: {type: string, format: date-time, nullable: true} expressCompany: {type: string, nullable: true} @@ -475,7 +494,7 @@ components: nullable: true properties: reviewStatus: {type: string, nullable: true} - actualGrossWeightKg: {type: number, nullable: true} + actualGrossWeightKg: {type: number, nullable: true, description: Firstleg 实测毛重;不得使用净重回退} actualVolumeCbm: {type: number, nullable: true} chargeableWeightKg: {type: number, nullable: true} completedAt: {type: string, format: date-time, nullable: true} @@ -514,8 +533,14 @@ components: cabinetNo: {type: string, nullable: true} vesselName: {type: string, nullable: true} voyageNo: {type: string, nullable: true} - sailingAt: {type: string, format: date-time, nullable: true} - portArrivalAt: {type: string, format: date-time, nullable: true} + estimatedSailingAt: {type: string, format: date-time, nullable: true, description: 当前关联柜次预计开船时间} + actualSailingAt: {type: string, format: date-time, nullable: true, description: 当前关联柜次实际开船时间} + effectiveSailingAt: {type: string, format: date-time, nullable: true, description: 实际开船时间优先,否则预计开船时间} + effectiveSailingAtEstimated: {type: boolean, nullable: true} + estimatedPortArrivalAt: {type: string, format: date-time, nullable: true, description: 当前关联柜次预计到港时间} + actualPortArrivalAt: {type: string, format: date-time, nullable: true, description: 当前关联柜次实际到港时间} + effectivePortArrivalAt: {type: string, format: date-time, nullable: true, description: 实际到港时间优先,否则预计到港时间} + effectivePortArrivalAtEstimated: {type: boolean, nullable: true} UnifiedOrderTimelineEvent: type: object properties: @@ -606,6 +631,11 @@ components: type: object required: [pricingTaxMode, cargoes] properties: + inboundCode: + type: string + maxLength: 40 + pattern: '^[A-Za-z0-9._:-]+$' + description: 仅 LEGACY_MANUAL 模式填写的旧系统入仓码;AUTO 模式必须省略 pricingTaxMode: {$ref: '#/components/schemas/PricingTaxMode'} cargoes: type: array @@ -615,6 +645,12 @@ components: 订单保存请求中的完整字段以服务端 FirstLegOrderSaveRequest 为准;pricingTaxMode 是新建和编辑订单的必填报价维度;customerId 不接受客户端指定,服务端始终使用认证企业 ID; 发货单位和收货单位仅通过 sender/receiver 保存业务快照。历史存量订单的响应字段仍允许为空。 + InboundCodePolicy: + type: object + required: [mode, manualEntryRequired] + properties: + mode: {type: string, enum: [AUTO, LEGACY_MANUAL]} + manualEntryRequired: {type: boolean} FirstLegBatchDraftItem: type: object required: [clientRowId, draft] @@ -644,7 +680,7 @@ components: success: {type: boolean} orderId: {type: string, pattern: '^[1-9][0-9]*$'} orderNo: {type: string} - inboundCode: {type: string, pattern: '^[A-Z]{4,6}[0-9]{10,}(?:-[1-9][0-9]*)?$', example: ZBYHK2608140001, description: 根订单使用渠道前缀+业务日期+全局票序;履约拆分产生的余量子订单追加根范围 splitSequence 后缀,如 ZBYHK2608140001-1} + inboundCode: {type: string, pattern: '^[A-Za-z0-9._:-]+$', example: ZBYHK2608140001, description: AUTO 模式为渠道前缀+业务日期+全局票序;LEGACY_MANUAL 模式为录入的旧系统入仓码;履约拆分的子订单追加 splitSequence 后缀} errorCode: {type: string} errorMessage: {type: string} FirstLegOrderCargo: diff --git a/order/src/test/java/com/baiyue/order/controller/InboundCodePolicyContractTest.java b/order/src/test/java/com/baiyue/order/controller/InboundCodePolicyContractTest.java new file mode 100644 index 000000000..37e46f845 --- /dev/null +++ b/order/src/test/java/com/baiyue/order/controller/InboundCodePolicyContractTest.java @@ -0,0 +1,37 @@ +package com.baiyue.order.controller; + +import com.baiyue.order.dto.InboundCodePolicyResponse; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; +import org.springframework.security.access.prepost.PreAuthorize; + +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; + +class InboundCodePolicyContractTest { + @Test + void exposesOneGlobalPolicyToOrderReadersAndEditors() throws Exception { + var method = MerchantFirstLegOrderController.class.getDeclaredMethod("inboundCodePolicy"); + var authorization = method.getAnnotation(PreAuthorize.class); + + assertThat(authorization).isNotNull(); + assertThat(authorization.value()) + .isEqualTo("hasAnyAuthority('merchant:order:list', 'merchant:order:create', 'merchant:order:edit')"); + assertThat(method.getGenericReturnType().getTypeName()) + .contains(InboundCodePolicyResponse.class.getName()); + } + + @Test + void documentsAutomaticAndLegacyManualModes() throws Exception { + String openapi = new ClassPathResource("openapi/order-v1.yaml") + .getContentAsString(StandardCharsets.UTF_8); + + assertThat(openapi) + .contains("/api/merchant/v1/orders/first-leg/inbound-code-policy:") + .contains("enum: [AUTO, LEGACY_MANUAL]") + .contains("manualEntryRequired: {type: boolean}") + .contains("该策略不按客户区分") + .contains("仅 LEGACY_MANUAL 模式填写的旧系统入仓码;AUTO 模式必须省略"); + } +} diff --git a/order/src/test/java/com/baiyue/order/controller/InternalOrderCargoControllerTest.java b/order/src/test/java/com/baiyue/order/controller/InternalOrderCargoControllerTest.java index 630fe41e7..d75be5c6f 100644 --- a/order/src/test/java/com/baiyue/order/controller/InternalOrderCargoControllerTest.java +++ b/order/src/test/java/com/baiyue/order/controller/InternalOrderCargoControllerTest.java @@ -128,6 +128,42 @@ class InternalOrderCargoControllerTest { verify(orders).findOwned(11L, 21L); } + @Test + void measurementDeclarationUsesOrderReferenceAndSumsCargoFacts() { + OrderEntity order = new OrderEntity(); + order.setId(21L); order.setTenantId(11L); order.setReferenceId("REF-50"); + OrderCargoEntity first = cargo(31L, 1, 60, "60", "40", "30"); + first.setCargoName("品名A"); first.setGrossWeightKg(new BigDecimal("1200")); + first.setVolumeCbm(new BigDecimal("12.5")); + OrderCargoEntity second = cargo(32L, 2, 40, "50", "30", "20"); + second.setCargoName("品名B"); second.setGrossWeightKg(new BigDecimal("800")); + second.setVolumeCbm(new BigDecimal("7.5")); + when(orders.findOwned(11L, 21L)).thenReturn(order); + when(orders.findCargo(11L, 21L)).thenReturn(List.of(first, second)); + + var result = controller.measurementDeclaration("internal-token", 11L, 21L).data(); + + assertThat(result.referenceId()).isEqualTo("REF-50"); + assertThat(result.originalGrossWeightKg()).isEqualByComparingTo("2000"); + assertThat(result.originalVolumeCbm()).isEqualByComparingTo("20.0"); + assertThat(result.defaultActualGoodsName()).isEqualTo("品名A、品名B"); + } + + @Test + void splitChildWithoutDirectCargoDoesNotCopyRootDeclarationTotals() { + OrderEntity child = new OrderEntity(); + child.setId(201L); child.setTenantId(11L); child.setOrderRole("CHILD"); child.setRootOrderId(21L); + when(orders.findOwned(11L, 201L)).thenReturn(child); + when(orders.findCargo(11L, 201L)).thenReturn(List.of()); + + var result = controller.measurementDeclaration("internal-token", 11L, 201L).data(); + + assertThat(result.originalGrossWeightKg()).isNull(); + assertThat(result.originalVolumeCbm()).isNull(); + assertThat(result.defaultActualGoodsName()).isEmpty(); + verify(orders).findCargo(11L, 201L); + } + private OrderCargoEntity cargo(long id, int itemNo, int packageCount, String length, String width, String height) { OrderCargoEntity cargo = new OrderCargoEntity(); diff --git a/order/src/test/java/com/baiyue/order/controller/UnifiedOrderApiContractTest.java b/order/src/test/java/com/baiyue/order/controller/UnifiedOrderApiContractTest.java index 8d9a759bd..563303a4d 100644 --- a/order/src/test/java/com/baiyue/order/controller/UnifiedOrderApiContractTest.java +++ b/order/src/test/java/com/baiyue/order/controller/UnifiedOrderApiContractTest.java @@ -55,7 +55,7 @@ class UnifiedOrderApiContractTest { "loading", "delivery", "transport", "customerRemark", "latestInternalNote", "expectedArrivalAt", "requestedPickupAt", "feeReviewStatus", "companyName", "businessType", "pickupMethod", "taxMode", "containerMode", "customsInspection", "destinationType", "destinationCode", - "totalPalletCount", "chargeableVolumeCbm", "volumetricWeightKg", "totalNetWeightKg", + "totalPalletCount", "chargeableWeightKg", "chargeableVolumeCbm", "volumetricWeightKg", "totalNetWeightKg", "measurementCompletedAt", "inboundCompletedAt", "expressCompany", "expressTrackingNo", "createdBy", "skuNos", "totalQuantity", "shipmentId", "referenceId"); assertThat(responseSchema(paths, "/api/platform/v1/orders", "get")) diff --git a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java index a161f7a92..9fabb539f 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java @@ -252,6 +252,42 @@ class OrderApplicationServiceStateTest { verify(repository).saveOrder(20002L, order); } + @Test + void legacyManualModeStoresTheExternalInboundCodeOnTheDraft() { + OrderRepository repository = mock(OrderRepository.class); + OrderMasterDataGuard masterData = mock(OrderMasterDataGuard.class); + OrderRequests.SaveRequest request = withInboundCode(request(), "nyexx2608140001"); + when(masterData.validate(10001L, request)).thenReturn(snapshots()); + when(repository.completeIdempotency(anyLong(), any(), anyLong())).thenReturn(1); + OrderApplicationService service = service(repository, mock(OrderEligibilityGuard.class), masterData); + ReflectionTestUtils.setField(service, "inboundCodeMode", "LEGACY_MANUAL"); + + service.createDraft(10001L, 2L, request, "legacy-code-draft"); + + verify(repository).insertOrder(argThat(order -> "NYEXX2608140001".equals(order.getInboundCode()))); + verify(repository, never()).nextInboundCodeSequence(any()); + } + + @Test + void legacyManualModeRejectsSubmissionWithoutAnExternalInboundCode() { + OrderRepository repository = mock(OrderRepository.class); + OrderEntity order = persistedDraft("SELF_EXPRESS", "OVERSEAS"); + when(repository.findOwned(20002L, 1L)).thenReturn(order); + when(repository.findCargo(20002L, 1L)).thenReturn(List.of(completeCargo())); + when(repository.findParties(20002L, 1L)).thenReturn(List.of()); + OrderApplicationService service = service(repository, mock(OrderEligibilityGuard.class), + mock(OrderMasterDataGuard.class)); + ReflectionTestUtils.setField(service, "inboundCodeMode", "LEGACY_MANUAL"); + + assertThatThrownBy(() -> service.transit( + 20002L, 2L, "u", 1L, OrderStatus.SUBMITTED, null)) + .isInstanceOf(BizException.class) + .hasMessage("当前系统使用旧系统入仓码,请先填写入仓码"); + + verify(repository, never()).nextInboundCodeSequence(any()); + verify(repository, never()).saveOrder(anyLong(), any()); + } + @Test void submittingLegacyDraftRequiresPricingTaxMode() { OrderRepository repository = mock(OrderRepository.class); @@ -531,6 +567,18 @@ class OrderApplicationServiceStateTest { "BUY_DECLARATION", "TAX_INCLUDED", "LCL", false, "OVERSEAS", null, null, null); } + private OrderRequests.SaveRequest withInboundCode(OrderRequests.SaveRequest source, String inboundCode) { + return new OrderRequests.SaveRequest( + source.customerId(), source.customerName(), source.transportMode(), source.tradeTerm(), + source.destinationCountryCode(), source.destinationWarehouseId(), source.destinationWarehouseName(), + source.expectedShipDate(), source.customerRemark(), source.sender(), source.receiver(), source.cargoes(), + source.pickupMethod(), source.requestedPickupAt(), source.receivingWarehouseId(), + source.receivingWarehouseName(), source.channelCode(), source.channelName(), source.carrierId(), + source.carrierName(), source.taxMode(), source.pricingTaxMode(), source.containerMode(), + source.customsInspection(), source.destinationType(), source.amazonWarehouseCode(), source.shipmentId(), + source.referenceId(), inboundCode); + } + private OrderRequests.SaveRequest request(Long customerId, String pickupMethod, OrderRequests.Party sender, OrderRequests.Party receiver, String destinationType) { diff --git a/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java b/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java index 7d7c91e4d..aa5c9a3d6 100644 --- a/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java @@ -5,6 +5,8 @@ import com.baiyue.common.core.api.PageResult; import com.baiyue.common.core.exception.BizException; import com.baiyue.order.client.FirstlegUnifiedOrderClient; import com.baiyue.order.dto.UnifiedOrderQuery; +import com.baiyue.order.dto.UnifiedOrderResponse; +import com.baiyue.order.entity.OrderCargoEntity; import com.baiyue.order.entity.OrderEntity; import com.baiyue.order.entity.OrderInternalNoteEntity; import com.baiyue.order.entity.OrderStatus; @@ -19,6 +21,7 @@ import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import java.time.LocalDate; import java.time.LocalDateTime; +import java.math.BigDecimal; import java.util.List; import java.util.stream.IntStream; @@ -74,6 +77,71 @@ class UnifiedOrderQueryServiceTest { verify(firstleg, never()).summaries(any(), any()); } + @Test + void newlyCreatedOrderUsesItsOwnCargoPackageCountBeforeInboundExists() { + UnifiedOrderQuery query = query(null); + OrderEntity order = order(OrderStatus.APPROVED); + OrderCargoEntity cargo = new OrderCargoEntity(); + cargo.setOrderId(77L); + cargo.setPackageCount(100); + when(orders.unifiedPage(eq(10001L), eq(query), any())) + .thenReturn(new PageResult<>(List.of(order), 1, 20, 1, 1)); + when(firstleg.summaries(eq("internal-token"), any())).thenReturn(ApiResponse.success(List.of())); + when(orders.findCargoByOrderIds(10001L, List.of(77L))).thenReturn(List.of(cargo)); + + assertThat(service.page(10001L, query).records()).singleElement() + .extracting(UnifiedOrderResponse.Row::totalPackageCount).isEqualTo(100); + verify(orders).findCargoByOrderIds(10001L, List.of(77L)); + } + + @Test + void inboundPackageCountRemainsTheFulfillmentFactAfterSplit() { + UnifiedOrderQuery query = query(null); + OrderEntity child = order(OrderStatus.APPROVED); + child.setParentOrderId(66L); + OrderCargoEntity inheritedOriginal = new OrderCargoEntity(); + inheritedOriginal.setOrderId(77L); + inheritedOriginal.setPackageCount(100); + var summary = summary("LOADING", null); + when(summary.inbound().totalPackageCount()).thenReturn(35); + when(orders.unifiedPage(eq(10001L), eq(query), any())) + .thenReturn(new PageResult<>(List.of(child), 1, 20, 1, 1)); + when(firstleg.summaries(eq("internal-token"), any())) + .thenReturn(ApiResponse.success(List.of(summary))); + when(orders.findCargoByOrderIds(10001L, List.of(77L))).thenReturn(List.of(inheritedOriginal)); + + assertThat(service.page(10001L, query).records()).singleElement() + .extracting(UnifiedOrderResponse.Row::totalPackageCount).isEqualTo(35); + } + + @Test + void inboundMasterUsesTotalPackageCountEvenWhenLegacyPackageCountIsAbsent() { + UnifiedOrderQuery query = query(null); + OrderEntity order = order(OrderStatus.APPROVED); + var summary = summary("WAIT_MEASURE", null); + when(summary.inbound().totalPackageCount()).thenReturn(100); + when(orders.unifiedPage(eq(10001L), eq(query), any())) + .thenReturn(new PageResult<>(List.of(order), 1, 20, 1, 1)); + when(firstleg.summaries(eq("internal-token"), any())) + .thenReturn(ApiResponse.success(List.of(summary))); + + assertThat(service.page(10001L, query).records()).singleElement() + .extracting(UnifiedOrderResponse.Row::totalPackageCount).isEqualTo(100); + } + + @Test + void missingCargoAndInboundReturnsNullInsteadOfFabricatedZero() { + UnifiedOrderQuery query = query(null); + OrderEntity order = order(OrderStatus.APPROVED); + when(orders.unifiedPage(eq(10001L), eq(query), any())) + .thenReturn(new PageResult<>(List.of(order), 1, 20, 1, 1)); + when(firstleg.summaries(eq("internal-token"), any())).thenReturn(ApiResponse.success(List.of())); + when(orders.findCargoByOrderIds(10001L, List.of(77L))).thenReturn(List.of()); + + assertThat(service.page(10001L, query).records()).singleElement() + .extracting(UnifiedOrderResponse.Row::totalPackageCount).isNull(); + } + @Test void pendingReviewInboundCodeFilterStaysInOrderQuery() { UnifiedOrderQuery query = new UnifiedOrderQuery( @@ -173,10 +241,27 @@ class UnifiedOrderQueryServiceTest { assertThat(row.companyName()).isEqualTo("测试客户有限公司"); assertThat(row.businessType()).isEqualTo("SEA_LCL"); assertThat(row.totalPalletCount()).isEqualTo(2); + assertThat(row.chargeableWeightKg()).isEqualByComparingTo("125.500"); + assertThat(row.actualGrossWeightKg()).isEqualByComparingTo("1787.500"); + assertThat(row.totalNetWeightKg()).isEqualByComparingTo("1747.500"); }); verify(orders, never()).unifiedPage(any(), any(), any()); } + @Test + void unifiedRowDoesNotUseGrossWeightAsNetWeightFallback() { + UnifiedOrderQuery query = query("INBOUNDED"); + var summary = summary("INBOUNDED", null); + when(summary.inbound().actualNetWeightKg()).thenReturn(null); + when(firstleg.page(eq("internal-token"), any())).thenReturn( + ApiResponse.success(new PageResult<>(List.of(summary), 1, 20, 1, 1))); + + var row = service.page(null, query).records().get(0); + + assertThat(row.actualGrossWeightKg()).isEqualByComparingTo("1787.500"); + assertThat(row.totalNetWeightKg()).isNull(); + } + @Test void waitSplitFilterForwardsAndReturnsWaitSplitIndependently() { UnifiedOrderQuery query = query("WAIT_SPLIT"); @@ -201,6 +286,9 @@ class UnifiedOrderQueryServiceTest { UnifiedOrderQuery query = query(null); OrderEntity order = order(OrderStatus.APPROVED); var summary = summary("LOADED", null); + when(summary.inbound().estimatedSailingAt()).thenReturn(LocalDateTime.of(2026, 8, 20, 8, 0)); + when(summary.inbound().actualSailingAt()).thenReturn(LocalDateTime.of(2026, 8, 21, 9, 0)); + when(summary.inbound().estimatedPortArrivalAt()).thenReturn(LocalDateTime.of(2026, 9, 1, 8, 0)); when(orders.unifiedPage(eq(10001L), eq(query), any())) .thenReturn(new PageResult<>(List.of(order), 1, 20, 1, 1)); when(firstleg.summaries(eq("internal-token"), any())) @@ -212,6 +300,10 @@ class UnifiedOrderQueryServiceTest { assertThat(row.mainStatus()).isEqualTo("LOADED"); assertThat(row.internalOrderStatus()).isEqualTo(OrderStatus.APPROVED); assertThat(row.inboundId()).isEqualTo(88L); + assertThat(row.transport().effectiveSailingAt()).isEqualTo(LocalDateTime.of(2026, 8, 21, 9, 0)); + assertThat(row.transport().effectiveSailingAtEstimated()).isFalse(); + assertThat(row.transport().effectivePortArrivalAt()).isEqualTo(LocalDateTime.of(2026, 9, 1, 8, 0)); + assertThat(row.transport().effectivePortArrivalAtEstimated()).isTrue(); }); ArgumentCaptor captor = ArgumentCaptor.forClass(FirstlegUnifiedOrderClient.BatchRequest.class); @@ -349,6 +441,9 @@ class UnifiedOrderQueryServiceTest { when(inbound.companyName()).thenReturn("测试客户有限公司"); when(inbound.businessType()).thenReturn("SEA_LCL"); when(inbound.palletCount()).thenReturn(2); + when(inbound.actualGrossWeightKg()).thenReturn(new BigDecimal("1787.500")); + when(inbound.actualNetWeightKg()).thenReturn(new BigDecimal("1747.500")); + when(inbound.chargeableWeightKg()).thenReturn(new BigDecimal("125.500")); when(inbound.createdAt()).thenReturn(LocalDateTime.of(2026, 8, 6, 10, 0)); when(inbound.updatedAt()).thenReturn(LocalDateTime.of(2026, 8, 6, 11, 0)); return new FirstlegUnifiedOrderClient.Summary(inbound, status, action,