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
@@ -31,6 +31,15 @@ public interface OrderCargoClient {
@PathVariable long tenantId,
@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,
Integer originalPackageCount, BigDecimal originalGrossWeightKg,
BigDecimal originalVolumeCbm, String skuNo, String cargoNameEn,
@@ -190,7 +190,7 @@ public class PlatformInboundMeasurementController {
resourceId = "#resultId", description = "确认 OCR 重量并累加实际总毛重")
@Operation(summary = "确认 OCR 重量",
description = "可在同一事务中携带人工毛重修改值并确认;统一换算 KG、扣除托盘皮重后,"
+ "按所有已确认净重重算入库单 actualGrossWeightKg。")
+ "按所有已确认结果分别重算入库单毛重与扣皮后的货物净重。")
@PostMapping("/weight-results/{resultId}/confirm")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:weight:confirm')"
+ " and (!#request.hasAdjustment() or hasAuthority('platform:firstleg:inbound:weight:edit'))")
@@ -6,7 +6,6 @@ import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.ExceptionReleaseRequest;
import com.baiyue.firstleg.dto.MeasurementReviewRequest;
import com.baiyue.firstleg.dto.MeasurementApprovalResponse;
import com.baiyue.firstleg.entity.MeasureReviewRecordEntity;
@@ -57,41 +56,6 @@ public class PlatformInboundMeasurementReviewController {
request.measurementId(), request.expectedVersion() + 1));
}
@BusinessAudit(event = "INBOUND_MEASURE_REJECT", resourceType = "INBOUND",
resourceId = "#inboundId", description = "测量审核驳回")
@Operation(summary = "测量审核驳回",
description = "驳回原因必填,只退回测量数据修改;已确认的托盘和库位占用保持不动。")
@PostMapping("/{inboundId}/measurements/reject")
@PreAuthorize("hasAuthority('firstleg:inbound:measurement-review')")
public ApiResponse<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 = "查询测量审核历史")
@GetMapping("/{inboundId}/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;
import com.fasterxml.jackson.annotation.JsonAnySetter;
import jakarta.validation.Valid;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.DecimalMax;
@@ -26,12 +27,13 @@ public final class InboundMeasurementRequests {
public record SaveDraftRequest(
Long measurementId,
@NotNull @PositiveOrZero Integer version,
@Size(max = 64) String originalDocumentNo,
@DecimalMin("0.000") @Digits(integer = 15, fraction = 3) BigDecimal originalGrossWeightKg,
@DecimalMin("0.0000") @Digits(integer = 14, fraction = 4) BigDecimal originalVolumeCbm,
@Size(max = 255) String actualGoodsName,
@NotEmpty @Size(max = 500) List<@Valid ItemRequest> items
) {
@JsonAnySetter
public void rejectUnknownField(String fieldName, Object ignored) {
throw new IllegalArgumentException("测量草稿不接受字段: " + fieldName);
}
}
public record ItemRequest(
@@ -40,6 +40,7 @@ public final class InboundMeasurementResponses {
Integer originalPackageCount,
Integer actualPackageCount,
BigDecimal actualGrossWeightKg,
BigDecimal actualNetWeightKg,
BigDecimal actualVolumeCbm,
BigDecimal volumeWeightKg,
String actualGoodsName,
@@ -28,12 +28,25 @@ public record InboundOrderSummary(
LocalDateTime measureCompletedAt, LocalDateTime inboundedAt,
String expressCompanyCode, String expressCompanyName, String expressTrackingNo,
String containerNo, String cabinetNo,
String vesselName, String voyageNo, LocalDateTime sailingAt, LocalDateTime portArrivalAt,
String vesselName, String voyageNo,
LocalDateTime estimatedSailingAt, LocalDateTime actualSailingAt,
LocalDateTime estimatedPortArrivalAt, LocalDateTime actualPortArrivalAt,
LocalDateTime createdAt, LocalDateTime updatedAt
) {
/** 非统一入仓接口保留既有快照适配;统一订单不得调用此重载。 */
public static InboundOrderSummary from(
InboundOrderEntity order, InboundTransportSnapshotEntity transport,
InboundPickupSnapshotEntity pickup, String locationCodes) {
CurrentContainerTransport adapted = transport == null ? null : new CurrentContainerTransport(
order.getId(), order.getTenantId(), null, transport.getContainerNo(), transport.getCabinetNo(),
transport.getVesselName(), transport.getVoyageNo(), transport.getSailingAt(), null,
transport.getPortArrivalAt(), null);
return from(order, adapted, pickup, locationCodes);
}
public static InboundOrderSummary from(
InboundOrderEntity order, CurrentContainerTransport transport,
InboundPickupSnapshotEntity pickup, String locationCodes) {
return new InboundOrderSummary(
order.getId(), order.getTenantId(), order.getOrderId(), order.getOrderNo(), order.getInboundCode(),
order.getProcessStatus(), order.getOrderStatusSnapshot(), order.getMeasureReviewStatus(),
@@ -53,12 +66,14 @@ public record InboundOrderSummary(
locationCodes,
order.getMeasureCompletedAt(), order.getInboundedAt(), order.getExpressCompanyCode(),
order.getExpressCompanyName(), order.getExpressTrackingNo(),
transport == null ? null : transport.getContainerNo(),
transport == null ? null : transport.getCabinetNo(),
transport == null ? null : transport.getVesselName(),
transport == null ? null : transport.getVoyageNo(),
transport == null ? null : transport.getSailingAt(),
transport == null ? null : transport.getPortArrivalAt(),
transport == null ? null : transport.containerNo(),
transport == null ? null : transport.cabinetNo(),
transport == null ? null : transport.vesselName(),
transport == null ? null : transport.voyageNo(),
transport == null ? null : transport.estimatedSailingAt(),
transport == null ? null : transport.actualSailingAt(),
transport == null ? null : transport.estimatedPortArrivalAt(),
transport == null ? null : transport.actualPortArrivalAt(),
order.getCreatedAt(), order.getUpdatedAt()
);
}
@@ -19,7 +19,7 @@ public record MerchantInboundBillingResponse(InboundOrderDetail inbound,
public record MeasurementSummary(
Long measurementId, String sourceType, String measurementStatus,
Integer actualPackageCount, BigDecimal actualGrossWeightKg,
Integer actualPackageCount, BigDecimal actualGrossWeightKg, BigDecimal actualNetWeightKg,
BigDecimal actualVolumeCbm, BigDecimal volumeWeightKg, String actualGoodsName) {
}
@@ -36,6 +36,7 @@ public class InboundPricingEntity extends BaseEntity {
private Long destinationWarehouseId;
private String destinationName;
private BigDecimal actualGrossWeightKg;
private BigDecimal actualNetWeightKg;
private BigDecimal actualVolumeCbm;
private BigDecimal chargeableWeightKg;
private BigDecimal chargeableVolumeCbm;
@@ -12,19 +12,14 @@ public interface InboundOrderMapper extends BaseMapper<InboundOrderEntity> {
@Update("""
UPDATE fl_inbound_order
SET process_status = 'WAIT_SPLIT', measure_review_status = 'APPROVED',
actual_gross_weight_kg = CASE
WHEN EXISTS (SELECT 1
FROM fl_inbound_weight_result weight
WHERE weight.tenant_id = #{tenantId} AND weight.inbound_id = #{inboundId}
AND weight.deleted = 0 AND weight.recognition_status = 'CONFIRMED')
THEN (SELECT COALESCE(SUM(weight.net_weight_kg), 0)
FROM fl_inbound_weight_result weight
WHERE weight.tenant_id = #{tenantId} AND weight.inbound_id = #{inboundId}
AND weight.deleted = 0 AND weight.recognition_status = 'CONFIRMED')
ELSE (SELECT COALESCE(SUM(item.total_weight_kg), 0)
FROM fl_inbound_measurement_item item
WHERE item.tenant_id = #{tenantId} AND item.measurement_id = #{measurementId})
END,
actual_gross_weight_kg = (SELECT SUM(weight.gross_weight_kg)
FROM fl_inbound_weight_result weight
WHERE weight.tenant_id = #{tenantId} AND weight.inbound_id = #{inboundId}
AND weight.deleted = 0 AND weight.recognition_status = 'CONFIRMED'),
actual_net_weight_kg = (SELECT SUM(weight.net_weight_kg)
FROM fl_inbound_weight_result weight
WHERE weight.tenant_id = #{tenantId} AND weight.inbound_id = #{inboundId}
AND weight.deleted = 0 AND weight.recognition_status = 'CONFIRMED'),
actual_volume_cbm = (SELECT COALESCE(SUM(item.total_volume_cbm), 0)
FROM fl_inbound_measurement_item item
WHERE item.tenant_id = #{tenantId} AND item.measurement_id = #{measurementId}),
@@ -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>()
.eq(InboundOrderEntity::getTenantId, inbound.getTenantId())
.eq(InboundOrderEntity::getId, inbound.getId())
.set(InboundOrderEntity::getActualGrossWeightKg, inbound.getActualGrossWeightKg())) == 1;
.set(InboundOrderEntity::getActualGrossWeightKg, inbound.getActualGrossWeightKg())
.set(InboundOrderEntity::getActualNetWeightKg, inbound.getActualNetWeightKg())) == 1;
}
public InboundMeasurementEntity findMeasurement(long tenantId, long inboundId, long measurementId) {
@@ -154,29 +154,31 @@ public class InboundOrderRepository {
+ "OR (newer.fee_version = f.fee_version AND newer.id > f.id))))",
query.feeReviewStatus())
.apply(StringUtils.hasText(query.containerNo()),
"EXISTS (SELECT 1 FROM fl_inbound_transport_snapshot t "
+ "WHERE t.tenant_id = fl_inbound_order.tenant_id AND t.inbound_id = fl_inbound_order.id "
+ "AND t.container_no LIKE CONCAT('%', {0}, '%'))", query.containerNo())
"EXISTS (SELECT 1 FROM fl_loading_plan_item i JOIN fl_loading_plan p ON p.id=i.loading_plan_id "
+ "JOIN fl_container_booking b ON b.id=p.container_booking_id WHERE i.deleted=0 "
+ "AND p.plan_status IN ('LOADING','COMPLETED') AND i.account_id=fl_inbound_order.tenant_id "
+ "AND i.inbound_id=fl_inbound_order.id AND b.container_no LIKE CONCAT('%', {0}, '%'))", query.containerNo())
.apply(StringUtils.hasText(query.cabinetNo()),
"EXISTS (SELECT 1 FROM fl_inbound_transport_snapshot t "
+ "WHERE t.tenant_id = fl_inbound_order.tenant_id AND t.inbound_id = fl_inbound_order.id "
+ "AND t.cabinet_no LIKE CONCAT('%', {0}, '%'))", query.cabinetNo())
"EXISTS (SELECT 1 FROM fl_loading_plan_item i JOIN fl_loading_plan p ON p.id=i.loading_plan_id "
+ "JOIN fl_container_booking b ON b.id=p.container_booking_id WHERE i.deleted=0 "
+ "AND p.plan_status IN ('LOADING','COMPLETED') AND i.account_id=fl_inbound_order.tenant_id "
+ "AND i.inbound_id=fl_inbound_order.id AND b.cabinet_no LIKE CONCAT('%', {0}, '%'))", query.cabinetNo())
.apply(query.sailingDate() != null,
"EXISTS (SELECT 1 FROM fl_inbound_transport_snapshot t "
+ "WHERE t.tenant_id = fl_inbound_order.tenant_id AND t.inbound_id = fl_inbound_order.id "
+ "AND t.sailing_at >= {0} AND t.sailing_at < {1})",
"EXISTS (SELECT 1 FROM fl_loading_plan_item i JOIN fl_loading_plan p ON p.id=i.loading_plan_id "
+ "JOIN fl_container_booking b ON b.id=p.container_booking_id WHERE i.deleted=0 "
+ "AND p.plan_status IN ('LOADING','COMPLETED') AND i.account_id=fl_inbound_order.tenant_id "
+ "AND i.inbound_id=fl_inbound_order.id AND COALESCE(b.actual_sailing_at,b.estimated_sailing_at) "
+ ">= {0} AND COALESCE(b.actual_sailing_at,b.estimated_sailing_at) < {1})",
query.sailingDate() == null ? null : query.sailingDate().atStartOfDay(),
query.sailingDate() == null ? null : query.sailingDate().plusDays(1).atStartOfDay())
.apply(Boolean.TRUE.equals(query.loaded()),
"EXISTS (SELECT 1 FROM fl_inbound_transport_snapshot t "
+ "WHERE t.tenant_id = fl_inbound_order.tenant_id AND t.inbound_id = fl_inbound_order.id "
+ "AND COALESCE(TRIM(t.loading_status), '') <> '' "
+ "AND t.loading_status NOT IN ('UNLOADED', 'PENDING'))")
"EXISTS (SELECT 1 FROM fl_loading_plan_item i JOIN fl_loading_plan p ON p.id=i.loading_plan_id "
+ "WHERE i.deleted=0 AND p.plan_status='COMPLETED' "
+ "AND i.account_id=fl_inbound_order.tenant_id AND i.inbound_id=fl_inbound_order.id)")
.apply(Boolean.FALSE.equals(query.loaded()),
"NOT EXISTS (SELECT 1 FROM fl_inbound_transport_snapshot t "
+ "WHERE t.tenant_id = fl_inbound_order.tenant_id AND t.inbound_id = fl_inbound_order.id "
+ "AND COALESCE(TRIM(t.loading_status), '') <> '' "
+ "AND t.loading_status NOT IN ('UNLOADED', 'PENDING'))")
"NOT EXISTS (SELECT 1 FROM fl_loading_plan_item i JOIN fl_loading_plan p ON p.id=i.loading_plan_id "
+ "WHERE i.deleted=0 AND p.plan_status='COMPLETED' "
+ "AND i.account_id=fl_inbound_order.tenant_id AND i.inbound_id=fl_inbound_order.id)")
.ge(query.createdFrom() != null, InboundOrderEntity::getCreatedAt, query.createdFrom())
.le(query.createdTo() != null, InboundOrderEntity::getCreatedAt, query.createdTo())
.orderByDesc(InboundOrderEntity::getCreatedAt).orderByDesc(InboundOrderEntity::getId);
@@ -8,9 +8,11 @@ import com.baiyue.firstleg.loading.mapper.InboundLoadingPickTaskMapper;
import com.baiyue.firstleg.split.entity.PreloadPlanEntity;
import com.baiyue.firstleg.split.mapper.PreloadPlanMapper;
import com.baiyue.firstleg.entity.InboundStatusHistoryEntity;
import com.baiyue.firstleg.entity.InboundTransportSnapshotEntity;
import com.baiyue.firstleg.dto.CurrentContainerTransport;
import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
import com.baiyue.firstleg.mapper.InboundTransportSnapshotMapper;
import com.baiyue.firstleg.mapper.UnifiedInboundTransportMapper;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
@@ -26,7 +28,7 @@ public class UnifiedInboundReadRepository {
@Resource private InboundLoadingPickTaskMapper picks;
@Resource private LoadingEvidenceMapper evidences;
@Resource private InboundStatusHistoryMapper histories;
@Resource private InboundTransportSnapshotMapper transports;
@Resource private UnifiedInboundTransportMapper transports;
public PreloadPlanEntity findPreload(long tenantId, long inboundId) {
return preloads.selectOne(new LambdaQueryWrapper<PreloadPlanEntity>()
@@ -84,19 +86,23 @@ public class UnifiedInboundReadRepository {
Function.identity(), (latest, ignored) -> latest));
}
public InboundTransportSnapshotEntity findTransport(long tenantId, long inboundId) {
return transports.selectOne(new LambdaQueryWrapper<InboundTransportSnapshotEntity>()
.eq(InboundTransportSnapshotEntity::getTenantId, tenantId)
.eq(InboundTransportSnapshotEntity::getInboundId, inboundId));
public CurrentContainerTransport findTransport(long tenantId, long inboundId) {
return findTransports(Map.of(inboundId, tenantId)).get(inboundId);
}
public Map<Long, InboundTransportSnapshotEntity> findTransports(Map<Long, Long> tenantByInbound) {
public Map<Long, CurrentContainerTransport> findTransports(Map<Long, Long> tenantByInbound) {
if (tenantByInbound.isEmpty()) return Map.of();
return transports.selectList(new LambdaQueryWrapper<InboundTransportSnapshotEntity>()
.in(InboundTransportSnapshotEntity::getInboundId, tenantByInbound.keySet()))
.stream().filter(item -> owns(tenantByInbound, item.getInboundId(), item.getTenantId()))
.collect(Collectors.toMap(InboundTransportSnapshotEntity::getInboundId,
Function.identity(), (left, right) -> left));
Map<Long, List<CurrentContainerTransport>> grouped = transports
.findCurrentByInboundIds(List.copyOf(tenantByInbound.keySet())).stream()
.filter(item -> owns(tenantByInbound, item.inboundId(), item.accountId()))
.collect(Collectors.groupingBy(CurrentContainerTransport::inboundId));
grouped.forEach((inboundId, rows) -> {
long bookingCount = rows.stream().map(CurrentContainerTransport::containerBookingId).distinct().count();
if (bookingCount > 1) throw new BizException(ResultCode.CONFLICT,
"同一入库业务单元关联多个有效柜次: inboundId=" + inboundId);
});
return grouped.entrySet().stream().collect(Collectors.toMap(Map.Entry::getKey,
entry -> entry.getValue().get(0)));
}
private static boolean owns(Map<Long, Long> tenantByInbound, Long inboundId, Long tenantId) {
@@ -150,21 +150,24 @@ public class CartonMeasurementService {
.filter(weight -> "CONFIRMED".equals(weight.getRecognitionStatus()))
.toList();
if (!confirmedWeights.isEmpty()) {
if (confirmedWeights.stream().anyMatch(weight -> !positive(weight.getGrossWeightKg())
|| !positive(weight.getNetWeightKg()))) {
throw new BizException(ResultCode.VALIDATION_FAILED,
"已确认称重结果缺少有效毛重或净重,请重新确认后再修改箱数");
}
BigDecimal confirmedGrossWeight = confirmedWeights.stream()
.map(InboundWeightResultEntity::getGrossWeightKg)
.reduce(BigDecimal.ZERO, BigDecimal::add)
.setScale(3, RoundingMode.HALF_UP);
BigDecimal confirmedNetWeight = confirmedWeights.stream()
.map(InboundWeightResultEntity::getNetWeightKg)
.filter(java.util.Objects::nonNull)
.reduce(BigDecimal.ZERO, BigDecimal::add)
.setScale(3, RoundingMode.CEILING);
inbound.setActualGrossWeightKg(confirmedNetWeight);
.setScale(3, RoundingMode.HALF_UP);
inbound.setActualGrossWeightKg(confirmedGrossWeight);
inbound.setActualNetWeightKg(confirmedNetWeight);
} else {
BigDecimal totalWeight = items.stream()
.map(InboundMeasurementItemEntity::getTotalWeightKg)
.filter(java.util.Objects::nonNull)
.reduce(BigDecimal.ZERO, BigDecimal::add)
.setScale(3, RoundingMode.CEILING);
if (positive(totalWeight)) {
inbound.setActualGrossWeightKg(totalWeight);
}
inbound.setActualGrossWeightKg(null);
inbound.setActualNetWeightKg(null);
}
inbound.setActualVolumeCbm(measurement.getTotalVolumeCbm());
inbound.setVolumeWeightKg(measurement.getVolumeWeightKg());
@@ -58,9 +58,9 @@ public class FbaPricingService {
ActivePriceBookEstimator.Estimate estimate = priceBookEstimator.estimate(new EstimateRequest(
inbound.getWarehouseId(), inbound.getChannelCode(), inbound.getDestinationCode(),
destinationType(inbound), inbound.getDestinationWarehouseId(), inbound.getDestinationName(), null,
pricingTaxMode(inbound), inbound.getActualGrossWeightKg(), inbound.getActualVolumeCbm()));
pricingTaxMode(inbound), inbound.getActualNetWeightKg(), inbound.getActualVolumeCbm()));
RateRow rate = estimate.rate();
BigDecimal weight = inbound.getActualGrossWeightKg().setScale(3, RoundingMode.HALF_UP);
BigDecimal weight = inbound.getActualNetWeightKg().setScale(3, RoundingMode.HALF_UP);
BigDecimal volume = inbound.getActualVolumeCbm().setScale(4, RoundingMode.HALF_UP);
BigDecimal chargeable = estimate.chargeableQuantity();
BigDecimal converted = chargeable;
@@ -240,9 +240,9 @@ public class FbaPricingService {
throw conflict("当前目的地类型不支持自动计费");
}
if (!"APPROVED".equals(inbound.getMeasureReviewStatus())) throw conflict("只能消费审核通过的测量快照");
if (inbound.getActualGrossWeightKg() == null || inbound.getActualGrossWeightKg().signum() <= 0
if (inbound.getActualNetWeightKg() == null || inbound.getActualNetWeightKg().signum() <= 0
|| inbound.getActualVolumeCbm() == null || inbound.getActualVolumeCbm().signum() <= 0) {
throw conflict("审核通过的测量快照必须包含大于零的实重和体积");
throw conflict("审核通过的测量快照必须包含大于零的货物净重和体积");
}
if (inbound.getCustomerId() == null || inbound.getCustomerId() <= 0) {
throw conflict("入库单缺少订单客户归属,不能生成对客费用");
@@ -390,7 +390,8 @@ public class InboundFeeDetailService {
value.setDestinationCode(inbound.getDestinationCode());
value.setDestinationName(inbound.getDestinationName());
value.setPackageCount(inbound.getTotalPackageCount());
value.setActualGrossWeightKg(inbound.getActualGrossWeightKg());
// 财务历史字段名保留,但计费事实统一使用扣除托盘皮重后的货物净重。
value.setActualGrossWeightKg(inbound.getActualNetWeightKg());
value.setActualVolumeCbm(inbound.getActualVolumeCbm());
value.setVolumeWeightKg(inbound.getVolumeWeightKg());
value.setChargeableWeightKg(inbound.getChargeableWeightKg());
@@ -68,7 +68,9 @@ public class InboundMeasurementService {
? List.of() : repository.listItems(tenantId, measurement.getId());
List<InboundWeightResultEntity> weights = repository.listWeights(tenantId, inboundId);
MeasureReviewRecordEntity review = repository.findLatestReview(tenantId, inboundId);
return toDetail(inbound, measurement, items, weights, review);
OrderCargoClient.MeasurementDeclarationSnapshot declaration = needsDeclarationFallback(measurement)
? loadDeclaration(inbound) : null;
return toDetail(inbound, measurement, items, weights, review, declaration);
}
public List<InboundMeasurementResponses.PalletSnapshot> weightPalletOptions(
@@ -87,11 +89,23 @@ public class InboundMeasurementService {
InboundMeasurementRequests.SaveDraftRequest request) {
InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
validateInboundDetails(tenantId, inbound, request.items());
InboundMeasurementEntity existing = request.measurementId() == null ? null
: repository.findMeasurement(tenantId, inboundId, request.measurementId());
OrderCargoClient.MeasurementDeclarationSnapshot declaration = loadDeclaration(inbound);
String actualGoodsName = StringUtils.hasText(request.actualGoodsName()) ? request.actualGoodsName().trim()
: existing != null && StringUtils.hasText(existing.getActualGoodsName())
? existing.getActualGoodsName() : defaultGoodsName(declaration);
MeasurementBatchCommand command = new MeasurementBatchCommand(
tenantId, inboundId, request.measurementId(), request.version(),
MeasurementBatchService.SOURCE_MANUAL, "PLATFORM", null, null, null,
LocalDateTime.now(), null, request.originalDocumentNo(),
request.originalGrossWeightKg(), request.originalVolumeCbm(), request.actualGoodsName(),
LocalDateTime.now(), null,
existing != null && StringUtils.hasText(existing.getOriginalDocumentNo())
? existing.getOriginalDocumentNo() : declaration.referenceId(),
existing != null && existing.getOriginalGrossWeightKg() != null
? existing.getOriginalGrossWeightKg() : declaration.originalGrossWeightKg(),
existing != null && existing.getOriginalVolumeCbm() != null
? existing.getOriginalVolumeCbm() : declaration.originalVolumeCbm(),
actualGoodsName,
operatorId, request.items().stream().map(item -> new MeasurementBatchCommand.Item(
item.inboundDetailId(), item.itemNo(), item.boxSpecification(), item.packageCount(), item.lengthCm(),
item.widthCm(), item.heightCm(), item.unitWeightKg(), item.intelligentImageFileId(),
@@ -116,10 +130,7 @@ public class InboundMeasurementService {
}
List<InboundMeasurementItemEntity> items = repository.listItems(tenantId, measurement.getId());
List<InboundWeightResultEntity> weights = repository.listWeights(tenantId, inboundId);
BigDecimal confirmedWeight = confirmedWeightForSubmission(weights);
int confirmedPalletCount = Math.toIntExact(weights.stream()
.filter(weight -> WEIGHT_CONFIRMED.equals(weight.getRecognitionStatus()))
.count());
ConfirmedWeightTotals confirmedTotals = confirmedWeightTotals(weights);
if (!alreadyAdvanced) {
requireConfirmedPackageTotal(inbound, items);
@@ -137,10 +148,9 @@ public class InboundMeasurementService {
if (measurement.getTotalPackageCount() != null) {
inbound.setTotalPackageCount(measurement.getTotalPackageCount());
}
if (confirmedWeight != null) {
inbound.setActualGrossWeightKg(confirmedWeight);
}
inbound.setPalletCount(confirmedPalletCount);
inbound.setActualGrossWeightKg(confirmedTotals.grossWeightKg());
inbound.setActualNetWeightKg(confirmedTotals.netWeightKg());
inbound.setPalletCount(confirmedTotals.palletCount());
if (measurement.getTotalVolumeCbm() != null) {
inbound.setActualVolumeCbm(measurement.getTotalVolumeCbm());
}
@@ -161,6 +171,7 @@ public class InboundMeasurementService {
long tenantId, long inboundId, long operatorId,
InboundMeasurementRequests.CreateWeightResultRequest request) {
InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
requireWeightEditableInbound(inbound);
if (repository.findWeightByFile(tenantId, inboundId, request.fileId()) != null) {
throw new BizException(ResultCode.CONFLICT, "同一称重图片不能重复计重");
}
@@ -192,6 +203,7 @@ public class InboundMeasurementService {
weight.setDeleted(false);
weight.setVersion(0);
repository.insertWeight(weight);
recalculateConfirmedWeight(inbound, operatorId);
return toWeight(weight);
}
@@ -200,14 +212,12 @@ public class InboundMeasurementService {
long tenantId, long inboundId, long resultId, long operatorId, String operatorName,
InboundMeasurementRequests.CorrectWeightResultRequest request) {
InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
requireWeightEditableInbound(inbound);
InboundWeightResultEntity weight = requireWeight(tenantId, inboundId, resultId);
requireVersion(request.version(), weight.getVersion());
boolean wasConfirmed = WEIGHT_CONFIRMED.equals(weight.getRecognitionStatus());
applyWeightCorrection(weight, request.version(), request.adjustedWeightValue(),
request.adjustedWeightUnit(), request.adjustmentReason(), operatorId, operatorName);
if (wasConfirmed) {
recalculateConfirmedWeight(inbound, operatorId);
}
recalculateConfirmedWeight(inbound, operatorId);
return toWeight(weight);
}
@@ -246,6 +256,7 @@ public class InboundMeasurementService {
long tenantId, long inboundId, long resultId, long operatorId, String operatorName,
InboundMeasurementRequests.ConfirmWeightResultRequest request) {
InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
requireWeightConfirmableInbound(inbound, request.hasAdjustment());
InboundWeightResultEntity weight = requireWeight(tenantId, inboundId, resultId);
requireVersion(request.version(), weight.getVersion());
int currentVersion = request.version();
@@ -291,19 +302,15 @@ public class InboundMeasurementService {
@Transactional
public void deleteWeightResult(long tenantId, long inboundId, long resultId,
int version, long operatorId) {
int version, long operatorId) {
InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
if (!WEIGHT_EDITABLE_STATUSES.contains(inbound.getProcessStatus())) {
throw new BizException(ResultCode.CONFLICT, "当前入库状态不允许删除称重图片");
}
requireWeightEditableInbound(inbound);
InboundWeightResultEntity weight = requireWeight(tenantId, inboundId, resultId);
requireVersion(version, weight.getVersion());
if (!repository.deleteWeight(tenantId, inboundId, resultId, version)) {
throw concurrentModification();
}
if (WEIGHT_CONFIRMED.equals(weight.getRecognitionStatus())) {
recalculateConfirmedWeight(inbound, operatorId);
}
recalculateConfirmedWeight(inbound, operatorId);
try {
ApiResponse<Void> response = imageClient.delete(
internalToken, operatorId, tenantId, inboundId, weight.getFileId());
@@ -319,15 +326,10 @@ public class InboundMeasurementService {
}
private void recalculateConfirmedWeight(InboundOrderEntity inbound, long operatorId) {
List<InboundWeightResultEntity> confirmedWeights = repository
.listWeights(inbound.getTenantId(), inbound.getId()).stream()
.filter(weight -> WEIGHT_CONFIRMED.equals(weight.getRecognitionStatus()))
.toList();
BigDecimal total = confirmedWeights.stream()
.map(InboundWeightResultEntity::getNetWeightKg)
.reduce(BigDecimal.ZERO, BigDecimal::add)
.setScale(3, RoundingMode.HALF_UP);
inbound.setActualGrossWeightKg(confirmedWeights.isEmpty() ? null : total);
ConfirmedWeightTotals totals = confirmedWeightTotals(
repository.listWeights(inbound.getTenantId(), inbound.getId()));
inbound.setActualGrossWeightKg(totals.grossWeightKg());
inbound.setActualNetWeightKg(totals.netWeightKg());
if ("WAIT_MEASURE".equals(inbound.getProcessStatus())) {
inbound.setProcessStatus("MEASURING");
}
@@ -337,13 +339,31 @@ public class InboundMeasurementService {
}
}
private BigDecimal confirmedWeightForSubmission(List<InboundWeightResultEntity> weights) {
BigDecimal total = weights.stream()
private ConfirmedWeightTotals confirmedWeightTotals(List<InboundWeightResultEntity> weights) {
List<InboundWeightResultEntity> confirmed = weights.stream()
.filter(weight -> WEIGHT_CONFIRMED.equals(weight.getRecognitionStatus()))
.toList();
if (confirmed.isEmpty()) {
return new ConfirmedWeightTotals(null, null, 0);
}
if (confirmed.stream().anyMatch(weight -> !isPositive(weight.getGrossWeightKg())
|| !isPositive(weight.getNetWeightKg()))) {
throw new BizException(ResultCode.VALIDATION_FAILED,
"已确认称重结果缺少有效毛重或净重,请重新确认后再继续");
}
BigDecimal grossWeightKg = confirmed.stream()
.map(InboundWeightResultEntity::getGrossWeightKg)
.reduce(BigDecimal.ZERO, BigDecimal::add)
.setScale(3, RoundingMode.HALF_UP);
BigDecimal netWeightKg = confirmed.stream()
.map(InboundWeightResultEntity::getNetWeightKg)
.filter(java.util.Objects::nonNull)
.reduce(BigDecimal.ZERO, BigDecimal::add).setScale(3, RoundingMode.HALF_UP);
return isPositive(total) ? total : null;
.reduce(BigDecimal.ZERO, BigDecimal::add)
.setScale(3, RoundingMode.HALF_UP);
return new ConfirmedWeightTotals(grossWeightKg, netWeightKg, confirmed.size());
}
private record ConfirmedWeightTotals(
BigDecimal grossWeightKg, BigDecimal netWeightKg, int palletCount) {
}
private void requireCompleteAdjustment(BigDecimal value, String unit, String reason) {
@@ -353,6 +373,23 @@ public class InboundMeasurementService {
}
}
private void requireWeightEditableInbound(InboundOrderEntity inbound) {
if (!WEIGHT_EDITABLE_STATUSES.contains(inbound.getProcessStatus())) {
throw new BizException(ResultCode.CONFLICT, "当前入库状态不允许修改称重结果");
}
}
private void requireWeightConfirmableInbound(InboundOrderEntity inbound, boolean hasAdjustment) {
if (WEIGHT_EDITABLE_STATUSES.contains(inbound.getProcessStatus())) {
return;
}
if ("MEASURE_PENDING".equals(inbound.getProcessStatus()) && hasAdjustment) {
return;
}
throw new BizException(ResultCode.CONFLICT,
"当前入库状态不允许确认称重结果,待审核状态仅允许人工修正并确认");
}
private void insertRevision(InboundWeightResultEntity weight, int revisionNo, String action,
BigDecimal beforeValue, String beforeUnit,
BigDecimal afterValue, String afterUnit,
@@ -380,7 +417,7 @@ public class InboundMeasurementService {
private InboundMeasurementResponses.Detail toDetail(
InboundOrderEntity inbound, InboundMeasurementEntity measurement,
List<InboundMeasurementItemEntity> items, List<InboundWeightResultEntity> weights,
MeasureReviewRecordEntity review) {
MeasureReviewRecordEntity review, OrderCargoClient.MeasurementDeclarationSnapshot declaration) {
return new InboundMeasurementResponses.Detail(
inbound.getId(), inbound.getInboundCode(), inbound.getCompanyName(), inbound.getChannelCode(),
inbound.getChannelName(), inbound.getProcessStatus(), inbound.getMeasureReviewStatus(),
@@ -391,18 +428,58 @@ public class InboundMeasurementService {
measurement == null ? null : measurement.getStatus(),
inbound.getVersion(),
measurement == null ? null : measurement.getVersion(),
measurement == null ? null : measurement.getOriginalDocumentNo(),
measurement == null ? null : measurement.getOriginalGrossWeightKg(),
measurement == null ? null : measurement.getOriginalVolumeCbm(),
measurement != null && StringUtils.hasText(measurement.getOriginalDocumentNo())
? measurement.getOriginalDocumentNo() : declaration == null ? null : declaration.referenceId(),
measurement != null && measurement.getOriginalGrossWeightKg() != null
? measurement.getOriginalGrossWeightKg() : declaration == null ? null : declaration.originalGrossWeightKg(),
measurement != null && measurement.getOriginalVolumeCbm() != null
? measurement.getOriginalVolumeCbm() : declaration == null ? null : declaration.originalVolumeCbm(),
inbound.getTotalPackageCount(),
measurement == null ? inbound.getTotalPackageCount() : measurement.getTotalPackageCount(),
inbound.getActualGrossWeightKg(),
inbound.getActualNetWeightKg(),
measurement == null ? inbound.getActualVolumeCbm() : measurement.getTotalVolumeCbm(),
measurement == null ? inbound.getVolumeWeightKg() : measurement.getVolumeWeightKg(),
measurement == null ? null : measurement.getActualGoodsName(),
measurement != null && StringUtils.hasText(measurement.getActualGoodsName())
? measurement.getActualGoodsName() : declaration == null ? null : declaration.defaultActualGoodsName(),
items.stream().map(this::toItem).toList(), weights.stream().map(this::toWeight).toList());
}
private boolean needsDeclarationFallback(InboundMeasurementEntity measurement) {
return measurement == null || !StringUtils.hasText(measurement.getOriginalDocumentNo())
|| measurement.getOriginalGrossWeightKg() == null || measurement.getOriginalVolumeCbm() == null
|| !StringUtils.hasText(measurement.getActualGoodsName());
}
private OrderCargoClient.MeasurementDeclarationSnapshot loadDeclaration(InboundOrderEntity inbound) {
if (inbound.getOrderId() == null || inbound.getOrderId() <= 0) {
throw new BizException(ResultCode.CONFLICT, "入库单未关联有效订单,无法读取客户申报数据");
}
try {
ApiResponse<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) {
return new InboundMeasurementResponses.Item(
item.getId(), item.getInboundDetailId(), item.getItemNo(), item.getBoxSpecification(), item.getPackageCount(),
@@ -28,6 +28,8 @@ public class MeasurementReviewService {
@Resource
private InboundWorkflowRepository repository;
@Resource
private com.baiyue.firstleg.repository.InboundMeasurementRepository measurements;
@Resource
private InboundIdempotencyService idempotency;
@Resource
private InboundProperties properties;
@@ -78,12 +80,30 @@ public class MeasurementReviewService {
throw new BizException(ResultCode.VALIDATION_FAILED,
"人工确认箱数合计与入库单总箱数不一致,不能审核通过");
}
BigDecimal weight = order.getActualGrossWeightKg();
var weights = measurements.listWeights(tenantId, inboundId);
if (weights.isEmpty() || weights.stream().anyMatch(weight ->
!"CONFIRMED".equals(weight.getRecognitionStatus())
|| weight.getGrossWeightKg() == null
|| weight.getGrossWeightKg().signum() <= 0
|| weight.getNetWeightKg() == null
|| weight.getNetWeightKg().signum() <= 0)) {
throw new BizException(ResultCode.VALIDATION_FAILED,
"所有称重结果必须已确认且包含大于零的毛重和货物净重,才能审核通过");
}
BigDecimal grossWeight = weights.stream()
.map(com.baiyue.firstleg.entity.InboundWeightResultEntity::getGrossWeightKg)
.reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal netWeight = weights.stream()
.map(com.baiyue.firstleg.entity.InboundWeightResultEntity::getNetWeightKg)
.reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal volume = items.stream().map(com.baiyue.firstleg.entity.InboundMeasurementItemEntity::getTotalVolumeCbm)
.filter(java.util.Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add);
if (weight == null || weight.signum() <= 0 || volume.signum() <= 0) {
if (order.getActualGrossWeightKg() == null || order.getActualNetWeightKg() == null
|| order.getActualGrossWeightKg().compareTo(grossWeight) != 0
|| order.getActualNetWeightKg().compareTo(netWeight) != 0
|| volume.signum() <= 0) {
throw new BizException(ResultCode.VALIDATION_FAILED,
"审核通过的测量快照必须包含大于零的货物总重量和体积");
"审核通过前称重毛重、货物净重汇总和体积必须完整且一致");
}
}
@@ -53,6 +53,7 @@ public class MerchantInboundBillingService {
var summary = new MerchantInboundBillingResponse.MeasurementSummary(
measurement.measurementId(), measurement.sourceType(), measurement.measurementStatus(),
measurement.actualPackageCount(), measurement.actualGrossWeightKg(),
measurement.actualNetWeightKg(),
measurement.actualVolumeCbm(), measurement.volumeWeightKg(), measurement.actualGoodsName());
var measurementImages = measurement.items().stream()
.filter(item -> item.intelligentImageFileId() != null)
@@ -7,7 +7,7 @@ import com.baiyue.firstleg.dto.UnifiedInboundDetail;
import com.baiyue.firstleg.dto.UnifiedInboundQuery;
import com.baiyue.firstleg.dto.UnifiedInboundSummary;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.entity.InboundTransportSnapshotEntity;
import com.baiyue.firstleg.dto.CurrentContainerTransport;
import com.baiyue.firstleg.loading.entity.LoadingEvidenceEntity;
import com.baiyue.firstleg.repository.InboundOrderRepository;
import com.baiyue.firstleg.repository.UnifiedInboundReadRepository;
@@ -75,7 +75,7 @@ public class UnifiedInboundQueryService {
evidence = operations.findEvidence(tenantId, inbound.getId());
deliveryAt = deliveryAt(detail.statusHistory());
}
InboundTransportSnapshotEntity transport = includePhaseThree
CurrentContainerTransport transport = includePhaseThree
? operations.findTransport(tenantId, inbound.getId()) : null;
return new UnifiedInboundDetail(summary(inbound, detail, preload, pick, evidence, deliveryAt,
transport, null, null), timeline);
@@ -105,7 +105,7 @@ public class UnifiedInboundQueryService {
com.baiyue.firstleg.split.entity.PreloadPlanEntity preload,
com.baiyue.firstleg.loading.entity.InboundLoadingPickTaskEntity pick,
List<LoadingEvidenceEntity> evidence, LocalDateTime deliveryAt,
InboundTransportSnapshotEntity transport, String locationCodes,
CurrentContainerTransport transport, String locationCodes,
com.baiyue.firstleg.entity.InboundPickupSnapshotEntity pickup) {
String mainStatus = mainStatus(inbound.getProcessStatus());
List<LoadingEvidenceEntity> activeEvidence = evidence.stream()
@@ -124,13 +124,22 @@ public class UnifiedInboundQueryService {
? new UnifiedInboundSummary.DeliverySummary(
"DELIVERED".equals(mainStatus) ? "COMPLETED" : "PENDING", deliveryAt,
inbound.getExpressTrackingNo()) : null;
var inboundSummary = detail == null
? com.baiyue.firstleg.dto.InboundOrderSummary.from(inbound, transport, pickup, locationCodes) : detail.summary();
var baseSummary = detail == null ? null : detail.summary();
var inboundSummary = com.baiyue.firstleg.dto.InboundOrderSummary.from(inbound, transport,
pickup == null && baseSummary != null && baseSummary.requestedPickupAt() != null
? pickupSnapshot(baseSummary.requestedPickupAt()) : pickup,
locationCodes == null && baseSummary != null ? baseSummary.locationCodes() : locationCodes);
return new UnifiedInboundSummary(inboundSummary, mainStatus,
nextAction(mainStatus, inbound.getProcessStatus(), preload == null ? null : preload.getStatus()),
preloadSummary, loadingSummary, deliverySummary);
}
private com.baiyue.firstleg.entity.InboundPickupSnapshotEntity pickupSnapshot(LocalDateTime pickupAt) {
var snapshot = new com.baiyue.firstleg.entity.InboundPickupSnapshotEntity();
snapshot.setPickupAt(pickupAt);
return snapshot;
}
private LocalDateTime deliveryAt(List<InboundOrderDetail.StatusHistorySummary> history) {
return history.stream()
.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 '当前是否扣货',
current_hold_reason VARCHAR(500) NULL COMMENT '当前扣货理由快照',
total_package_count INT UNSIGNED NULL COMMENT '确认后的总箱数',
actual_gross_weight_kg DECIMAL(18,3) NULL COMMENT '下游货物实际毛重kg,汇总已确认称重净重',
actual_gross_weight_kg DECIMAL(18,3) NULL COMMENT '已确认称重图片含托盘毛重汇总kg',
chargeable_weight_kg DECIMAL(18,3) NULL COMMENT '当前费用快照的计费重量kg',
actual_volume_cbm DECIMAL(18,4) NULL COMMENT '确认后的实际总体积m3',
chargeable_volume_cbm DECIMAL(18,4) NULL COMMENT '当前费用快照的计费体积m3',
pallet_count INT UNSIGNED NULL COMMENT '已分配总托盘数快照',
volume_weight_kg DECIMAL(18,3) NULL COMMENT '总材积重kg',
actual_net_weight_kg DECIMAL(18,3) NULL COMMENT '总净重kg',
actual_net_weight_kg DECIMAL(18,3) NULL COMMENT '已确认称重图片扣除托盘皮重后的货物净重汇总kg',
measure_completed_at DATETIME(3) NULL COMMENT '测量完成时间',
inbounded_at DATETIME(3) NULL COMMENT '入库完成时间',
express_company_code VARCHAR(64) NULL COMMENT '快递公司编码快照',
@@ -566,8 +566,8 @@ paths:
由稳定的 item.id(measurementItemId)与 intelligentImageFileId 关联;称重图片只随
weightResults 返回,由稳定的 weightResult.id 与 fileId 关联,并携带确认状态及服务端确认的
grossWeightKg/netWeightKg。两类证据不得合并为无明细归属的统一图片列表。
actualGrossWeightKg 是服务端权威的货物总重量;存在已确认称重结果时为所有已确认
netWeightKg 之和,消费者不得根据图片或 OCR 值自行重算。
actualGrossWeightKg 是服务端确认后的实测毛重,actualNetWeightKg 是扣除托盘皮重后的货物净重;
两者语义独立,消费者不得互相回退,也不得根据图片或 OCR 值自行重算。
parameters:
- {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
@@ -578,6 +578,23 @@ paths:
application/json:
schema: {$ref: '#/components/schemas/InboundMeasurementDetailEnvelope'}
'403': {description: 无入库测量查看权限}
/api/platform/v1/first-leg/inbounds/{inboundId}/measurement/draft:
put:
summary: 保存测量草稿
description: >-
原单据号码、申报毛重和申报体积由服务端从客户订单生成快照,客户端不得提交覆盖;
actualGoodsName 默认使用客户货物品名汇总,但允许测量人员修正。
parameters:
- {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/InboundMeasurementSaveDraftRequest'}
responses:
'200': {description: 成功或业务失败,按响应体 code 判断}
'403': {description: 无测量编辑权限}
/api/platform/v1/first-leg/inbounds/{inboundId}/measurement/carton-scans:
post:
summary: 平台模拟单箱设备扫描
@@ -697,7 +714,7 @@ paths:
platform:firstleg:inbound:weight:edit 权限。修改与确认在同一事务内完成,保留 OCR 原值,
分别写入 CORRECT 和 CONFIRM 修订记录。输入表示含托盘毛重;服务端统一换算为 KG,
使用创建时固化的托盘皮重计算 netWeightKg,且毛重必须大于皮重。
入库单 actualGrossWeightKg 明确保留为下游货物实际毛重字段,值汇总所有已确认 netWeightKg。
入库单 actualGrossWeightKg 为实测毛重;actualNetWeightKg 为扣除托盘皮重后的货物净重,两者不得混用。
parameters:
- {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
@@ -749,7 +766,7 @@ paths:
summary: 删除称重图片及重量结果
description: >-
需要 platform:firstleg:inbound:weight:edit 权限;仅允许在可编辑的测量状态删除。
删除已确认图片后重新汇总剩余已确认图片的净重,删除最后一张时 actualGrossWeightKg 恢复为空。
删除已确认图片后由生产者分别重算剩余确认称重的毛重、净重;消费端不得用净重填充毛重。
parameters:
- {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {name: version, in: query, required: true, schema: {type: integer, format: int32, minimum: 0}}
@@ -779,15 +796,6 @@ paths:
reason: {type: string, maxLength: 1000}
responses:
'200': {description: 成功或业务失败,按响应体 code 判断;审核成功 data.processStatus 固定为 WAIT_SPLIT,initialPricingSucceeded 表示是否同时生成初算费用,初算失败时 feeStatus=UNPRICED 且主流程仍成功}
/api/platform/v1/inbounds/{inboundId}/measurements/reject:
post:
summary: 驳回测量
description: 需要 firstleg:inbound:measurement-review 权限;只退回测量数据修改,已确认托盘和库位占用保持不动。
parameters:
- {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {name: Idempotency-Key, in: header, required: true, schema: {type: string}}
responses:
'200': {description: 成功或业务失败}
/api/platform/v1/inbounds/{inboundId}/measurements/submit:
post:
summary: 提交测量完成
@@ -811,15 +819,6 @@ paths:
application/json:
schema: {$ref: '#/components/schemas/MerchantInboundBillingEnvelope'}
'403': {description: 非当前客户或无订单查看权限}
/api/platform/v1/inbounds/{inboundId}/measurements/exception-release:
post:
summary: 测量异常放行
description: 需要 firstleg:inbound:exception-release 权限
parameters:
- {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {name: Idempotency-Key, in: header, required: true, schema: {type: string}}
responses:
'200': {description: 成功或业务失败}
components:
securitySchemes:
bearerAuth:
@@ -1035,8 +1034,8 @@ components:
currentHoldReason: {type: string, nullable: true}
totalPackageCount: {type: integer, nullable: true}
locationCodes: {type: string, nullable: true}
actualGrossWeightKg: {type: number, nullable: true, multipleOf: 0.001, description: 服务端权威货物总重量 KG;存在已确认称重图片时为全部 CONFIRMED netWeightKg 之和,消费者不得从图片重算}
actualNetWeightKg: {type: number, nullable: true}
actualGrossWeightKg: {type: number, nullable: true, multipleOf: 0.001, description: 服务端确认后的实测毛重 KG;只读取 fl_inbound_order.actual_gross_weight_kg,不以净重回退}
actualNetWeightKg: {type: number, nullable: true, multipleOf: 0.001, description: 服务端确认后的货物净重 KG;只读取 fl_inbound_order.actual_net_weight_kg,不以毛重回退}
actualVolumeCbm: {type: number, nullable: true}
volumeWeightKg: {type: number, nullable: true}
chargeableWeightKg: {type: number, nullable: true}
@@ -1050,8 +1049,10 @@ components:
cabinetNo: {type: string, nullable: true}
vesselName: {type: string, nullable: true}
voyageNo: {type: string, nullable: true}
sailingAt: {type: string, format: date-time, nullable: true}
portArrivalAt: {type: string, format: date-time, nullable: true}
estimatedSailingAt: {type: string, format: date-time, nullable: true, description: 当前关联柜次预计开船时间}
actualSailingAt: {type: string, format: date-time, nullable: true, description: 当前关联柜次实际开船时间}
estimatedPortArrivalAt: {type: string, format: date-time, nullable: true, description: 当前关联柜次预计到港时间}
actualPortArrivalAt: {type: string, format: date-time, nullable: true, description: 当前关联柜次实际到港时间}
expressTrackingNo: {type: string, nullable: true}
pickupMethod: {type: string, enum: [SELF_EXPRESS, PICKUP, SELF_TRUCK], nullable: true}
requestedPickupAt: {type: string, format: date-time, nullable: true, description: 客户要求平台上门提货的预约时间}
@@ -1286,9 +1287,9 @@ components:
measurementStatus: {type: string, nullable: true}
inboundVersion: {type: integer, minimum: 0}
version: {type: integer, minimum: 0, nullable: true}
originalDocumentNo: {type: string, nullable: true}
originalGrossWeightKg: {type: number, format: double, nullable: true}
originalVolumeCbm: {type: number, format: double, nullable: true}
originalDocumentNo: {type: string, nullable: true, description: 客户订单 referenceId 的服务端快照;旧空快照按订单事实回退}
originalGrossWeightKg: {type: number, format: double, nullable: true, description: 客户订单有效货物行 grossWeightKg 合计的服务端快照}
originalVolumeCbm: {type: number, format: double, nullable: true, description: 客户订单有效货物行 volumeCbm 合计的服务端快照}
originalPackageCount: {type: integer, nullable: true}
actualPackageCount: {type: integer, nullable: true}
actualGrossWeightKg:
@@ -1296,10 +1297,16 @@ components:
format: double
nullable: true
multipleOf: 0.001
description: 服务端权威货物总重量 KG;存在已确认称重结果时为所有 CONFIRMED netWeightKg 之和
description: 全部 CONFIRMED 称重结果 grossWeightKg 之和,含托盘毛重 KG;无确认结果时为空
actualNetWeightKg:
type: number
format: double
nullable: true
multipleOf: 0.001
description: 全部 CONFIRMED 称重结果 netWeightKg 之和,扣除托盘皮重后的货物净重 KG;无确认结果时为空,审核与计费以此为准
actualVolumeCbm: {type: number, format: double, nullable: true}
volumeWeightKg: {type: number, format: double, nullable: true}
actualGoodsName: {type: string, nullable: true}
actualGoodsName: {type: string, nullable: true, description: 默认由客户货物品名去重汇总,测量阶段允许人工修正}
items:
type: array
description: DIMENSION 尺寸测量明细及其图片证据
@@ -1308,6 +1315,38 @@ components:
type: array
description: WEIGHT 称重结果及其图片、确认状态和服务端重量事实
items: {$ref: '#/components/schemas/InboundWeightResult'}
InboundMeasurementSaveDraftRequest:
type: object
required: [version, items]
additionalProperties: false
properties:
measurementId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
version: {type: integer, minimum: 0}
actualGoodsName: {type: string, maxLength: 255, nullable: true}
items:
type: array
minItems: 1
maxItems: 500
items: {$ref: '#/components/schemas/InboundMeasurementItemRequest'}
InboundMeasurementItemRequest:
type: object
required: [inboundDetailId, itemNo, boxSpecification, packageCount, lengthCm, widthCm, heightCm, unitWeightKg]
additionalProperties: false
properties:
inboundDetailId: {type: string, pattern: '^[1-9][0-9]*$'}
itemNo: {type: string, minLength: 1, maxLength: 32}
boxSpecification: {type: string, minLength: 1, maxLength: 64}
packageCount: {type: integer, minimum: 1, maximum: 1000000}
lengthCm: {type: number, minimum: 0.001}
widthCm: {type: number, minimum: 0.001}
heightCm: {type: number, minimum: 0.001}
unitWeightKg: {type: number, minimum: 0.001}
intelligentImageFileId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
overLimitWarning: {type: string, maxLength: 500, nullable: true}
outerLengthCm: {type: number, minimum: 0, nullable: true}
outerWidthCm: {type: number, minimum: 0, nullable: true}
outerHeightCm: {type: number, minimum: 0, nullable: true}
remark: {type: string, maxLength: 500, nullable: true}
InboundMeasurementDetailEnvelope:
type: object
required: [code, message]
@@ -23,21 +23,33 @@ class InboundMeasurementEvidenceOpenApiContractTest {
Map<String, Object> schemas = stringMap(stringMap(document.get("components")).get("schemas"));
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")))
.containsEntry("description", "DIMENSION 尺寸测量明细及其图片证据");
assertThat(stringMap(detailProperties.get("weightResults")))
.containsEntry("description", "WEIGHT 称重结果及其图片、确认状态和服务端重量事实");
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");
assertThat(itemProperties).containsKeys("id", "intelligentImageFileId");
Map<String, Object> weightProperties = properties(schemas, "InboundWeightResult");
assertThat(weightProperties).containsKeys(
"id", "fileId", "recognitionStatus", "grossWeightKg", "netWeightKg", "confirmedAt");
assertThat(stringMap(properties(schemas, "UnifiedInboundOrderSummary")
.get("actualGrossWeightKg")).get("description").toString())
.contains("服务端权威货物总重量", "CONFIRMED netWeightKg", "不得从图片重算");
assertThat(stringMap(detailProperties.get("actualGrossWeightKg")).get("description").toString())
.contains("CONFIRMED", "grossWeightKg", "含托盘毛重");
assertThat(stringMap(detailProperties.get("actualNetWeightKg")).get("description").toString())
.contains("CONFIRMED", "netWeightKg", "审核与计费以此为准");
}
private static Map<String, Object> properties(Map<String, Object> schemas, String schemaName) {
@@ -1,7 +1,6 @@
package com.baiyue.firstleg.controller;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.ExceptionReleaseRequest;
import com.baiyue.firstleg.dto.MeasurementApprovalResponse;
import com.baiyue.firstleg.dto.MeasurementReviewRequest;
import com.baiyue.firstleg.service.InitialInboundPricingService;
@@ -24,14 +23,10 @@ import static org.mockito.Mockito.when;
@DisplayName("入库测量审核控制器契约")
class InboundMeasurementReviewContractTest {
@Test
@DisplayName("审核、放行和历史接口声明独立按钮权限")
@DisplayName("审核和历史接口声明独立按钮权限")
void shouldDeclareButtonPermissions() throws Exception {
assertAuthority("approve", "hasAuthority('firstleg:inbound:measurement-review')",
AuthenticatedUser.class, long.class, long.class, String.class, MeasurementReviewRequest.class);
assertAuthority("reject", "hasAuthority('firstleg:inbound:measurement-review')",
AuthenticatedUser.class, long.class, long.class, String.class, MeasurementReviewRequest.class);
assertAuthority("exceptionRelease", "hasAuthority('firstleg:inbound:exception-release')",
AuthenticatedUser.class, long.class, long.class, String.class, ExceptionReleaseRequest.class);
assertAuthority("history", "hasAuthority('firstleg:inbound:measurement-review-history')",
AuthenticatedUser.class, long.class, long.class, long.class, long.class);
}
@@ -43,14 +38,14 @@ class InboundMeasurementReviewContractTest {
.getContentAsString(StandardCharsets.UTF_8);
assertThat(openapi)
.contains("/api/platform/v1/inbounds/{inboundId}/measurements/approve:")
.contains("/api/platform/v1/inbounds/{inboundId}/measurements/reject:")
.contains("firstleg:inbound:exception-release")
.contains("Idempotency-Key")
.contains("初算异常只返回非阻断提示")
.contains("data.processStatus 固定为 WAIT_SPLIT")
.contains("feeStatus=UNPRICED 且主流程仍成功")
.contains("name: inboundId")
.contains("schema: {type: string, pattern:")
.doesNotContain("/api/platform/v1/inbounds/{inboundId}/measurements/reject:")
.doesNotContain("/api/platform/v1/inbounds/{inboundId}/measurements/exception-release:")
.doesNotContain("待客户确认");
}
@@ -51,7 +51,9 @@ class InboundOrderControllerContractTest {
.contains("required: false")
.contains("InboundOrderSummary:")
.contains("required: [id, tenantId]")
.contains("actualNetWeightKg: {type: number, nullable: true}")
.contains("actualNetWeightKg: {type: number, nullable: true")
.contains("只读取 fl_inbound_order.actual_gross_weight_kg,不以净重回退")
.contains("只读取 fl_inbound_order.actual_net_weight_kg,不以毛重回退")
.contains("chargeableVolumeCbm: {type: number, nullable: true}")
.contains("measureCompletedAt: {type: string, format: date-time, nullable: true}")
.contains("pricingTaxMode: {type: string, enum: [TAX_INCLUDED, TAX_EXCLUDED]")
@@ -2,17 +2,38 @@ package com.baiyue.firstleg.controller;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.InboundMeasurementRequests;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.multipart.MultipartFile;
import java.lang.reflect.Method;
import java.lang.reflect.RecordComponent;
import java.util.Arrays;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@DisplayName("平台入库测量接口权限契约")
class PlatformInboundMeasurementControllerContractTest {
@Test
void draftRequestOnlyAcceptsEditableGoodsNameAndMeasurementItems() {
assertThat(Arrays.stream(InboundMeasurementRequests.SaveDraftRequest.class.getRecordComponents())
.map(RecordComponent::getName)).containsExactly(
"measurementId", "version", "actualGoodsName", "items");
}
@Test
void draftRequestRejectsRemovedDeclarationFieldsAtDeserialization() {
String json = """
{"version":0,"originalGrossWeightKg":2000,"actualGoodsName":"货物","items":[]}
""";
assertThatThrownBy(() -> new ObjectMapper().readValue(
json, InboundMeasurementRequests.SaveDraftRequest.class))
.hasMessageContaining("originalGrossWeightKg");
}
@Test
void everyMeasurementOperationUsesDedicatedButtonPermission() throws Exception {
assertAuthority("detail", "hasAuthority('platform:firstleg:inbound:measure:view')",
@@ -41,7 +41,8 @@ class UnifiedInboundOpenApiContractTest {
"holdFlag", "actualGrossWeightKg", "actualNetWeightKg", "actualVolumeCbm",
"volumeWeightKg", "chargeableWeightKg", "chargeableVolumeCbm", "palletCount",
"inboundedAt", "requestedPickupAt", "expressCompanyCode", "expressCompanyName", "containerNo",
"cabinetNo", "sailingAt", "portArrivalAt", "createdByName", "createdAt", "updatedAt");
"cabinetNo", "estimatedSailingAt", "actualSailingAt", "estimatedPortArrivalAt",
"actualPortArrivalAt", "createdByName", "createdAt", "updatedAt");
}
@SuppressWarnings("unchecked")
@@ -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
void keepsConfirmedWeightNetSumAuthoritativeWhenMeasurementIsApproved() {
void keepsConfirmedGrossAndNetSumsSeparateWhenMeasurementIsApproved() {
assertThat(sql("approveMeasurement"))
.contains("FROM fl_inbound_weight_result weight")
.contains("weight.recognition_status = 'CONFIRMED'")
.contains("weight.deleted = 0")
.contains("SUM(weight.net_weight_kg)")
.contains("ELSE (SELECT COALESCE(SUM(item.total_weight_kg), 0)");
.contains("actual_gross_weight_kg = (SELECT SUM(weight.gross_weight_kg)")
.contains("actual_net_weight_kg = (SELECT SUM(weight.net_weight_kg)")
.doesNotContain("item.total_weight_kg");
}
private static String sql(String methodName) {
@@ -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
void confirmedPackageCountUsesCartonWeightAsFallbackWhenNoConfirmedImageExists() {
void confirmedPackageCountDoesNotUseCartonWeightAsWeightFallback() {
InboundOrderEntity inbound = inbound("MEASURE_PENDING");
InboundMeasurementEntity measurement = measurement(30001L, "SUBMITTED", 2);
measurement.setTotalPackageCount(1);
@@ -300,7 +300,8 @@ class CartonMeasurementServiceTest {
service.updateConfirmedPackageCount(10001L, 70001L, 40001L, 2, 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.getVolumeWeightKg()).isEqualByComparingTo("124.916");
verify(repository).updateInbound(inbound);
@@ -309,7 +310,8 @@ class CartonMeasurementServiceTest {
@Test
void cartonMeasurementAfterConfirmedWeightKeepsNetWeightAndUpdatesVolume() {
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);
measurement.setTotalPackageCount(1);
measurement.setTotalVolumeCbm(new BigDecimal("0.0600"));
@@ -323,6 +325,7 @@ class CartonMeasurementServiceTest {
item.setCalculationRuleVersion("ACTUAL_DIMENSION_V1");
InboundWeightResultEntity confirmedWeight = new InboundWeightResultEntity();
confirmedWeight.setRecognitionStatus("CONFIRMED");
confirmedWeight.setGrossWeightKg(new BigDecimal("30.000"));
confirmedWeight.setNetWeightKg(new BigDecimal("11.500"));
when(repository.findInbound(10001L, 70001L)).thenReturn(inbound);
when(repository.findItem(10001L, 40001L)).thenReturn(item);
@@ -335,7 +338,8 @@ class CartonMeasurementServiceTest {
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.getVolumeWeightKg()).isEqualByComparingTo("124.916");
verify(repository).updateInbound(inbound);
@@ -421,7 +421,8 @@ class FbaPricingServiceTest {
value.setChannelCode("MATSON_TRUCK"); value.setCarrierId(31L); value.setCarrierName("船司A");
value.setTaxMode("BUY_DECLARATION"); value.setPricingTaxMode("TAX_INCLUDED");
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;
}
@@ -459,6 +459,7 @@ class FeeManagementServiceTest {
inbound.setDestinationName("ONT8");
inbound.setTotalPackageCount(10);
inbound.setActualGrossWeightKg(new BigDecimal("10.5000"));
inbound.setActualNetWeightKg(new BigDecimal("10.5000"));
inbound.setActualVolumeCbm(new BigDecimal("1.250000"));
inbound.setVolumeWeightKg(new BigDecimal("208.3333"));
inbound.setChargeableWeightKg(new BigDecimal("208.3333"));
@@ -56,8 +56,12 @@ class InboundMeasurementServiceTest {
@BeforeEach
void stubWarehousePalletSnapshots() {
when(repository.updateInboundWeightSummary(any())).thenReturn(true);
when(warehousePallets.palletSnapshots("internal-token", 100L)).thenReturn(
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
@@ -98,6 +102,7 @@ class InboundMeasurementServiceTest {
void correctingConfirmedWeightImmediatelyRemovesItFromInboundTotal() {
InboundOrderEntity inbound = inbound("MEASURING");
inbound.setActualGrossWeightKg(new BigDecimal("12.500"));
inbound.setActualNetWeightKg(new BigDecimal("12.500"));
InboundWeightResultEntity weight = weight(20L, "12.500", "KG", "CONFIRMED", 4);
weight.setGrossWeightKg(new BigDecimal("12.500"));
weight.setNetWeightKg(new BigDecimal("12.500"));
@@ -115,6 +120,7 @@ class InboundMeasurementServiceTest {
assertThat(weight.getGrossWeightKg()).isNull();
assertThat(weight.getNetWeightKg()).isNull();
assertThat(inbound.getActualGrossWeightKg()).isNull();
assertThat(inbound.getActualNetWeightKg()).isNull();
verify(repository).updateInboundWeightSummary(inbound);
}
@@ -134,13 +140,18 @@ class InboundMeasurementServiceTest {
assertThat(result.recommendedPalletType()).isEqualTo("STANDARD");
assertThat(result.inboundVersion()).isEqualTo(7);
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());
}
@Test
void detailKeepsDimensionAndWeightEvidenceOnTheirStableBusinessRecords() {
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();
measurement.setId(30L);
measurement.setVersion(4);
@@ -161,7 +172,8 @@ class InboundMeasurementServiceTest {
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(dimension.id()).isEqualTo(40L);
assertThat(dimension.intelligentImageFileId()).isEqualTo(140L);
@@ -232,6 +244,7 @@ class InboundMeasurementServiceTest {
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
InboundWeightResultEntity current = weight(20L, "10.000", "LB", "PENDING_CONFIRM", 0);
InboundWeightResultEntity previous = weight(21L, "2.000", "KG", "CONFIRMED", 1);
previous.setGrossWeightKg(new BigDecimal("2.000"));
previous.setNetWeightKg(new BigDecimal("2.000"));
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
when(repository.findWeight(1L, 10L, 20L)).thenReturn(current);
@@ -246,6 +259,7 @@ class InboundMeasurementServiceTest {
assertThat(response.grossWeightKg()).isEqualByComparingTo("4.536");
assertThat(response.netWeightKg()).isEqualByComparingTo("4.536");
assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("6.536");
assertThat(inbound.getActualNetWeightKg()).isEqualByComparingTo("6.536");
assertThat(inbound.getProcessStatus()).isEqualTo("MEASURING");
verify(repository).insertRevision(any(InboundWeightRevisionEntity.class));
}
@@ -268,7 +282,8 @@ class InboundMeasurementServiceTest {
assertThat(response.grossWeightKg()).isEqualByComparingTo("30.000");
assertThat(response.netWeightKg()).isEqualByComparingTo("11.500");
assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("11.500");
assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("30.000");
assertThat(inbound.getActualNetWeightKg()).isEqualByComparingTo("11.500");
ArgumentCaptor<InboundWeightRevisionEntity> revision =
ArgumentCaptor.forClass(InboundWeightRevisionEntity.class);
verify(repository).insertRevision(revision.capture());
@@ -276,6 +291,31 @@ class InboundMeasurementServiceTest {
assertThat(revision.getValue().getNetWeightKg()).isEqualByComparingTo("11.500");
}
@Test
void confirmingFifthPalletSeparatelyAggregatesGrossAndCargoNetWeight() {
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
InboundWeightResultEntity current = weight(20L, "357.500", "KG", "PENDING_CONFIRM", 0);
current.setPalletTareWeightKg(new BigDecimal("8.000"));
List<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
void confirmingWeightRejectsGrossWeightNotAboveFrozenTare() {
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
@@ -545,7 +585,7 @@ class InboundMeasurementServiceTest {
verify(repository).deleteWeight(1L, 10L, 20L, 1);
verify(imageClient).delete("internal-token", 9L, 1L, 10L, 120L);
verify(repository, never()).updateInboundWeightSummary(any());
verify(repository).updateInboundWeightSummary(inbound);
}
@Test
@@ -566,6 +606,7 @@ class InboundMeasurementServiceTest {
service.deleteWeightResult(1L, 10L, 20L, 2, 9L);
assertThat(inbound.getActualGrossWeightKg()).isNull();
assertThat(inbound.getActualNetWeightKg()).isNull();
verify(repository).updateInboundWeightSummary(inbound);
}
@@ -574,8 +615,10 @@ class InboundMeasurementServiceTest {
InboundOrderEntity inbound = inbound("MEASURING");
inbound.setActualGrossWeightKg(new BigDecimal("15.000"));
InboundWeightResultEntity deleted = weight(20L, "10.000", "KG", "CONFIRMED", 2);
deleted.setGrossWeightKg(new BigDecimal("10.000"));
deleted.setNetWeightKg(new BigDecimal("10.000"));
InboundWeightResultEntity remaining = weight(21L, "5.000", "KG", "CONFIRMED", 1);
remaining.setGrossWeightKg(new BigDecimal("5.000"));
remaining.setNetWeightKg(new BigDecimal("5.000"));
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
when(repository.findWeight(1L, 10L, 20L)).thenReturn(deleted);
@@ -588,6 +631,7 @@ class InboundMeasurementServiceTest {
service.deleteWeightResult(1L, 10L, 20L, 2, 9L);
assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("5.000");
assertThat(inbound.getActualNetWeightKg()).isEqualByComparingTo("5.000");
verify(repository).updateInboundWeightSummary(inbound);
}
@@ -622,6 +666,7 @@ class InboundMeasurementServiceTest {
item.setPackageCount(3);
item.setSourceImageFileId(500L);
InboundWeightResultEntity weight = weight(20L, "20.000", "KG", "CONFIRMED", 1);
weight.setGrossWeightKg(new BigDecimal("20.000"));
weight.setNetWeightKg(new BigDecimal("20.000"));
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
when(repository.findMeasurement(1L, 10L, 30L)).thenReturn(measurement);
@@ -638,6 +683,7 @@ class InboundMeasurementServiceTest {
assertThat(inbound.getMeasureReviewStatus()).isEqualTo("PENDING");
assertThat(measurement.getStatus()).isEqualTo("SUBMITTED");
assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("20.000");
assertThat(inbound.getActualNetWeightKg()).isEqualByComparingTo("20.000");
assertThat(inbound.getPalletCount()).isEqualTo(1);
assertThat(inbound.getActualVolumeCbm()).isEqualByComparingTo("1.2345");
}
@@ -711,7 +757,7 @@ class InboundMeasurementServiceTest {
when(batchService.saveBatch(any())).thenReturn(saved);
InboundMeasurementResponses.DraftSaved result = service.saveDraft(1L, 10L, 9L,
new InboundMeasurementRequests.SaveDraftRequest(null, 0, null, null, null, null,
new InboundMeasurementRequests.SaveDraftRequest(null, 0, null,
List.of(new InboundMeasurementRequests.ItemRequest(80001L, "001", "A", 2,
new BigDecimal("40"), new BigDecimal("30"), new BigDecimal("20"),
new BigDecimal("3.250"), null, null, null, null, null, null))));
@@ -720,13 +766,38 @@ class InboundMeasurementServiceTest {
ArgumentCaptor<MeasurementBatchCommand> command = ArgumentCaptor.forClass(MeasurementBatchCommand.class);
verify(batchService).saveBatch(command.capture());
assertThat(command.getValue().items().get(0).inboundDetailId()).isEqualTo(80001L);
assertThat(command.getValue().originalDocumentNo()).isEqualTo("REF-50");
assertThat(command.getValue().originalGrossWeightKg()).isEqualByComparingTo("2000");
assertThat(command.getValue().originalVolumeCbm()).isEqualByComparingTo("20");
assertThat(command.getValue().actualGoodsName()).isEqualTo("123123");
verify(orderCargoClient).detail("internal-token", 1L, 80L, 80001L);
}
@Test
void savingDraftRejectsOverlongDefaultGoodsNameBeforeDatabaseWrite() {
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
InboundMeasurementRequests.ItemRequest item = new InboundMeasurementRequests.ItemRequest(
80001L, "001", "A", 2, new BigDecimal("40"), new BigDecimal("30"),
new BigDecimal("20"), new BigDecimal("3.250"), null, null, null, null, null, null);
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
when(orderCargoClient.detail("internal-token", 1L, 80L, 80001L)).thenReturn(
ApiResponse.success(new OrderCargoClient.CargoBinding(
80001L, 80L, 1, "Test", 2, new BigDecimal("6.5"), new BigDecimal("0.1"))));
when(orderCargoClient.measurementDeclaration("internal-token", 1L, 80L)).thenReturn(
ApiResponse.success(new OrderCargoClient.MeasurementDeclarationSnapshot(
"REF", new BigDecimal("6.5"), new BigDecimal("0.1"), "A".repeat(256))));
assertThatThrownBy(() -> service.saveDraft(1L, 10L, 9L,
new InboundMeasurementRequests.SaveDraftRequest(null, 0, null, List.of(item))))
.isInstanceOf(BizException.class).hasMessageContaining("超过255字符");
verify(batchService, never()).saveBatch(any());
}
private InboundOrderEntity inbound(String status) {
InboundOrderEntity inbound = new InboundOrderEntity();
inbound.setId(10L);
inbound.setTenantId(1L);
inbound.setOrderId(80L);
inbound.setWarehouseId(100L);
inbound.setProcessStatus(status);
inbound.setTotalPackageCount(3);
@@ -9,6 +9,8 @@ import com.baiyue.firstleg.entity.InboundMeasurementEntity;
import com.baiyue.firstleg.entity.InboundMeasurementItemEntity;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.entity.InboundStatusHistoryEntity;
import com.baiyue.firstleg.entity.InboundWeightResultEntity;
import com.baiyue.firstleg.repository.InboundMeasurementRepository;
import com.baiyue.firstleg.repository.InboundWorkflowRepository;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.DisplayName;
@@ -41,6 +43,7 @@ import static org.mockito.Mockito.when;
@SpringJUnitConfig(MeasurementReviewService.class)
class MeasurementReviewServiceTest {
@MockBean private InboundWorkflowRepository repository;
@MockBean private InboundMeasurementRepository measurements;
@MockBean private InboundIdempotencyService idempotency;
@MockBean private InboundProperties properties;
@Resource private MeasurementReviewService service;
@@ -212,7 +215,8 @@ class MeasurementReviewServiceTest {
order.setProcessStatus("MEASURE_PENDING");
order.setMeasureReviewStatus("PENDING");
order.setTotalPackageCount(5);
order.setActualGrossWeightKg(new BigDecimal("11.500"));
order.setActualGrossWeightKg(new BigDecimal("1787.500"));
order.setActualNetWeightKg(new BigDecimal("1747.500"));
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
measurement.setId(20L);
measurement.setStatus("SUBMITTED");
@@ -224,9 +228,23 @@ class MeasurementReviewServiceTest {
when(repository.findOwned(10001L, 10L)).thenReturn(order);
when(repository.findMeasurement(10001L, 10L, 20L)).thenReturn(measurement);
when(repository.listMeasurementItems(10001L, 20L)).thenReturn(List.of(item));
when(measurements.listWeights(10001L, 10L)).thenReturn(List.of(
confirmedWeight("357.500", "349.500"),
confirmedWeight("357.500", "349.500"),
confirmedWeight("357.500", "349.500"),
confirmedWeight("357.500", "349.500"),
confirmedWeight("357.500", "349.500")));
return new Fixture(service, repository, idempotency, order, measurement);
}
private InboundWeightResultEntity confirmedWeight(String grossWeightKg, String netWeightKg) {
InboundWeightResultEntity weight = new InboundWeightResultEntity();
weight.setRecognitionStatus("CONFIRMED");
weight.setGrossWeightKg(new BigDecimal(grossWeightKg));
weight.setNetWeightKg(new BigDecimal(netWeightKg));
return weight;
}
private record Fixture(MeasurementReviewService service, InboundWorkflowRepository repository,
InboundIdempotencyService idempotency, InboundOrderEntity order,
InboundMeasurementEntity measurement) {