This commit is contained in:
2026-08-14 17:37:59 +08:00
parent 87e919f3c4
commit feced8eb92
40 changed files with 1142 additions and 244 deletions
@@ -13,39 +13,44 @@ import java.time.LocalDateTime;
import java.util.List;
@FeignClient(name = "warehouse-service", contextId = "warehousePalletLocationClient",
url = "${baiyue.clients.warehouse.url:}", path = "/internal/warehouse/v1/pallet-locations")
url = "${baiyue.clients.warehouse.url:}", path = "/internal/warehouse/v1")
public interface WarehousePalletLocationClient {
@PostMapping("/reservations")
@GetMapping("/pallets/snapshots")
ApiResponse<List<PalletSnapshot>> palletSnapshots(
@RequestHeader("X-Internal-Service-Token") String token,
@RequestParam("warehouseId") long warehouseId);
@PostMapping("/pallet-locations/reservations")
ApiResponse<BatchResult> reserve(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody ReserveRequest request);
@PostMapping("/confirm")
@PostMapping("/pallet-locations/confirm")
ApiResponse<BatchResult> confirm(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody ConfirmRequest request);
@PostMapping("/release")
@PostMapping("/pallet-locations/release")
ApiResponse<List<ActiveResource>> release(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody ReleaseRequest request);
@PostMapping("/release-selected")
@PostMapping("/pallet-locations/release-selected")
ApiResponse<List<SelectiveReleaseResult>> releaseSelected(
@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody SelectiveReleaseRequest request);
@PostMapping("/transfer")
@PostMapping("/pallet-locations/transfer")
ApiResponse<List<TransferResult>> transfer(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody TransferRequest request);
@PostMapping("/settlements")
@PostMapping("/pallet-locations/settlements")
ApiResponse<SettlementResult> settle(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody SettlementRequest request);
@GetMapping("/settlements/target")
@GetMapping("/pallet-locations/settlements/target")
ApiResponse<Long> settlementTarget(@RequestHeader("X-Internal-Service-Token") String token,
@RequestParam("sourceBusinessId") long sourceBusinessId,
@RequestParam("idempotencyKey") String idempotencyKey);
@GetMapping("/occupied-resources")
@GetMapping("/pallet-locations/occupied-resources")
ApiResponse<List<OccupiedResource>> occupiedResources(
@RequestHeader("X-Internal-Service-Token") String token,
@RequestParam("tenantId") long tenantId,
@@ -90,6 +95,8 @@ public interface WarehousePalletLocationClient {
List<SettlementTransferItem> remainingItems) {}
record SettlementResult(List<SelectiveReleaseResult> released, List<TransferResult> transferred) {}
record OccupiedResource(long reservationId, long occupancyId, int allocationNo, long warehouseId,
long palletId, long locationId, String locationCode, boolean sharedOverflow,
int allocatedPackageCount) {}
long palletId, long locationId, String locationCode, boolean sharedOverflow,
int allocatedPackageCount) {}
record PalletSnapshot(long palletId, String palletCode, String palletType,
BigDecimal capacityCbm, BigDecimal tareWeightKg) {}
}
@@ -33,6 +33,8 @@ import org.springframework.web.bind.annotation.RestController;
import org.springframework.http.MediaType;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
/** 平台后台入库测量录入、提交和 OCR 重量结果维护接口。 */
@Tag(name = "平台入库测量", description = "维护测量草稿、箱规明细和可追溯 OCR 重量结果。")
@RestController
@@ -53,6 +55,19 @@ public class PlatformInboundMeasurementController {
return ApiResponse.success(service.detail(tenantId, inboundId));
}
@Operation(summary = "查询称重可选托盘快照",
description = "按入库单所属仓库返回未删除的托盘只读快照,不校验托盘状态或占用资格。")
@GetMapping("/weight-pallet-options")
@PreAuthorize("hasAnyAuthority('platform:firstleg:inbound:measure:view',"
+ " 'platform:firstleg:inbound:weight:edit')")
public ApiResponse<List<InboundMeasurementResponses.PalletSnapshot>> weightPalletOptions(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId,
@PathVariable long inboundId) {
requirePlatformTenant(user, tenantId);
return ApiResponse.success(service.weightPalletOptions(tenantId, inboundId));
}
@BusinessAudit(event = "INBOUND_MEASUREMENT_DRAFT_SAVE", resourceType = "INBOUND",
resourceId = "#inboundId", description = "保存入库测量草稿")
@Operation(summary = "保存测量草稿", description = "保存人工测量批次和服务端计算明细,不推进入库状态。")
@@ -142,7 +157,8 @@ public class PlatformInboundMeasurementController {
@BusinessAudit(event = "INBOUND_WEIGHT_OCR_RECORD", resourceType = "INBOUND",
resourceId = "#inboundId", description = "登记称重图片 OCR 原值")
@Operation(summary = "登记 OCR 重量结果", description = "按图片文件 ID 保存不可覆盖的 OCR 原值。")
@Operation(summary = "登记 OCR 重量结果",
description = "按图片文件 ID 和托盘 ID 保存 OCR 毛重原值及同仓托盘快照。")
@PostMapping("/weight-results")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:weight:edit')")
public ApiResponse<InboundMeasurementResponses.WeightResult> createWeightResult(
@@ -173,7 +189,8 @@ public class PlatformInboundMeasurementController {
@BusinessAudit(event = "INBOUND_WEIGHT_OCR_CONFIRM", resourceType = "INBOUND_WEIGHT_RESULT",
resourceId = "#resultId", description = "确认 OCR 重量并累加实际总毛重")
@Operation(summary = "确认 OCR 重量",
description = "可在同一事务中携带人工修改值并确认;统一换算为 KG 后重算所有已确认图片的总重量。")
description = "可在同一事务中携带人工毛重修改值并确认;统一换算 KG、扣除托盘皮重后,"
+ "按所有已确认净重重算入库单 actualGrossWeightKg。")
@PostMapping("/weight-results/{resultId}/confirm")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:weight:confirm')"
+ " and (!#request.hasAdjustment() or hasAuthority('platform:firstleg:inbound:weight:edit'))")
@@ -79,6 +79,7 @@ public final class InboundMeasurementRequests {
public record CreateWeightResultRequest(
@NotNull @Positive Long fileId,
@NotNull @Positive Long palletId,
@Size(max = 64) String ocrProvider,
@Size(max = 1000) String ocrRawText,
@DecimalMin("0.000") @Digits(integer = 15, fraction = 3) BigDecimal ocrWeightValue,
@@ -81,14 +81,20 @@ public final class InboundMeasurementResponses {
public record WeightResult(
Long id,
Long fileId,
Long palletId,
String palletCode,
String palletType,
BigDecimal palletCapacityCbm,
BigDecimal palletTareWeightKg,
String ocrProvider,
String ocrRawText,
BigDecimal ocrWeightValue,
String ocrWeightUnit,
BigDecimal confidence,
BigDecimal grossWeightKg,
BigDecimal adjustedWeightValue,
String adjustedWeightUnit,
BigDecimal finalWeightKg,
BigDecimal netWeightKg,
String recognitionStatus,
String adjustmentReason,
Long adjustedBy,
@@ -99,6 +105,15 @@ public final class InboundMeasurementResponses {
) {
}
public record PalletSnapshot(
Long palletId,
String palletCode,
String palletType,
BigDecimal capacityCbm,
BigDecimal tareWeightKg
) {
}
public record DraftSaved(Long measurementId, Integer version, String status) {
}
@@ -37,9 +37,11 @@ public record MerchantInboundBillingResponse(InboundOrderDetail inbound,
}
public record WeightResult(
Long id, Long fileId, BigDecimal recognizedWeightValue, String recognizedWeightUnit,
BigDecimal adjustedWeightValue, String adjustedWeightUnit,
BigDecimal finalWeightKg, String recognitionStatus) {
Long id, Long fileId, Long palletId, String palletCode, String palletType,
BigDecimal palletCapacityCbm, BigDecimal palletTareWeightKg,
BigDecimal recognizedWeightValue, String recognizedWeightUnit,
BigDecimal grossWeightKg, BigDecimal adjustedWeightValue, String adjustedWeightUnit,
BigDecimal netWeightKg, String recognitionStatus) {
}
public record BillingBasis(
@@ -10,7 +10,7 @@ import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/** 称重图片 OCR 原值、人工修正值和最终确认重量。 */
/** 称重图片、托盘皮重快照、OCR 原值和最终确认净重。 */
@Getter
@Setter
@TableName("fl_inbound_weight_result")
@@ -20,6 +20,11 @@ public class InboundWeightResultEntity extends BaseEntity {
private Long tenantId;
private Long inboundId;
private Long fileId;
private Long palletId;
private String palletCode;
private String palletType;
private BigDecimal palletCapacityCbm;
private BigDecimal palletTareWeightKg;
private String ocrProvider;
private String ocrRawText;
private BigDecimal ocrWeightValue;
@@ -27,7 +32,8 @@ public class InboundWeightResultEntity extends BaseEntity {
private BigDecimal confidence;
private BigDecimal adjustedWeightValue;
private String adjustedWeightUnit;
private BigDecimal finalWeightKg;
private BigDecimal grossWeightKg;
private BigDecimal netWeightKg;
private String recognitionStatus;
private String adjustmentReason;
private Long adjustedBy;
@@ -25,7 +25,8 @@ public class InboundWeightRevisionEntity {
private String beforeWeightUnit;
private BigDecimal afterWeightValue;
private String afterWeightUnit;
private BigDecimal finalWeightKg;
private BigDecimal grossWeightKg;
private BigDecimal netWeightKg;
private String reason;
private Long operatorId;
private String operatorName;
@@ -10,6 +10,7 @@ import com.baiyue.firstleg.dto.CartonMeasurementResult;
import com.baiyue.firstleg.entity.InboundMeasurementEntity;
import com.baiyue.firstleg.entity.InboundMeasurementItemEntity;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.entity.InboundWeightResultEntity;
import com.baiyue.firstleg.repository.InboundMeasurementRepository;
import feign.FeignException;
import org.slf4j.Logger;
@@ -143,13 +144,26 @@ public class CartonMeasurementService {
}
// 提交审核后仍允许平台人工修正代表箱数,但订单原始总箱数不可被测量汇总覆盖。
if ("MEASURE_PENDING".equals(inbound.getProcessStatus())) {
BigDecimal totalWeight = items.stream()
.map(InboundMeasurementItemEntity::getTotalWeightKg)
.filter(java.util.Objects::nonNull)
.reduce(BigDecimal.ZERO, BigDecimal::add)
.setScale(3, RoundingMode.CEILING);
if (positive(totalWeight)) {
inbound.setActualGrossWeightKg(totalWeight);
List<InboundWeightResultEntity> confirmedWeights = repository.listWeights(tenantId, inboundId)
.stream()
.filter(weight -> "CONFIRMED".equals(weight.getRecognitionStatus()))
.toList();
if (!confirmedWeights.isEmpty()) {
BigDecimal confirmedNetWeight = confirmedWeights.stream()
.map(InboundWeightResultEntity::getNetWeightKg)
.filter(java.util.Objects::nonNull)
.reduce(BigDecimal.ZERO, BigDecimal::add)
.setScale(3, RoundingMode.CEILING);
inbound.setActualGrossWeightKg(confirmedNetWeight);
} else {
BigDecimal totalWeight = items.stream()
.map(InboundMeasurementItemEntity::getTotalWeightKg)
.filter(java.util.Objects::nonNull)
.reduce(BigDecimal.ZERO, BigDecimal::add)
.setScale(3, RoundingMode.CEILING);
if (positive(totalWeight)) {
inbound.setActualGrossWeightKg(totalWeight);
}
}
inbound.setActualVolumeCbm(measurement.getTotalVolumeCbm());
inbound.setVolumeWeightKg(measurement.getVolumeWeightKg());
@@ -7,6 +7,7 @@ import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.OrderCargoClient;
import com.baiyue.firstleg.client.MeasurementImageClient;
import com.baiyue.firstleg.client.WarehousePalletLocationClient;
import com.baiyue.firstleg.dto.InboundMeasurementRequests;
import com.baiyue.firstleg.dto.InboundMeasurementResponses;
import com.baiyue.firstleg.dto.MeasurementBatchCommand;
@@ -57,6 +58,8 @@ public class InboundMeasurementService {
private String internalToken;
@Resource
private MeasurementImageClient imageClient;
@Resource
private WarehousePalletLocationClient warehousePallets;
public InboundMeasurementResponses.Detail detail(long tenantId, long inboundId) {
InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
@@ -68,6 +71,16 @@ public class InboundMeasurementService {
return toDetail(inbound, measurement, items, weights, review);
}
public List<InboundMeasurementResponses.PalletSnapshot> weightPalletOptions(
long tenantId, long inboundId) {
InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
return loadPalletSnapshots(inbound).stream()
.map(snapshot -> new InboundMeasurementResponses.PalletSnapshot(
snapshot.palletId(), snapshot.palletCode(), snapshot.palletType(),
snapshot.capacityCbm(), snapshot.tareWeightKg()))
.toList();
}
@Transactional
public InboundMeasurementResponses.DraftSaved saveDraft(
long tenantId, long inboundId, long operatorId,
@@ -147,11 +160,22 @@ public class InboundMeasurementService {
if (repository.findWeightByFile(tenantId, inboundId, request.fileId()) != null) {
throw new BizException(ResultCode.CONFLICT, "同一称重图片不能重复计重");
}
WarehousePalletLocationClient.PalletSnapshot pallet = loadPalletSnapshots(inbound).stream()
.filter(snapshot -> snapshot.palletId() == request.palletId())
.findFirst()
.orElseThrow(() -> new BizException(ResultCode.VALIDATION_FAILED,
"所选托盘不属于当前入库仓库或已被删除"));
validatePalletSnapshot(pallet);
String unit = rawOcrUnit(request.ocrWeightUnit());
InboundWeightResultEntity weight = new InboundWeightResultEntity();
weight.setTenantId(tenantId);
weight.setInboundId(inboundId);
weight.setFileId(request.fileId());
weight.setPalletId(pallet.palletId());
weight.setPalletCode(pallet.palletCode().trim());
weight.setPalletType(pallet.palletType().trim());
weight.setPalletCapacityCbm(pallet.capacityCbm().setScale(4, RoundingMode.HALF_UP));
weight.setPalletTareWeightKg(pallet.tareWeightKg().setScale(3, RoundingMode.HALF_UP));
weight.setOcrProvider(request.ocrProvider());
weight.setOcrRawText(request.ocrRawText());
weight.setOcrWeightValue(request.ocrWeightValue());
@@ -196,7 +220,8 @@ public class InboundMeasurementService {
weight.setAdjustmentReason(adjustmentReason.trim());
weight.setAdjustedBy(operatorId);
weight.setAdjustedAt(LocalDateTime.now());
weight.setFinalWeightKg(null);
weight.setGrossWeightKg(null);
weight.setNetWeightKg(null);
weight.setConfirmedBy(null);
weight.setConfirmedAt(null);
weight.setRecognitionStatus(WEIGHT_PENDING_CONFIRM);
@@ -207,7 +232,7 @@ public class InboundMeasurementService {
int correctedVersion = expectedVersion + 1;
weight.setVersion(correctedVersion);
insertRevision(weight, correctedVersion, "CORRECT", beforeValue, beforeUnit,
adjustedWeightValue, adjustedUnit, null, adjustmentReason.trim(),
adjustedWeightValue, adjustedUnit, null, null, adjustmentReason.trim(),
operatorId, operatorName);
return correctedVersion;
}
@@ -236,8 +261,15 @@ public class InboundMeasurementService {
throw new BizException(ResultCode.VALIDATION_FAILED, "OCR 重量无法确认,请先人工修正数值和单位");
}
String unit = normalizeUnit(rawUnit);
BigDecimal finalKg = requireConfirmableKg(value, unit);
weight.setFinalWeightKg(finalKg);
BigDecimal grossWeightKg = requireConfirmableKg(value, unit);
BigDecimal netWeightKg = grossWeightKg.subtract(weight.getPalletTareWeightKg())
.setScale(3, RoundingMode.HALF_UP);
if (!isPositive(netWeightKg)) {
throw new BizException(ResultCode.VALIDATION_FAILED,
"称重毛重必须大于托盘皮重");
}
weight.setGrossWeightKg(grossWeightKg);
weight.setNetWeightKg(netWeightKg);
weight.setRecognitionStatus(WEIGHT_CONFIRMED);
weight.setConfirmedBy(operatorId);
weight.setConfirmedAt(LocalDateTime.now());
@@ -248,7 +280,7 @@ public class InboundMeasurementService {
int confirmedVersion = currentVersion + 1;
weight.setVersion(confirmedVersion);
insertRevision(weight, confirmedVersion, "CONFIRM", value, unit, value, unit,
finalKg, null, operatorId, operatorName);
grossWeightKg, netWeightKg, null, operatorId, operatorName);
recalculateConfirmedWeight(inbound, operatorId);
return toWeight(weight);
}
@@ -288,7 +320,7 @@ public class InboundMeasurementService {
.filter(weight -> WEIGHT_CONFIRMED.equals(weight.getRecognitionStatus()))
.toList();
BigDecimal total = confirmedWeights.stream()
.map(InboundWeightResultEntity::getFinalWeightKg)
.map(InboundWeightResultEntity::getNetWeightKg)
.reduce(BigDecimal.ZERO, BigDecimal::add)
.setScale(3, RoundingMode.HALF_UP);
inbound.setActualGrossWeightKg(confirmedWeights.isEmpty() ? null : total);
@@ -304,7 +336,7 @@ public class InboundMeasurementService {
private BigDecimal confirmedWeightForSubmission(List<InboundWeightResultEntity> weights) {
BigDecimal total = weights.stream()
.filter(weight -> WEIGHT_CONFIRMED.equals(weight.getRecognitionStatus()))
.map(InboundWeightResultEntity::getFinalWeightKg)
.map(InboundWeightResultEntity::getNetWeightKg)
.filter(java.util.Objects::nonNull)
.reduce(BigDecimal.ZERO, BigDecimal::add).setScale(3, RoundingMode.HALF_UP);
return isPositive(total) ? total : null;
@@ -319,7 +351,8 @@ public class InboundMeasurementService {
private void insertRevision(InboundWeightResultEntity weight, int revisionNo, String action,
BigDecimal beforeValue, String beforeUnit,
BigDecimal afterValue, String afterUnit, BigDecimal finalWeightKg,
BigDecimal afterValue, String afterUnit,
BigDecimal grossWeightKg, BigDecimal netWeightKg,
String reason, long operatorId, String operatorName) {
InboundWeightRevisionEntity revision = new InboundWeightRevisionEntity();
revision.setTenantId(weight.getTenantId());
@@ -331,7 +364,8 @@ public class InboundMeasurementService {
revision.setBeforeWeightUnit(beforeUnit);
revision.setAfterWeightValue(afterValue);
revision.setAfterWeightUnit(afterUnit);
revision.setFinalWeightKg(finalWeightKg);
revision.setGrossWeightKg(grossWeightKg);
revision.setNetWeightKg(netWeightKg);
revision.setReason(reason);
revision.setOperatorId(operatorId);
revision.setOperatorName(operatorName);
@@ -396,13 +430,46 @@ public class InboundMeasurementService {
private InboundMeasurementResponses.WeightResult toWeight(InboundWeightResultEntity weight) {
return new InboundMeasurementResponses.WeightResult(
weight.getId(), weight.getFileId(), weight.getOcrProvider(), weight.getOcrRawText(),
weight.getId(), weight.getFileId(), weight.getPalletId(), weight.getPalletCode(),
weight.getPalletType(), weight.getPalletCapacityCbm(), weight.getPalletTareWeightKg(),
weight.getOcrProvider(), weight.getOcrRawText(),
weight.getOcrWeightValue(), weight.getOcrWeightUnit(), weight.getConfidence(),
weight.getAdjustedWeightValue(), weight.getAdjustedWeightUnit(), weight.getFinalWeightKg(),
weight.getGrossWeightKg(),
weight.getAdjustedWeightValue(), weight.getAdjustedWeightUnit(),
weight.getNetWeightKg(),
weight.getRecognitionStatus(), weight.getAdjustmentReason(), weight.getAdjustedBy(),
weight.getAdjustedAt(), weight.getConfirmedBy(), weight.getConfirmedAt(), weight.getVersion());
}
private List<WarehousePalletLocationClient.PalletSnapshot> loadPalletSnapshots(
InboundOrderEntity inbound) {
if (inbound.getWarehouseId() == null || inbound.getWarehouseId() <= 0) {
throw new BizException(ResultCode.CONFLICT, "入库单未绑定有效仓库,无法选择称重托盘");
}
try {
ApiResponse<List<WarehousePalletLocationClient.PalletSnapshot>> response =
warehousePallets.palletSnapshots(internalToken, inbound.getWarehouseId());
if (response == null || !response.successful() || response.data() == null) {
String message = response == null || !StringUtils.hasText(response.message())
? "托盘快照查询失败" : response.message();
throw new BizException(response == null ? ResultCode.SERVICE_UNAVAILABLE
: ResultCode.of(response.code()), message);
}
return response.data();
} catch (FeignException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "仓库服务暂不可用,无法查询托盘快照");
}
}
private void validatePalletSnapshot(WarehousePalletLocationClient.PalletSnapshot snapshot) {
if (snapshot.palletId() <= 0 || !StringUtils.hasText(snapshot.palletCode())
|| !StringUtils.hasText(snapshot.palletType()) || !isPositive(snapshot.capacityCbm())
|| snapshot.tareWeightKg() == null
|| snapshot.tareWeightKg().compareTo(BigDecimal.ZERO) < 0) {
throw new BizException(ResultCode.CONFLICT, "仓库服务返回的托盘快照不完整");
}
}
private InboundOrderEntity requireInbound(long tenantId, long inboundId) {
InboundOrderEntity inbound = repository.findInbound(tenantId, inboundId);
if (inbound == null) {
@@ -73,9 +73,11 @@ public class MerchantInboundBillingService {
.toList();
var weightResults = measurement.weightResults().stream()
.map(weight -> new MerchantInboundBillingResponse.WeightResult(
weight.id(), weight.fileId(), weight.ocrWeightValue(), weight.ocrWeightUnit(),
weight.adjustedWeightValue(), weight.adjustedWeightUnit(),
weight.finalWeightKg(), weight.recognitionStatus()))
weight.id(), weight.fileId(), weight.palletId(), weight.palletCode(), weight.palletType(),
weight.palletCapacityCbm(), weight.palletTareWeightKg(),
weight.ocrWeightValue(), weight.ocrWeightUnit(),
weight.grossWeightKg(), weight.adjustedWeightValue(), weight.adjustedWeightUnit(),
weight.netWeightKg(), weight.recognitionStatus()))
.toList();
var basis = new MerchantInboundBillingResponse.BillingBasis(
fee.chargeMode(), fee.chargeUnit(), chargeableBasis(fee), fee.actualWeightKg(),
@@ -77,7 +77,8 @@ CREATE TABLE IF NOT EXISTS fl_inbound_weight_revision (
before_weight_unit VARCHAR(8) NULL COMMENT '变更前单位',
after_weight_value DECIMAL(18,3) NULL COMMENT '变更后原单位重量',
after_weight_unit VARCHAR(8) NULL COMMENT '变更后单位',
final_weight_kg DECIMAL(18,3) NULL COMMENT '确认时统一换算后的重量kg',
gross_weight_kg DECIMAL(18,3) NULL COMMENT '确认时统一换算后的含托盘毛重kg',
net_weight_kg DECIMAL(18,3) NULL COMMENT '确认毛重扣除托盘皮重后的货物净重kg',
reason VARCHAR(500) NULL COMMENT '人工修正原因',
operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID',
operator_name VARCHAR(64) NULL COMMENT '操作人名称快照',
@@ -42,7 +42,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_order (
hold_flag TINYINT(1) NOT NULL DEFAULT 0 COMMENT '当前是否扣货',
current_hold_reason VARCHAR(500) NULL COMMENT '当前扣货理由快照',
total_package_count INT UNSIGNED NULL COMMENT '确认后的总箱数',
actual_gross_weight_kg DECIMAL(18,3) NULL COMMENT '确认后的实际总毛重kg',
actual_gross_weight_kg DECIMAL(18,3) NULL COMMENT '下游货物实际毛重kg,汇总已确认称重净重',
chargeable_weight_kg DECIMAL(18,3) NULL COMMENT '当前费用快照的计费重量kg',
actual_volume_cbm DECIMAL(18,4) NULL COMMENT '确认后的实际总体积m3',
chargeable_volume_cbm DECIMAL(18,4) NULL COMMENT '当前费用快照的计费体积m3',
@@ -270,6 +270,11 @@ CREATE TABLE IF NOT EXISTS fl_inbound_weight_result (
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
file_id BIGINT UNSIGNED NOT NULL COMMENT '称重图片文件ID',
pallet_id BIGINT UNSIGNED NOT NULL COMMENT 'Warehouse托盘ID',
pallet_code VARCHAR(50) NOT NULL COMMENT '托盘编码快照',
pallet_type VARCHAR(32) NOT NULL COMMENT '托盘类型快照',
pallet_capacity_cbm DECIMAL(18,4) NOT NULL COMMENT '托盘容量CBM快照',
pallet_tare_weight_kg DECIMAL(18,3) NOT NULL COMMENT '托盘皮重KG快照',
ocr_provider VARCHAR(64) NULL COMMENT 'OCR服务商编码',
ocr_raw_text VARCHAR(1000) NULL COMMENT 'OCR原始文本',
ocr_weight_value DECIMAL(18,3) NULL COMMENT 'OCR识别原始重量值',
@@ -277,7 +282,8 @@ CREATE TABLE IF NOT EXISTS fl_inbound_weight_result (
confidence DECIMAL(6,5) NULL COMMENT '识别置信度',
adjusted_weight_value DECIMAL(18,3) NULL COMMENT '人工修正后的原单位重量',
adjusted_weight_unit VARCHAR(8) NULL COMMENT '人工修正后的单位',
final_weight_kg DECIMAL(18,3) NULL COMMENT '确认并统一换算后的重量kg',
gross_weight_kg DECIMAL(18,3) NULL COMMENT '确认时统一换算后的含托盘毛重kg',
net_weight_kg DECIMAL(18,3) NULL COMMENT '确认毛重扣除托盘皮重后的货物净重kg',
recognition_status VARCHAR(24) NOT NULL DEFAULT 'PENDING' COMMENT '重量识别状态:PENDING待识别、PENDING_CONFIRM待确认、NEEDS_MANUAL需人工录入、CONFIRMED已确认',
adjustment_reason VARCHAR(500) NULL COMMENT '人工修正原因',
adjusted_by BIGINT UNSIGNED NULL COMMENT '修正人ID',
@@ -293,8 +299,9 @@ CREATE TABLE IF NOT EXISTS fl_inbound_weight_result (
PRIMARY KEY (id),
UNIQUE KEY uk_weight_inbound_file (tenant_id, inbound_id, file_id),
KEY idx_weight_inbound_status (tenant_id, inbound_id, recognition_status),
KEY idx_weight_inbound_pallet (tenant_id, inbound_id, pallet_id),
CONSTRAINT fk_weight_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id)
) ENGINE=InnoDB COMMENT='称重图片OCR原值、人工修正值和确认重量';
) ENGINE=InnoDB COMMENT='称重图片、托盘皮重快照、OCR毛重和确认净重';
CREATE TABLE IF NOT EXISTS fl_inbound_weight_revision (
id BIGINT UNSIGNED NOT NULL COMMENT 'OCR重量修订记录ID',
@@ -307,7 +314,8 @@ CREATE TABLE IF NOT EXISTS fl_inbound_weight_revision (
before_weight_unit VARCHAR(8) NULL COMMENT '变更前单位',
after_weight_value DECIMAL(18,3) NULL COMMENT '变更后原单位重量',
after_weight_unit VARCHAR(8) NULL COMMENT '变更后单位',
final_weight_kg DECIMAL(18,3) NULL COMMENT '确认时统一换算后的重量kg',
gross_weight_kg DECIMAL(18,3) NULL COMMENT '确认时统一换算后的含托盘毛重kg',
net_weight_kg DECIMAL(18,3) NULL COMMENT '确认毛重扣除托盘皮重后的货物净重kg',
reason VARCHAR(500) NULL COMMENT '人工修正原因',
operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID',
operator_name VARCHAR(64) NULL COMMENT '操作人名称快照',
@@ -664,7 +672,6 @@ CREATE TABLE IF NOT EXISTS fl_inbound_pallet_allocation (
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_pallet_allocation_no (tenant_id, inbound_id, allocation_no),
UNIQUE KEY uk_pallet_allocation_pallet (tenant_id, inbound_id, pallet_id),
KEY idx_allocation_location (tenant_id, warehouse_id, location_id, allocation_status),
KEY idx_allocation_reservation (reservation_id),
CONSTRAINT fk_allocation_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id)
@@ -542,13 +542,62 @@ paths:
responses:
'200': {description: 成功或业务失败,按响应体 code 判断}
'403': {description: 无测量编辑权限}
/api/platform/v1/first-leg/inbounds/{inboundId}/weight-pallet-options:
get:
summary: 查询称重可选托盘快照
description: >-
需要入库测量查看或称重编辑权限。Firstleg 按入库单 warehouseId 查询 Warehouse 公共托盘快照;
仅限定同仓且未删除,不校验托盘状态或占用资格,Admin 不需要 platform:pallet:list。
parameters:
- {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
responses:
'200':
description: 成功或业务失败,按响应体 code 判断
content:
application/json:
schema:
type: object
properties:
code: {type: string}
message: {type: string}
data: {type: array, items: {$ref: '#/components/schemas/WeightPalletSnapshot'}}
'403': {description: 无入库测量查看或称重编辑权限}
/api/platform/v1/first-leg/inbounds/{inboundId}/weight-results:
post:
summary: 登记称重图片 OCR 毛重结果
description: >-
需要称重编辑权限。请求必须绑定 palletId;Firstleg 按入库单 warehouseId 读取 Warehouse
权威快照并固化托盘编码、类型、容量和皮重,不信任前端托盘快照。
parameters:
- {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/CreateWeightResultRequest'}
responses:
'200':
description: 成功或业务失败,按响应体 code 判断
content:
application/json:
schema:
type: object
properties:
code: {type: string}
message: {type: string}
data: {$ref: '#/components/schemas/InboundWeightResult'}
'403': {description: 无称重编辑权限}
/api/platform/v1/first-leg/inbounds/{inboundId}/weight-results/{resultId}/confirm:
post:
summary: 确认 OCR 重量
description: >-
需要 platform:firstleg:inbound:weight:confirm 权限;携带人工修改字段时还需要
platform:firstleg:inbound:weight:edit 权限。修改与确认在同一事务内完成,保留 OCR 原值,
分别写入 CORRECT 和 CONFIRM 修订记录,并重算所有已确认图片的实际总毛重。
分别写入 CORRECT 和 CONFIRM 修订记录。输入表示含托盘毛重;服务端统一换算为 KG,
使用创建时固化的托盘皮重计算 netWeightKg,且毛重必须大于皮重。
入库单 actualGrossWeightKg 明确保留为下游货物实际毛重字段,值汇总所有已确认 netWeightKg。
parameters:
- {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
@@ -559,14 +608,48 @@ paths:
application/json:
schema: {$ref: '#/components/schemas/ConfirmWeightResultRequest'}
responses:
'200': {description: 成功或业务失败,按响应体 code 判断}
'200':
description: 成功或业务失败,按响应体 code 判断
content:
application/json:
schema:
type: object
properties:
code: {type: string}
message: {type: string}
data: {$ref: '#/components/schemas/InboundWeightResult'}
'403': {description: 无确认权限,或修改重量时无编辑权限}
/api/platform/v1/first-leg/inbounds/{inboundId}/weight-results/{resultId}:
put:
summary: 人工修正 OCR 毛重
description: >-
需要称重编辑权限。修正值仍表示含托盘毛重;保留 OCR 原值,已确认结果修正后立即移出净重汇总并回到待确认。
parameters:
- {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {name: resultId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/CorrectWeightResultRequest'}
responses:
'200':
description: 成功或业务失败,按响应体 code 判断
content:
application/json:
schema:
type: object
properties:
code: {type: string}
message: {type: string}
data: {$ref: '#/components/schemas/InboundWeightResult'}
'403': {description: 无称重编辑权限}
delete:
summary: 删除称重图片及重量结果
description: >-
需要 platform:firstleg:inbound:weight:edit 权限;仅允许在可编辑的测量状态删除。
删除已确认图片后重新汇总剩余已确认图片的实际总毛重,删除最后一张时总毛重恢复为空。
删除已确认图片后重新汇总剩余已确认图片的净重,删除最后一张时 actualGrossWeightKg 恢复为空。
parameters:
- {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {name: version, in: query, required: true, schema: {type: integer, format: int32, minimum: 0}}
@@ -986,10 +1069,65 @@ components:
properties:
configId: {type: string, pattern: '^[1-9][0-9]*$'}
createdByThisInitialization: {type: boolean}
WeightPalletSnapshot:
type: object
required: [palletId, palletCode, palletType, capacityCbm, tareWeightKg]
properties:
palletId: {type: string, pattern: '^[1-9][0-9]*$'}
palletCode: {type: string}
palletType: {type: string}
capacityCbm: {type: number, format: double, minimum: 0.0001}
tareWeightKg: {type: number, format: double, minimum: 0, multipleOf: 0.001}
CreateWeightResultRequest:
type: object
required: [fileId, palletId]
properties:
fileId: {type: string, pattern: '^[1-9][0-9]*$'}
palletId: {type: string, pattern: '^[1-9][0-9]*$'}
ocrProvider: {type: string, maxLength: 64, nullable: true}
ocrRawText: {type: string, maxLength: 1000, nullable: true}
ocrWeightValue: {type: number, format: double, minimum: 0, nullable: true, description: OCR 原始含托盘毛重}
ocrWeightUnit: {type: string, maxLength: 8, nullable: true}
confidence: {type: number, format: double, minimum: 0, maximum: 1, nullable: true}
CorrectWeightResultRequest:
type: object
required: [version, adjustedWeightValue, adjustedWeightUnit, adjustmentReason]
properties:
version: {type: integer, minimum: 0}
adjustedWeightValue: {type: number, format: double, minimum: 0.001, description: 人工修正后的含托盘毛重}
adjustedWeightUnit: {type: string, enum: [KG, LB]}
adjustmentReason: {type: string, minLength: 1, maxLength: 500}
InboundWeightResult:
type: object
required: [id, fileId, palletId, palletCode, palletType, palletCapacityCbm, palletTareWeightKg, recognitionStatus, version]
properties:
id: {type: string, pattern: '^[1-9][0-9]*$'}
fileId: {type: string, pattern: '^[1-9][0-9]*$'}
palletId: {type: string, pattern: '^[1-9][0-9]*$'}
palletCode: {type: string}
palletType: {type: string}
palletCapacityCbm: {type: number, format: double, minimum: 0.0001}
palletTareWeightKg: {type: number, format: double, minimum: 0, multipleOf: 0.001}
ocrProvider: {type: string, nullable: true}
ocrRawText: {type: string, nullable: true}
ocrWeightValue: {type: number, format: double, nullable: true, description: OCR 原始含托盘毛重}
ocrWeightUnit: {type: string, nullable: true}
confidence: {type: number, format: double, nullable: true}
grossWeightKg: {type: number, format: double, nullable: true, multipleOf: 0.001, description: 确认时标准化的含托盘毛重 KG}
adjustedWeightValue: {type: number, format: double, nullable: true, description: 人工修正后的含托盘毛重}
adjustedWeightUnit: {type: string, nullable: true}
netWeightKg: {type: number, format: double, nullable: true, multipleOf: 0.001, description: grossWeightKg - palletTareWeightKg}
recognitionStatus: {type: string, enum: [PENDING, PENDING_CONFIRM, NEEDS_MANUAL, CONFIRMED]}
adjustmentReason: {type: string, nullable: true}
adjustedBy: {type: string, nullable: true}
adjustedAt: {type: string, format: date-time, nullable: true}
confirmedBy: {type: string, nullable: true}
confirmedAt: {type: string, format: date-time, nullable: true}
version: {type: integer, minimum: 0}
ConfirmWeightResultRequest:
type: object
required: [version]
description: 人工修改字段必须同时提供;全部省略时直接确认现有 OCR 或已修正重量。
description: 人工修改字段表示含托盘毛重且必须同时提供;全部省略时直接确认现有 OCR 或已修正毛重。
properties:
version: {type: integer, minimum: 0}
adjustedWeightValue: {type: number, format: double, minimum: 0.001, nullable: true}
@@ -0,0 +1,37 @@
package com.baiyue.firstleg.client;
import org.junit.jupiter.api.Test;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import java.lang.reflect.Method;
import static org.assertj.core.api.Assertions.assertThat;
class WarehousePalletLocationClientContractTest {
@Test
void consumesTheWarehousePalletSnapshotContractExactly() throws Exception {
FeignClient client = WarehousePalletLocationClient.class.getAnnotation(FeignClient.class);
assertThat(client.path()).isEqualTo("/internal/warehouse/v1");
Method method = WarehousePalletLocationClient.class.getDeclaredMethod(
"palletSnapshots", String.class, long.class);
assertThat(method.getAnnotation(GetMapping.class).value())
.containsExactly("/pallets/snapshots");
assertThat(method.getParameters()[1].getAnnotation(RequestParam.class).value())
.isEqualTo("warehouseId");
var snapshot = new WarehousePalletLocationClient.PalletSnapshot(
101L, "PLT-101", "STANDARD", new java.math.BigDecimal("2.0000"),
new java.math.BigDecimal("18.500"));
assertThat(snapshot).extracting(
WarehousePalletLocationClient.PalletSnapshot::palletId,
WarehousePalletLocationClient.PalletSnapshot::palletCode,
WarehousePalletLocationClient.PalletSnapshot::palletType,
WarehousePalletLocationClient.PalletSnapshot::capacityCbm,
WarehousePalletLocationClient.PalletSnapshot::tareWeightKg)
.containsExactly(101L, "PLT-101", "STANDARD",
new java.math.BigDecimal("2.0000"), new java.math.BigDecimal("18.500"));
}
}
@@ -17,6 +17,10 @@ class PlatformInboundMeasurementControllerContractTest {
void everyMeasurementOperationUsesDedicatedButtonPermission() throws Exception {
assertAuthority("detail", "hasAuthority('platform:firstleg:inbound:measure:view')",
AuthenticatedUser.class, long.class, long.class);
assertAuthority("weightPalletOptions",
"hasAnyAuthority('platform:firstleg:inbound:measure:view',"
+ " 'platform:firstleg:inbound:weight:edit')",
AuthenticatedUser.class, long.class, long.class);
assertAuthority("saveDraft", "hasAuthority('platform:firstleg:inbound:measure:edit')",
AuthenticatedUser.class, long.class, long.class,
InboundMeasurementRequests.SaveDraftRequest.class);
@@ -0,0 +1,34 @@
package com.baiyue.firstleg.dto;
import jakarta.validation.Validation;
import jakarta.validation.Validator;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import static org.assertj.core.api.Assertions.assertThat;
class CreateWeightResultRequestValidationTest {
private final Validator validator = Validation.buildDefaultValidatorFactory().getValidator();
@Test
void requiresAValidFileAndPalletBinding() {
var valid = request(120L, 501L);
var missingPallet = request(120L, null);
var invalidPallet = request(120L, 0L);
assertThat(validator.validate(valid)).isEmpty();
assertThat(validator.validate(missingPallet))
.extracting(violation -> violation.getPropertyPath().toString())
.contains("palletId");
assertThat(validator.validate(invalidPallet))
.extracting(violation -> violation.getPropertyPath().toString())
.contains("palletId");
}
private InboundMeasurementRequests.CreateWeightResultRequest request(Long fileId, Long palletId) {
return new InboundMeasurementRequests.CreateWeightResultRequest(
fileId, palletId, "FILE_WEIGHT_OCR", "30.000 KG", new BigDecimal("30.000"),
"KG", new BigDecimal("0.99000"));
}
}
@@ -63,4 +63,26 @@ class InboundWorkflowSqlContractTest {
.contains("uk_measure_box_code")
.contains("单箱当前测量字段未完整创建");
}
@Test
@DisplayName("称重结果固化托盘快照、毛重和净重并允许托盘主数据复用")
void shouldPersistWeightPalletSnapshotsAndNetWeight() throws Exception {
String schema = new ClassPathResource("db/phase2-inbound-schema.sql")
.getContentAsString(StandardCharsets.UTF_8);
String unified = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"));
assertThat(schema)
.contains("pallet_tare_weight_kg DECIMAL(18,3) NOT NULL")
.contains("gross_weight_kg DECIMAL(18,3) NULL")
.contains("net_weight_kg DECIMAL(18,3) NULL")
.contains("idx_weight_inbound_pallet")
.doesNotContain("final_weight_kg")
.doesNotContain("uk_pallet_allocation_pallet");
assertThat(unified)
.contains("CALL phase2_measure_add_column('fl_inbound_weight_result', 'pallet_id'")
.contains("ALTER TABLE fl_inbound_weight_result DROP COLUMN final_weight_kg")
.contains("ALTER TABLE fl_inbound_pallet_allocation DROP INDEX uk_pallet_allocation_pallet")
.contains("称重托盘快照或毛重净重字段未正确收敛")
.contains("同票托盘唯一约束仍阻止托盘主数据复用");
}
}
@@ -10,6 +10,7 @@ import com.baiyue.firstleg.entity.CustomerDimensionConfigEntity;
import com.baiyue.firstleg.entity.InboundMeasurementEntity;
import com.baiyue.firstleg.entity.InboundMeasurementItemEntity;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.entity.InboundWeightResultEntity;
import com.baiyue.firstleg.repository.InboundMeasurementRepository;
import com.baiyue.firstleg.repository.CustomerDimensionConfigRepository;
import org.junit.jupiter.api.BeforeEach;
@@ -248,7 +249,7 @@ class CartonMeasurementServiceTest {
}
@Test
void confirmedPackageCountSynchronizesActualWeightAndVolumeForPricingAfterSubmission() {
void confirmedPackageCountUsesCartonWeightAsFallbackWhenNoConfirmedImageExists() {
InboundOrderEntity inbound = inbound("MEASURE_PENDING");
InboundMeasurementEntity measurement = measurement(30001L, "SUBMITTED", 2);
measurement.setTotalPackageCount(1);
@@ -266,6 +267,7 @@ class CartonMeasurementServiceTest {
when(repository.findMeasurement(10001L, 70001L, 30001L)).thenReturn(measurement);
when(repository.updateItem(item)).thenReturn(true);
when(repository.listItems(10001L, 30001L)).thenReturn(List.of(item));
when(repository.listWeights(10001L, 70001L)).thenReturn(List.of());
when(repository.updateMeasurement(measurement)).thenReturn(true);
when(repository.updateInbound(inbound)).thenReturn(true);
@@ -278,6 +280,41 @@ class CartonMeasurementServiceTest {
verify(repository).updateInbound(inbound);
}
@Test
void cartonMeasurementAfterConfirmedWeightKeepsNetWeightAndUpdatesVolume() {
InboundOrderEntity inbound = inbound("MEASURE_PENDING");
inbound.setActualGrossWeightKg(new BigDecimal("11.500"));
InboundMeasurementEntity measurement = measurement(30001L, "SUBMITTED", 2);
measurement.setTotalPackageCount(1);
measurement.setTotalVolumeCbm(new BigDecimal("0.0600"));
measurement.setVolumeWeightKg(new BigDecimal("10.020"));
InboundMeasurementItemEntity item = item(40001L, 30001L, 1, 99001L);
item.setUnitWeightKg(new BigDecimal("10.880"));
item.setOuterLengthCm(new BigDecimal("50"));
item.setOuterWidthCm(new BigDecimal("40"));
item.setOuterHeightCm(new BigDecimal("30"));
item.setUnitVolumeCbm(new BigDecimal("0.1496"));
item.setCalculationRuleVersion("ACTUAL_DIMENSION_V1");
InboundWeightResultEntity confirmedWeight = new InboundWeightResultEntity();
confirmedWeight.setRecognitionStatus("CONFIRMED");
confirmedWeight.setNetWeightKg(new BigDecimal("11.500"));
when(repository.findInbound(10001L, 70001L)).thenReturn(inbound);
when(repository.findItem(10001L, 40001L)).thenReturn(item);
when(repository.findMeasurement(10001L, 70001L, 30001L)).thenReturn(measurement);
when(repository.updateItem(item)).thenReturn(true);
when(repository.listItems(10001L, 30001L)).thenReturn(List.of(item));
when(repository.listWeights(10001L, 70001L)).thenReturn(List.of(confirmedWeight));
when(repository.updateMeasurement(measurement)).thenReturn(true);
when(repository.updateInbound(inbound)).thenReturn(true);
service.updateConfirmedPackageCount(10001L, 70001L, 40001L, 2, 5);
assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("11.500");
assertThat(inbound.getActualVolumeCbm()).isEqualByComparingTo("0.7480");
assertThat(inbound.getVolumeWeightKg()).isEqualByComparingTo("124.916");
verify(repository).updateInbound(inbound);
}
private CartonMeasurementCommand command() {
return new CartonMeasurementCommand("RK-001#5-1", new BigDecimal("59.100"),
new BigDecimal("40.200"), new BigDecimal("30.300"), new BigDecimal("12.500"),
@@ -4,6 +4,7 @@ import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.firstleg.client.OrderCargoClient;
import com.baiyue.firstleg.client.MeasurementImageClient;
import com.baiyue.firstleg.client.WarehousePalletLocationClient;
import com.baiyue.firstleg.dto.InboundMeasurementRequests;
import com.baiyue.firstleg.dto.MeasurementBatchCommand;
import com.baiyue.firstleg.dto.InboundMeasurementResponses;
@@ -49,8 +50,15 @@ class InboundMeasurementServiceTest {
@MockBean private MeasurementBatchService batchService;
@MockBean private OrderCargoClient orderCargoClient;
@MockBean private MeasurementImageClient imageClient;
@MockBean private WarehousePalletLocationClient warehousePallets;
@Resource private InboundMeasurementService service;
@BeforeEach
void stubWarehousePalletSnapshots() {
when(warehousePallets.palletSnapshots("internal-token", 100L)).thenReturn(
ApiResponse.success(List.of(palletSnapshot())));
}
@Test
void customerCannotReadAnotherCustomersMeasurement() {
assertThatThrownBy(() -> service.detail(2L, 10L))
@@ -90,7 +98,8 @@ class InboundMeasurementServiceTest {
InboundOrderEntity inbound = inbound("MEASURING");
inbound.setActualGrossWeightKg(new BigDecimal("12.500"));
InboundWeightResultEntity weight = weight(20L, "12.500", "KG", "CONFIRMED", 4);
weight.setFinalWeightKg(new BigDecimal("12.500"));
weight.setGrossWeightKg(new BigDecimal("12.500"));
weight.setNetWeightKg(new BigDecimal("12.500"));
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight);
when(repository.updateWeight(weight)).thenReturn(true);
@@ -102,7 +111,8 @@ class InboundMeasurementServiceTest {
4, new BigDecimal("13.200"), "KG", "秤面显示修正"));
assertThat(weight.getRecognitionStatus()).isEqualTo("PENDING_CONFIRM");
assertThat(weight.getFinalWeightKg()).isNull();
assertThat(weight.getGrossWeightKg()).isNull();
assertThat(weight.getNetWeightKg()).isNull();
assertThat(inbound.getActualGrossWeightKg()).isNull();
verify(repository).updateInboundWeightSummary(inbound);
}
@@ -134,22 +144,55 @@ class InboundMeasurementServiceTest {
InboundMeasurementResponses.WeightResult result = service.createWeightResult(
1L, 10L, 9L,
new InboundMeasurementRequests.CreateWeightResultRequest(
120L, "FILE_WEIGHT_OCR", "285.5", new BigDecimal("285.500"), "KG",
120L, 501L, "FILE_WEIGHT_OCR", "285.5", new BigDecimal("285.500"), "KG",
new BigDecimal("0.5097191334")));
ArgumentCaptor<InboundWeightResultEntity> saved =
ArgumentCaptor.forClass(InboundWeightResultEntity.class);
verify(repository).insertWeight(saved.capture());
assertThat(saved.getValue().getConfidence()).isEqualByComparingTo("0.50972");
assertThat(saved.getValue().getPalletId()).isEqualTo(501L);
assertThat(saved.getValue().getPalletCode()).isEqualTo("PLT-501");
assertThat(saved.getValue().getPalletTareWeightKg()).isEqualByComparingTo("18.500");
assertThat(result.confidence()).isEqualByComparingTo("0.50972");
}
@Test
void weightPalletOptionsUsesInboundWarehouseWithoutCustomerScope() {
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
List<InboundMeasurementResponses.PalletSnapshot> result =
service.weightPalletOptions(1L, 10L);
assertThat(result).singleElement().satisfies(snapshot -> {
assertThat(snapshot.palletId()).isEqualTo(501L);
assertThat(snapshot.tareWeightKg()).isEqualByComparingTo("18.500");
});
verify(warehousePallets).palletSnapshots("internal-token", 100L);
}
@Test
void creatingWeightResultRejectsPalletOutsideTheInboundWarehouse() {
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
assertThatThrownBy(() -> service.createWeightResult(1L, 10L, 9L,
new InboundMeasurementRequests.CreateWeightResultRequest(
120L, 999L, "FILE_WEIGHT_OCR", "20.0 KG", new BigDecimal("20.000"),
"KG", new BigDecimal("0.99"))))
.isInstanceOf(BizException.class)
.hasMessageContaining("不属于当前入库仓库");
verify(repository, never()).insertWeight(any());
}
@Test
void confirmingLbWeightConvertsToKgAndRecalculatesOnlyConfirmedImages() {
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
InboundWeightResultEntity current = weight(20L, "10.000", "LB", "PENDING_CONFIRM", 0);
InboundWeightResultEntity previous = weight(21L, "2.000", "KG", "CONFIRMED", 1);
previous.setFinalWeightKg(new BigDecimal("2.000"));
previous.setNetWeightKg(new BigDecimal("2.000"));
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
when(repository.findWeight(1L, 10L, 20L)).thenReturn(current);
when(repository.updateWeight(current)).thenReturn(true);
@@ -160,12 +203,58 @@ class InboundMeasurementServiceTest {
1L, 10L, 20L, 9L, "操作员",
new InboundMeasurementRequests.ConfirmWeightResultRequest(0, null, null, null));
assertThat(response.finalWeightKg()).isEqualByComparingTo("4.536");
assertThat(response.grossWeightKg()).isEqualByComparingTo("4.536");
assertThat(response.netWeightKg()).isEqualByComparingTo("4.536");
assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("6.536");
assertThat(inbound.getProcessStatus()).isEqualTo("MEASURING");
verify(repository).insertRevision(any(InboundWeightRevisionEntity.class));
}
@Test
void confirmingWeightAfterCartonMeasurementReplacesFallbackWithNetWeight() {
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
inbound.setActualGrossWeightKg(new BigDecimal("54.400"));
InboundWeightResultEntity weight = weight(20L, "30.000", "KG", "PENDING_CONFIRM", 0);
weight.setPalletTareWeightKg(new BigDecimal("18.500"));
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight);
when(repository.updateWeight(weight)).thenReturn(true);
when(repository.listWeights(1L, 10L)).thenReturn(List.of(weight));
when(repository.updateInboundWeightSummary(inbound)).thenReturn(true);
InboundMeasurementResponses.WeightResult response = service.confirmWeightResult(
1L, 10L, 20L, 9L, "操作员",
new InboundMeasurementRequests.ConfirmWeightResultRequest(0, null, null, null));
assertThat(response.grossWeightKg()).isEqualByComparingTo("30.000");
assertThat(response.netWeightKg()).isEqualByComparingTo("11.500");
assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("11.500");
ArgumentCaptor<InboundWeightRevisionEntity> revision =
ArgumentCaptor.forClass(InboundWeightRevisionEntity.class);
verify(repository).insertRevision(revision.capture());
assertThat(revision.getValue().getGrossWeightKg()).isEqualByComparingTo("30.000");
assertThat(revision.getValue().getNetWeightKg()).isEqualByComparingTo("11.500");
}
@Test
void confirmingWeightRejectsGrossWeightNotAboveFrozenTare() {
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
InboundWeightResultEntity weight = weight(20L, "18.500", "KG", "PENDING_CONFIRM", 0);
weight.setPalletTareWeightKg(new BigDecimal("18.500"));
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight);
assertThatThrownBy(() -> service.confirmWeightResult(
1L, 10L, 20L, 9L, "操作员",
new InboundMeasurementRequests.ConfirmWeightResultRequest(0, null, null, null)))
.isInstanceOf(BizException.class)
.hasMessageContaining("毛重必须大于托盘皮重");
verify(repository, never()).updateWeight(any());
verify(repository, never()).insertRevision(any());
verify(repository, never()).updateInboundWeightSummary(any());
}
@ParameterizedTest
@CsvSource({
"kg,2.000",
@@ -190,7 +279,8 @@ class InboundMeasurementServiceTest {
assertThat(response.adjustedWeightUnit()).isEqualTo(
unit.trim().toUpperCase().startsWith("LB") ? "LB" : "KG");
assertThat(response.finalWeightKg()).isEqualByComparingTo(expectedKg);
assertThat(response.grossWeightKg()).isEqualByComparingTo(expectedKg);
assertThat(response.netWeightKg()).isEqualByComparingTo(expectedKg);
assertThat(response.version()).isEqualTo(2);
}
@@ -208,7 +298,7 @@ class InboundMeasurementServiceTest {
1L, 10L, 20L, 9L, "操作员",
new InboundMeasurementRequests.ConfirmWeightResultRequest(0, null, null, null));
assertThat(response.finalWeightKg()).isEqualByComparingTo("0.001");
assertThat(response.netWeightKg()).isEqualByComparingTo("0.001");
assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("0.001");
}
@@ -283,7 +373,8 @@ class InboundMeasurementServiceTest {
0, new BigDecimal("286.125"), "kg", "OCR 原值 285.500 KG,人工确认 286.125 KG"));
assertThat(response.adjustedWeightValue()).isEqualByComparingTo("286.125");
assertThat(response.finalWeightKg()).isEqualByComparingTo("286.125");
assertThat(response.grossWeightKg()).isEqualByComparingTo("286.125");
assertThat(response.netWeightKg()).isEqualByComparingTo("286.125");
assertThat(response.recognitionStatus()).isEqualTo("CONFIRMED");
assertThat(response.version()).isEqualTo(2);
assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("286.125");
@@ -344,7 +435,8 @@ class InboundMeasurementServiceTest {
void confirmingAnAlreadyConfirmedWeightWithCurrentVersionIsIdempotent() {
InboundOrderEntity inbound = inbound("MEASURING");
InboundWeightResultEntity weight = weight(20L, "285.500", "KG", "CONFIRMED", 2);
weight.setFinalWeightKg(new BigDecimal("285.500"));
weight.setGrossWeightKg(new BigDecimal("285.500"));
weight.setNetWeightKg(new BigDecimal("285.500"));
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight);
@@ -363,7 +455,8 @@ class InboundMeasurementServiceTest {
void confirmingWithStaleVersionDoesNotTreatRequestAsIdempotent() {
InboundOrderEntity inbound = inbound("MEASURING");
InboundWeightResultEntity weight = weight(20L, "285.500", "KG", "CONFIRMED", 2);
weight.setFinalWeightKg(new BigDecimal("285.500"));
weight.setGrossWeightKg(new BigDecimal("285.500"));
weight.setNetWeightKg(new BigDecimal("285.500"));
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight);
@@ -420,7 +513,8 @@ class InboundMeasurementServiceTest {
InboundOrderEntity inbound = inbound("MEASURING");
inbound.setActualGrossWeightKg(new BigDecimal("285.500"));
InboundWeightResultEntity weight = weight(20L, "285.500", "KG", "CONFIRMED", 2);
weight.setFinalWeightKg(new BigDecimal("285.500"));
weight.setGrossWeightKg(new BigDecimal("285.500"));
weight.setNetWeightKg(new BigDecimal("285.500"));
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight);
when(repository.deleteWeight(1L, 10L, 20L, 2)).thenReturn(true);
@@ -440,9 +534,9 @@ class InboundMeasurementServiceTest {
InboundOrderEntity inbound = inbound("MEASURING");
inbound.setActualGrossWeightKg(new BigDecimal("15.000"));
InboundWeightResultEntity deleted = weight(20L, "10.000", "KG", "CONFIRMED", 2);
deleted.setFinalWeightKg(new BigDecimal("10.000"));
deleted.setNetWeightKg(new BigDecimal("10.000"));
InboundWeightResultEntity remaining = weight(21L, "5.000", "KG", "CONFIRMED", 1);
remaining.setFinalWeightKg(new BigDecimal("5.000"));
remaining.setNetWeightKg(new BigDecimal("5.000"));
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
when(repository.findWeight(1L, 10L, 20L)).thenReturn(deleted);
when(repository.deleteWeight(1L, 10L, 20L, 2)).thenReturn(true);
@@ -488,7 +582,7 @@ class InboundMeasurementServiceTest {
item.setPackageCount(3);
item.setSourceImageFileId(500L);
InboundWeightResultEntity weight = weight(20L, "20.000", "KG", "CONFIRMED", 1);
weight.setFinalWeightKg(new BigDecimal("20.000"));
weight.setNetWeightKg(new BigDecimal("20.000"));
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
when(repository.findMeasurement(1L, 10L, 30L)).thenReturn(measurement);
when(repository.listItems(1L, 30L)).thenReturn(List.of(item));
@@ -592,6 +686,7 @@ class InboundMeasurementServiceTest {
InboundOrderEntity inbound = new InboundOrderEntity();
inbound.setId(10L);
inbound.setTenantId(1L);
inbound.setWarehouseId(100L);
inbound.setProcessStatus(status);
inbound.setTotalPackageCount(3);
inbound.setVersion(0);
@@ -605,10 +700,21 @@ class InboundMeasurementServiceTest {
weight.setTenantId(1L);
weight.setInboundId(10L);
weight.setFileId(id + 100L);
weight.setPalletId(501L);
weight.setPalletCode("PLT-501");
weight.setPalletType("STANDARD");
weight.setPalletCapacityCbm(new BigDecimal("2.0000"));
weight.setPalletTareWeightKg(BigDecimal.ZERO.setScale(3));
weight.setOcrWeightValue(new BigDecimal(value));
weight.setOcrWeightUnit(unit);
weight.setRecognitionStatus(status);
weight.setVersion(version);
return weight;
}
private WarehousePalletLocationClient.PalletSnapshot palletSnapshot() {
return new WarehousePalletLocationClient.PalletSnapshot(
501L, "PLT-501", "STANDARD", new BigDecimal("2.0000"),
new BigDecimal("18.500"));
}
}