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()))