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(),