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) {
@@ -0,0 +1,53 @@
-- 单箱当前测量记录增量;可重复执行。
USE baiyue_firstleg;
DROP PROCEDURE IF EXISTS phase2_add_carton_measurement_column;
DELIMITER $$
CREATE PROCEDURE phase2_add_carton_measurement_column(
IN p_column VARCHAR(64), IN p_definition TEXT)
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'fl_inbound_measurement_item'
AND COLUMN_NAME = p_column
) THEN
SET @ddl = CONCAT(
'ALTER TABLE fl_inbound_measurement_item ADD COLUMN `',
p_column, '` ', p_definition);
PREPARE statement FROM @ddl;
EXECUTE statement;
DEALLOCATE PREPARE statement;
END IF;
END$$
DELIMITER ;
CALL phase2_add_carton_measurement_column(
'box_code',
'VARCHAR(128) NULL COMMENT ''柏悦唛头箱号;设备单箱测量时同一客户内唯一'' AFTER external_detail_no');
CALL phase2_add_carton_measurement_column(
'machine_no',
'VARCHAR(64) NULL COMMENT ''本次测量设备编号'' AFTER source_image_file_id');
CALL phase2_add_carton_measurement_column(
'device_measured_at',
'DATETIME(3) NULL COMMENT ''本次设备测量时间'' AFTER machine_no');
CALL phase2_add_carton_measurement_column(
'updated_at',
'DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT ''最后更新时间'' AFTER created_at');
DROP PROCEDURE IF EXISTS phase2_add_carton_measurement_column;
SET @has_box_code_index = (
SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'fl_inbound_measurement_item'
AND INDEX_NAME = 'uk_measure_box_code'
);
SET @ddl = IF(
@has_box_code_index = 0,
'ALTER TABLE fl_inbound_measurement_item ADD UNIQUE KEY uk_measure_box_code (tenant_id, box_code)',
'SELECT 1'
);
PREPARE statement FROM @ddl;
EXECUTE statement;
DEALLOCATE PREPARE statement;
@@ -0,0 +1,28 @@
SET NAMES utf8mb4;
USE baiyue_firstleg;
-- 存量环境可重复执行:首次打印前冻结整单箱号;Print 服务继续保持无状态。
CREATE TABLE IF NOT EXISTS fl_inbound_carton_label (
id BIGINT UNSIGNED NOT NULL COMMENT '实际箱唛头映射ID(雪花ID)',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '当前客户隔离ID',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
order_id BIGINT UNSIGNED NOT NULL COMMENT '订单服务订单ID快照',
inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '订单货物明细ID,不建立跨库外键',
cargo_item_no INT UNSIGNED NOT NULL COMMENT '订单货物明细序号快照',
cargo_name VARCHAR(255) NULL COMMENT '货物名称快照',
detail_package_count INT UNSIGNED NOT NULL COMMENT '该订单货物明细箱数快照',
inbound_code VARCHAR(40) NOT NULL COMMENT '入仓码快照',
box_code VARCHAR(80) NOT NULL COMMENT '固定二维码内容:入仓码#总箱数-箱下标',
total_carton_count INT UNSIGNED NOT NULL COMMENT '首次分配时整单总箱数',
carton_index INT UNSIGNED NOT NULL COMMENT '整单从1开始的稳定箱下标',
box_specification VARCHAR(255) NOT NULL COMMENT '订单箱规或货物明细描述快照',
length_cm DECIMAL(12,3) NULL COMMENT '下单箱规长度cm快照',
width_cm DECIMAL(12,3) NULL COMMENT '下单箱规宽度cm快照',
height_cm DECIMAL(12,3) NULL COMMENT '下单箱规高度cm快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '首次分配时间',
PRIMARY KEY (id),
UNIQUE KEY uk_carton_label_box_code (tenant_id, box_code),
UNIQUE KEY uk_carton_label_index (tenant_id, inbound_id, carton_index),
KEY idx_carton_label_detail (tenant_id, inbound_id, inbound_detail_id),
CONSTRAINT fk_carton_label_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id)
) ENGINE=InnoDB COMMENT='首次打印前冻结的实际箱号与订单货物明细映射';
@@ -140,6 +140,31 @@ CREATE TABLE IF NOT EXISTS fl_inbound_transport_snapshot (
CONSTRAINT fk_transport_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id)
) ENGINE=InnoDB COMMENT='入库单关联运输信息只读快照';
CREATE TABLE IF NOT EXISTS fl_inbound_carton_label (
id BIGINT UNSIGNED NOT NULL COMMENT '实际箱唛头映射ID(雪花ID)',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '当前客户隔离ID',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
order_id BIGINT UNSIGNED NOT NULL COMMENT '订单服务订单ID快照',
inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '订单货物明细ID,不建立跨库外键',
cargo_item_no INT UNSIGNED NOT NULL COMMENT '订单货物明细序号快照',
cargo_name VARCHAR(255) NULL COMMENT '货物名称快照',
detail_package_count INT UNSIGNED NOT NULL COMMENT '该订单货物明细箱数快照',
inbound_code VARCHAR(40) NOT NULL COMMENT '入仓码快照',
box_code VARCHAR(80) NOT NULL COMMENT '固定二维码内容:入仓码#总箱数-箱下标',
total_carton_count INT UNSIGNED NOT NULL COMMENT '首次分配时整单总箱数',
carton_index INT UNSIGNED NOT NULL COMMENT '整单从1开始的稳定箱下标',
box_specification VARCHAR(255) NOT NULL COMMENT '订单箱规或货物明细描述快照',
length_cm DECIMAL(12,3) NULL COMMENT '下单箱规长度cm快照',
width_cm DECIMAL(12,3) NULL COMMENT '下单箱规宽度cm快照',
height_cm DECIMAL(12,3) NULL COMMENT '下单箱规高度cm快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '首次分配时间',
PRIMARY KEY (id),
UNIQUE KEY uk_carton_label_box_code (tenant_id, box_code),
UNIQUE KEY uk_carton_label_index (tenant_id, inbound_id, carton_index),
KEY idx_carton_label_detail (tenant_id, inbound_id, inbound_detail_id),
CONSTRAINT fk_carton_label_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id)
) ENGINE=InnoDB COMMENT='首次打印前冻结的实际箱号与订单货物明细映射';
CREATE TABLE IF NOT EXISTS fl_inbound_measurement (
id BIGINT UNSIGNED NOT NULL COMMENT '测量批次ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
@@ -177,6 +202,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_measurement_item (
measurement_id BIGINT UNSIGNED NOT NULL COMMENT '测量批次ID',
inbound_detail_id BIGINT UNSIGNED NULL COMMENT '绑定的订单货物明细ID,平台手工明细可为空',
external_detail_no VARCHAR(128) NULL COMMENT '第三方测量明细唯一号,平台手工明细可为空',
box_code VARCHAR(128) NULL COMMENT '柏悦唛头箱号;设备单箱测量时同一客户内唯一',
item_no VARCHAR(32) NOT NULL COMMENT '批次内明细号',
box_specification VARCHAR(64) NOT NULL COMMENT '箱规标识',
package_count INT UNSIGNED NOT NULL COMMENT '相同箱规箱数',
@@ -193,15 +219,19 @@ CREATE TABLE IF NOT EXISTS fl_inbound_measurement_item (
volume_weight_kg DECIMAL(18,3) NOT NULL COMMENT '本箱规材积重kg',
calculation_rule_version VARCHAR(32) NOT NULL COMMENT '体积计算和舍入规则版本',
source_image_file_id BIGINT UNSIGNED NULL COMMENT '测量设备图片文件ID',
machine_no VARCHAR(64) NULL COMMENT '本次测量设备编号',
device_measured_at DATETIME(3) NULL COMMENT '本次设备测量时间',
over_limit_warning VARCHAR(500) NULL COMMENT '超线提醒快照',
outer_length_cm DECIMAL(12,3) NULL COMMENT '外箱规长度cm',
outer_width_cm DECIMAL(12,3) NULL COMMENT '外箱规宽度cm',
outer_height_cm DECIMAL(12,3) NULL COMMENT '外箱规高度cm',
remark VARCHAR(500) NULL COMMENT '测量明细备注',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_measure_item_no (tenant_id, measurement_id, item_no),
UNIQUE KEY uk_measure_external_detail (tenant_id, inbound_detail_id, external_detail_no),
UNIQUE KEY uk_measure_box_code (tenant_id, box_code),
CONSTRAINT fk_measure_item_batch FOREIGN KEY (measurement_id) REFERENCES fl_inbound_measurement (id)
) ENGINE=InnoDB COMMENT='入库长宽高和箱数测量明细';
@@ -0,0 +1,28 @@
package com.baiyue.firstleg.repository;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat;
class InboundCartonLabelSqlContractTest {
@Test
void baseSchemaAndExistingDatabaseMigrationKeepStableCartonKeys() throws Exception {
String schema = resource("db/phase2-inbound-schema.sql");
String migration = resource("db/phase2-inbound-carton-label.sql");
assertThat(schema).contains("CREATE TABLE IF NOT EXISTS fl_inbound_carton_label")
.contains("UNIQUE KEY uk_carton_label_box_code (tenant_id, box_code)")
.contains("UNIQUE KEY uk_carton_label_index (tenant_id, inbound_id, carton_index)")
.contains("inbound_detail_id BIGINT UNSIGNED NOT NULL");
assertThat(migration).contains("CREATE TABLE IF NOT EXISTS fl_inbound_carton_label")
.contains("固定二维码内容:入仓码#总箱数-箱下标")
.contains("idx_carton_label_detail (tenant_id, inbound_id, inbound_detail_id)");
}
private String resource(String path) throws Exception {
return new ClassPathResource(path).getContentAsString(StandardCharsets.UTF_8);
}
}
@@ -0,0 +1,155 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.OrderCargoClient;
import com.baiyue.firstleg.entity.InboundCartonLabelEntity;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.repository.InboundCartonLabelRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class InboundCartonLabelServiceTest {
@Mock private InboundCartonLabelRepository repository;
@Mock private OrderCargoClient orderCargoClient;
private InboundCartonLabelService service;
@BeforeEach
void setUp() {
service = new InboundCartonLabelService();
ReflectionTestUtils.setField(service, "repository", repository);
ReflectionTestUtils.setField(service, "orderCargoClient", orderCargoClient);
ReflectionTestUtils.setField(service, "internalToken", "internal-token");
}
@Test
void firstAllocationUsesCargoOrderAndFreezesEveryPhysicalCarton() {
InboundOrderEntity inbound = inbound();
when(repository.lockInbound(10001L, 70001L)).thenReturn(inbound);
when(repository.findByInbound(10001L, 70001L)).thenReturn(new ArrayList<>());
var second = cargo(80002L, 2, 2, "50", "30", "20");
var first = cargo(80001L, 1, 3, "60", "40", "30");
when(orderCargoClient.listForCartonLabels("internal-token", 10001L, 60001L))
.thenReturn(ApiResponse.success(List.of(second, first)));
List<InboundCartonLabelEntity> inserted = new ArrayList<>();
org.mockito.Mockito.doAnswer(invocation -> {
inserted.add(invocation.getArgument(0));
return null;
}).when(repository).insert(any());
var batch = service.getOrCreate(10001L, 70001L);
assertThat(batch.totalCount()).isEqualTo(5);
assertThat(batch.labels()).extracting(label -> label.boxCode())
.containsExactly("PCHK2608040174#5-1", "PCHK2608040174#5-2", "PCHK2608040174#5-3",
"PCHK2608040174#5-4", "PCHK2608040174#5-5");
assertThat(batch.labels()).extracting(label -> label.inboundDetailId())
.containsExactly(80001L, 80001L, 80001L, 80002L, 80002L);
assertThat(batch.labels().get(0).boxSpecification()).isEqualTo("60x40x30cm");
assertThat(batch.labels().get(0).cartonText()).isEqualTo("CTNS:5-1");
assertThat(inserted).hasSize(5);
}
@Test
void repeatedAndPartialPrintsReadFrozenMappingWithoutReallocation() {
InboundOrderEntity inbound = inbound();
List<InboundCartonLabelEntity> existing = labels(inbound, 5);
when(repository.lockInbound(10001L, 70001L)).thenReturn(inbound);
when(repository.findByInbound(10001L, 70001L)).thenReturn(existing);
var batch = service.list(10001L, 70001L, List.of(5, 2, 2));
assertThat(batch.totalCount()).isEqualTo(5);
assertThat(batch.labels()).extracting(label -> label.cartonIndex()).containsExactly(2, 5);
verify(orderCargoClient, never()).listForCartonLabels(any(String.class),
org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyLong());
verify(repository, never()).insert(any());
}
@Test
void resolvesBoxCodeInsideCustomerBoundary() {
InboundCartonLabelEntity label = labels(inbound(), 1).get(0);
when(repository.findByBoxCode(10001L, "PCHK2608040174#1-1")).thenReturn(label);
var resolved = service.resolveByBoxCode(10001L, " PCHK2608040174#1-1 ");
assertThat(resolved.inboundId()).isEqualTo(70001L);
assertThat(resolved.orderId()).isEqualTo(60001L);
assertThat(resolved.inboundDetailId()).isEqualTo(80001L);
assertThat(resolved.cartonIndex()).isEqualTo(1);
verify(repository).findByBoxCode(10001L, "PCHK2608040174#1-1");
}
@Test
void refusesFirstAllocationAfterMeasurementSubmission() {
InboundOrderEntity inbound = inbound();
inbound.setProcessStatus("MEASURE_PENDING");
when(repository.lockInbound(10001L, 70001L)).thenReturn(inbound);
when(repository.findByInbound(10001L, 70001L)).thenReturn(List.of());
assertThatThrownBy(() -> service.getOrCreate(10001L, 70001L))
.isInstanceOf(BizException.class)
.hasMessageContaining("不允许首次生成箱号");
verify(orderCargoClient, never()).listForCartonLabels(any(String.class),
org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyLong());
}
private InboundOrderEntity inbound() {
InboundOrderEntity inbound = new InboundOrderEntity();
inbound.setId(70001L);
inbound.setTenantId(10001L);
inbound.setOrderId(60001L);
inbound.setInboundCode("PCHK2608040174");
inbound.setProcessStatus("WAIT_MEASURE");
inbound.setWarehouseName("MCC1");
inbound.setChannelName("洛杉矶普船海卡");
return inbound;
}
private OrderCargoClient.CargoLabelBinding cargo(long id, int itemNo, int count,
String length, String width, String height) {
return new OrderCargoClient.CargoLabelBinding(id, 60001L, itemNo, "货物" + itemNo, count,
new BigDecimal(length), new BigDecimal(width), new BigDecimal(height));
}
private List<InboundCartonLabelEntity> labels(InboundOrderEntity inbound, int total) {
List<InboundCartonLabelEntity> values = new ArrayList<>();
for (int index = 1; index <= total; index++) {
InboundCartonLabelEntity label = new InboundCartonLabelEntity();
label.setId((long) index);
label.setTenantId(inbound.getTenantId());
label.setInboundId(inbound.getId());
label.setOrderId(inbound.getOrderId());
label.setInboundDetailId(80001L);
label.setCargoItemNo(1);
label.setCargoName("货物1");
label.setDetailPackageCount(total);
label.setInboundCode(inbound.getInboundCode());
label.setBoxCode(inbound.getInboundCode() + "#" + total + "-" + index);
label.setTotalCartonCount(total);
label.setCartonIndex(index);
label.setBoxSpecification("60x40x30cm");
label.setLengthCm(new BigDecimal("60"));
label.setWidthCm(new BigDecimal("40"));
label.setHeightCm(new BigDecimal("30"));
values.add(label);
}
return values;
}
}
@@ -17,6 +17,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.List;
/** 供头程履约服务校验入仓单所关联订单货物明细。 */
@RestController
@@ -49,6 +50,22 @@ public class InternalOrderCargoController {
cargo.getCargoName(), cargo.getPackageCount(), cargo.getGrossWeightKg(), cargo.getVolumeCbm()));
}
@GetMapping("/{tenantId}/{orderId}")
public ApiResponse<List<CargoLabelBinding>> listForCartonLabels(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@PathVariable long tenantId,
@PathVariable long orderId) {
requireToken(token);
if (tenantId <= 0 || orderId <= 0) {
throw new BizException(ResultCode.BAD_REQUEST, "租户和订单ID必须大于0");
}
return ApiResponse.success(orders.findCargo(tenantId, orderId).stream()
.map(cargo -> new CargoLabelBinding(cargo.getId(), cargo.getOrderId(), cargo.getItemNo(),
cargo.getCargoName(), cargo.getPackageCount(), cargo.getLengthCm(), cargo.getWidthCm(),
cargo.getHeightCm()))
.toList());
}
private void requireToken(String token) {
if (token == null || !MessageDigest.isEqual(expectedToken, token.getBytes(StandardCharsets.UTF_8))) {
throw new BizException(ResultCode.FORBIDDEN, "内部服务凭证无效");
@@ -60,4 +77,9 @@ public class InternalOrderCargoController {
java.math.BigDecimal originalGrossWeightKg,
java.math.BigDecimal originalVolumeCbm) {
}
public record CargoLabelBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
Integer packageCount, java.math.BigDecimal lengthCm,
java.math.BigDecimal widthCm, java.math.BigDecimal heightCm) {
}
}
@@ -16,6 +16,9 @@ import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
import static org.mockito.Mockito.when;
import java.math.BigDecimal;
import java.util.List;
@SpringJUnitConfig(InternalOrderCargoController.class)
@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token")
class InternalOrderCargoControllerTest {
@@ -58,4 +61,34 @@ class InternalOrderCargoControllerTest {
verify(orders).findCargo(12L, 21L, 31L);
verifyNoMoreInteractions(orders);
}
@Test
void listsCargoInRepositoryOrderForStableCartonAllocation() {
OrderCargoEntity first = cargo(31L, 1, 3, "60", "40", "30");
OrderCargoEntity second = cargo(32L, 2, 2, "50", "30", "20");
when(orders.findCargo(11L, 21L)).thenReturn(List.of(first, second));
var response = controller.listForCartonLabels("internal-token", 11L, 21L);
assertThat(response.successful()).isTrue();
assertThat(response.data()).extracting(InternalOrderCargoController.CargoLabelBinding::cargoId)
.containsExactly(31L, 32L);
assertThat(response.data().get(0).packageCount()).isEqualTo(3);
assertThat(response.data().get(0).lengthCm()).isEqualByComparingTo("60");
verify(orders).findCargo(11L, 21L);
}
private OrderCargoEntity cargo(long id, int itemNo, int packageCount,
String length, String width, String height) {
OrderCargoEntity cargo = new OrderCargoEntity();
cargo.setId(id);
cargo.setOrderId(21L);
cargo.setItemNo(itemNo);
cargo.setCargoName("货物" + itemNo);
cargo.setPackageCount(packageCount);
cargo.setLengthCm(new BigDecimal(length));
cargo.setWidthCm(new BigDecimal(width));
cargo.setHeightCm(new BigDecimal(height));
return cargo;
}
}