This commit is contained in:
2026-08-07 15:53:55 +08:00
parent a4a03a8ae8
commit 3f597677fe
23 changed files with 1157 additions and 2 deletions
@@ -0,0 +1,334 @@
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.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("WAIT_MEASURE", "MEASURE_REJECTED");
private static final BigDecimal CUBIC_CENTIMETERS_PER_CBM = new BigDecimal("1000000");
private static final BigDecimal VOLUME_WEIGHT_FACTOR = new BigDecimal("167");
private static final String CALCULATION_RULE_VERSION = "V1_CBM_CEILING_4_GIRTH_L2WH";
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;
@Value("${baiyue.security.internal-service-token}")
private String internalToken;
public CartonMeasurementService(InboundMeasurementRepository repository,
ObjectProvider<CartonLabelBindingResolver> resolverProvider,
MeasurementImageClient imageClient,
TransactionTemplate transactionTemplate) {
this.repository = repository;
this.resolverProvider = resolverProvider;
this.imageClient = imageClient;
this.transactionTemplate = transactionTemplate;
}
public CartonMeasurementResult upsert(long tenantId, long operatorId, CartonMeasurementCommand command) {
validateCommand(command);
String boxCode = command.boxCode().trim();
CartonLabelBindingResolver.Binding binding = requireBinding(tenantId, boxCode);
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, inbound, boxCode, command, uploaded.fileId()));
} 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的整数");
}
requireDeviceWritableInbound(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);
applyCalculatedValues(item);
item.setUpdatedAt(LocalDateTime.now());
if (!repository.updateItem(item)) {
throw concurrentModification();
}
recalculateMeasurement(measurement, repository.listItems(tenantId, measurement.getId()));
if (!repository.updateMeasurement(measurement)) {
throw concurrentModification();
}
return result(measurement, item, false);
}
private UpsertOutcome persist(long tenantId, long operatorId,
CartonLabelBindingResolver.Binding binding,
InboundOrderEntity inbound, String boxCode,
CartonMeasurementCommand command, long imageFileId) {
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());
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.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.setUnitWeightKg(command.unitWeightKg());
item.setSourceImageFileId(imageFileId);
item.setMachineNo(command.machineNo().trim());
item.setDeviceMeasuredAt(command.measuredAt());
item.setUpdatedAt(LocalDateTime.now());
applyCalculatedValues(item);
if (created) {
repository.insertItem(item);
} else if (!repository.updateItem(item)) {
throw concurrentModification();
}
measurement.setStatus(MeasurementBatchService.STATUS_RECEIVED);
measurement.setSourceType(MeasurementBatchService.SOURCE_THIRD_PARTY);
measurement.setSourceCode("DEVICE");
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) {
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
measurement.setId(IdWorker.getId());
measurement.setTenantId(tenantId);
measurement.setInboundId(inboundId);
measurement.setSourceType(MeasurementBatchService.SOURCE_THIRD_PARTY);
measurement.setSourceCode("DEVICE");
measurement.setStatus(MeasurementBatchService.STATUS_RECEIVED);
measurement.setCreatedBy(operatorId);
measurement.setCreatedAt(LocalDateTime.now());
measurement.setUpdatedAt(LocalDateTime.now());
measurement.setVersion(0);
return measurement;
}
private void applyCalculatedValues(InboundMeasurementItemEntity item) {
BigDecimal unitRaw = item.getLengthCm().multiply(item.getWidthCm()).multiply(item.getHeightCm())
.divide(CUBIC_CENTIMETERS_PER_CBM, 6, RoundingMode.HALF_UP);
BigDecimal unitVolume = unitRaw.setScale(4, RoundingMode.CEILING);
BigDecimal count = BigDecimal.valueOf(item.getPackageCount());
BigDecimal totalRaw = unitRaw.multiply(count).setScale(6, RoundingMode.HALF_UP);
BigDecimal totalVolume = unitVolume.multiply(count).setScale(4, RoundingMode.CEILING);
item.setGirthCm(item.getLengthCm().add(item.getWidthCm().add(item.getHeightCm())
.multiply(BigDecimal.valueOf(2))).setScale(3, RoundingMode.CEILING));
item.setUnitWeightKg(item.getUnitWeightKg() == null ? null
: item.getUnitWeightKg().setScale(3, RoundingMode.CEILING));
item.setUnitVolumeCbm(unitVolume);
item.setVolumeRawCbm(totalRaw);
item.setVolumeCbm(totalVolume);
item.setTotalWeightKg(item.getUnitWeightKg() == null ? null
: item.getUnitWeightKg().multiply(count).setScale(3, RoundingMode.CEILING));
item.setTotalVolumeCbm(totalVolume);
item.setVolumeWeightKg(totalVolume.multiply(VOLUME_WEIGHT_FACTOR).setScale(3, RoundingMode.CEILING));
item.setCalculationRuleVersion(CALCULATION_RULE_VERSION);
}
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 (!DEVICE_WRITABLE_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 (command.unitWeightKg() != null && !positive(command.unitWeightKg())) {
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 (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.getPackageCount(), item.getLengthCm(), item.getWidthCm(),
item.getHeightCm(), item.getUnitWeightKg(), item.getSourceImageFileId(), item.getMachineNo(),
item.getDeviceMeasuredAt(), created);
}
private BizException concurrentModification() {
return new BizException(ResultCode.CONFLICT, "测量数据已被其他人修改,请刷新后重试");
}
private record UpsertOutcome(CartonMeasurementResult result, Long oldImageFileId) {
}
}