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
@@ -7,6 +7,7 @@ import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader;
import java.math.BigDecimal;
import java.util.List;
@FeignClient(name = "order-service", contextId = "firstlegOrderCargoClient",
url = "${baiyue.clients.order.url:}", path = "/internal/order/v1/cargoes")
@@ -17,8 +18,19 @@ public interface OrderCargoClient {
@PathVariable long orderId,
@PathVariable long cargoId);
@GetMapping("/{tenantId}/{orderId}")
ApiResponse<List<CargoLabelBinding>> listForCartonLabels(
@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable long tenantId,
@PathVariable long orderId);
record CargoBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
Integer originalPackageCount, BigDecimal originalGrossWeightKg,
BigDecimal originalVolumeCbm) {
}
record CargoLabelBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
Integer packageCount, BigDecimal lengthCm, BigDecimal widthCm,
BigDecimal heightCm) {
}
}
@@ -9,6 +9,8 @@ import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.InboundMeasurementRequests;
import com.baiyue.firstleg.dto.InboundMeasurementResponses;
import com.baiyue.firstleg.dto.CartonMeasurementResult;
import com.baiyue.firstleg.service.CartonMeasurementService;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import com.baiyue.firstleg.service.InboundMeasurementService;
import com.baiyue.firstleg.service.MeasurementSubmissionService;
@@ -33,6 +35,7 @@ import org.springframework.web.bind.annotation.RestController;
public class PlatformInboundMeasurementController {
@Resource private InboundMeasurementService service;
@Resource private MeasurementSubmissionService submission;
@Resource private CartonMeasurementService cartonMeasurements;
@Operation(summary = "查询入库测量详情", description = "返回原单、实测箱规、审核快照和 OCR 重量结果。")
@GetMapping("/measurement")
@@ -74,6 +77,24 @@ public class PlatformInboundMeasurementController {
return ApiResponse.success(submission.submit(tenantId, inboundId, user.userId(), request));
}
@BusinessAudit(event = "INBOUND_CARTON_MEASUREMENT_COUNT_CONFIRM",
resourceType = "INBOUND_MEASUREMENT_ITEM", resourceId = "#itemId",
description = "确认单箱测量记录代表箱数")
@Operation(summary = "确认单箱测量记录代表箱数",
description = "只修改人工确认箱数并由服务端重算当前测量批次汇总。")
@PutMapping("/measurement/items/{itemId}/confirmed-package-count")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:measure:edit')")
public ApiResponse<CartonMeasurementResult> updateConfirmedPackageCount(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId,
@PathVariable long inboundId,
@PathVariable long itemId,
@Valid @RequestBody InboundMeasurementRequests.ConfirmedPackageCountRequest request) {
requirePlatformTenant(user, tenantId);
return ApiResponse.success(cartonMeasurements.updateConfirmedPackageCount(
tenantId, inboundId, itemId, request.measurementVersion(), request.confirmedPackageCount()));
}
@BusinessAudit(event = "INBOUND_WEIGHT_OCR_RECORD", resourceType = "INBOUND",
resourceId = "#inboundId", description = "登记称重图片 OCR 原值")
@Operation(summary = "登记 OCR 重量结果", description = "按图片文件 ID 保存不可覆盖的 OCR 原值。")
@@ -0,0 +1,19 @@
package com.baiyue.firstleg.dto;
import org.springframework.web.multipart.MultipartFile;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/** 已完成外部协议转换后的单箱设备测量命令。 */
public record CartonMeasurementCommand(
String boxCode,
BigDecimal lengthCm,
BigDecimal widthCm,
BigDecimal heightCm,
BigDecimal unitWeightKg,
String machineNo,
LocalDateTime measuredAt,
MultipartFile image
) {
}
@@ -0,0 +1,22 @@
package com.baiyue.firstleg.dto;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/** 单箱当前测量结果;confirmedPackageCount 为平台人工确认的代表箱数。 */
public record CartonMeasurementResult(
Long measurementId,
Integer measurementVersion,
Long itemId,
String boxCode,
Integer confirmedPackageCount,
BigDecimal lengthCm,
BigDecimal widthCm,
BigDecimal heightCm,
BigDecimal unitWeightKg,
Long imageFileId,
String machineNo,
LocalDateTime measuredAt,
boolean created
) {
}
@@ -0,0 +1,26 @@
package com.baiyue.firstleg.dto;
import java.math.BigDecimal;
import java.util.List;
/** 稳定箱号分配、补打和测量解析契约。 */
public final class InboundCartonLabels {
private InboundCartonLabels() {
}
public record Batch(Long inboundId, Long orderId, String inboundCode, Integer totalCount,
List<Label> labels) {
}
public record Label(Long id, Long inboundDetailId, Integer cargoItemNo, String cargoName,
Integer detailPackageCount, String boxCode, Integer totalCount,
Integer cartonIndex, String boxSpecification, BigDecimal lengthCm,
BigDecimal widthCm, BigDecimal heightCm, String topCode,
String warehouseText, String cartonText, String routeText) {
}
public record Resolution(Long inboundId, Long orderId, Long inboundDetailId, String boxCode,
Integer totalCount, Integer cartonIndex, Integer cargoItemNo,
String cargoName, String boxSpecification) {
}
}
@@ -56,6 +56,12 @@ public final class InboundMeasurementRequests {
) {
}
public record ConfirmedPackageCountRequest(
@NotNull @PositiveOrZero Integer measurementVersion,
@NotNull @Positive @Max(1000000) Integer confirmedPackageCount
) {
}
public record CreateWeightResultRequest(
@NotNull @Positive Long fileId,
@Size(max = 64) String ocrProvider,
@@ -63,7 +63,12 @@ public final class InboundMeasurementResponses {
BigDecimal outerLengthCm,
BigDecimal outerWidthCm,
BigDecimal outerHeightCm,
String remark
String remark,
String boxCode,
Integer confirmedPackageCount,
String machineNo,
LocalDateTime deviceMeasuredAt,
LocalDateTime updatedAt
) {
}
@@ -0,0 +1,35 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/** 首次打印前冻结的实际箱号与订单货物明细映射。 */
@Getter
@Setter
@TableName("fl_inbound_carton_label")
public class InboundCartonLabelEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private Long inboundId;
private Long orderId;
private Long inboundDetailId;
private Integer cargoItemNo;
private String cargoName;
private Integer detailPackageCount;
private String inboundCode;
private String boxCode;
private Integer totalCartonCount;
private Integer cartonIndex;
private String boxSpecification;
private BigDecimal lengthCm;
private BigDecimal widthCm;
private BigDecimal heightCm;
private LocalDateTime createdAt;
}
@@ -20,6 +20,7 @@ public class InboundMeasurementItemEntity {
private Long measurementId;
private Long inboundDetailId;
private String externalDetailNo;
private String boxCode;
private String itemNo;
private String boxSpecification;
private Integer packageCount;
@@ -36,10 +37,13 @@ public class InboundMeasurementItemEntity {
private BigDecimal volumeWeightKg;
private String calculationRuleVersion;
private Long sourceImageFileId;
private String machineNo;
private LocalDateTime deviceMeasuredAt;
private String overLimitWarning;
private BigDecimal outerLengthCm;
private BigDecimal outerWidthCm;
private BigDecimal outerHeightCm;
private String remark;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,9 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.InboundCartonLabelEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface InboundCartonLabelMapper extends BaseMapper<InboundCartonLabelEntity> {
}
@@ -0,0 +1,42 @@
package com.baiyue.firstleg.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baiyue.firstleg.entity.InboundCartonLabelEntity;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.mapper.InboundCartonLabelMapper;
import com.baiyue.firstleg.mapper.InboundOrderMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public class InboundCartonLabelRepository {
@Resource private InboundOrderMapper inbounds;
@Resource private InboundCartonLabelMapper labels;
public InboundOrderEntity lockInbound(long tenantId, long inboundId) {
return inbounds.selectOne(new LambdaQueryWrapper<InboundOrderEntity>()
.eq(InboundOrderEntity::getTenantId, tenantId)
.eq(InboundOrderEntity::getId, inboundId)
.eq(InboundOrderEntity::getDeleted, false)
.last("FOR UPDATE"));
}
public List<InboundCartonLabelEntity> findByInbound(long tenantId, long inboundId) {
return labels.selectList(new LambdaQueryWrapper<InboundCartonLabelEntity>()
.eq(InboundCartonLabelEntity::getTenantId, tenantId)
.eq(InboundCartonLabelEntity::getInboundId, inboundId)
.orderByAsc(InboundCartonLabelEntity::getCartonIndex));
}
public InboundCartonLabelEntity findByBoxCode(long tenantId, String boxCode) {
return labels.selectOne(new LambdaQueryWrapper<InboundCartonLabelEntity>()
.eq(InboundCartonLabelEntity::getTenantId, tenantId)
.eq(InboundCartonLabelEntity::getBoxCode, boxCode));
}
public void insert(InboundCartonLabelEntity label) {
labels.insert(label);
}
}
@@ -70,6 +70,37 @@ public class InboundMeasurementRepository {
.orderByAsc(InboundMeasurementItemEntity::getItemNo));
}
public InboundMeasurementItemEntity findItem(long tenantId, long itemId) {
return items.selectOne(new LambdaQueryWrapper<InboundMeasurementItemEntity>()
.eq(InboundMeasurementItemEntity::getTenantId, tenantId)
.eq(InboundMeasurementItemEntity::getId, itemId));
}
public InboundMeasurementItemEntity findItemByBoxCode(long tenantId, String boxCode) {
return items.selectOne(new LambdaQueryWrapper<InboundMeasurementItemEntity>()
.eq(InboundMeasurementItemEntity::getTenantId, tenantId)
.eq(InboundMeasurementItemEntity::getBoxCode, boxCode));
}
public void insertItem(InboundMeasurementItemEntity item) {
items.insert(item);
}
public boolean updateItem(InboundMeasurementItemEntity item) {
return items.update(item, new LambdaUpdateWrapper<InboundMeasurementItemEntity>()
.eq(InboundMeasurementItemEntity::getTenantId, item.getTenantId())
.eq(InboundMeasurementItemEntity::getId, item.getId())) == 1;
}
public InboundMeasurementEntity findLatestEditableMeasurement(long tenantId, long inboundId) {
return measurements.selectOne(new LambdaQueryWrapper<InboundMeasurementEntity>()
.eq(InboundMeasurementEntity::getTenantId, tenantId)
.eq(InboundMeasurementEntity::getInboundId, inboundId)
.in(InboundMeasurementEntity::getStatus, "DRAFT", "RECEIVED", "REJECTED")
.orderByDesc(InboundMeasurementEntity::getCreatedAt)
.last("LIMIT 1"));
}
public void insertMeasurement(InboundMeasurementEntity measurement) {
measurements.insert(measurement);
}
@@ -0,0 +1,12 @@
package com.baiyue.firstleg.service;
/**
* 箱号映射查询端口。标签模块负责提供实现,测量模块不解析或推导箱号业务归属。
*/
public interface CartonLabelBindingResolver {
Binding resolveByBoxCode(long tenantId, String boxCode);
record Binding(long inboundId, long inboundDetailId, String inboundCode,
int cartonIndex, int totalCartons, String boxSpecification) {
}
}
@@ -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) {
}
}
@@ -0,0 +1,209 @@
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.OrderCargoClient;
import com.baiyue.firstleg.dto.InboundCartonLabels;
import com.baiyue.firstleg.entity.InboundCartonLabelEntity;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.repository.InboundCartonLabelRepository;
import feign.FeignException;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/** 首次生成后不可变的箱号分配;重打和测量解析只读取该映射。 */
@Service
public class InboundCartonLabelService {
private static final int MAX_LABELS = 2000;
private static final int MAX_QR_CODE_POINTS = 34;
private static final Set<String> ALLOCATABLE_STATUSES = Set.of("WAIT_MEASURE", "MEASURING");
@Resource private InboundCartonLabelRepository repository;
@Resource private OrderCargoClient orderCargoClient;
@Value("${baiyue.security.internal-service-token}") private String internalToken;
@Transactional
public InboundCartonLabels.Batch getOrCreate(long tenantId, long inboundId) {
requireIds(tenantId, inboundId);
InboundOrderEntity inbound = repository.lockInbound(tenantId, inboundId);
if (inbound == null) throw new BizException(ResultCode.NOT_FOUND, "入库单不存在");
List<InboundCartonLabelEntity> existing = new ArrayList<>(repository.findByInbound(tenantId, inboundId));
if (!existing.isEmpty()) return batch(inbound, requireConsistent(existing));
if (!ALLOCATABLE_STATUSES.contains(inbound.getProcessStatus())) {
throw new BizException(ResultCode.CONFLICT, "当前入库单状态不允许首次生成箱号");
}
List<OrderCargoClient.CargoLabelBinding> cargoes = loadCargoes(inbound);
int total = cargoes.stream().mapToInt(this::requirePackageCount).sum();
if (total < 1 || total > MAX_LABELS) {
throw new BizException(ResultCode.VALIDATION_FAILED, "唛头箱数必须为1至2000箱");
}
requirePrintableInboundCode(inbound.getInboundCode(), total);
int cartonIndex = 0;
LocalDateTime now = LocalDateTime.now();
for (OrderCargoClient.CargoLabelBinding cargo : cargoes) {
int detailCount = requirePackageCount(cargo);
for (int offset = 0; offset < detailCount; offset++) {
cartonIndex++;
InboundCartonLabelEntity label = label(inbound, cargo, total, cartonIndex, now);
repository.insert(label);
existing.add(label);
}
}
return batch(inbound, List.copyOf(existing));
}
@Transactional
public InboundCartonLabels.Batch list(long tenantId, long inboundId, List<Integer> cartonIndexes) {
InboundCartonLabels.Batch batch = getOrCreate(tenantId, inboundId);
if (cartonIndexes == null || cartonIndexes.isEmpty()) return batch;
Set<Integer> selected = new HashSet<>(cartonIndexes);
if (selected.stream().anyMatch(index -> index == null || index < 1 || index > batch.totalCount())) {
throw new BizException(ResultCode.BAD_REQUEST, "补打箱下标超出有效范围");
}
List<InboundCartonLabels.Label> labels = batch.labels().stream()
.filter(label -> selected.contains(label.cartonIndex()))
.toList();
return new InboundCartonLabels.Batch(batch.inboundId(), batch.orderId(), batch.inboundCode(),
batch.totalCount(), labels);
}
public InboundCartonLabels.Resolution resolveByBoxCode(long tenantId, String boxCode) {
if (tenantId <= 0 || !StringUtils.hasText(boxCode)) {
throw new BizException(ResultCode.BAD_REQUEST, "客户ID和箱号不能为空");
}
InboundCartonLabelEntity label = repository.findByBoxCode(tenantId, boxCode.trim());
if (label == null) throw new BizException(ResultCode.NOT_FOUND, "箱号不存在");
return new InboundCartonLabels.Resolution(label.getInboundId(), label.getOrderId(),
label.getInboundDetailId(), label.getBoxCode(), label.getTotalCartonCount(),
label.getCartonIndex(), label.getCargoItemNo(), label.getCargoName(),
label.getBoxSpecification());
}
private List<OrderCargoClient.CargoLabelBinding> loadCargoes(InboundOrderEntity inbound) {
try {
ApiResponse<List<OrderCargoClient.CargoLabelBinding>> response = orderCargoClient.listForCartonLabels(
internalToken, inbound.getTenantId(), inbound.getOrderId());
if (response == null || !response.successful() || response.data() == null) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "订单货物明细查询失败");
}
if (response.data().isEmpty()) {
throw new BizException(ResultCode.VALIDATION_FAILED, "订单没有可生成唛头的货物明细");
}
return response.data().stream()
.sorted(java.util.Comparator.comparing(OrderCargoClient.CargoLabelBinding::itemNo)
.thenComparing(OrderCargoClient.CargoLabelBinding::cargoId))
.toList();
} catch (FeignException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "订单服务暂不可用,无法生成唛头");
}
}
private int requirePackageCount(OrderCargoClient.CargoLabelBinding cargo) {
if (cargo.cargoId() == null || cargo.cargoId() <= 0 || cargo.itemNo() == null
|| cargo.itemNo() < 1 || cargo.packageCount() == null || cargo.packageCount() < 1) {
throw new BizException(ResultCode.VALIDATION_FAILED, "订单货物明细缺少有效箱数或序号");
}
return cargo.packageCount();
}
private void requirePrintableInboundCode(String inboundCode, int total) {
if (!StringUtils.hasText(inboundCode) || !inboundCode.matches("[A-Za-z0-9._:-]+")) {
throw new BizException(ResultCode.VALIDATION_FAILED, "入仓码不能生成可打印二维码");
}
String longest = inboundCode + "#" + total + "-" + total;
if (longest.codePointCount(0, longest.length()) > MAX_QR_CODE_POINTS) {
throw new BizException(ResultCode.VALIDATION_FAILED, "入仓码和箱号组合超过二维码长度限制");
}
}
private InboundCartonLabelEntity label(InboundOrderEntity inbound,
OrderCargoClient.CargoLabelBinding cargo,
int total, int index, LocalDateTime now) {
InboundCartonLabelEntity label = new InboundCartonLabelEntity();
label.setId(IdWorker.getId());
label.setTenantId(inbound.getTenantId());
label.setInboundId(inbound.getId());
label.setOrderId(inbound.getOrderId());
label.setInboundDetailId(cargo.cargoId());
label.setCargoItemNo(cargo.itemNo());
label.setCargoName(cargo.cargoName());
label.setDetailPackageCount(cargo.packageCount());
label.setInboundCode(inbound.getInboundCode());
label.setBoxCode(inbound.getInboundCode() + "#" + total + "-" + index);
label.setTotalCartonCount(total);
label.setCartonIndex(index);
label.setLengthCm(cargo.lengthCm());
label.setWidthCm(cargo.widthCm());
label.setHeightCm(cargo.heightCm());
label.setBoxSpecification(specification(cargo));
label.setCreatedAt(now);
return label;
}
private String specification(OrderCargoClient.CargoLabelBinding cargo) {
if (cargo.lengthCm() == null || cargo.widthCm() == null || cargo.heightCm() == null) {
return StringUtils.hasText(cargo.cargoName()) ? cargo.cargoName().trim() : "货物明细" + cargo.itemNo();
}
return decimal(cargo.lengthCm()) + "x" + decimal(cargo.widthCm()) + "x"
+ decimal(cargo.heightCm()) + "cm";
}
private String decimal(BigDecimal value) {
return value.stripTrailingZeros().toPlainString();
}
private List<InboundCartonLabelEntity> requireConsistent(List<InboundCartonLabelEntity> labels) {
int total = labels.get(0).getTotalCartonCount();
if (total != labels.size()) throw corrupt();
for (int index = 0; index < labels.size(); index++) {
InboundCartonLabelEntity label = labels.get(index);
if (label.getTotalCartonCount() != total || label.getCartonIndex() != index + 1) throw corrupt();
}
return labels;
}
private BizException corrupt() {
return new BizException(ResultCode.INTERNAL_ERROR, "入库单箱号映射不完整,请联系管理员");
}
private InboundCartonLabels.Batch batch(InboundOrderEntity inbound,
List<InboundCartonLabelEntity> entities) {
List<InboundCartonLabels.Label> labels = entities.stream().map(entity ->
new InboundCartonLabels.Label(entity.getId(), entity.getInboundDetailId(), entity.getCargoItemNo(),
entity.getCargoName(), entity.getDetailPackageCount(), entity.getBoxCode(),
entity.getTotalCartonCount(), entity.getCartonIndex(), entity.getBoxSpecification(),
entity.getLengthCm(), entity.getWidthCm(), entity.getHeightCm(), inbound.getInboundCode(),
fallback(inbound.getWarehouseName(), "WAREHOUSE"),
"CTNS:" + entity.getTotalCartonCount() + "-" + entity.getCartonIndex(),
fallback(inbound.getChannelName(), fallback(inbound.getChannelCode(), "ROUTE"))))
.toList();
int total = entities.isEmpty() ? 0 : entities.get(0).getTotalCartonCount();
return new InboundCartonLabels.Batch(inbound.getId(), inbound.getOrderId(), inbound.getInboundCode(),
total, labels);
}
private String fallback(String value, String fallback) {
return StringUtils.hasText(value) ? value.trim() : fallback;
}
private void requireIds(long tenantId, long inboundId) {
if (tenantId <= 0 || inboundId <= 0) {
throw new BizException(ResultCode.BAD_REQUEST, "客户ID和入库单ID必须大于0");
}
}
}
@@ -88,6 +88,7 @@ public class InboundMeasurementService {
BigDecimal confirmedWeight = confirmedWeightForSubmission(weights);
if (!alreadyAdvanced) {
requireConfirmedPackageTotal(inbound, items);
measurement.setStatus(STATUS_SUBMITTED);
if (!repository.updateMeasurement(measurement)) {
throw concurrentModification();
@@ -287,7 +288,25 @@ public class InboundMeasurementService {
item.getUnitWeightKg(), item.getUnitVolumeCbm(), item.getTotalWeightKg(),
item.getTotalVolumeCbm(), item.getVolumeWeightKg(), item.getSourceImageFileId(),
item.getOverLimitWarning(), item.getOuterLengthCm(), item.getOuterWidthCm(),
item.getOuterHeightCm(), item.getRemark());
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) {