680 lines
36 KiB
Java
680 lines
36 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.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 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);
|
|
List<InboundMeasurementItemEntity> items = measurement == null
|
|
? List.of() : repository.listItems(tenantId, measurement.getId());
|
|
List<InboundWeightResultEntity> weights = repository.listWeights(tenantId, inboundId);
|
|
MeasureReviewRecordEntity review = repository.findLatestReview(tenantId, inboundId);
|
|
OrderCargoClient.MeasurementDeclarationSnapshot declaration = needsDeclarationFallback(measurement)
|
|
? loadDeclaration(inbound) : null;
|
|
return toDetail(inbound, measurement, items, weights, review, declaration);
|
|
}
|
|
|
|
public List<InboundMeasurementResponses.PalletSnapshot> weightPalletOptions(
|
|
long tenantId, long inboundId) {
|
|
InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
|
|
return loadPalletSnapshots(inbound).stream()
|
|
.map(snapshot -> new InboundMeasurementResponses.PalletSnapshot(
|
|
snapshot.palletId(), snapshot.palletCode(), snapshot.palletType(),
|
|
snapshot.capacityCbm(), snapshot.tareWeightKg()))
|
|
.toList();
|
|
}
|
|
|
|
@Transactional
|
|
public InboundMeasurementResponses.DraftSaved saveDraft(
|
|
long tenantId, long inboundId, long operatorId,
|
|
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,
|
|
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.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);
|
|
InboundMeasurementEntity measurement = requireMeasurement(tenantId, inboundId, request.measurementId());
|
|
requireVersion(request.version(), measurement.getVersion());
|
|
boolean alreadyAdvanced = !Set.of("WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED")
|
|
.contains(inbound.getProcessStatus());
|
|
if (!alreadyAdvanced && !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);
|
|
|
|
if (!alreadyAdvanced) {
|
|
requireConfirmedPackageTotal(inbound, items);
|
|
measurement.setStatus(STATUS_SUBMITTED);
|
|
if (!repository.updateMeasurement(measurement)) {
|
|
throw concurrentModification();
|
|
}
|
|
}
|
|
|
|
if (!alreadyAdvanced) {
|
|
inbound.setProcessStatus("MEASURE_PENDING");
|
|
inbound.setMeasureReviewStatus("PENDING");
|
|
inbound.setMeasureCompletedAt(LocalDateTime.now());
|
|
}
|
|
if (measurement.getTotalPackageCount() != null) {
|
|
inbound.setTotalPackageCount(measurement.getTotalPackageCount());
|
|
}
|
|
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 = loadPalletSnapshots(inbound).stream()
|
|
.filter(snapshot -> snapshot.palletId() == request.palletId())
|
|
.findFirst()
|
|
.orElseThrow(() -> new BizException(ResultCode.VALIDATION_FAILED,
|
|
"所选托盘不属于当前入库仓库或已被删除"));
|
|
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());
|
|
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, OrderCargoClient.MeasurementDeclarationSnapshot declaration) {
|
|
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() : 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 && 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) {
|
|
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.getUnitWeightKg(), item.getUnitVolumeCbm(), item.getTotalWeightKg(),
|
|
item.getTotalVolumeCbm(), item.getVolumeWeightKg(), item.getSourceImageFileId(),
|
|
item.getOverLimitWarning(), item.getOuterLengthCm(), item.getOuterWidthCm(),
|
|
item.getOuterHeightCm(), 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 List<WarehousePalletLocationClient.PalletSnapshot> loadPalletSnapshots(
|
|
InboundOrderEntity inbound) {
|
|
if (inbound.getWarehouseId() == null || inbound.getWarehouseId() <= 0) {
|
|
throw new BizException(ResultCode.CONFLICT, "入库单未绑定有效仓库,无法选择称重托盘");
|
|
}
|
|
try {
|
|
ApiResponse<List<WarehousePalletLocationClient.PalletSnapshot>> response =
|
|
warehousePallets.palletSnapshots(internalToken, 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, "数据版本已变化,请刷新后重试");
|
|
}
|
|
}
|