This commit is contained in:
2026-08-18 11:32:55 +08:00
parent ea26eecc50
commit 3e1010fa26
57 changed files with 1133 additions and 247 deletions
+4
View File
@@ -8,3 +8,7 @@ spring:
baiyue.clients: baiyue.clients:
# 容器环境默认通过 Nacos 的 warehouse-service 实例调用;仅端口回归时显式设置直连 URL。 # 容器环境默认通过 Nacos 的 warehouse-service 实例调用;仅端口回归时显式设置直连 URL。
warehouse.url: ${WAREHOUSE_SERVICE_URL:} warehouse.url: ${WAREHOUSE_SERVICE_URL:}
baiyue.order:
# 两套系统并跑期间统一使用旧系统入仓码;正式切换后改为 AUTO。
inbound-code-mode: ${ORDER_INBOUND_CODE_MODE:LEGACY_MANUAL}
+4
View File
@@ -5,3 +5,7 @@ spring.datasource:
password: ${ORDER_DB_PASSWORD:123456} password: ${ORDER_DB_PASSWORD:123456}
baiyue.clients: baiyue.clients:
warehouse.url: ${WAREHOUSE_SERVICE_URL:} warehouse.url: ${WAREHOUSE_SERVICE_URL:}
baiyue.order:
# 两套系统并跑期间统一使用旧系统入仓码;正式切换后改为 AUTO。
inbound-code-mode: ${ORDER_INBOUND_CODE_MODE:LEGACY_MANUAL}
@@ -31,6 +31,15 @@ public interface OrderCargoClient {
@PathVariable long tenantId, @PathVariable long tenantId,
@PathVariable long orderId); @PathVariable long orderId);
@GetMapping("/{tenantId}/{orderId}/measurement-declaration")
ApiResponse<MeasurementDeclarationSnapshot> 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, record CargoBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
Integer originalPackageCount, BigDecimal originalGrossWeightKg, Integer originalPackageCount, BigDecimal originalGrossWeightKg,
BigDecimal originalVolumeCbm, String skuNo, String cargoNameEn, BigDecimal originalVolumeCbm, String skuNo, String cargoNameEn,
@@ -190,7 +190,7 @@ public class PlatformInboundMeasurementController {
resourceId = "#resultId", description = "确认 OCR 重量并累加实际总毛重") resourceId = "#resultId", description = "确认 OCR 重量并累加实际总毛重")
@Operation(summary = "确认 OCR 重量", @Operation(summary = "确认 OCR 重量",
description = "可在同一事务中携带人工毛重修改值并确认;统一换算 KG、扣除托盘皮重后," description = "可在同一事务中携带人工毛重修改值并确认;统一换算 KG、扣除托盘皮重后,"
+ "按所有已确认净重重算入库单 actualGrossWeightKg。") + "按所有已确认结果分别重算入库单毛重与扣皮后的货物净重。")
@PostMapping("/weight-results/{resultId}/confirm") @PostMapping("/weight-results/{resultId}/confirm")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:weight:confirm')" @PreAuthorize("hasAuthority('platform:firstleg:inbound:weight:confirm')"
+ " and (!#request.hasAdjustment() or hasAuthority('platform:firstleg:inbound:weight:edit'))") + " and (!#request.hasAdjustment() or hasAuthority('platform:firstleg:inbound:weight:edit'))")
@@ -6,7 +6,6 @@ import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult; import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.ExceptionReleaseRequest;
import com.baiyue.firstleg.dto.MeasurementReviewRequest; import com.baiyue.firstleg.dto.MeasurementReviewRequest;
import com.baiyue.firstleg.dto.MeasurementApprovalResponse; import com.baiyue.firstleg.dto.MeasurementApprovalResponse;
import com.baiyue.firstleg.entity.MeasureReviewRecordEntity; import com.baiyue.firstleg.entity.MeasureReviewRecordEntity;
@@ -57,41 +56,6 @@ public class PlatformInboundMeasurementReviewController {
request.measurementId(), request.expectedVersion() + 1)); 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<Void> 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<Void> 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 = "查询测量审核历史") @Operation(summary = "查询测量审核历史")
@GetMapping("/{inboundId}/measurement-review-history") @GetMapping("/{inboundId}/measurement-review-history")
@PreAuthorize("hasAuthority('firstleg:inbound:measurement-review-history')") @PreAuthorize("hasAuthority('firstleg:inbound:measurement-review-history')")
@@ -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
) {}
@@ -1,5 +1,6 @@
package com.baiyue.firstleg.dto; package com.baiyue.firstleg.dto;
import com.fasterxml.jackson.annotation.JsonAnySetter;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import jakarta.validation.constraints.DecimalMin; import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.DecimalMax; import jakarta.validation.constraints.DecimalMax;
@@ -26,12 +27,13 @@ public final class InboundMeasurementRequests {
public record SaveDraftRequest( public record SaveDraftRequest(
Long measurementId, Long measurementId,
@NotNull @PositiveOrZero Integer version, @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, @Size(max = 255) String actualGoodsName,
@NotEmpty @Size(max = 500) List<@Valid ItemRequest> items @NotEmpty @Size(max = 500) List<@Valid ItemRequest> items
) { ) {
@JsonAnySetter
public void rejectUnknownField(String fieldName, Object ignored) {
throw new IllegalArgumentException("测量草稿不接受字段: " + fieldName);
}
} }
public record ItemRequest( public record ItemRequest(
@@ -40,6 +40,7 @@ public final class InboundMeasurementResponses {
Integer originalPackageCount, Integer originalPackageCount,
Integer actualPackageCount, Integer actualPackageCount,
BigDecimal actualGrossWeightKg, BigDecimal actualGrossWeightKg,
BigDecimal actualNetWeightKg,
BigDecimal actualVolumeCbm, BigDecimal actualVolumeCbm,
BigDecimal volumeWeightKg, BigDecimal volumeWeightKg,
String actualGoodsName, String actualGoodsName,
@@ -28,12 +28,25 @@ public record InboundOrderSummary(
LocalDateTime measureCompletedAt, LocalDateTime inboundedAt, LocalDateTime measureCompletedAt, LocalDateTime inboundedAt,
String expressCompanyCode, String expressCompanyName, String expressTrackingNo, String expressCompanyCode, String expressCompanyName, String expressTrackingNo,
String containerNo, String cabinetNo, 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 LocalDateTime createdAt, LocalDateTime updatedAt
) { ) {
/** 非统一入仓接口保留既有快照适配;统一订单不得调用此重载。 */
public static InboundOrderSummary from( public static InboundOrderSummary from(
InboundOrderEntity order, InboundTransportSnapshotEntity transport, InboundOrderEntity order, InboundTransportSnapshotEntity transport,
InboundPickupSnapshotEntity pickup, String locationCodes) { 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( return new InboundOrderSummary(
order.getId(), order.getTenantId(), order.getOrderId(), order.getOrderNo(), order.getInboundCode(), order.getId(), order.getTenantId(), order.getOrderId(), order.getOrderNo(), order.getInboundCode(),
order.getProcessStatus(), order.getOrderStatusSnapshot(), order.getMeasureReviewStatus(), order.getProcessStatus(), order.getOrderStatusSnapshot(), order.getMeasureReviewStatus(),
@@ -53,12 +66,14 @@ public record InboundOrderSummary(
locationCodes, locationCodes,
order.getMeasureCompletedAt(), order.getInboundedAt(), order.getExpressCompanyCode(), order.getMeasureCompletedAt(), order.getInboundedAt(), order.getExpressCompanyCode(),
order.getExpressCompanyName(), order.getExpressTrackingNo(), order.getExpressCompanyName(), order.getExpressTrackingNo(),
transport == null ? null : transport.getContainerNo(), transport == null ? null : transport.containerNo(),
transport == null ? null : transport.getCabinetNo(), transport == null ? null : transport.cabinetNo(),
transport == null ? null : transport.getVesselName(), transport == null ? null : transport.vesselName(),
transport == null ? null : transport.getVoyageNo(), transport == null ? null : transport.voyageNo(),
transport == null ? null : transport.getSailingAt(), transport == null ? null : transport.estimatedSailingAt(),
transport == null ? null : transport.getPortArrivalAt(), transport == null ? null : transport.actualSailingAt(),
transport == null ? null : transport.estimatedPortArrivalAt(),
transport == null ? null : transport.actualPortArrivalAt(),
order.getCreatedAt(), order.getUpdatedAt() order.getCreatedAt(), order.getUpdatedAt()
); );
} }
@@ -19,7 +19,7 @@ public record MerchantInboundBillingResponse(InboundOrderDetail inbound,
public record MeasurementSummary( public record MeasurementSummary(
Long measurementId, String sourceType, String measurementStatus, Long measurementId, String sourceType, String measurementStatus,
Integer actualPackageCount, BigDecimal actualGrossWeightKg, Integer actualPackageCount, BigDecimal actualGrossWeightKg, BigDecimal actualNetWeightKg,
BigDecimal actualVolumeCbm, BigDecimal volumeWeightKg, String actualGoodsName) { BigDecimal actualVolumeCbm, BigDecimal volumeWeightKg, String actualGoodsName) {
} }
@@ -36,6 +36,7 @@ public class InboundPricingEntity extends BaseEntity {
private Long destinationWarehouseId; private Long destinationWarehouseId;
private String destinationName; private String destinationName;
private BigDecimal actualGrossWeightKg; private BigDecimal actualGrossWeightKg;
private BigDecimal actualNetWeightKg;
private BigDecimal actualVolumeCbm; private BigDecimal actualVolumeCbm;
private BigDecimal chargeableWeightKg; private BigDecimal chargeableWeightKg;
private BigDecimal chargeableVolumeCbm; private BigDecimal chargeableVolumeCbm;
@@ -12,19 +12,14 @@ public interface InboundOrderMapper extends BaseMapper<InboundOrderEntity> {
@Update(""" @Update("""
UPDATE fl_inbound_order UPDATE fl_inbound_order
SET process_status = 'WAIT_SPLIT', measure_review_status = 'APPROVED', SET process_status = 'WAIT_SPLIT', measure_review_status = 'APPROVED',
actual_gross_weight_kg = CASE actual_gross_weight_kg = (SELECT SUM(weight.gross_weight_kg)
WHEN EXISTS (SELECT 1 FROM fl_inbound_weight_result weight
FROM fl_inbound_weight_result weight WHERE weight.tenant_id = #{tenantId} AND weight.inbound_id = #{inboundId}
WHERE weight.tenant_id = #{tenantId} AND weight.inbound_id = #{inboundId} AND weight.deleted = 0 AND weight.recognition_status = 'CONFIRMED'),
AND weight.deleted = 0 AND weight.recognition_status = 'CONFIRMED') actual_net_weight_kg = (SELECT SUM(weight.net_weight_kg)
THEN (SELECT COALESCE(SUM(weight.net_weight_kg), 0) FROM fl_inbound_weight_result weight
FROM fl_inbound_weight_result weight WHERE weight.tenant_id = #{tenantId} AND weight.inbound_id = #{inboundId}
WHERE weight.tenant_id = #{tenantId} AND weight.inbound_id = #{inboundId} AND weight.deleted = 0 AND weight.recognition_status = 'CONFIRMED'),
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_volume_cbm = (SELECT COALESCE(SUM(item.total_volume_cbm), 0) actual_volume_cbm = (SELECT COALESCE(SUM(item.total_volume_cbm), 0)
FROM fl_inbound_measurement_item item FROM fl_inbound_measurement_item item
WHERE item.tenant_id = #{tenantId} AND item.measurement_id = #{measurementId}), WHERE item.tenant_id = #{tenantId} AND item.measurement_id = #{measurementId}),
@@ -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("""
<script>
SELECT DISTINCT i.inbound_id, i.account_id, p.container_booking_id,
b.container_no, b.cabinet_no, b.vessel_name, b.voyage_no,
b.estimated_sailing_at, b.actual_sailing_at,
b.estimated_port_arrival_at, b.actual_port_arrival_at
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.inbound_id IN
<foreach collection="inboundIds" item="id" open="(" separator="," close=")">#{id}</foreach>
</script>
""")
List<CurrentContainerTransport> findCurrentByInboundIds(@Param("inboundIds") List<Long> inboundIds);
}
@@ -59,7 +59,8 @@ public class InboundMeasurementRepository {
return inboundOrders.update(inbound, new LambdaUpdateWrapper<InboundOrderEntity>() return inboundOrders.update(inbound, new LambdaUpdateWrapper<InboundOrderEntity>()
.eq(InboundOrderEntity::getTenantId, inbound.getTenantId()) .eq(InboundOrderEntity::getTenantId, inbound.getTenantId())
.eq(InboundOrderEntity::getId, inbound.getId()) .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) { public InboundMeasurementEntity findMeasurement(long tenantId, long inboundId, long measurementId) {
@@ -154,29 +154,31 @@ public class InboundOrderRepository {
+ "OR (newer.fee_version = f.fee_version AND newer.id > f.id))))", + "OR (newer.fee_version = f.fee_version AND newer.id > f.id))))",
query.feeReviewStatus()) query.feeReviewStatus())
.apply(StringUtils.hasText(query.containerNo()), .apply(StringUtils.hasText(query.containerNo()),
"EXISTS (SELECT 1 FROM fl_inbound_transport_snapshot t " "EXISTS (SELECT 1 FROM fl_loading_plan_item i JOIN fl_loading_plan p ON p.id=i.loading_plan_id "
+ "WHERE t.tenant_id = fl_inbound_order.tenant_id AND t.inbound_id = fl_inbound_order.id " + "JOIN fl_container_booking b ON b.id=p.container_booking_id WHERE i.deleted=0 "
+ "AND t.container_no LIKE CONCAT('%', {0}, '%'))", query.containerNo()) + "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()), .apply(StringUtils.hasText(query.cabinetNo()),
"EXISTS (SELECT 1 FROM fl_inbound_transport_snapshot t " "EXISTS (SELECT 1 FROM fl_loading_plan_item i JOIN fl_loading_plan p ON p.id=i.loading_plan_id "
+ "WHERE t.tenant_id = fl_inbound_order.tenant_id AND t.inbound_id = fl_inbound_order.id " + "JOIN fl_container_booking b ON b.id=p.container_booking_id WHERE i.deleted=0 "
+ "AND t.cabinet_no LIKE CONCAT('%', {0}, '%'))", query.cabinetNo()) + "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, .apply(query.sailingDate() != null,
"EXISTS (SELECT 1 FROM fl_inbound_transport_snapshot t " "EXISTS (SELECT 1 FROM fl_loading_plan_item i JOIN fl_loading_plan p ON p.id=i.loading_plan_id "
+ "WHERE t.tenant_id = fl_inbound_order.tenant_id AND t.inbound_id = fl_inbound_order.id " + "JOIN fl_container_booking b ON b.id=p.container_booking_id WHERE i.deleted=0 "
+ "AND t.sailing_at >= {0} AND t.sailing_at < {1})", + "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().atStartOfDay(),
query.sailingDate() == null ? null : query.sailingDate().plusDays(1).atStartOfDay()) query.sailingDate() == null ? null : query.sailingDate().plusDays(1).atStartOfDay())
.apply(Boolean.TRUE.equals(query.loaded()), .apply(Boolean.TRUE.equals(query.loaded()),
"EXISTS (SELECT 1 FROM fl_inbound_transport_snapshot t " "EXISTS (SELECT 1 FROM fl_loading_plan_item i JOIN fl_loading_plan p ON p.id=i.loading_plan_id "
+ "WHERE t.tenant_id = fl_inbound_order.tenant_id AND t.inbound_id = fl_inbound_order.id " + "WHERE i.deleted=0 AND p.plan_status='COMPLETED' "
+ "AND COALESCE(TRIM(t.loading_status), '') <> '' " + "AND i.account_id=fl_inbound_order.tenant_id AND i.inbound_id=fl_inbound_order.id)")
+ "AND t.loading_status NOT IN ('UNLOADED', 'PENDING'))")
.apply(Boolean.FALSE.equals(query.loaded()), .apply(Boolean.FALSE.equals(query.loaded()),
"NOT EXISTS (SELECT 1 FROM fl_inbound_transport_snapshot t " "NOT EXISTS (SELECT 1 FROM fl_loading_plan_item i JOIN fl_loading_plan p ON p.id=i.loading_plan_id "
+ "WHERE t.tenant_id = fl_inbound_order.tenant_id AND t.inbound_id = fl_inbound_order.id " + "WHERE i.deleted=0 AND p.plan_status='COMPLETED' "
+ "AND COALESCE(TRIM(t.loading_status), '') <> '' " + "AND i.account_id=fl_inbound_order.tenant_id AND i.inbound_id=fl_inbound_order.id)")
+ "AND t.loading_status NOT IN ('UNLOADED', 'PENDING'))")
.ge(query.createdFrom() != null, InboundOrderEntity::getCreatedAt, query.createdFrom()) .ge(query.createdFrom() != null, InboundOrderEntity::getCreatedAt, query.createdFrom())
.le(query.createdTo() != null, InboundOrderEntity::getCreatedAt, query.createdTo()) .le(query.createdTo() != null, InboundOrderEntity::getCreatedAt, query.createdTo())
.orderByDesc(InboundOrderEntity::getCreatedAt).orderByDesc(InboundOrderEntity::getId); .orderByDesc(InboundOrderEntity::getCreatedAt).orderByDesc(InboundOrderEntity::getId);
@@ -8,9 +8,11 @@ import com.baiyue.firstleg.loading.mapper.InboundLoadingPickTaskMapper;
import com.baiyue.firstleg.split.entity.PreloadPlanEntity; import com.baiyue.firstleg.split.entity.PreloadPlanEntity;
import com.baiyue.firstleg.split.mapper.PreloadPlanMapper; import com.baiyue.firstleg.split.mapper.PreloadPlanMapper;
import com.baiyue.firstleg.entity.InboundStatusHistoryEntity; 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.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 jakarta.annotation.Resource;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
@@ -26,7 +28,7 @@ public class UnifiedInboundReadRepository {
@Resource private InboundLoadingPickTaskMapper picks; @Resource private InboundLoadingPickTaskMapper picks;
@Resource private LoadingEvidenceMapper evidences; @Resource private LoadingEvidenceMapper evidences;
@Resource private InboundStatusHistoryMapper histories; @Resource private InboundStatusHistoryMapper histories;
@Resource private InboundTransportSnapshotMapper transports; @Resource private UnifiedInboundTransportMapper transports;
public PreloadPlanEntity findPreload(long tenantId, long inboundId) { public PreloadPlanEntity findPreload(long tenantId, long inboundId) {
return preloads.selectOne(new LambdaQueryWrapper<PreloadPlanEntity>() return preloads.selectOne(new LambdaQueryWrapper<PreloadPlanEntity>()
@@ -84,19 +86,23 @@ public class UnifiedInboundReadRepository {
Function.identity(), (latest, ignored) -> latest)); Function.identity(), (latest, ignored) -> latest));
} }
public InboundTransportSnapshotEntity findTransport(long tenantId, long inboundId) { public CurrentContainerTransport findTransport(long tenantId, long inboundId) {
return transports.selectOne(new LambdaQueryWrapper<InboundTransportSnapshotEntity>() return findTransports(Map.of(inboundId, tenantId)).get(inboundId);
.eq(InboundTransportSnapshotEntity::getTenantId, tenantId)
.eq(InboundTransportSnapshotEntity::getInboundId, inboundId));
} }
public Map<Long, InboundTransportSnapshotEntity> findTransports(Map<Long, Long> tenantByInbound) { public Map<Long, CurrentContainerTransport> findTransports(Map<Long, Long> tenantByInbound) {
if (tenantByInbound.isEmpty()) return Map.of(); if (tenantByInbound.isEmpty()) return Map.of();
return transports.selectList(new LambdaQueryWrapper<InboundTransportSnapshotEntity>() Map<Long, List<CurrentContainerTransport>> grouped = transports
.in(InboundTransportSnapshotEntity::getInboundId, tenantByInbound.keySet())) .findCurrentByInboundIds(List.copyOf(tenantByInbound.keySet())).stream()
.stream().filter(item -> owns(tenantByInbound, item.getInboundId(), item.getTenantId())) .filter(item -> owns(tenantByInbound, item.inboundId(), item.accountId()))
.collect(Collectors.toMap(InboundTransportSnapshotEntity::getInboundId, .collect(Collectors.groupingBy(CurrentContainerTransport::inboundId));
Function.identity(), (left, right) -> left)); 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<Long, Long> tenantByInbound, Long inboundId, Long tenantId) { private static boolean owns(Map<Long, Long> tenantByInbound, Long inboundId, Long tenantId) {
@@ -150,21 +150,24 @@ public class CartonMeasurementService {
.filter(weight -> "CONFIRMED".equals(weight.getRecognitionStatus())) .filter(weight -> "CONFIRMED".equals(weight.getRecognitionStatus()))
.toList(); .toList();
if (!confirmedWeights.isEmpty()) { 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() BigDecimal confirmedNetWeight = confirmedWeights.stream()
.map(InboundWeightResultEntity::getNetWeightKg) .map(InboundWeightResultEntity::getNetWeightKg)
.filter(java.util.Objects::nonNull)
.reduce(BigDecimal.ZERO, BigDecimal::add) .reduce(BigDecimal.ZERO, BigDecimal::add)
.setScale(3, RoundingMode.CEILING); .setScale(3, RoundingMode.HALF_UP);
inbound.setActualGrossWeightKg(confirmedNetWeight); inbound.setActualGrossWeightKg(confirmedGrossWeight);
inbound.setActualNetWeightKg(confirmedNetWeight);
} else { } else {
BigDecimal totalWeight = items.stream() inbound.setActualGrossWeightKg(null);
.map(InboundMeasurementItemEntity::getTotalWeightKg) inbound.setActualNetWeightKg(null);
.filter(java.util.Objects::nonNull)
.reduce(BigDecimal.ZERO, BigDecimal::add)
.setScale(3, RoundingMode.CEILING);
if (positive(totalWeight)) {
inbound.setActualGrossWeightKg(totalWeight);
}
} }
inbound.setActualVolumeCbm(measurement.getTotalVolumeCbm()); inbound.setActualVolumeCbm(measurement.getTotalVolumeCbm());
inbound.setVolumeWeightKg(measurement.getVolumeWeightKg()); inbound.setVolumeWeightKg(measurement.getVolumeWeightKg());
@@ -58,9 +58,9 @@ public class FbaPricingService {
ActivePriceBookEstimator.Estimate estimate = priceBookEstimator.estimate(new EstimateRequest( ActivePriceBookEstimator.Estimate estimate = priceBookEstimator.estimate(new EstimateRequest(
inbound.getWarehouseId(), inbound.getChannelCode(), inbound.getDestinationCode(), inbound.getWarehouseId(), inbound.getChannelCode(), inbound.getDestinationCode(),
destinationType(inbound), inbound.getDestinationWarehouseId(), inbound.getDestinationName(), null, destinationType(inbound), inbound.getDestinationWarehouseId(), inbound.getDestinationName(), null,
pricingTaxMode(inbound), inbound.getActualGrossWeightKg(), inbound.getActualVolumeCbm())); pricingTaxMode(inbound), inbound.getActualNetWeightKg(), inbound.getActualVolumeCbm()));
RateRow rate = estimate.rate(); 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 volume = inbound.getActualVolumeCbm().setScale(4, RoundingMode.HALF_UP);
BigDecimal chargeable = estimate.chargeableQuantity(); BigDecimal chargeable = estimate.chargeableQuantity();
BigDecimal converted = chargeable; BigDecimal converted = chargeable;
@@ -240,9 +240,9 @@ public class FbaPricingService {
throw conflict("当前目的地类型不支持自动计费"); throw conflict("当前目的地类型不支持自动计费");
} }
if (!"APPROVED".equals(inbound.getMeasureReviewStatus())) 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) { || inbound.getActualVolumeCbm() == null || inbound.getActualVolumeCbm().signum() <= 0) {
throw conflict("审核通过的测量快照必须包含大于零的实重和体积"); throw conflict("审核通过的测量快照必须包含大于零的货物净重和体积");
} }
if (inbound.getCustomerId() == null || inbound.getCustomerId() <= 0) { if (inbound.getCustomerId() == null || inbound.getCustomerId() <= 0) {
throw conflict("入库单缺少订单客户归属,不能生成对客费用"); throw conflict("入库单缺少订单客户归属,不能生成对客费用");
@@ -390,7 +390,8 @@ public class InboundFeeDetailService {
value.setDestinationCode(inbound.getDestinationCode()); value.setDestinationCode(inbound.getDestinationCode());
value.setDestinationName(inbound.getDestinationName()); value.setDestinationName(inbound.getDestinationName());
value.setPackageCount(inbound.getTotalPackageCount()); value.setPackageCount(inbound.getTotalPackageCount());
value.setActualGrossWeightKg(inbound.getActualGrossWeightKg()); // 财务历史字段名保留,但计费事实统一使用扣除托盘皮重后的货物净重。
value.setActualGrossWeightKg(inbound.getActualNetWeightKg());
value.setActualVolumeCbm(inbound.getActualVolumeCbm()); value.setActualVolumeCbm(inbound.getActualVolumeCbm());
value.setVolumeWeightKg(inbound.getVolumeWeightKg()); value.setVolumeWeightKg(inbound.getVolumeWeightKg());
value.setChargeableWeightKg(inbound.getChargeableWeightKg()); value.setChargeableWeightKg(inbound.getChargeableWeightKg());
@@ -68,7 +68,9 @@ public class InboundMeasurementService {
? List.of() : repository.listItems(tenantId, measurement.getId()); ? List.of() : repository.listItems(tenantId, measurement.getId());
List<InboundWeightResultEntity> weights = repository.listWeights(tenantId, inboundId); List<InboundWeightResultEntity> weights = repository.listWeights(tenantId, inboundId);
MeasureReviewRecordEntity review = repository.findLatestReview(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<InboundMeasurementResponses.PalletSnapshot> weightPalletOptions( public List<InboundMeasurementResponses.PalletSnapshot> weightPalletOptions(
@@ -87,11 +89,23 @@ public class InboundMeasurementService {
InboundMeasurementRequests.SaveDraftRequest request) { InboundMeasurementRequests.SaveDraftRequest request) {
InboundOrderEntity inbound = requireInbound(tenantId, inboundId); InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
validateInboundDetails(tenantId, inbound, request.items()); 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( MeasurementBatchCommand command = new MeasurementBatchCommand(
tenantId, inboundId, request.measurementId(), request.version(), tenantId, inboundId, request.measurementId(), request.version(),
MeasurementBatchService.SOURCE_MANUAL, "PLATFORM", null, null, null, MeasurementBatchService.SOURCE_MANUAL, "PLATFORM", null, null, null,
LocalDateTime.now(), null, request.originalDocumentNo(), LocalDateTime.now(), null,
request.originalGrossWeightKg(), request.originalVolumeCbm(), request.actualGoodsName(), 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( operatorId, request.items().stream().map(item -> new MeasurementBatchCommand.Item(
item.inboundDetailId(), item.itemNo(), item.boxSpecification(), item.packageCount(), item.lengthCm(), item.inboundDetailId(), item.itemNo(), item.boxSpecification(), item.packageCount(), item.lengthCm(),
item.widthCm(), item.heightCm(), item.unitWeightKg(), item.intelligentImageFileId(), item.widthCm(), item.heightCm(), item.unitWeightKg(), item.intelligentImageFileId(),
@@ -116,10 +130,7 @@ public class InboundMeasurementService {
} }
List<InboundMeasurementItemEntity> items = repository.listItems(tenantId, measurement.getId()); List<InboundMeasurementItemEntity> items = repository.listItems(tenantId, measurement.getId());
List<InboundWeightResultEntity> weights = repository.listWeights(tenantId, inboundId); List<InboundWeightResultEntity> weights = repository.listWeights(tenantId, inboundId);
BigDecimal confirmedWeight = confirmedWeightForSubmission(weights); ConfirmedWeightTotals confirmedTotals = confirmedWeightTotals(weights);
int confirmedPalletCount = Math.toIntExact(weights.stream()
.filter(weight -> WEIGHT_CONFIRMED.equals(weight.getRecognitionStatus()))
.count());
if (!alreadyAdvanced) { if (!alreadyAdvanced) {
requireConfirmedPackageTotal(inbound, items); requireConfirmedPackageTotal(inbound, items);
@@ -137,10 +148,9 @@ public class InboundMeasurementService {
if (measurement.getTotalPackageCount() != null) { if (measurement.getTotalPackageCount() != null) {
inbound.setTotalPackageCount(measurement.getTotalPackageCount()); inbound.setTotalPackageCount(measurement.getTotalPackageCount());
} }
if (confirmedWeight != null) { inbound.setActualGrossWeightKg(confirmedTotals.grossWeightKg());
inbound.setActualGrossWeightKg(confirmedWeight); inbound.setActualNetWeightKg(confirmedTotals.netWeightKg());
} inbound.setPalletCount(confirmedTotals.palletCount());
inbound.setPalletCount(confirmedPalletCount);
if (measurement.getTotalVolumeCbm() != null) { if (measurement.getTotalVolumeCbm() != null) {
inbound.setActualVolumeCbm(measurement.getTotalVolumeCbm()); inbound.setActualVolumeCbm(measurement.getTotalVolumeCbm());
} }
@@ -161,6 +171,7 @@ public class InboundMeasurementService {
long tenantId, long inboundId, long operatorId, long tenantId, long inboundId, long operatorId,
InboundMeasurementRequests.CreateWeightResultRequest request) { InboundMeasurementRequests.CreateWeightResultRequest request) {
InboundOrderEntity inbound = requireInbound(tenantId, inboundId); InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
requireWeightEditableInbound(inbound);
if (repository.findWeightByFile(tenantId, inboundId, request.fileId()) != null) { if (repository.findWeightByFile(tenantId, inboundId, request.fileId()) != null) {
throw new BizException(ResultCode.CONFLICT, "同一称重图片不能重复计重"); throw new BizException(ResultCode.CONFLICT, "同一称重图片不能重复计重");
} }
@@ -192,6 +203,7 @@ public class InboundMeasurementService {
weight.setDeleted(false); weight.setDeleted(false);
weight.setVersion(0); weight.setVersion(0);
repository.insertWeight(weight); repository.insertWeight(weight);
recalculateConfirmedWeight(inbound, operatorId);
return toWeight(weight); return toWeight(weight);
} }
@@ -200,14 +212,12 @@ public class InboundMeasurementService {
long tenantId, long inboundId, long resultId, long operatorId, String operatorName, long tenantId, long inboundId, long resultId, long operatorId, String operatorName,
InboundMeasurementRequests.CorrectWeightResultRequest request) { InboundMeasurementRequests.CorrectWeightResultRequest request) {
InboundOrderEntity inbound = requireInbound(tenantId, inboundId); InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
requireWeightEditableInbound(inbound);
InboundWeightResultEntity weight = requireWeight(tenantId, inboundId, resultId); InboundWeightResultEntity weight = requireWeight(tenantId, inboundId, resultId);
requireVersion(request.version(), weight.getVersion()); requireVersion(request.version(), weight.getVersion());
boolean wasConfirmed = WEIGHT_CONFIRMED.equals(weight.getRecognitionStatus());
applyWeightCorrection(weight, request.version(), request.adjustedWeightValue(), applyWeightCorrection(weight, request.version(), request.adjustedWeightValue(),
request.adjustedWeightUnit(), request.adjustmentReason(), operatorId, operatorName); request.adjustedWeightUnit(), request.adjustmentReason(), operatorId, operatorName);
if (wasConfirmed) { recalculateConfirmedWeight(inbound, operatorId);
recalculateConfirmedWeight(inbound, operatorId);
}
return toWeight(weight); return toWeight(weight);
} }
@@ -246,6 +256,7 @@ public class InboundMeasurementService {
long tenantId, long inboundId, long resultId, long operatorId, String operatorName, long tenantId, long inboundId, long resultId, long operatorId, String operatorName,
InboundMeasurementRequests.ConfirmWeightResultRequest request) { InboundMeasurementRequests.ConfirmWeightResultRequest request) {
InboundOrderEntity inbound = requireInbound(tenantId, inboundId); InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
requireWeightConfirmableInbound(inbound, request.hasAdjustment());
InboundWeightResultEntity weight = requireWeight(tenantId, inboundId, resultId); InboundWeightResultEntity weight = requireWeight(tenantId, inboundId, resultId);
requireVersion(request.version(), weight.getVersion()); requireVersion(request.version(), weight.getVersion());
int currentVersion = request.version(); int currentVersion = request.version();
@@ -291,19 +302,15 @@ public class InboundMeasurementService {
@Transactional @Transactional
public void deleteWeightResult(long tenantId, long inboundId, long resultId, public void deleteWeightResult(long tenantId, long inboundId, long resultId,
int version, long operatorId) { int version, long operatorId) {
InboundOrderEntity inbound = requireInbound(tenantId, inboundId); InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
if (!WEIGHT_EDITABLE_STATUSES.contains(inbound.getProcessStatus())) { requireWeightEditableInbound(inbound);
throw new BizException(ResultCode.CONFLICT, "当前入库状态不允许删除称重图片");
}
InboundWeightResultEntity weight = requireWeight(tenantId, inboundId, resultId); InboundWeightResultEntity weight = requireWeight(tenantId, inboundId, resultId);
requireVersion(version, weight.getVersion()); requireVersion(version, weight.getVersion());
if (!repository.deleteWeight(tenantId, inboundId, resultId, version)) { if (!repository.deleteWeight(tenantId, inboundId, resultId, version)) {
throw concurrentModification(); throw concurrentModification();
} }
if (WEIGHT_CONFIRMED.equals(weight.getRecognitionStatus())) { recalculateConfirmedWeight(inbound, operatorId);
recalculateConfirmedWeight(inbound, operatorId);
}
try { try {
ApiResponse<Void> response = imageClient.delete( ApiResponse<Void> response = imageClient.delete(
internalToken, operatorId, tenantId, inboundId, weight.getFileId()); internalToken, operatorId, tenantId, inboundId, weight.getFileId());
@@ -319,15 +326,10 @@ public class InboundMeasurementService {
} }
private void recalculateConfirmedWeight(InboundOrderEntity inbound, long operatorId) { private void recalculateConfirmedWeight(InboundOrderEntity inbound, long operatorId) {
List<InboundWeightResultEntity> confirmedWeights = repository ConfirmedWeightTotals totals = confirmedWeightTotals(
.listWeights(inbound.getTenantId(), inbound.getId()).stream() repository.listWeights(inbound.getTenantId(), inbound.getId()));
.filter(weight -> WEIGHT_CONFIRMED.equals(weight.getRecognitionStatus())) inbound.setActualGrossWeightKg(totals.grossWeightKg());
.toList(); inbound.setActualNetWeightKg(totals.netWeightKg());
BigDecimal total = confirmedWeights.stream()
.map(InboundWeightResultEntity::getNetWeightKg)
.reduce(BigDecimal.ZERO, BigDecimal::add)
.setScale(3, RoundingMode.HALF_UP);
inbound.setActualGrossWeightKg(confirmedWeights.isEmpty() ? null : total);
if ("WAIT_MEASURE".equals(inbound.getProcessStatus())) { if ("WAIT_MEASURE".equals(inbound.getProcessStatus())) {
inbound.setProcessStatus("MEASURING"); inbound.setProcessStatus("MEASURING");
} }
@@ -337,13 +339,31 @@ public class InboundMeasurementService {
} }
} }
private BigDecimal confirmedWeightForSubmission(List<InboundWeightResultEntity> weights) { private ConfirmedWeightTotals confirmedWeightTotals(List<InboundWeightResultEntity> weights) {
BigDecimal total = weights.stream() List<InboundWeightResultEntity> confirmed = weights.stream()
.filter(weight -> WEIGHT_CONFIRMED.equals(weight.getRecognitionStatus())) .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) .map(InboundWeightResultEntity::getNetWeightKg)
.filter(java.util.Objects::nonNull) .reduce(BigDecimal.ZERO, BigDecimal::add)
.reduce(BigDecimal.ZERO, BigDecimal::add).setScale(3, RoundingMode.HALF_UP); .setScale(3, RoundingMode.HALF_UP);
return isPositive(total) ? total : null; 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) { 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, private void insertRevision(InboundWeightResultEntity weight, int revisionNo, String action,
BigDecimal beforeValue, String beforeUnit, BigDecimal beforeValue, String beforeUnit,
BigDecimal afterValue, String afterUnit, BigDecimal afterValue, String afterUnit,
@@ -380,7 +417,7 @@ public class InboundMeasurementService {
private InboundMeasurementResponses.Detail toDetail( private InboundMeasurementResponses.Detail toDetail(
InboundOrderEntity inbound, InboundMeasurementEntity measurement, InboundOrderEntity inbound, InboundMeasurementEntity measurement,
List<InboundMeasurementItemEntity> items, List<InboundWeightResultEntity> weights, List<InboundMeasurementItemEntity> items, List<InboundWeightResultEntity> weights,
MeasureReviewRecordEntity review) { MeasureReviewRecordEntity review, OrderCargoClient.MeasurementDeclarationSnapshot declaration) {
return new InboundMeasurementResponses.Detail( return new InboundMeasurementResponses.Detail(
inbound.getId(), inbound.getInboundCode(), inbound.getCompanyName(), inbound.getChannelCode(), inbound.getId(), inbound.getInboundCode(), inbound.getCompanyName(), inbound.getChannelCode(),
inbound.getChannelName(), inbound.getProcessStatus(), inbound.getMeasureReviewStatus(), inbound.getChannelName(), inbound.getProcessStatus(), inbound.getMeasureReviewStatus(),
@@ -391,18 +428,58 @@ public class InboundMeasurementService {
measurement == null ? null : measurement.getStatus(), measurement == null ? null : measurement.getStatus(),
inbound.getVersion(), inbound.getVersion(),
measurement == null ? null : measurement.getVersion(), measurement == null ? null : measurement.getVersion(),
measurement == null ? null : measurement.getOriginalDocumentNo(), measurement != null && StringUtils.hasText(measurement.getOriginalDocumentNo())
measurement == null ? null : measurement.getOriginalGrossWeightKg(), ? measurement.getOriginalDocumentNo() : declaration == null ? null : declaration.referenceId(),
measurement == null ? null : measurement.getOriginalVolumeCbm(), 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(), inbound.getTotalPackageCount(),
measurement == null ? inbound.getTotalPackageCount() : measurement.getTotalPackageCount(), measurement == null ? inbound.getTotalPackageCount() : measurement.getTotalPackageCount(),
inbound.getActualGrossWeightKg(), inbound.getActualGrossWeightKg(),
inbound.getActualNetWeightKg(),
measurement == null ? inbound.getActualVolumeCbm() : measurement.getTotalVolumeCbm(), measurement == null ? inbound.getActualVolumeCbm() : measurement.getTotalVolumeCbm(),
measurement == null ? inbound.getVolumeWeightKg() : measurement.getVolumeWeightKg(), 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()); 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<OrderCargoClient.MeasurementDeclarationSnapshot> 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) { private InboundMeasurementResponses.Item toItem(InboundMeasurementItemEntity item) {
return new InboundMeasurementResponses.Item( return new InboundMeasurementResponses.Item(
item.getId(), item.getInboundDetailId(), item.getItemNo(), item.getBoxSpecification(), item.getPackageCount(), item.getId(), item.getInboundDetailId(), item.getItemNo(), item.getBoxSpecification(), item.getPackageCount(),
@@ -28,6 +28,8 @@ public class MeasurementReviewService {
@Resource @Resource
private InboundWorkflowRepository repository; private InboundWorkflowRepository repository;
@Resource @Resource
private com.baiyue.firstleg.repository.InboundMeasurementRepository measurements;
@Resource
private InboundIdempotencyService idempotency; private InboundIdempotencyService idempotency;
@Resource @Resource
private InboundProperties properties; private InboundProperties properties;
@@ -78,12 +80,30 @@ public class MeasurementReviewService {
throw new BizException(ResultCode.VALIDATION_FAILED, 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) BigDecimal volume = items.stream().map(com.baiyue.firstleg.entity.InboundMeasurementItemEntity::getTotalVolumeCbm)
.filter(java.util.Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add); .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, throw new BizException(ResultCode.VALIDATION_FAILED,
"审核通过的测量快照必须包含大于零的货物总重量和体积"); "审核通过前称重毛重、货物净重汇总和体积必须完整且一致");
} }
} }
@@ -53,6 +53,7 @@ public class MerchantInboundBillingService {
var summary = new MerchantInboundBillingResponse.MeasurementSummary( var summary = new MerchantInboundBillingResponse.MeasurementSummary(
measurement.measurementId(), measurement.sourceType(), measurement.measurementStatus(), measurement.measurementId(), measurement.sourceType(), measurement.measurementStatus(),
measurement.actualPackageCount(), measurement.actualGrossWeightKg(), measurement.actualPackageCount(), measurement.actualGrossWeightKg(),
measurement.actualNetWeightKg(),
measurement.actualVolumeCbm(), measurement.volumeWeightKg(), measurement.actualGoodsName()); measurement.actualVolumeCbm(), measurement.volumeWeightKg(), measurement.actualGoodsName());
var measurementImages = measurement.items().stream() var measurementImages = measurement.items().stream()
.filter(item -> item.intelligentImageFileId() != null) .filter(item -> item.intelligentImageFileId() != null)
@@ -7,7 +7,7 @@ import com.baiyue.firstleg.dto.UnifiedInboundDetail;
import com.baiyue.firstleg.dto.UnifiedInboundQuery; import com.baiyue.firstleg.dto.UnifiedInboundQuery;
import com.baiyue.firstleg.dto.UnifiedInboundSummary; import com.baiyue.firstleg.dto.UnifiedInboundSummary;
import com.baiyue.firstleg.entity.InboundOrderEntity; 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.loading.entity.LoadingEvidenceEntity;
import com.baiyue.firstleg.repository.InboundOrderRepository; import com.baiyue.firstleg.repository.InboundOrderRepository;
import com.baiyue.firstleg.repository.UnifiedInboundReadRepository; import com.baiyue.firstleg.repository.UnifiedInboundReadRepository;
@@ -75,7 +75,7 @@ public class UnifiedInboundQueryService {
evidence = operations.findEvidence(tenantId, inbound.getId()); evidence = operations.findEvidence(tenantId, inbound.getId());
deliveryAt = deliveryAt(detail.statusHistory()); deliveryAt = deliveryAt(detail.statusHistory());
} }
InboundTransportSnapshotEntity transport = includePhaseThree CurrentContainerTransport transport = includePhaseThree
? operations.findTransport(tenantId, inbound.getId()) : null; ? operations.findTransport(tenantId, inbound.getId()) : null;
return new UnifiedInboundDetail(summary(inbound, detail, preload, pick, evidence, deliveryAt, return new UnifiedInboundDetail(summary(inbound, detail, preload, pick, evidence, deliveryAt,
transport, null, null), timeline); transport, null, null), timeline);
@@ -105,7 +105,7 @@ public class UnifiedInboundQueryService {
com.baiyue.firstleg.split.entity.PreloadPlanEntity preload, com.baiyue.firstleg.split.entity.PreloadPlanEntity preload,
com.baiyue.firstleg.loading.entity.InboundLoadingPickTaskEntity pick, com.baiyue.firstleg.loading.entity.InboundLoadingPickTaskEntity pick,
List<LoadingEvidenceEntity> evidence, LocalDateTime deliveryAt, List<LoadingEvidenceEntity> evidence, LocalDateTime deliveryAt,
InboundTransportSnapshotEntity transport, String locationCodes, CurrentContainerTransport transport, String locationCodes,
com.baiyue.firstleg.entity.InboundPickupSnapshotEntity pickup) { com.baiyue.firstleg.entity.InboundPickupSnapshotEntity pickup) {
String mainStatus = mainStatus(inbound.getProcessStatus()); String mainStatus = mainStatus(inbound.getProcessStatus());
List<LoadingEvidenceEntity> activeEvidence = evidence.stream() List<LoadingEvidenceEntity> activeEvidence = evidence.stream()
@@ -124,13 +124,22 @@ public class UnifiedInboundQueryService {
? new UnifiedInboundSummary.DeliverySummary( ? new UnifiedInboundSummary.DeliverySummary(
"DELIVERED".equals(mainStatus) ? "COMPLETED" : "PENDING", deliveryAt, "DELIVERED".equals(mainStatus) ? "COMPLETED" : "PENDING", deliveryAt,
inbound.getExpressTrackingNo()) : null; inbound.getExpressTrackingNo()) : null;
var inboundSummary = detail == null var baseSummary = detail == null ? null : detail.summary();
? com.baiyue.firstleg.dto.InboundOrderSummary.from(inbound, transport, pickup, locationCodes) : 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, return new UnifiedInboundSummary(inboundSummary, mainStatus,
nextAction(mainStatus, inbound.getProcessStatus(), preload == null ? null : preload.getStatus()), nextAction(mainStatus, inbound.getProcessStatus(), preload == null ? null : preload.getStatus()),
preloadSummary, loadingSummary, deliverySummary); 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<InboundOrderDetail.StatusHistorySummary> history) { private LocalDateTime deliveryAt(List<InboundOrderDetail.StatusHistorySummary> history) {
return history.stream() return history.stream()
.filter(item -> "COMPLETE_DELIVERY".equals(item.action()) || "DELIVERED".equals(item.toStatus())) .filter(item -> "COMPLETE_DELIVERY".equals(item.action()) || "DELIVERED".equals(item.toStatus()))
@@ -42,13 +42,13 @@ CREATE TABLE IF NOT EXISTS fl_inbound_order (
hold_flag TINYINT(1) NOT NULL DEFAULT 0 COMMENT '当前是否扣货', hold_flag TINYINT(1) NOT NULL DEFAULT 0 COMMENT '当前是否扣货',
current_hold_reason VARCHAR(500) NULL COMMENT '当前扣货理由快照', current_hold_reason VARCHAR(500) NULL COMMENT '当前扣货理由快照',
total_package_count INT UNSIGNED 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', chargeable_weight_kg DECIMAL(18,3) NULL COMMENT '当前费用快照的计费重量kg',
actual_volume_cbm DECIMAL(18,4) NULL COMMENT '确认后的实际总体积m3', actual_volume_cbm DECIMAL(18,4) NULL COMMENT '确认后的实际总体积m3',
chargeable_volume_cbm DECIMAL(18,4) NULL COMMENT '当前费用快照的计费体积m3', chargeable_volume_cbm DECIMAL(18,4) NULL COMMENT '当前费用快照的计费体积m3',
pallet_count INT UNSIGNED NULL COMMENT '已分配总托盘数快照', pallet_count INT UNSIGNED NULL COMMENT '已分配总托盘数快照',
volume_weight_kg DECIMAL(18,3) NULL COMMENT '总材积重kg', 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 '测量完成时间', measure_completed_at DATETIME(3) NULL COMMENT '测量完成时间',
inbounded_at DATETIME(3) NULL COMMENT '入库完成时间', inbounded_at DATETIME(3) NULL COMMENT '入库完成时间',
express_company_code VARCHAR(64) NULL COMMENT '快递公司编码快照', express_company_code VARCHAR(64) NULL COMMENT '快递公司编码快照',
@@ -566,8 +566,8 @@ paths:
由稳定的 item.id(measurementItemId)与 intelligentImageFileId 关联;称重图片只随 由稳定的 item.id(measurementItemId)与 intelligentImageFileId 关联;称重图片只随
weightResults 返回,由稳定的 weightResult.id 与 fileId 关联,并携带确认状态及服务端确认的 weightResults 返回,由稳定的 weightResult.id 与 fileId 关联,并携带确认状态及服务端确认的
grossWeightKg/netWeightKg。两类证据不得合并为无明细归属的统一图片列表。 grossWeightKg/netWeightKg。两类证据不得合并为无明细归属的统一图片列表。
actualGrossWeightKg 是服务端权威的货物总重量;存在已确认称重结果时为所有已确认 actualGrossWeightKg 是服务端确认后的实测毛重,actualNetWeightKg 是扣除托盘皮重后的货物净重;
netWeightKg 之和,消费者不得根据图片或 OCR 值自行重算。 两者语义独立,消费者不得互相回退,也不得根据图片或 OCR 值自行重算。
parameters: parameters:
- {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} - {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]*$'}} - {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
@@ -578,6 +578,23 @@ paths:
application/json: application/json:
schema: {$ref: '#/components/schemas/InboundMeasurementDetailEnvelope'} schema: {$ref: '#/components/schemas/InboundMeasurementDetailEnvelope'}
'403': {description: 无入库测量查看权限} '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: /api/platform/v1/first-leg/inbounds/{inboundId}/measurement/carton-scans:
post: post:
summary: 平台模拟单箱设备扫描 summary: 平台模拟单箱设备扫描
@@ -697,7 +714,7 @@ paths:
platform:firstleg:inbound:weight:edit 权限。修改与确认在同一事务内完成,保留 OCR 原值, platform:firstleg:inbound:weight:edit 权限。修改与确认在同一事务内完成,保留 OCR 原值,
分别写入 CORRECT 和 CONFIRM 修订记录。输入表示含托盘毛重;服务端统一换算为 KG, 分别写入 CORRECT 和 CONFIRM 修订记录。输入表示含托盘毛重;服务端统一换算为 KG,
使用创建时固化的托盘皮重计算 netWeightKg,且毛重必须大于皮重。 使用创建时固化的托盘皮重计算 netWeightKg,且毛重必须大于皮重。
入库单 actualGrossWeightKg 明确保留为下游货物实际毛重字段,值汇总所有已确认 netWeightKg。 入库单 actualGrossWeightKg 为实测毛重;actualNetWeightKg 为扣除托盘皮重后的货物净重,两者不得混用。
parameters: parameters:
- {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} - {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]*$'}} - {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
@@ -749,7 +766,7 @@ paths:
summary: 删除称重图片及重量结果 summary: 删除称重图片及重量结果
description: >- description: >-
需要 platform:firstleg:inbound:weight:edit 权限;仅允许在可编辑的测量状态删除。 需要 platform:firstleg:inbound:weight:edit 权限;仅允许在可编辑的测量状态删除。
删除已确认图片后重新汇总剩余已确认图片的净重,删除最后一张时 actualGrossWeightKg 恢复为空。 删除已确认图片后由生产者分别重算剩余确认称重的毛重、净重;消费端不得用净重填充毛重。
parameters: parameters:
- {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} - {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}} - {name: version, in: query, required: true, schema: {type: integer, format: int32, minimum: 0}}
@@ -779,15 +796,6 @@ paths:
reason: {type: string, maxLength: 1000} reason: {type: string, maxLength: 1000}
responses: responses:
'200': {description: 成功或业务失败,按响应体 code 判断;审核成功 data.processStatus 固定为 WAIT_SPLIT,initialPricingSucceeded 表示是否同时生成初算费用,初算失败时 feeStatus=UNPRICED 且主流程仍成功} '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: /api/platform/v1/inbounds/{inboundId}/measurements/submit:
post: post:
summary: 提交测量完成 summary: 提交测量完成
@@ -811,15 +819,6 @@ paths:
application/json: application/json:
schema: {$ref: '#/components/schemas/MerchantInboundBillingEnvelope'} schema: {$ref: '#/components/schemas/MerchantInboundBillingEnvelope'}
'403': {description: 非当前客户或无订单查看权限} '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: components:
securitySchemes: securitySchemes:
bearerAuth: bearerAuth:
@@ -1035,8 +1034,8 @@ components:
currentHoldReason: {type: string, nullable: true} currentHoldReason: {type: string, nullable: true}
totalPackageCount: {type: integer, nullable: true} totalPackageCount: {type: integer, nullable: true}
locationCodes: {type: string, nullable: true} locationCodes: {type: string, nullable: true}
actualGrossWeightKg: {type: number, nullable: true, multipleOf: 0.001, description: 服务端权威货物总重量 KG;存在已确认称重图片时为全部 CONFIRMED netWeightKg 之和,消费者不得从图片重算} actualGrossWeightKg: {type: number, nullable: true, multipleOf: 0.001, description: 服务端确认后的实测毛重 KG;只读取 fl_inbound_order.actual_gross_weight_kg,不以净重回退}
actualNetWeightKg: {type: number, nullable: true} actualNetWeightKg: {type: number, nullable: true, multipleOf: 0.001, description: 服务端确认后的货物净重 KG;只读取 fl_inbound_order.actual_net_weight_kg,不以毛重回退}
actualVolumeCbm: {type: number, nullable: true} actualVolumeCbm: {type: number, nullable: true}
volumeWeightKg: {type: number, nullable: true} volumeWeightKg: {type: number, nullable: true}
chargeableWeightKg: {type: number, nullable: true} chargeableWeightKg: {type: number, nullable: true}
@@ -1050,8 +1049,10 @@ components:
cabinetNo: {type: string, nullable: true} cabinetNo: {type: string, nullable: true}
vesselName: {type: string, nullable: true} vesselName: {type: string, nullable: true}
voyageNo: {type: string, nullable: true} voyageNo: {type: string, nullable: true}
sailingAt: {type: string, format: date-time, nullable: true} estimatedSailingAt: {type: string, format: date-time, nullable: true, description: 当前关联柜次预计开船时间}
portArrivalAt: {type: string, format: date-time, nullable: true} 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} expressTrackingNo: {type: string, nullable: true}
pickupMethod: {type: string, enum: [SELF_EXPRESS, PICKUP, SELF_TRUCK], nullable: true} pickupMethod: {type: string, enum: [SELF_EXPRESS, PICKUP, SELF_TRUCK], nullable: true}
requestedPickupAt: {type: string, format: date-time, nullable: true, description: 客户要求平台上门提货的预约时间} requestedPickupAt: {type: string, format: date-time, nullable: true, description: 客户要求平台上门提货的预约时间}
@@ -1286,9 +1287,9 @@ components:
measurementStatus: {type: string, nullable: true} measurementStatus: {type: string, nullable: true}
inboundVersion: {type: integer, minimum: 0} inboundVersion: {type: integer, minimum: 0}
version: {type: integer, minimum: 0, nullable: true} version: {type: integer, minimum: 0, nullable: true}
originalDocumentNo: {type: string, nullable: true} originalDocumentNo: {type: string, nullable: true, description: 客户订单 referenceId 的服务端快照;旧空快照按订单事实回退}
originalGrossWeightKg: {type: number, format: double, nullable: true} originalGrossWeightKg: {type: number, format: double, nullable: true, description: 客户订单有效货物行 grossWeightKg 合计的服务端快照}
originalVolumeCbm: {type: number, format: double, nullable: true} originalVolumeCbm: {type: number, format: double, nullable: true, description: 客户订单有效货物行 volumeCbm 合计的服务端快照}
originalPackageCount: {type: integer, nullable: true} originalPackageCount: {type: integer, nullable: true}
actualPackageCount: {type: integer, nullable: true} actualPackageCount: {type: integer, nullable: true}
actualGrossWeightKg: actualGrossWeightKg:
@@ -1296,10 +1297,16 @@ components:
format: double format: double
nullable: true nullable: true
multipleOf: 0.001 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} actualVolumeCbm: {type: number, format: double, nullable: true}
volumeWeightKg: {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: items:
type: array type: array
description: DIMENSION 尺寸测量明细及其图片证据 description: DIMENSION 尺寸测量明细及其图片证据
@@ -1308,6 +1315,38 @@ components:
type: array type: array
description: WEIGHT 称重结果及其图片、确认状态和服务端重量事实 description: WEIGHT 称重结果及其图片、确认状态和服务端重量事实
items: {$ref: '#/components/schemas/InboundWeightResult'} 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: InboundMeasurementDetailEnvelope:
type: object type: object
required: [code, message] required: [code, message]
@@ -23,21 +23,33 @@ class InboundMeasurementEvidenceOpenApiContractTest {
Map<String, Object> schemas = stringMap(stringMap(document.get("components")).get("schemas")); Map<String, Object> schemas = stringMap(stringMap(document.get("components")).get("schemas"));
Map<String, Object> detailProperties = properties(schemas, "InboundMeasurementDetail"); Map<String, Object> detailProperties = properties(schemas, "InboundMeasurementDetail");
assertThat(detailProperties).containsKeys("actualGrossWeightKg", "items", "weightResults"); assertThat(detailProperties).containsKeys(
"actualGrossWeightKg", "actualNetWeightKg", "items", "weightResults");
assertThat(stringMap(detailProperties.get("items"))) assertThat(stringMap(detailProperties.get("items")))
.containsEntry("description", "DIMENSION 尺寸测量明细及其图片证据"); .containsEntry("description", "DIMENSION 尺寸测量明细及其图片证据");
assertThat(stringMap(detailProperties.get("weightResults"))) assertThat(stringMap(detailProperties.get("weightResults")))
.containsEntry("description", "WEIGHT 称重结果及其图片、确认状态和服务端重量事实"); .containsEntry("description", "WEIGHT 称重结果及其图片、确认状态和服务端重量事实");
assertThat(detailProperties).doesNotContainKey("images"); assertThat(detailProperties).doesNotContainKey("images");
Map<String, Object> 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<String, Object> itemProperties = properties(schemas, "InboundMeasurementItem"); Map<String, Object> itemProperties = properties(schemas, "InboundMeasurementItem");
assertThat(itemProperties).containsKeys("id", "intelligentImageFileId"); assertThat(itemProperties).containsKeys("id", "intelligentImageFileId");
Map<String, Object> weightProperties = properties(schemas, "InboundWeightResult"); Map<String, Object> weightProperties = properties(schemas, "InboundWeightResult");
assertThat(weightProperties).containsKeys( assertThat(weightProperties).containsKeys(
"id", "fileId", "recognitionStatus", "grossWeightKg", "netWeightKg", "confirmedAt"); "id", "fileId", "recognitionStatus", "grossWeightKg", "netWeightKg", "confirmedAt");
assertThat(stringMap(properties(schemas, "UnifiedInboundOrderSummary") assertThat(stringMap(detailProperties.get("actualGrossWeightKg")).get("description").toString())
.get("actualGrossWeightKg")).get("description").toString()) .contains("CONFIRMED", "grossWeightKg", "含托盘毛重");
.contains("服务端权威货物总重量", "CONFIRMED netWeightKg", "不得从图片重算"); assertThat(stringMap(detailProperties.get("actualNetWeightKg")).get("description").toString())
.contains("CONFIRMED", "netWeightKg", "审核与计费以此为准");
} }
private static Map<String, Object> properties(Map<String, Object> schemas, String schemaName) { private static Map<String, Object> properties(Map<String, Object> schemas, String schemaName) {
@@ -1,7 +1,6 @@
package com.baiyue.firstleg.controller; package com.baiyue.firstleg.controller;
import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.ExceptionReleaseRequest;
import com.baiyue.firstleg.dto.MeasurementApprovalResponse; import com.baiyue.firstleg.dto.MeasurementApprovalResponse;
import com.baiyue.firstleg.dto.MeasurementReviewRequest; import com.baiyue.firstleg.dto.MeasurementReviewRequest;
import com.baiyue.firstleg.service.InitialInboundPricingService; import com.baiyue.firstleg.service.InitialInboundPricingService;
@@ -24,14 +23,10 @@ import static org.mockito.Mockito.when;
@DisplayName("入库测量审核控制器契约") @DisplayName("入库测量审核控制器契约")
class InboundMeasurementReviewContractTest { class InboundMeasurementReviewContractTest {
@Test @Test
@DisplayName("审核、放行和历史接口声明独立按钮权限") @DisplayName("审核和历史接口声明独立按钮权限")
void shouldDeclareButtonPermissions() throws Exception { void shouldDeclareButtonPermissions() throws Exception {
assertAuthority("approve", "hasAuthority('firstleg:inbound:measurement-review')", assertAuthority("approve", "hasAuthority('firstleg:inbound:measurement-review')",
AuthenticatedUser.class, long.class, long.class, String.class, MeasurementReviewRequest.class); 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')", assertAuthority("history", "hasAuthority('firstleg:inbound:measurement-review-history')",
AuthenticatedUser.class, long.class, long.class, long.class, long.class); AuthenticatedUser.class, long.class, long.class, long.class, long.class);
} }
@@ -43,14 +38,14 @@ class InboundMeasurementReviewContractTest {
.getContentAsString(StandardCharsets.UTF_8); .getContentAsString(StandardCharsets.UTF_8);
assertThat(openapi) assertThat(openapi)
.contains("/api/platform/v1/inbounds/{inboundId}/measurements/approve:") .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("Idempotency-Key")
.contains("初算异常只返回非阻断提示") .contains("初算异常只返回非阻断提示")
.contains("data.processStatus 固定为 WAIT_SPLIT") .contains("data.processStatus 固定为 WAIT_SPLIT")
.contains("feeStatus=UNPRICED 且主流程仍成功") .contains("feeStatus=UNPRICED 且主流程仍成功")
.contains("name: inboundId") .contains("name: inboundId")
.contains("schema: {type: string, pattern:") .contains("schema: {type: string, pattern:")
.doesNotContain("/api/platform/v1/inbounds/{inboundId}/measurements/reject:")
.doesNotContain("/api/platform/v1/inbounds/{inboundId}/measurements/exception-release:")
.doesNotContain("待客户确认"); .doesNotContain("待客户确认");
} }
@@ -51,7 +51,9 @@ class InboundOrderControllerContractTest {
.contains("required: false") .contains("required: false")
.contains("InboundOrderSummary:") .contains("InboundOrderSummary:")
.contains("required: [id, tenantId]") .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("chargeableVolumeCbm: {type: number, nullable: true}")
.contains("measureCompletedAt: {type: string, format: date-time, nullable: true}") .contains("measureCompletedAt: {type: string, format: date-time, nullable: true}")
.contains("pricingTaxMode: {type: string, enum: [TAX_INCLUDED, TAX_EXCLUDED]") .contains("pricingTaxMode: {type: string, enum: [TAX_INCLUDED, TAX_EXCLUDED]")
@@ -2,17 +2,38 @@ package com.baiyue.firstleg.controller;
import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.InboundMeasurementRequests; import com.baiyue.firstleg.dto.InboundMeasurementRequests;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import java.lang.reflect.Method; 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.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@DisplayName("平台入库测量接口权限契约") @DisplayName("平台入库测量接口权限契约")
class PlatformInboundMeasurementControllerContractTest { 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 @Test
void everyMeasurementOperationUsesDedicatedButtonPermission() throws Exception { void everyMeasurementOperationUsesDedicatedButtonPermission() throws Exception {
assertAuthority("detail", "hasAuthority('platform:firstleg:inbound:measure:view')", assertAuthority("detail", "hasAuthority('platform:firstleg:inbound:measure:view')",
@@ -41,7 +41,8 @@ class UnifiedInboundOpenApiContractTest {
"holdFlag", "actualGrossWeightKg", "actualNetWeightKg", "actualVolumeCbm", "holdFlag", "actualGrossWeightKg", "actualNetWeightKg", "actualVolumeCbm",
"volumeWeightKg", "chargeableWeightKg", "chargeableVolumeCbm", "palletCount", "volumeWeightKg", "chargeableWeightKg", "chargeableVolumeCbm", "palletCount",
"inboundedAt", "requestedPickupAt", "expressCompanyCode", "expressCompanyName", "containerNo", "inboundedAt", "requestedPickupAt", "expressCompanyCode", "expressCompanyName", "containerNo",
"cabinetNo", "sailingAt", "portArrivalAt", "createdByName", "createdAt", "updatedAt"); "cabinetNo", "estimatedSailingAt", "actualSailingAt", "estimatedPortArrivalAt",
"actualPortArrivalAt", "createdByName", "createdAt", "updatedAt");
} }
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
@@ -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();
}
}
@@ -23,13 +23,14 @@ class InboundOrderStatusSqlContractTest {
} }
@Test @Test
void keepsConfirmedWeightNetSumAuthoritativeWhenMeasurementIsApproved() { void keepsConfirmedGrossAndNetSumsSeparateWhenMeasurementIsApproved() {
assertThat(sql("approveMeasurement")) assertThat(sql("approveMeasurement"))
.contains("FROM fl_inbound_weight_result weight") .contains("FROM fl_inbound_weight_result weight")
.contains("weight.recognition_status = 'CONFIRMED'") .contains("weight.recognition_status = 'CONFIRMED'")
.contains("weight.deleted = 0") .contains("weight.deleted = 0")
.contains("SUM(weight.net_weight_kg)") .contains("actual_gross_weight_kg = (SELECT SUM(weight.gross_weight_kg)")
.contains("ELSE (SELECT COALESCE(SUM(item.total_weight_kg), 0)"); .contains("actual_net_weight_kg = (SELECT SUM(weight.net_weight_kg)")
.doesNotContain("item.total_weight_kg");
} }
private static String sql(String methodName) { private static String sql(String methodName) {
@@ -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);
}
}
@@ -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", "<foreach");
assertThat(sql).doesNotContain("fl_inbound_transport_snapshot", "LIMIT 1");
}
}
@@ -275,7 +275,7 @@ class CartonMeasurementServiceTest {
} }
@Test @Test
void confirmedPackageCountUsesCartonWeightAsFallbackWhenNoConfirmedImageExists() { void confirmedPackageCountDoesNotUseCartonWeightAsWeightFallback() {
InboundOrderEntity inbound = inbound("MEASURE_PENDING"); InboundOrderEntity inbound = inbound("MEASURE_PENDING");
InboundMeasurementEntity measurement = measurement(30001L, "SUBMITTED", 2); InboundMeasurementEntity measurement = measurement(30001L, "SUBMITTED", 2);
measurement.setTotalPackageCount(1); measurement.setTotalPackageCount(1);
@@ -300,7 +300,8 @@ class CartonMeasurementServiceTest {
service.updateConfirmedPackageCount(10001L, 70001L, 40001L, 2, 5); service.updateConfirmedPackageCount(10001L, 70001L, 40001L, 2, 5);
assertThat(inbound.getTotalPackageCount()).isEqualTo(5); assertThat(inbound.getTotalPackageCount()).isEqualTo(5);
assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("54.400"); assertThat(inbound.getActualGrossWeightKg()).isNull();
assertThat(inbound.getActualNetWeightKg()).isNull();
assertThat(inbound.getActualVolumeCbm()).isEqualByComparingTo("0.7480"); assertThat(inbound.getActualVolumeCbm()).isEqualByComparingTo("0.7480");
assertThat(inbound.getVolumeWeightKg()).isEqualByComparingTo("124.916"); assertThat(inbound.getVolumeWeightKg()).isEqualByComparingTo("124.916");
verify(repository).updateInbound(inbound); verify(repository).updateInbound(inbound);
@@ -309,7 +310,8 @@ class CartonMeasurementServiceTest {
@Test @Test
void cartonMeasurementAfterConfirmedWeightKeepsNetWeightAndUpdatesVolume() { void cartonMeasurementAfterConfirmedWeightKeepsNetWeightAndUpdatesVolume() {
InboundOrderEntity inbound = inbound("MEASURE_PENDING"); InboundOrderEntity inbound = inbound("MEASURE_PENDING");
inbound.setActualGrossWeightKg(new BigDecimal("11.500")); inbound.setActualGrossWeightKg(new BigDecimal("30.000"));
inbound.setActualNetWeightKg(new BigDecimal("11.500"));
InboundMeasurementEntity measurement = measurement(30001L, "SUBMITTED", 2); InboundMeasurementEntity measurement = measurement(30001L, "SUBMITTED", 2);
measurement.setTotalPackageCount(1); measurement.setTotalPackageCount(1);
measurement.setTotalVolumeCbm(new BigDecimal("0.0600")); measurement.setTotalVolumeCbm(new BigDecimal("0.0600"));
@@ -323,6 +325,7 @@ class CartonMeasurementServiceTest {
item.setCalculationRuleVersion("ACTUAL_DIMENSION_V1"); item.setCalculationRuleVersion("ACTUAL_DIMENSION_V1");
InboundWeightResultEntity confirmedWeight = new InboundWeightResultEntity(); InboundWeightResultEntity confirmedWeight = new InboundWeightResultEntity();
confirmedWeight.setRecognitionStatus("CONFIRMED"); confirmedWeight.setRecognitionStatus("CONFIRMED");
confirmedWeight.setGrossWeightKg(new BigDecimal("30.000"));
confirmedWeight.setNetWeightKg(new BigDecimal("11.500")); confirmedWeight.setNetWeightKg(new BigDecimal("11.500"));
when(repository.findInbound(10001L, 70001L)).thenReturn(inbound); when(repository.findInbound(10001L, 70001L)).thenReturn(inbound);
when(repository.findItem(10001L, 40001L)).thenReturn(item); when(repository.findItem(10001L, 40001L)).thenReturn(item);
@@ -335,7 +338,8 @@ class CartonMeasurementServiceTest {
service.updateConfirmedPackageCount(10001L, 70001L, 40001L, 2, 5); service.updateConfirmedPackageCount(10001L, 70001L, 40001L, 2, 5);
assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("11.500"); assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("30.000");
assertThat(inbound.getActualNetWeightKg()).isEqualByComparingTo("11.500");
assertThat(inbound.getActualVolumeCbm()).isEqualByComparingTo("0.7480"); assertThat(inbound.getActualVolumeCbm()).isEqualByComparingTo("0.7480");
assertThat(inbound.getVolumeWeightKg()).isEqualByComparingTo("124.916"); assertThat(inbound.getVolumeWeightKg()).isEqualByComparingTo("124.916");
verify(repository).updateInbound(inbound); verify(repository).updateInbound(inbound);
@@ -421,7 +421,8 @@ class FbaPricingServiceTest {
value.setChannelCode("MATSON_TRUCK"); value.setCarrierId(31L); value.setCarrierName("船司A"); value.setChannelCode("MATSON_TRUCK"); value.setCarrierId(31L); value.setCarrierName("船司A");
value.setTaxMode("BUY_DECLARATION"); value.setPricingTaxMode("TAX_INCLUDED"); value.setTaxMode("BUY_DECLARATION"); value.setPricingTaxMode("TAX_INCLUDED");
value.setDestinationType("FBA"); value.setDestinationCode("ONT8"); value.setDestinationType("FBA"); value.setDestinationCode("ONT8");
value.setActualGrossWeightKg(new BigDecimal(weight)); value.setActualVolumeCbm(new BigDecimal(volume)); value.setActualGrossWeightKg(new BigDecimal(weight).add(new BigDecimal("8.000")));
value.setActualNetWeightKg(new BigDecimal(weight)); value.setActualVolumeCbm(new BigDecimal(volume));
return value; return value;
} }
@@ -459,6 +459,7 @@ class FeeManagementServiceTest {
inbound.setDestinationName("ONT8"); inbound.setDestinationName("ONT8");
inbound.setTotalPackageCount(10); inbound.setTotalPackageCount(10);
inbound.setActualGrossWeightKg(new BigDecimal("10.5000")); inbound.setActualGrossWeightKg(new BigDecimal("10.5000"));
inbound.setActualNetWeightKg(new BigDecimal("10.5000"));
inbound.setActualVolumeCbm(new BigDecimal("1.250000")); inbound.setActualVolumeCbm(new BigDecimal("1.250000"));
inbound.setVolumeWeightKg(new BigDecimal("208.3333")); inbound.setVolumeWeightKg(new BigDecimal("208.3333"));
inbound.setChargeableWeightKg(new BigDecimal("208.3333")); inbound.setChargeableWeightKg(new BigDecimal("208.3333"));
@@ -56,8 +56,12 @@ class InboundMeasurementServiceTest {
@BeforeEach @BeforeEach
void stubWarehousePalletSnapshots() { void stubWarehousePalletSnapshots() {
when(repository.updateInboundWeightSummary(any())).thenReturn(true);
when(warehousePallets.palletSnapshots("internal-token", 100L)).thenReturn( when(warehousePallets.palletSnapshots("internal-token", 100L)).thenReturn(
ApiResponse.success(List.of(palletSnapshot()))); ApiResponse.success(List.of(palletSnapshot())));
when(orderCargoClient.measurementDeclaration("internal-token", 1L, 80L)).thenReturn(
ApiResponse.success(new OrderCargoClient.MeasurementDeclarationSnapshot(
"REF-50", new BigDecimal("2000"), new BigDecimal("20"), "123123")));
} }
@Test @Test
@@ -98,6 +102,7 @@ class InboundMeasurementServiceTest {
void correctingConfirmedWeightImmediatelyRemovesItFromInboundTotal() { void correctingConfirmedWeightImmediatelyRemovesItFromInboundTotal() {
InboundOrderEntity inbound = inbound("MEASURING"); InboundOrderEntity inbound = inbound("MEASURING");
inbound.setActualGrossWeightKg(new BigDecimal("12.500")); inbound.setActualGrossWeightKg(new BigDecimal("12.500"));
inbound.setActualNetWeightKg(new BigDecimal("12.500"));
InboundWeightResultEntity weight = weight(20L, "12.500", "KG", "CONFIRMED", 4); InboundWeightResultEntity weight = weight(20L, "12.500", "KG", "CONFIRMED", 4);
weight.setGrossWeightKg(new BigDecimal("12.500")); weight.setGrossWeightKg(new BigDecimal("12.500"));
weight.setNetWeightKg(new BigDecimal("12.500")); weight.setNetWeightKg(new BigDecimal("12.500"));
@@ -115,6 +120,7 @@ class InboundMeasurementServiceTest {
assertThat(weight.getGrossWeightKg()).isNull(); assertThat(weight.getGrossWeightKg()).isNull();
assertThat(weight.getNetWeightKg()).isNull(); assertThat(weight.getNetWeightKg()).isNull();
assertThat(inbound.getActualGrossWeightKg()).isNull(); assertThat(inbound.getActualGrossWeightKg()).isNull();
assertThat(inbound.getActualNetWeightKg()).isNull();
verify(repository).updateInboundWeightSummary(inbound); verify(repository).updateInboundWeightSummary(inbound);
} }
@@ -134,13 +140,18 @@ class InboundMeasurementServiceTest {
assertThat(result.recommendedPalletType()).isEqualTo("STANDARD"); assertThat(result.recommendedPalletType()).isEqualTo("STANDARD");
assertThat(result.inboundVersion()).isEqualTo(7); assertThat(result.inboundVersion()).isEqualTo(7);
assertThat(result.version()).isEqualTo(4); assertThat(result.version()).isEqualTo(4);
assertThat(result.originalDocumentNo()).isEqualTo("REF-50");
assertThat(result.originalGrossWeightKg()).isEqualByComparingTo("2000");
assertThat(result.originalVolumeCbm()).isEqualByComparingTo("20");
assertThat(result.actualGoodsName()).isEqualTo("123123");
verify(repository, never()).updateInbound(any()); verify(repository, never()).updateInbound(any());
} }
@Test @Test
void detailKeepsDimensionAndWeightEvidenceOnTheirStableBusinessRecords() { void detailKeepsDimensionAndWeightEvidenceOnTheirStableBusinessRecords() {
InboundOrderEntity inbound = inbound("MEASURE_PENDING"); InboundOrderEntity inbound = inbound("MEASURE_PENDING");
inbound.setActualGrossWeightKg(new BigDecimal("11.500")); inbound.setActualGrossWeightKg(new BigDecimal("30.000"));
inbound.setActualNetWeightKg(new BigDecimal("11.500"));
InboundMeasurementEntity measurement = new InboundMeasurementEntity(); InboundMeasurementEntity measurement = new InboundMeasurementEntity();
measurement.setId(30L); measurement.setId(30L);
measurement.setVersion(4); measurement.setVersion(4);
@@ -161,7 +172,8 @@ class InboundMeasurementServiceTest {
InboundMeasurementResponses.Detail result = service.detail(1L, 10L); InboundMeasurementResponses.Detail result = service.detail(1L, 10L);
assertThat(result.actualGrossWeightKg()).isEqualByComparingTo("11.500"); assertThat(result.actualGrossWeightKg()).isEqualByComparingTo("30.000");
assertThat(result.actualNetWeightKg()).isEqualByComparingTo("11.500");
assertThat(result.items()).singleElement().satisfies(dimension -> { assertThat(result.items()).singleElement().satisfies(dimension -> {
assertThat(dimension.id()).isEqualTo(40L); assertThat(dimension.id()).isEqualTo(40L);
assertThat(dimension.intelligentImageFileId()).isEqualTo(140L); assertThat(dimension.intelligentImageFileId()).isEqualTo(140L);
@@ -232,6 +244,7 @@ class InboundMeasurementServiceTest {
InboundOrderEntity inbound = inbound("WAIT_MEASURE"); InboundOrderEntity inbound = inbound("WAIT_MEASURE");
InboundWeightResultEntity current = weight(20L, "10.000", "LB", "PENDING_CONFIRM", 0); InboundWeightResultEntity current = weight(20L, "10.000", "LB", "PENDING_CONFIRM", 0);
InboundWeightResultEntity previous = weight(21L, "2.000", "KG", "CONFIRMED", 1); InboundWeightResultEntity previous = weight(21L, "2.000", "KG", "CONFIRMED", 1);
previous.setGrossWeightKg(new BigDecimal("2.000"));
previous.setNetWeightKg(new BigDecimal("2.000")); previous.setNetWeightKg(new BigDecimal("2.000"));
when(repository.findInbound(1L, 10L)).thenReturn(inbound); when(repository.findInbound(1L, 10L)).thenReturn(inbound);
when(repository.findWeight(1L, 10L, 20L)).thenReturn(current); when(repository.findWeight(1L, 10L, 20L)).thenReturn(current);
@@ -246,6 +259,7 @@ class InboundMeasurementServiceTest {
assertThat(response.grossWeightKg()).isEqualByComparingTo("4.536"); assertThat(response.grossWeightKg()).isEqualByComparingTo("4.536");
assertThat(response.netWeightKg()).isEqualByComparingTo("4.536"); assertThat(response.netWeightKg()).isEqualByComparingTo("4.536");
assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("6.536"); assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("6.536");
assertThat(inbound.getActualNetWeightKg()).isEqualByComparingTo("6.536");
assertThat(inbound.getProcessStatus()).isEqualTo("MEASURING"); assertThat(inbound.getProcessStatus()).isEqualTo("MEASURING");
verify(repository).insertRevision(any(InboundWeightRevisionEntity.class)); verify(repository).insertRevision(any(InboundWeightRevisionEntity.class));
} }
@@ -268,7 +282,8 @@ class InboundMeasurementServiceTest {
assertThat(response.grossWeightKg()).isEqualByComparingTo("30.000"); assertThat(response.grossWeightKg()).isEqualByComparingTo("30.000");
assertThat(response.netWeightKg()).isEqualByComparingTo("11.500"); 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<InboundWeightRevisionEntity> revision = ArgumentCaptor<InboundWeightRevisionEntity> revision =
ArgumentCaptor.forClass(InboundWeightRevisionEntity.class); ArgumentCaptor.forClass(InboundWeightRevisionEntity.class);
verify(repository).insertRevision(revision.capture()); verify(repository).insertRevision(revision.capture());
@@ -276,6 +291,31 @@ class InboundMeasurementServiceTest {
assertThat(revision.getValue().getNetWeightKg()).isEqualByComparingTo("11.500"); 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<InboundWeightResultEntity> 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 @Test
void confirmingWeightRejectsGrossWeightNotAboveFrozenTare() { void confirmingWeightRejectsGrossWeightNotAboveFrozenTare() {
InboundOrderEntity inbound = inbound("WAIT_MEASURE"); InboundOrderEntity inbound = inbound("WAIT_MEASURE");
@@ -545,7 +585,7 @@ class InboundMeasurementServiceTest {
verify(repository).deleteWeight(1L, 10L, 20L, 1); verify(repository).deleteWeight(1L, 10L, 20L, 1);
verify(imageClient).delete("internal-token", 9L, 1L, 10L, 120L); verify(imageClient).delete("internal-token", 9L, 1L, 10L, 120L);
verify(repository, never()).updateInboundWeightSummary(any()); verify(repository).updateInboundWeightSummary(inbound);
} }
@Test @Test
@@ -566,6 +606,7 @@ class InboundMeasurementServiceTest {
service.deleteWeightResult(1L, 10L, 20L, 2, 9L); service.deleteWeightResult(1L, 10L, 20L, 2, 9L);
assertThat(inbound.getActualGrossWeightKg()).isNull(); assertThat(inbound.getActualGrossWeightKg()).isNull();
assertThat(inbound.getActualNetWeightKg()).isNull();
verify(repository).updateInboundWeightSummary(inbound); verify(repository).updateInboundWeightSummary(inbound);
} }
@@ -574,8 +615,10 @@ class InboundMeasurementServiceTest {
InboundOrderEntity inbound = inbound("MEASURING"); InboundOrderEntity inbound = inbound("MEASURING");
inbound.setActualGrossWeightKg(new BigDecimal("15.000")); inbound.setActualGrossWeightKg(new BigDecimal("15.000"));
InboundWeightResultEntity deleted = weight(20L, "10.000", "KG", "CONFIRMED", 2); InboundWeightResultEntity deleted = weight(20L, "10.000", "KG", "CONFIRMED", 2);
deleted.setGrossWeightKg(new BigDecimal("10.000"));
deleted.setNetWeightKg(new BigDecimal("10.000")); deleted.setNetWeightKg(new BigDecimal("10.000"));
InboundWeightResultEntity remaining = weight(21L, "5.000", "KG", "CONFIRMED", 1); InboundWeightResultEntity remaining = weight(21L, "5.000", "KG", "CONFIRMED", 1);
remaining.setGrossWeightKg(new BigDecimal("5.000"));
remaining.setNetWeightKg(new BigDecimal("5.000")); remaining.setNetWeightKg(new BigDecimal("5.000"));
when(repository.findInbound(1L, 10L)).thenReturn(inbound); when(repository.findInbound(1L, 10L)).thenReturn(inbound);
when(repository.findWeight(1L, 10L, 20L)).thenReturn(deleted); when(repository.findWeight(1L, 10L, 20L)).thenReturn(deleted);
@@ -588,6 +631,7 @@ class InboundMeasurementServiceTest {
service.deleteWeightResult(1L, 10L, 20L, 2, 9L); service.deleteWeightResult(1L, 10L, 20L, 2, 9L);
assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("5.000"); assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("5.000");
assertThat(inbound.getActualNetWeightKg()).isEqualByComparingTo("5.000");
verify(repository).updateInboundWeightSummary(inbound); verify(repository).updateInboundWeightSummary(inbound);
} }
@@ -622,6 +666,7 @@ class InboundMeasurementServiceTest {
item.setPackageCount(3); item.setPackageCount(3);
item.setSourceImageFileId(500L); item.setSourceImageFileId(500L);
InboundWeightResultEntity weight = weight(20L, "20.000", "KG", "CONFIRMED", 1); InboundWeightResultEntity weight = weight(20L, "20.000", "KG", "CONFIRMED", 1);
weight.setGrossWeightKg(new BigDecimal("20.000"));
weight.setNetWeightKg(new BigDecimal("20.000")); weight.setNetWeightKg(new BigDecimal("20.000"));
when(repository.findInbound(1L, 10L)).thenReturn(inbound); when(repository.findInbound(1L, 10L)).thenReturn(inbound);
when(repository.findMeasurement(1L, 10L, 30L)).thenReturn(measurement); when(repository.findMeasurement(1L, 10L, 30L)).thenReturn(measurement);
@@ -638,6 +683,7 @@ class InboundMeasurementServiceTest {
assertThat(inbound.getMeasureReviewStatus()).isEqualTo("PENDING"); assertThat(inbound.getMeasureReviewStatus()).isEqualTo("PENDING");
assertThat(measurement.getStatus()).isEqualTo("SUBMITTED"); assertThat(measurement.getStatus()).isEqualTo("SUBMITTED");
assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("20.000"); assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("20.000");
assertThat(inbound.getActualNetWeightKg()).isEqualByComparingTo("20.000");
assertThat(inbound.getPalletCount()).isEqualTo(1); assertThat(inbound.getPalletCount()).isEqualTo(1);
assertThat(inbound.getActualVolumeCbm()).isEqualByComparingTo("1.2345"); assertThat(inbound.getActualVolumeCbm()).isEqualByComparingTo("1.2345");
} }
@@ -711,7 +757,7 @@ class InboundMeasurementServiceTest {
when(batchService.saveBatch(any())).thenReturn(saved); when(batchService.saveBatch(any())).thenReturn(saved);
InboundMeasurementResponses.DraftSaved result = service.saveDraft(1L, 10L, 9L, 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, List.of(new InboundMeasurementRequests.ItemRequest(80001L, "001", "A", 2,
new BigDecimal("40"), new BigDecimal("30"), new BigDecimal("20"), new BigDecimal("40"), new BigDecimal("30"), new BigDecimal("20"),
new BigDecimal("3.250"), null, null, null, null, null, null)))); new BigDecimal("3.250"), null, null, null, null, null, null))));
@@ -720,13 +766,38 @@ class InboundMeasurementServiceTest {
ArgumentCaptor<MeasurementBatchCommand> command = ArgumentCaptor.forClass(MeasurementBatchCommand.class); ArgumentCaptor<MeasurementBatchCommand> command = ArgumentCaptor.forClass(MeasurementBatchCommand.class);
verify(batchService).saveBatch(command.capture()); verify(batchService).saveBatch(command.capture());
assertThat(command.getValue().items().get(0).inboundDetailId()).isEqualTo(80001L); 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); 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) { private InboundOrderEntity inbound(String status) {
InboundOrderEntity inbound = new InboundOrderEntity(); InboundOrderEntity inbound = new InboundOrderEntity();
inbound.setId(10L); inbound.setId(10L);
inbound.setTenantId(1L); inbound.setTenantId(1L);
inbound.setOrderId(80L);
inbound.setWarehouseId(100L); inbound.setWarehouseId(100L);
inbound.setProcessStatus(status); inbound.setProcessStatus(status);
inbound.setTotalPackageCount(3); inbound.setTotalPackageCount(3);
@@ -9,6 +9,8 @@ import com.baiyue.firstleg.entity.InboundMeasurementEntity;
import com.baiyue.firstleg.entity.InboundMeasurementItemEntity; import com.baiyue.firstleg.entity.InboundMeasurementItemEntity;
import com.baiyue.firstleg.entity.InboundOrderEntity; import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.entity.InboundStatusHistoryEntity; 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 com.baiyue.firstleg.repository.InboundWorkflowRepository;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayName;
@@ -41,6 +43,7 @@ import static org.mockito.Mockito.when;
@SpringJUnitConfig(MeasurementReviewService.class) @SpringJUnitConfig(MeasurementReviewService.class)
class MeasurementReviewServiceTest { class MeasurementReviewServiceTest {
@MockBean private InboundWorkflowRepository repository; @MockBean private InboundWorkflowRepository repository;
@MockBean private InboundMeasurementRepository measurements;
@MockBean private InboundIdempotencyService idempotency; @MockBean private InboundIdempotencyService idempotency;
@MockBean private InboundProperties properties; @MockBean private InboundProperties properties;
@Resource private MeasurementReviewService service; @Resource private MeasurementReviewService service;
@@ -212,7 +215,8 @@ class MeasurementReviewServiceTest {
order.setProcessStatus("MEASURE_PENDING"); order.setProcessStatus("MEASURE_PENDING");
order.setMeasureReviewStatus("PENDING"); order.setMeasureReviewStatus("PENDING");
order.setTotalPackageCount(5); 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(); InboundMeasurementEntity measurement = new InboundMeasurementEntity();
measurement.setId(20L); measurement.setId(20L);
measurement.setStatus("SUBMITTED"); measurement.setStatus("SUBMITTED");
@@ -224,9 +228,23 @@ class MeasurementReviewServiceTest {
when(repository.findOwned(10001L, 10L)).thenReturn(order); when(repository.findOwned(10001L, 10L)).thenReturn(order);
when(repository.findMeasurement(10001L, 10L, 20L)).thenReturn(measurement); when(repository.findMeasurement(10001L, 10L, 20L)).thenReturn(measurement);
when(repository.listMeasurementItems(10001L, 20L)).thenReturn(List.of(item)); 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); 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, private record Fixture(MeasurementReviewService service, InboundWorkflowRepository repository,
InboundIdempotencyService idempotency, InboundOrderEntity order, InboundIdempotencyService idempotency, InboundOrderEntity order,
InboundMeasurementEntity measurement) { InboundMeasurementEntity measurement) {
@@ -55,7 +55,8 @@ public interface FirstlegUnifiedOrderClient {
BigDecimal chargeableWeightKg, LocalDateTime measureCompletedAt, BigDecimal chargeableWeightKg, LocalDateTime measureCompletedAt,
LocalDateTime inboundedAt, String expressCompanyName, String createdByName, LocalDateTime inboundedAt, String expressCompanyName, String createdByName,
String containerNo, String cabinetNo, String vesselName, String voyageNo, 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) {} String expressTrackingNo, LocalDateTime createdAt, LocalDateTime updatedAt) {}
record Preload(String status, Integer version, Integer itemCount, LocalDateTime updatedAt) {} record Preload(String status, Integer version, Integer itemCount, LocalDateTime updatedAt) {}
record Loading(String status, Integer loadingVersion, Integer pickingVersion, record Loading(String status, Integer loadingVersion, Integer pickingVersion,
@@ -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;
}
}
@@ -20,6 +20,9 @@ import org.springframework.web.bind.annotation.RestController;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.security.MessageDigest; import java.security.MessageDigest;
import java.util.List; import java.util.List;
import java.math.BigDecimal;
import java.util.Objects;
import java.util.stream.Collectors;
/** 供头程履约服务校验入仓单所关联订单货物明细。 */ /** 供头程履约服务校验入仓单所关联订单货物明细。 */
@RestController @RestController
@@ -89,6 +92,32 @@ public class InternalOrderCargoController {
return ApiResponse.success(OrderApplicationService.inboundCommand(order, packageCount)); return ApiResponse.success(OrderApplicationService.inboundCommand(order, packageCount));
} }
@GetMapping("/{tenantId}/{orderId}/measurement-declaration")
public ApiResponse<MeasurementDeclarationSnapshot> 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<OrderCargoEntity> 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<OrderCargoEntity> cargoes,
java.util.function.Function<OrderCargoEntity, BigDecimal> getter) {
List<BigDecimal> 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) { private void requireToken(String token) {
if (token == null || !MessageDigest.isEqual(expectedToken, token.getBytes(StandardCharsets.UTF_8))) { if (token == null || !MessageDigest.isEqual(expectedToken, token.getBytes(StandardCharsets.UTF_8))) {
throw new BizException(ResultCode.FORBIDDEN, "内部服务凭证无效"); throw new BizException(ResultCode.FORBIDDEN, "内部服务凭证无效");
@@ -113,4 +142,8 @@ public class InternalOrderCargoController {
Integer packageCount, java.math.BigDecimal lengthCm, Integer packageCount, java.math.BigDecimal lengthCm,
java.math.BigDecimal widthCm, java.math.BigDecimal heightCm) { java.math.BigDecimal widthCm, java.math.BigDecimal heightCm) {
} }
public record MeasurementDeclarationSnapshot(
String referenceId, BigDecimal originalGrossWeightKg,
BigDecimal originalVolumeCbm, String defaultActualGoodsName) {}
} }
@@ -9,6 +9,7 @@ import com.baiyue.order.dto.OrderRequests;
import com.baiyue.order.dto.MerchantBatchDraftDtos; import com.baiyue.order.dto.MerchantBatchDraftDtos;
import com.baiyue.order.dto.MerchantInboundOrderQuery; import com.baiyue.order.dto.MerchantInboundOrderQuery;
import com.baiyue.order.dto.MerchantInboundOrderRow; import com.baiyue.order.dto.MerchantInboundOrderRow;
import com.baiyue.order.dto.InboundCodePolicyResponse;
import com.baiyue.order.entity.OrderStatus; import com.baiyue.order.entity.OrderStatus;
import com.baiyue.order.service.OrderApplicationService; import com.baiyue.order.service.OrderApplicationService;
import com.baiyue.order.service.OrderScopeGuard; import com.baiyue.order.service.OrderScopeGuard;
@@ -50,6 +51,12 @@ public class MerchantFirstLegOrderController {
@Resource @Resource
private MerchantBatchDraftService batchDraftService; private MerchantBatchDraftService batchDraftService;
@GetMapping("/inbound-code-policy")
@PreAuthorize("hasAnyAuthority('merchant:order:list', 'merchant:order:create', 'merchant:order:edit')")
public ApiResponse<InboundCodePolicyResponse> inboundCodePolicy() {
return ApiResponse.success(service.inboundCodePolicy());
}
@BusinessAudit(event = "ORDER_CREATE", resourceType = "FIRST_LEG_ORDER", description = "Create draft / 新建草稿") @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. / 在当前认证商户租户下创建头程订单草稿。") @Operation(summary = "Create draft / 新建草稿", description = "Creates a first-leg draft order in the authenticated merchant tenant. / 在当前认证商户租户下创建头程订单草稿。")
@ApiResponses({ @ApiResponses({
@@ -69,7 +76,7 @@ public class MerchantFirstLegOrderController {
@BusinessAudit(event = "ORDER_BATCH_CREATE", resourceType = "FIRST_LEG_ORDER", description = "Batch create drafts / 批量创建草稿") @BusinessAudit(event = "ORDER_BATCH_CREATE", resourceType = "FIRST_LEG_ORDER", description = "Batch create drafts / 批量创建草稿")
@Operation(summary = "Batch create drafts / 批量创建草稿", @Operation(summary = "Batch create drafts / 批量创建草稿",
description = "每个数组元素代表一张草稿;单行失败不影响其它行,响应直接返回订单号和入仓码。") description = "每个数组元素代表一张草稿;单行失败不影响其它行。AUTO 模式的入仓码在提交订单时生成,草稿结果允许为空。")
@PostMapping("/batch") @PostMapping("/batch")
@PreAuthorize("hasAuthority('merchant:order:create')") @PreAuthorize("hasAuthority('merchant:order:create')")
public ApiResponse<MerchantBatchDraftDtos.Response> batchCreate( public ApiResponse<MerchantBatchDraftDtos.Response> batchCreate(
@@ -0,0 +1,5 @@
package com.baiyue.order.dto;
/** 全系统统一的入仓码录入策略。 */
public record InboundCodePolicyResponse(String mode, boolean manualEntryRequired) {
}
@@ -71,8 +71,27 @@ public final class OrderRequests {
@NotBlank @Pattern(regexp = "FBA|OVERSEAS|PRIVATE") String destinationType, @NotBlank @Pattern(regexp = "FBA|OVERSEAS|PRIVATE") String destinationType,
@Size(max = 32) String amazonWarehouseCode, @Size(max = 32) String amazonWarehouseCode,
@Size(max = 64) String shipmentId, @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<Cargo> 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, public SaveRequest(Long customerId, String customerName, String transportMode, String tradeTerm,
String destinationCountryCode, Long destinationWarehouseId, String destinationCountryCode, Long destinationWarehouseId,
String destinationWarehouseName, LocalDate expectedShipDate, String customerRemark, String destinationWarehouseName, LocalDate expectedShipDate, String customerRemark,
@@ -87,7 +106,7 @@ public final class OrderRequests {
sender, receiver, cargoes, pickupMethod, null, receivingWarehouseId, sender, receiver, cargoes, pickupMethod, null, receivingWarehouseId,
receivingWarehouseName, channelCode, channelName, carrierId, carrierName, taxMode, receivingWarehouseName, channelCode, channelName, carrierId, carrierName, taxMode,
pricingTaxMode, containerMode, customsInspection, destinationType, amazonWarehouseCode, pricingTaxMode, containerMode, customsInspection, destinationType, amazonWarehouseCode,
shipmentId, referenceId); shipmentId, referenceId, null);
} }
@AssertTrue(message = "承运商 ID 和名称必须同时填写或同时留空") @AssertTrue(message = "承运商 ID 和名称必须同时填写或同时留空")
@@ -25,7 +25,7 @@ public final class UnifiedOrderResponse {
String companyName, String businessType, String pickupMethod, String companyName, String businessType, String pickupMethod,
String taxMode, String containerMode, Boolean customsInspection, String taxMode, String containerMode, Boolean customsInspection,
String destinationType, String destinationCode, String destinationType, String destinationCode,
Integer totalPalletCount, BigDecimal chargeableVolumeCbm, Integer totalPalletCount, BigDecimal chargeableWeightKg, BigDecimal chargeableVolumeCbm,
BigDecimal volumetricWeightKg, BigDecimal totalNetWeightKg, BigDecimal volumetricWeightKg, BigDecimal totalNetWeightKg,
LocalDateTime measurementCompletedAt, LocalDateTime inboundCompletedAt, LocalDateTime measurementCompletedAt, LocalDateTime inboundCompletedAt,
String expressCompany, String expressTrackingNo, String createdBy, String expressCompany, String expressTrackingNo, String createdBy,
@@ -52,8 +52,11 @@ public final class UnifiedOrderResponse {
String sealNo, LocalDateTime updatedAt) {} String sealNo, LocalDateTime updatedAt) {}
public record DeliverySummary(String status, LocalDateTime completedAt, String trackingNo) {} public record DeliverySummary(String status, LocalDateTime completedAt, String trackingNo) {}
public record TransportSummary(String containerNo, String cabinetNo, public record TransportSummary(String containerNo, String cabinetNo,
String vesselName, String voyageNo, LocalDateTime sailingAt, String vesselName, String voyageNo,
LocalDateTime portArrivalAt) {} 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, public record TimelineEvent(String source, String eventType, String mainStatus, String action,
String remark, Long operatorId, String operatorName, String remark, Long operatorId, String operatorName,
LocalDateTime occurredAt) {} LocalDateTime occurredAt) {}
@@ -121,6 +121,14 @@ public class OrderRepository {
.eq(OrderEntity::getId, orderId)); .eq(OrderEntity::getId, orderId));
} }
public boolean inboundCodeExists(long tenantId, String inboundCode, Long excludedOrderId) {
if (!StringUtils.hasText(inboundCode)) return false;
return orderMapper.selectCount(new LambdaQueryWrapper<OrderEntity>()
.eq(OrderEntity::getTenantId, tenantId)
.eq(OrderEntity::getInboundCode, inboundCode)
.ne(excludedOrderId != null, OrderEntity::getId, excludedOrderId)) > 0;
}
public PageResult<OrderEntity> page(Long tenantId, OrderRequests.QueryRequest query) { public PageResult<OrderEntity> page(Long tenantId, OrderRequests.QueryRequest query) {
LambdaQueryWrapper<OrderEntity> wrapper = new LambdaQueryWrapper<OrderEntity>() LambdaQueryWrapper<OrderEntity> wrapper = new LambdaQueryWrapper<OrderEntity>()
.eq(tenantId != null, OrderEntity::getTenantId, tenantId) .eq(tenantId != null, OrderEntity::getTenantId, tenantId)
@@ -6,9 +6,11 @@ import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.exception.BizException;
import com.baiyue.order.dto.OrderDetailResponse; import com.baiyue.order.dto.OrderDetailResponse;
import com.baiyue.order.dto.OrderRequests; import com.baiyue.order.dto.OrderRequests;
import com.baiyue.order.dto.InboundCodePolicyResponse;
import com.baiyue.order.dto.TenantOrderCountResponse; import com.baiyue.order.dto.TenantOrderCountResponse;
import com.baiyue.order.dto.TenantOrderStatisticsResponse; import com.baiyue.order.dto.TenantOrderStatisticsResponse;
import com.baiyue.order.client.FirstlegInboundClient; import com.baiyue.order.client.FirstlegInboundClient;
import com.baiyue.order.config.InboundCodeModeProvider;
import com.baiyue.order.entity.*; import com.baiyue.order.entity.*;
import com.baiyue.order.repository.OrderRepository; import com.baiyue.order.repository.OrderRepository;
import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.baomidou.mybatisplus.core.toolkit.IdWorker;
@@ -45,9 +47,15 @@ public class OrderApplicationService {
@Resource private OrderMasterDataGuard masterDataGuard; @Resource private OrderMasterDataGuard masterDataGuard;
@Resource private OrderPricingChannelGuard pricingChannelGuard; @Resource private OrderPricingChannelGuard pricingChannelGuard;
@Resource private ProductImageGuard productImageGuard; @Resource private ProductImageGuard productImageGuard;
@Resource private InboundCodeModeProvider inboundCodeModeProvider;
@Resource private ObjectProvider<FirstlegInboundClient> firstlegProvider; @Resource private ObjectProvider<FirstlegInboundClient> firstlegProvider;
@Value("${baiyue.security.internal-service-token:}") private String internalToken; @Value("${baiyue.security.internal-service-token:}") private String internalToken;
public InboundCodePolicyResponse inboundCodePolicy() {
boolean manual = legacyManualMode();
return new InboundCodePolicyResponse(inboundCodeModeProvider.mode().name(), manual);
}
/** 创建草稿时使用数据库唯一键保证请求幂等。 */ /** 创建草稿时使用数据库唯一键保证请求幂等。 */
@Transactional @Transactional
public Long createDraft(long tenantId, long userId, OrderRequests.SaveRequest request, String idempotencyKey) { 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) { private Long createDraftInternal(long tenantId, long userId, OrderRequests.SaveRequest request) {
productImageGuard.requireOwned(tenantId, request.cargoes()); productImageGuard.requireOwned(tenantId, request.cargoes());
var snapshots = masterDataGuard.validate(tenantId, request); var snapshots = masterDataGuard.validate(tenantId, request);
pricingChannelGuard.requireAvailable(tenantId, request.receivingWarehouseId(), requirePricingChannel(tenantId, request.receivingWarehouseId(), request.channelCode(),
request.channelCode(), request.destinationType()); request.destinationType());
OrderEntity order = new OrderEntity(); OrderEntity order = new OrderEntity();
long orderId = IdWorker.getId(); long orderId = IdWorker.getId();
order.setId(orderId); order.setTenantId(tenantId); order.setOrderNo(nextNo(orderId)); order.setId(orderId); order.setTenantId(tenantId); order.setOrderNo(nextNo(orderId));
@@ -104,7 +112,7 @@ public class OrderApplicationService {
&& !(allowSubmitted && before == OrderStatus.SUBMITTED)) { && !(allowSubmitted && before == OrderStatus.SUBMITTED)) {
throw new BizException(ResultCode.CONFLICT, "当前订单状态不允许编辑订单资料"); throw new BizException(ResultCode.CONFLICT, "当前订单状态不允许编辑订单资料");
} }
pricingChannelGuard.requireAvailable(tenantId, request.receivingWarehouseId(), request.channelCode(), requirePricingChannel(tenantId, request.receivingWarehouseId(), request.channelCode(),
request.destinationType()); request.destinationType());
apply(order, tenantId, request, snapshots); order.setStatus(OrderStatus.DRAFT); order.setUpdatedBy(userId); apply(order, tenantId, request, snapshots); order.setStatus(OrderStatus.DRAFT); order.setUpdatedBy(userId);
if (before == OrderStatus.SUBMITTED) { if (before == OrderStatus.SUBMITTED) {
@@ -129,7 +137,7 @@ public class OrderApplicationService {
validatePersistedPrototypeFields(tenantId, order); validatePersistedPrototypeFields(tenantId, order);
masterDataGuard.validatePersisted(tenantId, order.getCustomerId(), order.getReceivingWarehouseId(), masterDataGuard.validatePersisted(tenantId, order.getCustomerId(), order.getReceivingWarehouseId(),
order.getDestinationWarehouseId(), order.getDestinationType()); order.getDestinationWarehouseId(), order.getDestinationType());
inboundCodePrefix = pricingChannelGuard.requireAvailable(tenantId, order.getReceivingWarehouseId(), inboundCodePrefix = requirePricingChannel(tenantId, order.getReceivingWarehouseId(),
order.getChannelCode(), order.getDestinationType()); order.getChannelCode(), order.getDestinationType());
} }
if ((target == OrderStatus.REJECTED || target == OrderStatus.CANCELLED) && !StringUtils.hasText(reason)) { if ((target == OrderStatus.REJECTED || target == OrderStatus.CANCELLED) && !StringUtils.hasText(reason)) {
@@ -137,7 +145,13 @@ public class OrderApplicationService {
} }
order.setStatus(target); order.setUpdatedBy(userId); order.setStatus(target); order.setUpdatedBy(userId);
if (target == OrderStatus.SUBMITTED) { 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()); order.setSubmittedAt(LocalDateTime.now());
} }
if (target == OrderStatus.COMPLETED) order.setCompletedAt(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) { private void apply(OrderEntity order, long tenantId, OrderRequests.SaveRequest request, OrderMasterDataGuard.Snapshots snapshots) {
order.setCustomerId(tenantId); order.setCustomerId(tenantId);
applyInboundCode(order, request.inboundCode());
order.setCustomerName(request.customerName().trim()); order.setCustomerName(request.customerName().trim());
order.setTransportMode(request.transportMode()); order.setTradeTerm(request.tradeTerm()); order.setTransportMode(request.transportMode()); order.setTradeTerm(request.tradeTerm());
order.setDestinationCountryCode(snapshots.destination() == null ? request.destinationCountryCode() order.setDestinationCountryCode(snapshots.destination() == null ? request.destinationCountryCode()
@@ -443,6 +458,48 @@ public class OrderApplicationService {
? snapshots.destination().warehouse().warehouseCode() : request.amazonWarehouseCode()); ? snapshots.destination().warehouse().warehouseCode() : request.amazonWarehouseCode());
order.setShipmentId(request.shipmentId()); order.setReferenceId(request.referenceId()); 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) { private String formatAddress(com.baiyue.order.client.WarehouseClient.Detail detail) {
if (detail.addresses() == null) return null; if (detail.addresses() == null) return null;
var a = detail.addresses().stream().filter(java.util.Objects::nonNull).findFirst().orElse(null); var a = detail.addresses().stream().filter(java.util.Objects::nonNull).findFirst().orElse(null);
@@ -20,6 +20,7 @@ import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import java.util.ArrayList; import java.util.ArrayList;
import java.time.LocalDateTime;
import java.util.Comparator; import java.util.Comparator;
import java.util.EnumSet; import java.util.EnumSet;
import java.util.List; import java.util.List;
@@ -180,10 +181,10 @@ public class UnifiedOrderQueryService {
order.getChannelName(), order.getReceivingWarehouseId(), order.getReceivingWarehouseName(), order.getChannelName(), order.getReceivingWarehouseId(), order.getReceivingWarehouseName(),
order.getDestinationWarehouseId(), order.getDestinationWarehouseName(), null, null, order.getDestinationWarehouseId(), order.getDestinationWarehouseName(), null, null,
order.getInboundCode(), 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(), null, null, null, null, null, order.getPickupMethod(), order.getTaxMode(), order.getContainerMode(),
order.getCustomsInspection(), order.getDestinationType(), order.getAmazonWarehouseCode(), 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(), string(order.getCreatedBy()), skuNos(cargo), totalQuantity(cargo), order.getShipmentId(), order.getReferenceId(),
null, null, null, null, null, null, null, null, null, null,
order.getCustomerRemark(), latestInternalNote, order.getCustomerRemark(), latestInternalNote,
@@ -199,7 +200,7 @@ public class UnifiedOrderQueryService {
inbound.actualGrossWeightKg(), inbound.actualVolumeCbm(), inbound.expectedArrivalAt(), inbound.feeReviewStatus(), inbound.actualGrossWeightKg(), inbound.actualVolumeCbm(), inbound.expectedArrivalAt(), inbound.feeReviewStatus(),
inbound.companyName(), inbound.businessType(), order.getPickupMethod(), inbound.taxMode(), inbound.containerMode(), inbound.companyName(), inbound.businessType(), order.getPickupMethod(), inbound.taxMode(), inbound.containerMode(),
inbound.customsInspection(), inbound.destinationType(), inbound.destinationCode(), inbound.palletCount(), 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.measureCompletedAt(), inbound.inboundedAt(), inbound.expressCompanyName(), inbound.expressTrackingNo(),
inbound.createdByName(), skuNos(cargo), totalQuantity(cargo), order.getShipmentId(), order.getReferenceId(), measurement(inbound), inbound.createdByName(), skuNos(cargo), totalQuantity(cargo), order.getShipmentId(), order.getReferenceId(), measurement(inbound),
preload(summary.preload()), loading(summary.loading()), preload(summary.preload()), loading(summary.loading()),
@@ -221,7 +222,7 @@ public class UnifiedOrderQueryService {
inbound.actualVolumeCbm(), inbound.expectedArrivalAt(), inbound.feeReviewStatus(), inbound.companyName(), inbound.actualVolumeCbm(), inbound.expectedArrivalAt(), inbound.feeReviewStatus(), inbound.companyName(),
inbound.businessType(), order == null ? null : order.getPickupMethod(), inbound.taxMode(), inbound.containerMode(), inbound.businessType(), order == null ? null : order.getPickupMethod(), inbound.taxMode(), inbound.containerMode(),
inbound.customsInspection(), inbound.destinationType(), inbound.destinationCode(), inbound.palletCount(), 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.measureCompletedAt(), inbound.inboundedAt(), inbound.expressCompanyName(), inbound.expressTrackingNo(),
inbound.createdByName(), skuNos(cargo), totalQuantity(cargo), order == null ? null : order.getShipmentId(), inbound.createdByName(), skuNos(cargo), totalQuantity(cargo), order == null ? null : order.getShipmentId(),
order == null ? null : order.getReferenceId(), measurement(inbound), preload(summary.preload()), loading(summary.loading()), 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()); value.status(), value.completedAt(), value.trackingNo());
} }
private UnifiedOrderResponse.TransportSummary transport(FirstlegUnifiedOrderClient.Inbound inbound) { 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(), 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) { private String orderMainStatus(OrderStatus status) {
@@ -270,13 +278,21 @@ public class UnifiedOrderQueryService {
return cargo == null ? null : cargo.totalQuantity(); return cargo == null ? null : cargo.totalQuantity();
} }
private record CargoSummary(List<String> skuNos, Integer totalQuantity) { private Integer totalPackageCount(CargoSummary cargo) {
return cargo == null ? null : cargo.totalPackageCount();
}
private record CargoSummary(List<String> skuNos, Integer totalQuantity, Integer totalPackageCount) {
private static CargoSummary from(List<OrderCargoEntity> cargoes) { private static CargoSummary from(List<OrderCargoEntity> cargoes) {
List<String> skuNos = cargoes.stream().map(OrderCargoEntity::getSkuNo) List<String> skuNos = cargoes.stream().map(OrderCargoEntity::getSkuNo)
.filter(Objects::nonNull).map(String::trim).filter(value -> !value.isEmpty()).distinct().toList(); .filter(Objects::nonNull).map(String::trim).filter(value -> !value.isEmpty()).distinct().toList();
int totalQuantity = cargoes.stream().map(OrderCargoEntity::getTotalQuantity) int totalQuantity = cargoes.stream().map(OrderCargoEntity::getTotalQuantity)
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum(); .filter(Objects::nonNull).mapToInt(Integer::intValue).sum();
return new CargoSummary(skuNos, totalQuantity); List<Integer> 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) { private String inboundMainStatus(String status) {
@@ -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} 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}} username: ${ORDER_TEST_DB_USERNAME:${TEST_DB_USERNAME:root}}
password: ${ORDER_TEST_DB_PASSWORD:${TEST_DB_PASSWORD}} password: ${ORDER_TEST_DB_PASSWORD:${TEST_DB_PASSWORD}}
baiyue.order.inbound-code-mode: AUTO
-1
View File
@@ -35,7 +35,6 @@ baiyue:
url: ${WAREHOUSE_SERVICE_URL:} url: ${WAREHOUSE_SERVICE_URL:}
file: file:
url: ${FILE_SERVICE_URL:} url: ${FILE_SERVICE_URL:}
logging: logging:
file: file:
name: "${LOGGING_FILE_NAME:${LOG_PATH:./logs}/${spring.application.name}.log}" name: "${LOGGING_FILE_NAME:${LOG_PATH:./logs}/${spring.application.name}.log}"
+44 -8
View File
@@ -11,7 +11,8 @@ paths:
summary: 平台统一订单列表 summary: 平台统一订单列表
description: >- description: >-
统一返回七态产品主状态、下一步动作及测量/预装/装柜/派送摘要,需要 platform:order:list。 统一返回七态产品主状态、下一步动作及测量/预装/装柜/派送摘要,需要 platform:order:list。
订单创建时按物流渠道生成固定 inboundCode,格式为物流前缀+YYMMDD+当天全平台票序;每日按上海日期从0001重新开始;有入库单时同时返回 入仓码来源由全系统统一模式控制:AUTO 在订单提交时按物流渠道生成,格式为物流前缀+YYMMDD+当天全平台票序;
LEGACY_MANUAL 在草稿中录入旧系统入仓码。两种模式下入仓码一经提交均固定;有入库单时同时返回
inboundId、inboundVersion、loadingVersion、pickingVersion;inboundVersion inboundId、inboundVersion、loadingVersion、pickingVersion;inboundVersion
才是 C04 loading/complete 的 expectedVersion,loadingVersion 当前等于 pickingVersion(兼容别名)。 才是 C04 loading/complete 的 expectedVersion,loadingVersion 当前等于 pickingVersion(兼容别名)。
tenantId 省略时跨全部客户;mainStatus 省略时按订单创建时间分页,指定 PENDING_REVIEW 时由 Order 查询, tenantId 省略时跨全部客户;mainStatus 省略时按订单创建时间分页,指定 PENDING_REVIEW 时由 Order 查询,
@@ -197,6 +198,23 @@ paths:
schema: {$ref: '#/components/schemas/FirstLegBatchDraftResponse'} schema: {$ref: '#/components/schemas/FirstLegBatchDraftResponse'}
'400': {description: 请求数组为空、超过 50 项或幂等键缺失} '400': {description: 请求数组为空、超过 50 项或幂等键缺失}
'403': {description: 需要 merchant:order:create 权限} '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: /api/merchant/v1/orders/first-leg/{id}/submit:
post: post:
summary: 提交订单 summary: 提交订单
@@ -423,10 +441,10 @@ components:
inboundVersion: {type: integer, nullable: true, description: C04 loading/complete 的 expectedVersion} inboundVersion: {type: integer, nullable: true, description: C04 loading/complete 的 expectedVersion}
inboundCode: {type: string, nullable: true} inboundCode: {type: string, nullable: true}
expectedShipDate: {type: string, format: date, 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,不得返回固定托盘类型} recommendedPalletType: {type: string, nullable: true, description: 当前无列表级权威事实时返回 null,不得返回固定托盘类型}
locationCodes: {type: string, nullable: true} locationCodes: {type: string, nullable: true}
actualGrossWeightKg: {type: number, nullable: true} actualGrossWeightKg: {type: number, nullable: true, description: Firstleg 实测毛重;来源 actualGrossWeightKg,不以净重回退}
actualVolumeCbm: {type: number, nullable: true} actualVolumeCbm: {type: number, nullable: true}
expectedArrivalAt: {type: string, format: date-time, nullable: true} expectedArrivalAt: {type: string, format: date-time, nullable: true}
feeReviewStatus: {type: string, nullable: true, description: Firstleg 入仓费用审核快照;财务状态不在本字段表达} feeReviewStatus: {type: string, nullable: true, description: Firstleg 入仓费用审核快照;财务状态不在本字段表达}
@@ -440,9 +458,10 @@ components:
destinationType: {type: string, nullable: true} destinationType: {type: string, nullable: true}
destinationCode: {type: string, nullable: true} destinationCode: {type: string, nullable: true}
totalPalletCount: {type: integer, 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} chargeableVolumeCbm: {type: number, nullable: true}
volumetricWeightKg: {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} measurementCompletedAt: {type: string, format: date-time, nullable: true}
inboundCompletedAt: {type: string, format: date-time, nullable: true} inboundCompletedAt: {type: string, format: date-time, nullable: true}
expressCompany: {type: string, nullable: true} expressCompany: {type: string, nullable: true}
@@ -475,7 +494,7 @@ components:
nullable: true nullable: true
properties: properties:
reviewStatus: {type: string, nullable: true} reviewStatus: {type: string, nullable: true}
actualGrossWeightKg: {type: number, nullable: true} actualGrossWeightKg: {type: number, nullable: true, description: Firstleg 实测毛重;不得使用净重回退}
actualVolumeCbm: {type: number, nullable: true} actualVolumeCbm: {type: number, nullable: true}
chargeableWeightKg: {type: number, nullable: true} chargeableWeightKg: {type: number, nullable: true}
completedAt: {type: string, format: date-time, nullable: true} completedAt: {type: string, format: date-time, nullable: true}
@@ -514,8 +533,14 @@ components:
cabinetNo: {type: string, nullable: true} cabinetNo: {type: string, nullable: true}
vesselName: {type: string, nullable: true} vesselName: {type: string, nullable: true}
voyageNo: {type: string, nullable: true} voyageNo: {type: string, nullable: true}
sailingAt: {type: string, format: date-time, nullable: true} estimatedSailingAt: {type: string, format: date-time, nullable: true, description: 当前关联柜次预计开船时间}
portArrivalAt: {type: string, format: date-time, nullable: true} 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: UnifiedOrderTimelineEvent:
type: object type: object
properties: properties:
@@ -606,6 +631,11 @@ components:
type: object type: object
required: [pricingTaxMode, cargoes] required: [pricingTaxMode, cargoes]
properties: properties:
inboundCode:
type: string
maxLength: 40
pattern: '^[A-Za-z0-9._:-]+$'
description: 仅 LEGACY_MANUAL 模式填写的旧系统入仓码;AUTO 模式必须省略
pricingTaxMode: {$ref: '#/components/schemas/PricingTaxMode'} pricingTaxMode: {$ref: '#/components/schemas/PricingTaxMode'}
cargoes: cargoes:
type: array type: array
@@ -615,6 +645,12 @@ components:
订单保存请求中的完整字段以服务端 FirstLegOrderSaveRequest 为准;pricingTaxMode 订单保存请求中的完整字段以服务端 FirstLegOrderSaveRequest 为准;pricingTaxMode
是新建和编辑订单的必填报价维度;customerId 不接受客户端指定,服务端始终使用认证企业 ID; 是新建和编辑订单的必填报价维度;customerId 不接受客户端指定,服务端始终使用认证企业 ID;
发货单位和收货单位仅通过 sender/receiver 保存业务快照。历史存量订单的响应字段仍允许为空。 发货单位和收货单位仅通过 sender/receiver 保存业务快照。历史存量订单的响应字段仍允许为空。
InboundCodePolicy:
type: object
required: [mode, manualEntryRequired]
properties:
mode: {type: string, enum: [AUTO, LEGACY_MANUAL]}
manualEntryRequired: {type: boolean}
FirstLegBatchDraftItem: FirstLegBatchDraftItem:
type: object type: object
required: [clientRowId, draft] required: [clientRowId, draft]
@@ -644,7 +680,7 @@ components:
success: {type: boolean} success: {type: boolean}
orderId: {type: string, pattern: '^[1-9][0-9]*$'} orderId: {type: string, pattern: '^[1-9][0-9]*$'}
orderNo: {type: string} 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} errorCode: {type: string}
errorMessage: {type: string} errorMessage: {type: string}
FirstLegOrderCargo: FirstLegOrderCargo:
@@ -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 模式必须省略");
}
}
@@ -128,6 +128,42 @@ class InternalOrderCargoControllerTest {
verify(orders).findOwned(11L, 21L); 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, private OrderCargoEntity cargo(long id, int itemNo, int packageCount,
String length, String width, String height) { String length, String width, String height) {
OrderCargoEntity cargo = new OrderCargoEntity(); OrderCargoEntity cargo = new OrderCargoEntity();
@@ -55,7 +55,7 @@ class UnifiedOrderApiContractTest {
"loading", "delivery", "transport", "customerRemark", "latestInternalNote", "loading", "delivery", "transport", "customerRemark", "latestInternalNote",
"expectedArrivalAt", "requestedPickupAt", "feeReviewStatus", "companyName", "businessType", "pickupMethod", "expectedArrivalAt", "requestedPickupAt", "feeReviewStatus", "companyName", "businessType", "pickupMethod",
"taxMode", "containerMode", "customsInspection", "destinationType", "destinationCode", "taxMode", "containerMode", "customsInspection", "destinationType", "destinationCode",
"totalPalletCount", "chargeableVolumeCbm", "volumetricWeightKg", "totalNetWeightKg", "totalPalletCount", "chargeableWeightKg", "chargeableVolumeCbm", "volumetricWeightKg", "totalNetWeightKg",
"measurementCompletedAt", "inboundCompletedAt", "expressCompany", "expressTrackingNo", "measurementCompletedAt", "inboundCompletedAt", "expressCompany", "expressTrackingNo",
"createdBy", "skuNos", "totalQuantity", "shipmentId", "referenceId"); "createdBy", "skuNos", "totalQuantity", "shipmentId", "referenceId");
assertThat(responseSchema(paths, "/api/platform/v1/orders", "get")) assertThat(responseSchema(paths, "/api/platform/v1/orders", "get"))
@@ -252,6 +252,42 @@ class OrderApplicationServiceStateTest {
verify(repository).saveOrder(20002L, order); 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 @Test
void submittingLegacyDraftRequiresPricingTaxMode() { void submittingLegacyDraftRequiresPricingTaxMode() {
OrderRepository repository = mock(OrderRepository.class); OrderRepository repository = mock(OrderRepository.class);
@@ -531,6 +567,18 @@ class OrderApplicationServiceStateTest {
"BUY_DECLARATION", "TAX_INCLUDED", "LCL", false, "OVERSEAS", null, null, null); "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, private OrderRequests.SaveRequest request(Long customerId, String pickupMethod,
OrderRequests.Party sender, OrderRequests.Party receiver, OrderRequests.Party sender, OrderRequests.Party receiver,
String destinationType) { String destinationType) {
@@ -5,6 +5,8 @@ import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.exception.BizException;
import com.baiyue.order.client.FirstlegUnifiedOrderClient; import com.baiyue.order.client.FirstlegUnifiedOrderClient;
import com.baiyue.order.dto.UnifiedOrderQuery; 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.OrderEntity;
import com.baiyue.order.entity.OrderInternalNoteEntity; import com.baiyue.order.entity.OrderInternalNoteEntity;
import com.baiyue.order.entity.OrderStatus; 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.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.math.BigDecimal;
import java.util.List; import java.util.List;
import java.util.stream.IntStream; import java.util.stream.IntStream;
@@ -74,6 +77,71 @@ class UnifiedOrderQueryServiceTest {
verify(firstleg, never()).summaries(any(), any()); 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 @Test
void pendingReviewInboundCodeFilterStaysInOrderQuery() { void pendingReviewInboundCodeFilterStaysInOrderQuery() {
UnifiedOrderQuery query = new UnifiedOrderQuery( UnifiedOrderQuery query = new UnifiedOrderQuery(
@@ -173,10 +241,27 @@ class UnifiedOrderQueryServiceTest {
assertThat(row.companyName()).isEqualTo("测试客户有限公司"); assertThat(row.companyName()).isEqualTo("测试客户有限公司");
assertThat(row.businessType()).isEqualTo("SEA_LCL"); assertThat(row.businessType()).isEqualTo("SEA_LCL");
assertThat(row.totalPalletCount()).isEqualTo(2); 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()); 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 @Test
void waitSplitFilterForwardsAndReturnsWaitSplitIndependently() { void waitSplitFilterForwardsAndReturnsWaitSplitIndependently() {
UnifiedOrderQuery query = query("WAIT_SPLIT"); UnifiedOrderQuery query = query("WAIT_SPLIT");
@@ -201,6 +286,9 @@ class UnifiedOrderQueryServiceTest {
UnifiedOrderQuery query = query(null); UnifiedOrderQuery query = query(null);
OrderEntity order = order(OrderStatus.APPROVED); OrderEntity order = order(OrderStatus.APPROVED);
var summary = summary("LOADED", null); 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())) when(orders.unifiedPage(eq(10001L), eq(query), any()))
.thenReturn(new PageResult<>(List.of(order), 1, 20, 1, 1)); .thenReturn(new PageResult<>(List.of(order), 1, 20, 1, 1));
when(firstleg.summaries(eq("internal-token"), any())) when(firstleg.summaries(eq("internal-token"), any()))
@@ -212,6 +300,10 @@ class UnifiedOrderQueryServiceTest {
assertThat(row.mainStatus()).isEqualTo("LOADED"); assertThat(row.mainStatus()).isEqualTo("LOADED");
assertThat(row.internalOrderStatus()).isEqualTo(OrderStatus.APPROVED); assertThat(row.internalOrderStatus()).isEqualTo(OrderStatus.APPROVED);
assertThat(row.inboundId()).isEqualTo(88L); 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<FirstlegUnifiedOrderClient.BatchRequest> captor = ArgumentCaptor<FirstlegUnifiedOrderClient.BatchRequest> captor =
ArgumentCaptor.forClass(FirstlegUnifiedOrderClient.BatchRequest.class); ArgumentCaptor.forClass(FirstlegUnifiedOrderClient.BatchRequest.class);
@@ -349,6 +441,9 @@ class UnifiedOrderQueryServiceTest {
when(inbound.companyName()).thenReturn("测试客户有限公司"); when(inbound.companyName()).thenReturn("测试客户有限公司");
when(inbound.businessType()).thenReturn("SEA_LCL"); when(inbound.businessType()).thenReturn("SEA_LCL");
when(inbound.palletCount()).thenReturn(2); 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.createdAt()).thenReturn(LocalDateTime.of(2026, 8, 6, 10, 0));
when(inbound.updatedAt()).thenReturn(LocalDateTime.of(2026, 8, 6, 11, 0)); when(inbound.updatedAt()).thenReturn(LocalDateTime.of(2026, 8, 6, 11, 0));
return new FirstlegUnifiedOrderClient.Summary(inbound, status, action, return new FirstlegUnifiedOrderClient.Summary(inbound, status, action,