Files
baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java
T
liuyihe 2b81fd40e7 Merge branch 'test' into test-02huogui
# Conflicts:
#	deploy/sql/头程数据库统一增量-MySQL8.sql
#	firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java
#	firstleg/src/main/resources/application.yml
#	firstleg/src/main/resources/openapi/fba-pricing-v1.yaml
#	firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java
#	order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java
#	order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java
2026-08-07 19:49:09 +08:00

485 lines
24 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.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 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;
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);
return toDetail(inbound, measurement, items, weights, review);
}
@Transactional
public InboundMeasurementResponses.DraftSaved saveDraft(
long tenantId, long inboundId, long operatorId,
InboundMeasurementRequests.SaveDraftRequest request) {
InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
validateInboundDetails(tenantId, inbound, request.items());
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(),
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);
BigDecimal confirmedWeight = confirmedWeightForSubmission(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());
}
if (confirmedWeight != null) {
inbound.setActualGrossWeightKg(confirmedWeight);
}
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);
if (repository.findWeightByFile(tenantId, inboundId, request.fileId()) != null) {
throw new BizException(ResultCode.CONFLICT, "同一称重图片不能重复计重");
}
String unit = rawOcrUnit(request.ocrWeightUnit());
InboundWeightResultEntity weight = new InboundWeightResultEntity();
weight.setTenantId(tenantId);
weight.setInboundId(inboundId);
weight.setFileId(request.fileId());
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);
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);
InboundWeightResultEntity weight = requireWeight(tenantId, inboundId, resultId);
requireVersion(request.version(), weight.getVersion());
applyWeightCorrection(weight, request.version(), request.adjustedWeightValue(),
request.adjustedWeightUnit(), request.adjustmentReason(), operatorId, operatorName);
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.setFinalWeightKg(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, 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);
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 finalKg = requireConfirmableKg(value, unit);
weight.setFinalWeightKg(finalKg);
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,
finalKg, null, operatorId, operatorName);
recalculateConfirmedWeight(inbound, operatorId);
return toWeight(weight);
}
private void recalculateConfirmedWeight(InboundOrderEntity inbound, long operatorId) {
BigDecimal total = repository.listWeights(inbound.getTenantId(), inbound.getId()).stream()
.filter(weight -> WEIGHT_CONFIRMED.equals(weight.getRecognitionStatus()))
.map(InboundWeightResultEntity::getFinalWeightKg)
.reduce(BigDecimal.ZERO, BigDecimal::add)
.setScale(3, RoundingMode.HALF_UP);
inbound.setActualGrossWeightKg(total);
if ("WAIT_MEASURE".equals(inbound.getProcessStatus())) {
inbound.setProcessStatus("MEASURING");
}
inbound.setUpdatedBy(operatorId);
if (!repository.updateInbound(inbound)) {
throw concurrentModification();
}
}
private BigDecimal confirmedWeightForSubmission(List<InboundWeightResultEntity> weights) {
BigDecimal total = weights.stream()
.filter(weight -> WEIGHT_CONFIRMED.equals(weight.getRecognitionStatus()))
.map(InboundWeightResultEntity::getFinalWeightKg)
.filter(java.util.Objects::nonNull)
.reduce(BigDecimal.ZERO, BigDecimal::add).setScale(3, RoundingMode.HALF_UP);
return isPositive(total) ? total : null;
}
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 insertRevision(InboundWeightResultEntity weight, int revisionNo, String action,
BigDecimal beforeValue, String beforeUnit,
BigDecimal afterValue, String afterUnit, BigDecimal finalWeightKg,
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.setFinalWeightKg(finalWeightKg);
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) {
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(),
measurement == null ? null : measurement.getVersion(),
measurement == null ? null : measurement.getOriginalDocumentNo(),
measurement == null ? null : measurement.getOriginalGrossWeightKg(),
measurement == null ? null : measurement.getOriginalVolumeCbm(),
inbound.getTotalPackageCount(),
measurement == null ? inbound.getTotalPackageCount() : measurement.getTotalPackageCount(),
inbound.getActualGrossWeightKg(),
measurement == null ? inbound.getActualVolumeCbm() : measurement.getTotalVolumeCbm(),
measurement == null ? inbound.getVolumeWeightKg() : measurement.getVolumeWeightKg(),
measurement == null ? null : measurement.getActualGoodsName(),
items.stream().map(this::toItem).toList(), weights.stream().map(this::toWeight).toList());
}
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.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.getOcrProvider(), weight.getOcrRawText(),
weight.getOcrWeightValue(), weight.getOcrWeightUnit(), weight.getConfidence(),
weight.getAdjustedWeightValue(), weight.getAdjustedWeightUnit(), weight.getFinalWeightKg(),
weight.getRecognitionStatus(), weight.getAdjustmentReason(), weight.getAdjustedBy(),
weight.getAdjustedAt(), weight.getConfirmedBy(), weight.getConfirmedAt(), weight.getVersion());
}
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, "数据版本已变化,请刷新后重试");
}
}