Files
baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java
T
2026-09-04 10:13:46 +08:00

762 lines
40 KiB
Java

package com.baiyue.firstleg.service;
import jakarta.annotation.Resource;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.OrderCargoClient;
import com.baiyue.firstleg.client.MeasurementImageClient;
import com.baiyue.firstleg.client.WarehousePalletLocationClient;
import com.baiyue.firstleg.dto.InboundMeasurementRequests;
import com.baiyue.firstleg.dto.InboundMeasurementResponses;
import com.baiyue.firstleg.dto.MeasurementBatchCommand;
import com.baiyue.firstleg.entity.InboundMeasurementEntity;
import com.baiyue.firstleg.entity.InboundMeasurementItemEntity;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.entity.InboundWeightResultEntity;
import com.baiyue.firstleg.entity.InboundWeightRevisionEntity;
import com.baiyue.firstleg.entity.MeasureReviewRecordEntity;
import com.baiyue.firstleg.repository.InboundMeasurementRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.beans.factory.annotation.Value;
import feign.FeignException;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Locale;
import java.util.Set;
/** 平台端入库测量、OCR 修正、重量确认和提交审核应用服务。 */
@Service
public class InboundMeasurementService {
/** 测量批次已提交,等待测量审核。 */
private static final String STATUS_SUBMITTED = "SUBMITTED";
/** OCR 或人工修正的重量已确认,可参与计费。 */
private static final String WEIGHT_CONFIRMED = "CONFIRMED";
/** OCR 已识别到有效重量,等待人工确认。 */
private static final String WEIGHT_PENDING_CONFIRM = "PENDING_CONFIRM";
/** OCR 结果无效或不完整,必须人工录入或修正。 */
private static final String WEIGHT_NEEDS_MANUAL = "NEEDS_MANUAL";
private static final Set<String> WEIGHT_EDITABLE_STATUSES = Set.of(
"WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED");
/** 测量完成后的默认普通托盘类型;实际拆托和库位占用由提交编排服务执行。 */
private static final String RECOMMENDED_PALLET_TYPE = "STANDARD";
private static final BigDecimal LB_TO_KG = new BigDecimal("0.45359237");
@Resource
private InboundMeasurementRepository repository;
@Resource
private MeasurementBatchService batchService;
@Resource
private FbaMasterDataService fbaMasterDataService;
@Resource
private OrderCargoClient orderCargoClient;
@Value("${baiyue.security.internal-service-token:}")
private String internalToken;
@Resource
private MeasurementImageClient imageClient;
@Resource
private WarehousePalletLocationClient warehousePallets;
public InboundMeasurementResponses.Detail detail(long tenantId, long inboundId) {
InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
InboundMeasurementEntity measurement = repository.findLatestMeasurement(tenantId, inboundId);
return detail(tenantId, inbound, measurement);
}
public InboundMeasurementResponses.Detail detail(long tenantId, long inboundId, long measurementId) {
InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
InboundMeasurementEntity measurement = repository.findMeasurement(tenantId, inboundId, measurementId);
if (measurement == null) throw new BizException(ResultCode.NOT_FOUND, "费用绑定的测量批次不存在");
return detail(tenantId, inbound, measurement);
}
private InboundMeasurementResponses.Detail detail(long tenantId, InboundOrderEntity inbound,
InboundMeasurementEntity measurement) {
List<InboundMeasurementItemEntity> items = measurement == null
? List.of() : repository.listItems(tenantId, measurement.getId());
List<InboundWeightResultEntity> weights = repository.listWeights(tenantId, inbound.getId());
MeasureReviewRecordEntity review = repository.findLatestReview(tenantId, inbound.getId());
InboundMeasurementResponses.DeclarationSnapshot latestDeclaration = loadLatestDeclaration(inbound);
return toDetail(inbound, measurement, items, weights, review, latestDeclaration);
}
public PageResult<InboundMeasurementResponses.PalletSnapshot> weightPalletOptions(
long tenantId, long inboundId, long page, long size, String keyword) {
InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
PageResult<WarehousePalletLocationClient.PalletSnapshot> source =
loadPalletSnapshotPage(inbound, page, size, keyword);
return new PageResult<>(source.records().stream()
.map(snapshot -> new InboundMeasurementResponses.PalletSnapshot(
snapshot.palletId(), snapshot.palletCode(), snapshot.palletType(),
snapshot.capacityCbm(), snapshot.tareWeightKg()))
.toList(), source.page(), source.size(), source.total(), source.pages());
}
@Transactional
public InboundMeasurementResponses.DraftSaved saveDraft(
long tenantId, long inboundId, long operatorId,
InboundMeasurementRequests.SaveDraftRequest request) {
InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
validateInboundDetails(tenantId, inbound, request.items());
request.items().stream().map(InboundMeasurementRequests.ItemRequest::chargeRuleId)
.filter(java.util.Objects::nonNull).distinct().forEach(ruleId -> {
var rule = fbaMasterDataService.rule(tenantId, ruleId);
if (!"ACTIVE".equals(rule.getStatus()) || !java.util.Objects.equals(rule.getTenantId(), tenantId)) {
throw new BizException(ResultCode.VALIDATION_FAILED, "测量明细绑定的计费规则不属于当前客户或已停用");
}
});
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,
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(),
item.overLimitWarning(), item.outerLengthCm(), item.outerWidthCm(), item.outerHeightCm(),
item.chargeRuleId(), item.coefficientMode(), item.remark())).toList());
InboundMeasurementEntity saved = batchService.saveBatch(command);
return new InboundMeasurementResponses.DraftSaved(saved.getId(), saved.getVersion(), saved.getStatus());
}
@Transactional
public InboundMeasurementResponses.MeasurementSubmitted submit(
long tenantId, long inboundId, long operatorId,
InboundMeasurementRequests.SubmitRequest request) {
InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
if (!Set.of("WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED")
.contains(inbound.getProcessStatus())) {
throw new BizException(ResultCode.CONFLICT, "测量已提交或审核完成,不能重复提交或修改冻结结果");
}
InboundMeasurementEntity measurement = requireMeasurement(tenantId, inboundId, request.measurementId());
requireVersion(request.version(), measurement.getVersion());
if (!Set.of(MeasurementBatchService.STATUS_DRAFT, MeasurementBatchService.STATUS_RECEIVED)
.contains(measurement.getStatus())) {
throw new BizException(ResultCode.CONFLICT, "当前测量批次不能重复提交审核");
}
List<InboundMeasurementItemEntity> items = repository.listItems(tenantId, measurement.getId());
List<InboundWeightResultEntity> weights = repository.listWeights(tenantId, inboundId);
ConfirmedWeightTotals confirmedTotals = confirmedWeightTotals(weights);
requireConfirmedPackageTotal(inbound, items);
measurement.setStatus(STATUS_SUBMITTED);
if (!repository.updateMeasurement(measurement)) {
throw concurrentModification();
}
inbound.setProcessStatus("MEASURE_PENDING");
inbound.setMeasureReviewStatus("PENDING");
inbound.setMeasureCompletedAt(LocalDateTime.now());
inbound.setActualGrossWeightKg(confirmedTotals.grossWeightKg());
inbound.setActualNetWeightKg(confirmedTotals.netWeightKg());
inbound.setPalletCount(confirmedTotals.palletCount());
if (measurement.getTotalVolumeCbm() != null) {
inbound.setActualVolumeCbm(measurement.getTotalVolumeCbm());
}
if (measurement.getVolumeWeightKg() != null) {
inbound.setVolumeWeightKg(measurement.getVolumeWeightKg());
}
inbound.setUpdatedBy(operatorId);
if (!repository.updateInbound(inbound)) {
throw concurrentModification();
}
return new InboundMeasurementResponses.MeasurementSubmitted(
measurement.getId(), measurement.getVersion(), inbound.getProcessStatus(),
inbound.getMeasureReviewStatus(), RECOMMENDED_PALLET_TYPE, null, null, null);
}
@Transactional
public InboundMeasurementResponses.WeightResult createWeightResult(
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, "同一称重图片不能重复计重");
}
WarehousePalletLocationClient.PalletSnapshot pallet = loadPalletSnapshot(
inbound, request.palletId());
validatePalletSnapshot(pallet);
String unit = rawOcrUnit(request.ocrWeightUnit());
InboundWeightResultEntity weight = new InboundWeightResultEntity();
weight.setTenantId(tenantId);
weight.setInboundId(inboundId);
weight.setFileId(request.fileId());
weight.setPalletId(pallet.palletId());
weight.setPalletCode(pallet.palletCode().trim());
weight.setPalletType(pallet.palletType().trim());
weight.setPalletCapacityCbm(pallet.capacityCbm().setScale(4, RoundingMode.HALF_UP));
weight.setPalletTareWeightKg(pallet.tareWeightKg().setScale(3, RoundingMode.HALF_UP));
weight.setOcrProvider(request.ocrProvider());
weight.setOcrRawText(request.ocrRawText());
weight.setOcrWeightValue(request.ocrWeightValue());
weight.setOcrWeightUnit(unit);
weight.setConfidence(normalizeConfidence(request.confidence()));
weight.setRecognitionStatus(isPositive(request.ocrWeightValue()) && isSupportedUnit(unit)
? WEIGHT_PENDING_CONFIRM : WEIGHT_NEEDS_MANUAL);
weight.setCreatedBy(operatorId);
weight.setUpdatedBy(operatorId);
weight.setDeleted(false);
weight.setVersion(0);
repository.insertWeight(weight);
recalculateConfirmedWeight(inbound, operatorId);
return toWeight(weight);
}
@Transactional
public InboundMeasurementResponses.WeightResult correctWeightResult(
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());
applyWeightCorrection(weight, request.version(), request.adjustedWeightValue(),
request.adjustedWeightUnit(), request.adjustmentReason(), operatorId, operatorName);
recalculateConfirmedWeight(inbound, operatorId);
return toWeight(weight);
}
private int applyWeightCorrection(
InboundWeightResultEntity weight, int expectedVersion, BigDecimal adjustedWeightValue,
String adjustedWeightUnit, String adjustmentReason, long operatorId, String operatorName) {
requireCompleteAdjustment(adjustedWeightValue, adjustedWeightUnit, adjustmentReason);
String adjustedUnit = normalizeUnit(adjustedWeightUnit);
BigDecimal beforeValue = effectiveValue(weight);
String beforeUnit = effectiveUnit(weight);
weight.setAdjustedWeightValue(adjustedWeightValue);
weight.setAdjustedWeightUnit(adjustedUnit);
weight.setAdjustmentReason(adjustmentReason.trim());
weight.setAdjustedBy(operatorId);
weight.setAdjustedAt(LocalDateTime.now());
weight.setGrossWeightKg(null);
weight.setNetWeightKg(null);
weight.setConfirmedBy(null);
weight.setConfirmedAt(null);
weight.setRecognitionStatus(WEIGHT_PENDING_CONFIRM);
weight.setUpdatedBy(operatorId);
if (!repository.updateWeight(weight)) {
throw concurrentModification();
}
int correctedVersion = expectedVersion + 1;
weight.setVersion(correctedVersion);
insertRevision(weight, correctedVersion, "CORRECT", beforeValue, beforeUnit,
adjustedWeightValue, adjustedUnit, null, null, adjustmentReason.trim(),
operatorId, operatorName);
return correctedVersion;
}
@Transactional
public InboundMeasurementResponses.WeightResult confirmWeightResult(
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();
if (request.hasAdjustment()) {
requireCompleteAdjustment(request.adjustedWeightValue(), request.adjustedWeightUnit(),
request.adjustmentReason());
requireConfirmableKg(request.adjustedWeightValue(), request.adjustedWeightUnit());
currentVersion = applyWeightCorrection(weight, currentVersion,
request.adjustedWeightValue(), request.adjustedWeightUnit(),
request.adjustmentReason(), operatorId, operatorName);
} else if (WEIGHT_CONFIRMED.equals(weight.getRecognitionStatus())) {
return toWeight(weight);
}
BigDecimal value = effectiveValue(weight);
String rawUnit = effectiveUnit(weight);
if (!isPositive(value) || !StringUtils.hasText(rawUnit)) {
throw new BizException(ResultCode.VALIDATION_FAILED, "OCR 重量无法确认,请先人工修正数值和单位");
}
String unit = normalizeUnit(rawUnit);
BigDecimal grossWeightKg = requireConfirmableKg(value, unit);
BigDecimal netWeightKg = grossWeightKg.subtract(weight.getPalletTareWeightKg())
.setScale(3, RoundingMode.HALF_UP);
if (!isPositive(netWeightKg)) {
throw new BizException(ResultCode.VALIDATION_FAILED,
"称重毛重必须大于托盘皮重");
}
weight.setGrossWeightKg(grossWeightKg);
weight.setNetWeightKg(netWeightKg);
weight.setRecognitionStatus(WEIGHT_CONFIRMED);
weight.setConfirmedBy(operatorId);
weight.setConfirmedAt(LocalDateTime.now());
weight.setUpdatedBy(operatorId);
if (!repository.updateWeight(weight)) {
throw concurrentModification();
}
int confirmedVersion = currentVersion + 1;
weight.setVersion(confirmedVersion);
insertRevision(weight, confirmedVersion, "CONFIRM", value, unit, value, unit,
grossWeightKg, netWeightKg, null, operatorId, operatorName);
recalculateConfirmedWeight(inbound, operatorId);
return toWeight(weight);
}
@Transactional
public void deleteWeightResult(long tenantId, long inboundId, long resultId,
int version, long operatorId) {
InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
requireWeightEditableInbound(inbound);
InboundWeightResultEntity weight = requireWeight(tenantId, inboundId, resultId);
requireVersion(version, weight.getVersion());
if (!repository.deleteWeight(tenantId, inboundId, resultId, version)) {
throw concurrentModification();
}
recalculateConfirmedWeight(inbound, operatorId);
try {
ApiResponse<Void> response = imageClient.delete(
internalToken, operatorId, tenantId, inboundId, weight.getFileId());
if (response == null || !response.successful()) {
String message = response == null || !StringUtils.hasText(response.message())
? "称重图片删除失败" : response.message();
throw new BizException(response == null ? ResultCode.SERVICE_UNAVAILABLE
: ResultCode.of(response.code()), message);
}
} catch (FeignException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "文件服务暂不可用,称重图片未删除");
}
}
private void recalculateConfirmedWeight(InboundOrderEntity inbound, long operatorId) {
ConfirmedWeightTotals totals = confirmedWeightTotals(
repository.listWeights(inbound.getTenantId(), inbound.getId()));
inbound.setActualGrossWeightKg(totals.grossWeightKg());
inbound.setActualNetWeightKg(totals.netWeightKg());
inbound.setPalletCount(totals.palletCount());
if ("WAIT_MEASURE".equals(inbound.getProcessStatus())) {
inbound.setProcessStatus("MEASURING");
}
inbound.setUpdatedBy(operatorId);
if (!repository.updateInboundWeightSummary(inbound)) {
throw concurrentModification();
}
}
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)
.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) {
if (!isPositive(value) || !StringUtils.hasText(unit) || !StringUtils.hasText(reason)) {
throw new BizException(ResultCode.VALIDATION_FAILED,
"人工修改重量时必须同时填写有效重量、单位和修改原因");
}
}
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,
BigDecimal grossWeightKg, BigDecimal netWeightKg,
String reason, long operatorId, String operatorName) {
InboundWeightRevisionEntity revision = new InboundWeightRevisionEntity();
revision.setTenantId(weight.getTenantId());
revision.setInboundId(weight.getInboundId());
revision.setWeightResultId(weight.getId());
revision.setRevisionNo(revisionNo);
revision.setAction(action);
revision.setBeforeWeightValue(beforeValue);
revision.setBeforeWeightUnit(beforeUnit);
revision.setAfterWeightValue(afterValue);
revision.setAfterWeightUnit(afterUnit);
revision.setGrossWeightKg(grossWeightKg);
revision.setNetWeightKg(netWeightKg);
revision.setReason(reason);
revision.setOperatorId(operatorId);
revision.setOperatorName(operatorName);
revision.setOperatedAt(LocalDateTime.now());
repository.insertRevision(revision);
}
private InboundMeasurementResponses.Detail toDetail(
InboundOrderEntity inbound, InboundMeasurementEntity measurement,
List<InboundMeasurementItemEntity> items, List<InboundWeightResultEntity> weights,
MeasureReviewRecordEntity review,
InboundMeasurementResponses.DeclarationSnapshot latestDeclaration) {
return new InboundMeasurementResponses.Detail(
inbound.getId(), inbound.getInboundCode(), inbound.getCompanyName(), inbound.getChannelCode(),
inbound.getChannelName(), inbound.getProcessStatus(), inbound.getMeasureReviewStatus(),
RECOMMENDED_PALLET_TYPE,
review == null ? null : review.getReviewReason(), review == null ? null : review.getReviewerId(),
review == null ? null : review.getReviewerName(), measurement == null ? null : measurement.getId(),
measurement == null ? null : measurement.getSourceType(),
measurement == null ? null : measurement.getStatus(),
inbound.getVersion(),
measurement == null ? null : measurement.getVersion(),
measurement != null && StringUtils.hasText(measurement.getOriginalDocumentNo())
? measurement.getOriginalDocumentNo() : latestDeclaration.referenceId(),
measurement != null && measurement.getOriginalGrossWeightKg() != null
? measurement.getOriginalGrossWeightKg() : latestDeclaration.grossWeightKg(),
measurement != null && measurement.getOriginalVolumeCbm() != null
? measurement.getOriginalVolumeCbm() : latestDeclaration.volumeCbm(),
latestDeclaration.totalPackageCount(),
measurement == null ? inbound.getTotalPackageCount() : measurement.getTotalPackageCount(),
inbound.getActualGrossWeightKg(),
inbound.getActualNetWeightKg(),
measurement == null ? inbound.getActualVolumeCbm() : measurement.getTotalVolumeCbm(),
measurement == null ? inbound.getVolumeWeightKg() : measurement.getVolumeWeightKg(),
measurement != null && StringUtils.hasText(measurement.getActualGoodsName())
? measurement.getActualGoodsName() : latestDeclaration.goodsName(),
latestDeclaration,
items.stream().map(this::toItem).toList(), weights.stream().map(this::toWeight).toList());
}
/**
* Latest customer declaration is a live, read-only Order view. It must never be persisted over
* the measurement batch, carton-label mapping, weight results or review decision.
*/
private InboundMeasurementResponses.DeclarationSnapshot loadLatestDeclaration(
InboundOrderEntity inbound) {
OrderCargoClient.MeasurementDeclarationSnapshot aggregate = loadDeclaration(inbound);
List<OrderCargoClient.CargoLabelBinding> cargoes = loadDeclarationCargoes(inbound);
long packages = cargoes.stream()
.map(OrderCargoClient.CargoLabelBinding::packageCount)
.filter(java.util.Objects::nonNull)
.mapToLong(Integer::longValue)
.sum();
if (packages > Integer.MAX_VALUE) {
throw new BizException(ResultCode.VALIDATION_FAILED, "客户最新申报总箱数超过系统上限");
}
return new InboundMeasurementResponses.DeclarationSnapshot(
aggregate.referenceId(), (int) packages, aggregate.originalGrossWeightKg(),
aggregate.originalVolumeCbm(), aggregate.defaultActualGoodsName(),
cargoes.stream()
.sorted(java.util.Comparator.comparing(OrderCargoClient.CargoLabelBinding::itemNo,
java.util.Comparator.nullsLast(Integer::compareTo))
.thenComparing(OrderCargoClient.CargoLabelBinding::cargoId,
java.util.Comparator.nullsLast(Long::compareTo)))
.map(cargo -> new InboundMeasurementResponses.DeclarationItem(
cargo.cargoId(), cargo.itemNo(), cargo.cargoName(), cargo.packageCount(),
cargo.lengthCm(), cargo.widthCm(), cargo.heightCm()))
.toList());
}
private List<OrderCargoClient.CargoLabelBinding> loadDeclarationCargoes(InboundOrderEntity inbound) {
try {
ApiResponse<List<OrderCargoClient.CargoLabelBinding>> response = orderCargoClient.listForCartonLabels(
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 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) {
if (exception.status() == 400) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE,
"订单服务申报快照契约未就绪,请同步部署 order-service 与 firstleg-service");
}
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(),
item.getLengthCm(), item.getWidthCm(), item.getHeightCm(), item.getGirthCm(),
item.getUnitVolumeCbm(),
item.getTotalVolumeCbm(), item.getVolumeWeightKg(), item.getSourceImageFileId(),
item.getOverLimitWarning(), item.getManualOuterLengthCm(),
item.getManualOuterWidthCm(), item.getManualOuterHeightCm(),
item.getOuterLengthCm(), item.getOuterWidthCm(), item.getOuterHeightCm(),
item.getCalculationRuleVersion(),
item.getChargeRuleId(), item.getCoefficientMode(),
item.getRemark(), item.getBoxCode(), item.getScannedShipmentId(),
item.getPackageCount(),
item.getMachineNo(), item.getDeviceMeasuredAt(), item.getUpdatedAt());
}
private void requireConfirmedPackageTotal(InboundOrderEntity inbound,
List<InboundMeasurementItemEntity> items) {
if (inbound.getTotalPackageCount() == null || inbound.getTotalPackageCount() < 1) {
throw new BizException(ResultCode.VALIDATION_FAILED, "入库单原始总箱数无效,不能提交测量");
}
long confirmed = items.stream()
.map(InboundMeasurementItemEntity::getPackageCount)
.filter(java.util.Objects::nonNull)
.mapToLong(Integer::longValue)
.sum();
if (confirmed != inbound.getTotalPackageCount()) {
throw new BizException(ResultCode.VALIDATION_FAILED,
"人工确认箱数合计" + confirmed + "箱,与入库单总箱数"
+ inbound.getTotalPackageCount() + "箱不一致");
}
}
private InboundMeasurementResponses.WeightResult toWeight(InboundWeightResultEntity weight) {
return new InboundMeasurementResponses.WeightResult(
weight.getId(), weight.getFileId(), weight.getPalletId(), weight.getPalletCode(),
weight.getPalletType(), weight.getPalletCapacityCbm(), weight.getPalletTareWeightKg(),
weight.getOcrProvider(), weight.getOcrRawText(),
weight.getOcrWeightValue(), weight.getOcrWeightUnit(), weight.getConfidence(),
weight.getGrossWeightKg(),
weight.getAdjustedWeightValue(), weight.getAdjustedWeightUnit(),
weight.getNetWeightKg(),
weight.getRecognitionStatus(), weight.getAdjustmentReason(), weight.getAdjustedBy(),
weight.getAdjustedAt(), weight.getConfirmedBy(), weight.getConfirmedAt(), weight.getVersion());
}
private PageResult<WarehousePalletLocationClient.PalletSnapshot> loadPalletSnapshotPage(
InboundOrderEntity inbound, long page, long size, String keyword) {
if (inbound.getWarehouseId() == null || inbound.getWarehouseId() <= 0) {
throw new BizException(ResultCode.CONFLICT, "入库单未绑定有效仓库,无法选择称重托盘");
}
try {
ApiResponse<PageResult<WarehousePalletLocationClient.PalletSnapshot>> response =
warehousePallets.palletSnapshots(internalToken, inbound.getWarehouseId(), page, size, keyword);
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 WarehousePalletLocationClient.PalletSnapshot loadPalletSnapshot(
InboundOrderEntity inbound, long palletId) {
if (inbound.getWarehouseId() == null || inbound.getWarehouseId() <= 0) {
throw new BizException(ResultCode.CONFLICT, "入库单未绑定有效仓库,无法选择称重托盘");
}
try {
ApiResponse<WarehousePalletLocationClient.PalletSnapshot> response = warehousePallets.palletSnapshot(
internalToken, palletId, inbound.getWarehouseId());
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 void validatePalletSnapshot(WarehousePalletLocationClient.PalletSnapshot snapshot) {
if (snapshot.palletId() <= 0 || !StringUtils.hasText(snapshot.palletCode())
|| !StringUtils.hasText(snapshot.palletType()) || !isPositive(snapshot.capacityCbm())
|| snapshot.tareWeightKg() == null
|| snapshot.tareWeightKg().compareTo(BigDecimal.ZERO) < 0) {
throw new BizException(ResultCode.CONFLICT, "仓库服务返回的托盘快照不完整");
}
}
private InboundOrderEntity requireInbound(long tenantId, long inboundId) {
InboundOrderEntity inbound = repository.findInbound(tenantId, inboundId);
if (inbound == null) {
throw new BizException(ResultCode.NOT_FOUND, "入库单不存在");
}
return inbound;
}
private void validateInboundDetails(long tenantId, InboundOrderEntity inbound,
List<InboundMeasurementRequests.ItemRequest> items) {
if (inbound.getOrderId() == null || inbound.getOrderId() <= 0) {
throw new BizException(ResultCode.CONFLICT, "入库单未关联有效订单,不能保存测量明细");
}
for (InboundMeasurementRequests.ItemRequest item : items) {
try {
ApiResponse<OrderCargoClient.CargoBinding> response = orderCargoClient.detail(
internalToken, tenantId, inbound.getOrderId(), item.inboundDetailId());
if (response == null || !response.successful() || response.data() == null) {
String message = response == null || !StringUtils.hasText(response.message())
? "订单货物明细校验失败" : response.message();
throw new BizException(ResultCode.of(response == null
? ResultCode.SERVICE_UNAVAILABLE.code() : response.code()), message);
}
} catch (FeignException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "订单服务暂不可用,无法校验入库明细");
}
}
}
private InboundMeasurementEntity requireMeasurement(long tenantId, long inboundId, long measurementId) {
InboundMeasurementEntity measurement = repository.findMeasurement(tenantId, inboundId, measurementId);
if (measurement == null) {
throw new BizException(ResultCode.NOT_FOUND, "测量批次不存在");
}
return measurement;
}
private InboundWeightResultEntity requireWeight(long tenantId, long inboundId, long resultId) {
InboundWeightResultEntity weight = repository.findWeight(tenantId, inboundId, resultId);
if (weight == null) {
throw new BizException(ResultCode.NOT_FOUND, "OCR 重量结果不存在");
}
return weight;
}
private void requireVersion(Integer expected, Integer actual) {
if (expected == null || !expected.equals(actual)) {
throw concurrentModification();
}
}
private String rawOcrUnit(String unit) {
if (!StringUtils.hasText(unit)) {
return null;
}
return unit.trim().toUpperCase(Locale.ROOT);
}
private BigDecimal normalizeConfidence(BigDecimal confidence) {
if (confidence == null) {
return null;
}
if (confidence.compareTo(BigDecimal.ZERO) < 0
|| confidence.compareTo(BigDecimal.ONE) > 0) {
throw new BizException(ResultCode.VALIDATION_FAILED, "OCR 置信度必须在 0 到 1 之间");
}
return confidence.setScale(5, RoundingMode.HALF_UP);
}
private boolean isSupportedUnit(String unit) {
if (!StringUtils.hasText(unit)) {
return false;
}
String normalized = unit.trim().toUpperCase(Locale.ROOT);
return Set.of("KG", "KGS", "LB", "LBS").contains(normalized);
}
private String normalizeUnit(String unit) {
String normalized = unit.trim().toUpperCase(Locale.ROOT);
if ("KGS".equals(normalized)) {
normalized = "KG";
} else if ("LBS".equals(normalized)) {
normalized = "LB";
}
if (!Set.of("KG", "LB").contains(normalized)) {
throw new BizException(ResultCode.VALIDATION_FAILED, "重量单位仅支持 KG 或 LB");
}
return normalized;
}
private BigDecimal toKg(BigDecimal value, String unit) {
BigDecimal kg = "LB".equals(unit) ? value.multiply(LB_TO_KG) : value;
return kg.setScale(3, RoundingMode.HALF_UP);
}
private BigDecimal requireConfirmableKg(BigDecimal value, String unit) {
BigDecimal finalKg = toKg(value, normalizeUnit(unit));
if (!isPositive(finalKg)) {
throw new BizException(ResultCode.VALIDATION_FAILED,
"换算后重量不能小于 0.001 KG");
}
return finalKg;
}
private BigDecimal effectiveValue(InboundWeightResultEntity weight) {
return weight.getAdjustedWeightValue() == null
? weight.getOcrWeightValue() : weight.getAdjustedWeightValue();
}
private String effectiveUnit(InboundWeightResultEntity weight) {
return weight.getAdjustedWeightUnit() == null
? weight.getOcrWeightUnit() : weight.getAdjustedWeightUnit();
}
private boolean isPositive(BigDecimal value) {
return value != null && value.compareTo(BigDecimal.ZERO) > 0;
}
private BizException concurrentModification() {
return new BizException(ResultCode.CONFLICT, "数据版本已变化,请刷新后重试");
}
}