Files
baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/service/CartonMeasurementService.java
T

437 lines
23 KiB
Java

package com.baiyue.firstleg.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.MeasurementImageClient;
import com.baiyue.firstleg.dto.CartonMeasurementCommand;
import com.baiyue.firstleg.dto.CartonMeasurementResult;
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.repository.InboundMeasurementRepository;
import feign.FeignException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Set;
/** 单箱当前测量新增/覆盖与人工确认箱数编排。 */
@Service
public class CartonMeasurementService {
private static final Logger log = LoggerFactory.getLogger(CartonMeasurementService.class);
private static final Set<String> DEVICE_WRITABLE_STATUSES = Set.of(
"PREPARING", "WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED");
private static final Set<String> MANUAL_COUNT_EDITABLE_STATUSES = Set.of(
"PREPARING", "WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED");
private static final BigDecimal VOLUME_WEIGHT_FACTOR = new BigDecimal("167");
private static final long MAX_IMAGE_BYTES = 5L * 1024 * 1024;
private final InboundMeasurementRepository repository;
private final ObjectProvider<CartonLabelBindingResolver> resolverProvider;
private final MeasurementImageClient imageClient;
private final TransactionTemplate transactionTemplate;
private final MeasurementBatchService measurementBatchService;
@Value("${baiyue.security.internal-service-token}")
private String internalToken;
/** 设备及手工单箱录入复用测量批次的计算规则。 */
public CartonMeasurementService(InboundMeasurementRepository repository,
ObjectProvider<CartonLabelBindingResolver> resolverProvider,
MeasurementImageClient imageClient,
TransactionTemplate transactionTemplate,
MeasurementBatchService measurementBatchService) {
this.repository = repository;
this.resolverProvider = resolverProvider;
this.imageClient = imageClient;
this.transactionTemplate = transactionTemplate;
this.measurementBatchService = measurementBatchService;
}
public CartonMeasurementResult upsert(long tenantId, long operatorId, CartonMeasurementCommand command) {
return upsert(tenantId, operatorId, null, command,
MeasurementBatchService.SOURCE_THIRD_PARTY, "DEVICE");
}
public CartonMeasurementResult upsertForInbound(long tenantId, long operatorId, long inboundId,
CartonMeasurementCommand command) {
if (inboundId <= 0) {
throw new BizException(ResultCode.BAD_REQUEST, "入库单ID必须大于0");
}
return upsert(tenantId, operatorId, inboundId, command,
MeasurementBatchService.SOURCE_THIRD_PARTY, "DEVICE");
}
public CartonMeasurementResult upsertManualForInbound(long tenantId, long operatorId, long inboundId,
CartonMeasurementCommand command) {
if (inboundId <= 0) {
throw new BizException(ResultCode.BAD_REQUEST, "入库单ID必须大于0");
}
return upsert(tenantId, operatorId, inboundId, command,
MeasurementBatchService.SOURCE_MANUAL, "PLATFORM");
}
private CartonMeasurementResult upsert(long tenantId, long operatorId, Long expectedInboundId,
CartonMeasurementCommand command,
String sourceType, String sourceCode) {
validateCommand(command);
String boxCode = command.boxCode().trim();
CartonLabelBindingResolver.Binding binding = requireBinding(tenantId, boxCode);
if (expectedInboundId != null && binding.inboundId() != expectedInboundId) {
throw new BizException(ResultCode.NOT_FOUND, "箱号不属于当前入库单");
}
InboundOrderEntity inbound = requireDeviceWritableInbound(tenantId, binding.inboundId());
MeasurementImageClient.UploadResult uploaded = uploadImage(
tenantId, operatorId, inbound.getId(), command.image());
UpsertOutcome outcome;
try {
outcome = transactionTemplate.execute(status -> persist(
tenantId, operatorId, binding, boxCode, command, uploaded.fileId(), sourceType, sourceCode));
} catch (RuntimeException exception) {
cleanupImage(tenantId, operatorId, inbound.getId(), uploaded.fileId(), "回传落库失败补偿");
throw exception;
}
if (outcome == null) {
cleanupImage(tenantId, operatorId, inbound.getId(), uploaded.fileId(), "事务未返回结果补偿");
throw new BizException(ResultCode.INTERNAL_ERROR, "单箱测量保存失败");
}
if (outcome.oldImageFileId() != null && !outcome.oldImageFileId().equals(uploaded.fileId())) {
cleanupImage(tenantId, operatorId, inbound.getId(), outcome.oldImageFileId(), "同箱复测旧图清理");
}
return outcome.result();
}
@Transactional
public CartonMeasurementResult updateConfirmedPackageCount(
long tenantId, long inboundId, long itemId, int expectedMeasurementVersion,
int confirmedPackageCount) {
if (confirmedPackageCount < 1 || confirmedPackageCount > 1_000_000) {
throw new BizException(ResultCode.VALIDATION_FAILED, "确认箱数必须是1至1000000的整数");
}
InboundOrderEntity inbound = requireManualCountEditableInbound(tenantId, inboundId);
InboundMeasurementItemEntity item = repository.findItem(tenantId, itemId);
if (item == null) {
throw new BizException(ResultCode.NOT_FOUND, "单箱测量记录不存在");
}
InboundMeasurementEntity measurement = repository.findMeasurement(
tenantId, inboundId, item.getMeasurementId());
if (measurement == null) {
throw new BizException(ResultCode.NOT_FOUND, "单箱测量记录不属于当前入库单");
}
requireVersion(expectedMeasurementVersion, measurement.getVersion());
item.setPackageCount(confirmedPackageCount);
applyCalculatedValuesFromSnapshot(item);
item.setUpdatedAt(LocalDateTime.now());
if (!repository.updateItem(item)) {
throw concurrentModification();
}
List<InboundMeasurementItemEntity> items = repository.listItems(tenantId, measurement.getId());
recalculateMeasurement(measurement, items);
if (!repository.updateMeasurement(measurement)) {
throw concurrentModification();
}
// 提交审核后仍允许平台人工修正代表箱数,但订单原始总箱数不可被测量汇总覆盖。
if ("MEASURE_PENDING".equals(inbound.getProcessStatus())) {
List<InboundWeightResultEntity> confirmedWeights = repository.listWeights(tenantId, inboundId)
.stream()
.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)
.reduce(BigDecimal.ZERO, BigDecimal::add)
.setScale(3, RoundingMode.HALF_UP);
inbound.setActualGrossWeightKg(confirmedGrossWeight);
inbound.setActualNetWeightKg(confirmedNetWeight);
} else {
inbound.setActualGrossWeightKg(null);
inbound.setActualNetWeightKg(null);
}
inbound.setActualVolumeCbm(measurement.getTotalVolumeCbm());
inbound.setVolumeWeightKg(measurement.getVolumeWeightKg());
if (!repository.updateInbound(inbound)) {
throw concurrentModification();
}
}
return result(measurement, item, false);
}
/** 保存设备原始尺寸与证据,按客户默认算法或已保存的行算法生成计费快照。 */
private UpsertOutcome persist(long tenantId, long operatorId,
CartonLabelBindingResolver.Binding binding,
String boxCode, CartonMeasurementCommand command, long imageFileId,
String sourceType, String sourceCode) {
InboundOrderEntity inbound = repository.lockInbound(tenantId, binding.inboundId());
if (inbound == null) {
throw new BizException(ResultCode.NOT_FOUND, "入库单不存在");
}
if (!deviceWritable(inbound, tenantId)) {
throw new BizException(ResultCode.CONFLICT, "测量已提交,设备不能新增或覆盖数据");
}
InboundMeasurementItemEntity item = repository.findItemByBoxCode(tenantId, boxCode);
boolean created = item == null;
InboundMeasurementEntity measurement;
Long oldImageFileId = null;
if (created) {
measurement = repository.findLatestEditableMeasurement(tenantId, inbound.getId());
if (measurement == null) {
measurement = newMeasurement(tenantId, operatorId, inbound.getId(), sourceType, sourceCode);
repository.insertMeasurement(measurement);
}
item = new InboundMeasurementItemEntity();
item.setId(IdWorker.getId());
item.setTenantId(tenantId);
item.setMeasurementId(measurement.getId());
item.setPackageCount(1);
item.setCreatedAt(LocalDateTime.now());
} else {
measurement = repository.findMeasurement(tenantId, inbound.getId(), item.getMeasurementId());
if (measurement == null) {
throw new BizException(ResultCode.CONFLICT, "箱号已绑定到其他入库单");
}
oldImageFileId = item.getSourceImageFileId();
}
item.setInboundDetailId(binding.inboundDetailId());
item.setExternalDetailNo(boxCode);
item.setBoxCode(boxCode);
item.setScannedShipmentId(StringUtils.hasText(command.scannedShipmentId())
? command.scannedShipmentId().trim() : null);
item.setItemNo("B" + binding.cartonIndex());
item.setBoxSpecification(StringUtils.hasText(binding.boxSpecification())
? binding.boxSpecification().trim() : boxCode);
item.setLengthCm(command.lengthCm());
item.setWidthCm(command.widthCm());
item.setHeightCm(command.heightCm());
item.setSourceImageFileId(imageFileId);
item.setMachineNo(command.machineNo().trim());
item.setDeviceMeasuredAt(command.measuredAt());
item.setUpdatedAt(LocalDateTime.now());
measurementBatchService.calculateCartonDimensions(item);
if (created) {
repository.insertItem(item);
} else if (!repository.updateItem(item)) {
throw concurrentModification();
}
measurement.setStatus(MeasurementBatchService.STATUS_RECEIVED);
measurement.setSourceType(sourceType);
measurement.setSourceCode(sourceCode);
measurement.setDeviceCode(command.machineNo().trim());
measurement.setMeasuredAt(command.measuredAt());
measurement.setUpdatedAt(LocalDateTime.now());
recalculateMeasurement(measurement, repository.listItems(tenantId, measurement.getId()));
if (!repository.updateMeasurement(measurement)) {
throw concurrentModification();
}
return new UpsertOutcome(result(measurement, item, created), oldImageFileId);
}
private InboundMeasurementEntity newMeasurement(long tenantId, long operatorId, long inboundId,
String sourceType, String sourceCode) {
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
measurement.setId(IdWorker.getId());
measurement.setTenantId(tenantId);
measurement.setInboundId(inboundId);
measurement.setSourceType(sourceType);
measurement.setSourceCode(sourceCode);
measurement.setStatus(MeasurementBatchService.STATUS_RECEIVED);
measurement.setCreatedBy(operatorId);
measurement.setCreatedAt(LocalDateTime.now());
measurement.setUpdatedAt(LocalDateTime.now());
measurement.setVersion(0);
return measurement;
}
/** 仅修改确认箱数时复用已保存的单箱快照,不重新套用当前客户规则。 */
private void applyCalculatedValuesFromSnapshot(InboundMeasurementItemEntity item) {
if (!positive(item.getOuterLengthCm()) || !positive(item.getOuterWidthCm())
|| !positive(item.getOuterHeightCm()) || !positive(item.getUnitVolumeCbm())
|| !StringUtils.hasText(item.getCalculationRuleVersion())) {
measurementBatchService.calculateCartonDimensions(item);
return;
}
BigDecimal count = BigDecimal.valueOf(item.getPackageCount());
BigDecimal totalRaw = item.getOuterLengthCm().multiply(item.getOuterWidthCm())
.multiply(item.getOuterHeightCm()).divide(new BigDecimal("1000000"), 6, RoundingMode.HALF_UP)
.multiply(count).setScale(6, RoundingMode.HALF_UP);
BigDecimal totalVolume = item.getUnitVolumeCbm().multiply(count)
.setScale(4, RoundingMode.UNNECESSARY);
item.setVolumeRawCbm(totalRaw);
item.setVolumeCbm(totalVolume);
item.setTotalVolumeCbm(totalVolume);
item.setVolumeWeightKg(totalVolume.multiply(VOLUME_WEIGHT_FACTOR).setScale(3, RoundingMode.CEILING));
}
private void recalculateMeasurement(InboundMeasurementEntity measurement,
List<InboundMeasurementItemEntity> items) {
long packages = items.stream().mapToLong(InboundMeasurementItemEntity::getPackageCount).sum();
if (packages > Integer.MAX_VALUE) {
throw new BizException(ResultCode.VALIDATION_FAILED, "测量确认总箱数超过系统上限");
}
BigDecimal rawVolume = items.stream().map(InboundMeasurementItemEntity::getVolumeRawCbm)
.reduce(BigDecimal.ZERO, BigDecimal::add).setScale(6, RoundingMode.HALF_UP);
BigDecimal totalVolume = items.stream().map(InboundMeasurementItemEntity::getTotalVolumeCbm)
.reduce(BigDecimal.ZERO, BigDecimal::add).setScale(4, RoundingMode.CEILING);
measurement.setTotalPackageCount((int) packages);
measurement.setTotalVolumeRawCbm(rawVolume);
measurement.setTotalVolumeCbm(totalVolume);
measurement.setVolumeWeightKg(totalVolume.multiply(VOLUME_WEIGHT_FACTOR)
.setScale(3, RoundingMode.CEILING));
}
private CartonLabelBindingResolver.Binding requireBinding(long tenantId, String boxCode) {
CartonLabelBindingResolver resolver = resolverProvider.getIfAvailable();
if (resolver == null) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "箱号映射服务暂不可用");
}
CartonLabelBindingResolver.Binding binding = resolver.resolveByBoxCode(tenantId, boxCode);
if (binding == null) {
throw new BizException(ResultCode.NOT_FOUND, "箱号不存在或不属于当前客户");
}
return binding;
}
private InboundOrderEntity requireDeviceWritableInbound(long tenantId, long inboundId) {
InboundOrderEntity inbound = repository.findInbound(tenantId, inboundId);
if (inbound == null) {
throw new BizException(ResultCode.NOT_FOUND, "入库单不存在");
}
if (!deviceWritable(inbound, tenantId)) {
throw new BizException(ResultCode.CONFLICT, "测量已提交,设备不能新增或覆盖数据");
}
return inbound;
}
/**
* 设备回传门禁以入库流程状态为主,并用测量批次状态兜底校验提交语义。
* RECEIVED/DRAFT 只是设备资料,不能把处于 WAIT_MEASURE 的入库单误判为已提交;
* SUBMITTED/APPROVED 则必须继续保留设备覆盖保护,即使旧数据的主状态快照滞后。
*/
private boolean deviceWritable(InboundOrderEntity inbound, long tenantId) {
if (!DEVICE_WRITABLE_STATUSES.contains(inbound.getProcessStatus())) {
return false;
}
InboundMeasurementEntity latest = repository.findLatestMeasurement(tenantId, inbound.getId());
return latest == null || !Set.of("SUBMITTED", "APPROVED").contains(latest.getStatus());
}
private InboundOrderEntity requireManualCountEditableInbound(long tenantId, long inboundId) {
InboundOrderEntity inbound = repository.findInbound(tenantId, inboundId);
if (inbound == null) {
throw new BizException(ResultCode.NOT_FOUND, "入库单不存在");
}
if (!MANUAL_COUNT_EDITABLE_STATUSES.contains(inbound.getProcessStatus())) {
throw new BizException(ResultCode.CONFLICT, "当前入库单状态不允许修改确认箱数");
}
return inbound;
}
private MeasurementImageClient.UploadResult uploadImage(long tenantId, long operatorId,
long inboundId, MultipartFile image) {
try {
ApiResponse<MeasurementImageClient.UploadResult> response = imageClient.upload(
internalToken, operatorId, tenantId, inboundId, image);
if (response == null || !response.successful() || response.data() == null
|| response.data().fileId() == null) {
throw downstream(response, "测量图片保存失败");
}
return response.data();
} catch (FeignException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "文件服务暂不可用,测量数据未保存");
}
}
private void cleanupImage(long tenantId, long operatorId, long inboundId, long fileId, String action) {
try {
imageClient.delete(internalToken, operatorId, tenantId, inboundId, fileId);
} catch (RuntimeException exception) {
log.warn("{}失败 tenantId={}, inboundId={}, fileId={}", action, tenantId, inboundId, fileId, exception);
}
}
private BizException downstream(ApiResponse<?> response, String fallback) {
if (response == null) {
return new BizException(ResultCode.SERVICE_UNAVAILABLE, fallback);
}
String message = StringUtils.hasText(response.message()) ? response.message() : fallback;
return new BizException(ResultCode.of(response.code()), message);
}
private void validateCommand(CartonMeasurementCommand command) {
if (command == null || !StringUtils.hasText(command.boxCode())
|| command.boxCode().trim().length() > 128) {
throw new BizException(ResultCode.VALIDATION_FAILED, "箱号不能为空且不能超过128个字符");
}
if (!positive(command.lengthCm()) || !positive(command.widthCm()) || !positive(command.heightCm())) {
throw new BizException(ResultCode.VALIDATION_FAILED, "长、宽、高必须大于0");
}
if (!StringUtils.hasText(command.machineNo()) || command.machineNo().trim().length() > 64) {
throw new BizException(ResultCode.VALIDATION_FAILED, "设备编号不能为空且不能超过64个字符");
}
if (StringUtils.hasText(command.scannedShipmentId())
&& command.scannedShipmentId().trim().length() > 64) {
throw new BizException(ResultCode.VALIDATION_FAILED, "实扫Shipment ID不能超过64个字符");
}
if (command.measuredAt() == null) {
throw new BizException(ResultCode.VALIDATION_FAILED, "设备测量时间不能为空");
}
if (command.image() == null || command.image().isEmpty()) {
throw new BizException(ResultCode.VALIDATION_FAILED, "测量图片不能为空");
}
if (command.image().getSize() > MAX_IMAGE_BYTES) {
throw new BizException(ResultCode.VALIDATION_FAILED, "测量图片不能超过5MB");
}
}
private boolean positive(BigDecimal value) {
return value != null && value.compareTo(BigDecimal.ZERO) > 0;
}
private void requireVersion(int expected, Integer actual) {
if (actual == null || expected != actual) {
throw concurrentModification();
}
}
private CartonMeasurementResult result(InboundMeasurementEntity measurement,
InboundMeasurementItemEntity item, boolean created) {
return new CartonMeasurementResult(measurement.getId(), measurement.getVersion(), item.getId(),
item.getBoxCode(), item.getScannedShipmentId(), item.getPackageCount(),
item.getLengthCm(), item.getWidthCm(),
item.getHeightCm(), item.getSourceImageFileId(), item.getMachineNo(),
item.getDeviceMeasuredAt(), created);
}
private BizException concurrentModification() {
return new BizException(ResultCode.CONFLICT, "测量数据已被其他人修改,请刷新后重试");
}
private record UpsertOutcome(CartonMeasurementResult result, Long oldImageFileId) {
}
}