This commit is contained in:
2026-08-07 17:14:40 +08:00
parent f4c3a95772
commit 91f31d9567
20 changed files with 354 additions and 7 deletions
@@ -0,0 +1,32 @@
package com.baiyue.firstleg.client;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import java.math.BigDecimal;
import java.util.List;
/** Print Service contract for stateless inbound carton label rendering. */
@FeignClient(name = "print-service", contextId = "firstlegPrintPdfClient",
url = "${baiyue.clients.print.url:}", path = "/internal/print/v1/pdfs")
public interface PrintPdfClient {
@PostMapping(value = "/inbound-marks", consumes = "application/json")
ResponseEntity<byte[]> generateInboundMarks(
@RequestHeader("X-Internal-Service-Token") String token,
@RequestHeader(value = "X-Request-Id", required = false) String requestId,
@RequestBody InboundMarkPdfRequest request);
record InboundMarkPdfRequest(String templateCode, PageSpec pageSpec,
String downloadFileName, List<Label> labels) {
}
record PageSpec(BigDecimal widthMm, BigDecimal heightMm) {
}
record Label(String topCode, String warehouseText, String cartonText,
String routeText, String qrContent) {
}
}
@@ -11,6 +11,7 @@ import com.baiyue.firstleg.dto.InboundOrderDetail;
import com.baiyue.firstleg.dto.InboundOrderQuery;
import com.baiyue.firstleg.dto.InboundOrderSummary;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import com.baiyue.firstleg.service.InboundCartonLabelPrintService;
import com.baiyue.firstleg.service.InboundOrderQueryService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
@@ -22,9 +23,14 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.http.ResponseEntity;
import java.util.List;
/** 平台端入库单列表和详情只读接口。 */
@Tag(name = "平台入库单", description = "二期入库单列表、页签、筛选和只读聚合详情")
@@ -33,6 +39,7 @@ import org.springframework.web.bind.annotation.RestController;
@RequestMapping("/api/platform/inbounds")
public class PlatformInboundOrderController {
@Resource private InboundOrderQueryService service;
@Resource private InboundCartonLabelPrintService cartonLabelPrintService;
@Operation(summary = "分页查询入库单",
description = "默认分页查询全部企业入库单;可按目标租户和二期状态页签筛选入库单快照。")
@@ -61,6 +68,23 @@ public class PlatformInboundOrderController {
return ApiResponse.success(service.detail(tenantId, inboundId));
}
@Operation(summary = "生成入仓箱唛 PDF",
description = "首次调用按订单货物明细冻结整单箱号;重复打印及指定箱下标补打复用原箱号映射。")
@PostMapping("/{inboundId}/carton-label-pdf")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:view')")
public ResponseEntity<byte[]> printCartonLabels(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId,
@PathVariable long inboundId,
@RequestParam(required = false) List<@Min(1) Integer> cartonIndex,
@RequestHeader(value = "X-Request-Id", required = false) String requestId) {
requirePlatformTenant(user, tenantId);
if (inboundId <= 0) {
throw new BizException(ResultCode.BAD_REQUEST, "入库单ID必须大于零");
}
return cartonLabelPrintService.generate(tenantId, inboundId, cartonIndex, requestId);
}
private static void requirePlatformTenant(AuthenticatedUser user, long tenantId) {
FirstlegScopeGuard.platform(user, tenantId);
}
@@ -34,6 +34,7 @@ public final class InboundMeasurementResponses {
String originalDocumentNo,
BigDecimal originalGrossWeightKg,
BigDecimal originalVolumeCbm,
Integer originalPackageCount,
Integer actualPackageCount,
BigDecimal actualGrossWeightKg,
BigDecimal actualVolumeCbm,
@@ -1,4 +1,10 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.PriceBookChannelEntity;
public interface PriceBookChannelMapper extends BaseMapper<PriceBookChannelEntity> {}
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Param;
public interface PriceBookChannelMapper extends BaseMapper<PriceBookChannelEntity> {
@Delete("DELETE FROM fl_price_book_channel WHERE price_book_version_id = #{versionId}")
int physicallyDeleteByPriceBookVersionId(@Param("versionId") long versionId);
}
@@ -42,6 +42,13 @@ public class InboundMeasurementRepository {
.eq(InboundOrderEntity::getId, inboundId));
}
public InboundOrderEntity lockInbound(long tenantId, long inboundId) {
return inboundOrders.selectOne(new LambdaQueryWrapper<InboundOrderEntity>()
.eq(InboundOrderEntity::getTenantId, tenantId)
.eq(InboundOrderEntity::getId, inboundId)
.last("FOR UPDATE"));
}
public boolean updateInbound(InboundOrderEntity inbound) {
return inboundOrders.update(inbound, new LambdaUpdateWrapper<InboundOrderEntity>()
.eq(InboundOrderEntity::getTenantId, inbound.getTenantId())
@@ -8,6 +8,7 @@ import com.baiyue.common.core.api.PageResult;
import com.baiyue.firstleg.entity.InboundFeeEntity;
import com.baiyue.firstleg.entity.InboundIdempotencyEntity;
import com.baiyue.firstleg.entity.InboundMeasurementEntity;
import com.baiyue.firstleg.entity.InboundMeasurementItemEntity;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.entity.InboundOutboxEntity;
import com.baiyue.firstleg.entity.InboundStatusHistoryEntity;
@@ -16,6 +17,7 @@ import com.baiyue.firstleg.mapper.InboundAllocationQueryMapper;
import com.baiyue.firstleg.mapper.InboundFeeMapper;
import com.baiyue.firstleg.mapper.InboundIdempotencyMapper;
import com.baiyue.firstleg.mapper.InboundMeasurementMapper;
import com.baiyue.firstleg.mapper.InboundMeasurementItemMapper;
import com.baiyue.firstleg.mapper.InboundOrderMapper;
import com.baiyue.firstleg.mapper.InboundOutboxMapper;
import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
@@ -30,6 +32,8 @@ public class InboundWorkflowRepository {
@Resource
private InboundMeasurementMapper measurements;
@Resource
private InboundMeasurementItemMapper measurementItems;
@Resource
private MeasureReviewRecordMapper reviews;
@Resource
private InboundStatusHistoryMapper histories;
@@ -55,6 +59,13 @@ public class InboundWorkflowRepository {
.eq(InboundMeasurementEntity::getId, measurementId));
}
public java.util.List<InboundMeasurementItemEntity> listMeasurementItems(
long tenantId, long measurementId) {
return measurementItems.selectList(new LambdaQueryWrapper<InboundMeasurementItemEntity>()
.eq(InboundMeasurementItemEntity::getTenantId, tenantId)
.eq(InboundMeasurementItemEntity::getMeasurementId, measurementId));
}
public int transitionReview(long tenantId, long inboundId, long operatorId, int expectedVersion,
boolean approved) {
return approved
@@ -99,7 +99,7 @@ public class PriceBookRepository {
}
public void deletePendingChildren(long versionId) {
rates.physicallyDeletePendingByPriceBookVersionId(versionId);
channels.delete(new LambdaQueryWrapper<PriceBookChannelEntity>().eq(PriceBookChannelEntity::getPriceBookVersionId, versionId));
channels.physicallyDeleteByPriceBookVersionId(versionId);
}
public List<PriceChannelCatalogEntity> activeCatalog() {
return catalog.selectList(new LambdaQueryWrapper<PriceChannelCatalogEntity>()
@@ -68,7 +68,7 @@ public class CartonMeasurementService {
UpsertOutcome outcome;
try {
outcome = transactionTemplate.execute(status -> persist(
tenantId, operatorId, binding, inbound, boxCode, command, uploaded.fileId()));
tenantId, operatorId, binding, boxCode, command, uploaded.fileId()));
} catch (RuntimeException exception) {
cleanupImage(tenantId, operatorId, inbound.getId(), uploaded.fileId(), "回传落库失败补偿");
throw exception;
@@ -111,9 +111,8 @@ public class CartonMeasurementService {
if (!repository.updateMeasurement(measurement)) {
throw concurrentModification();
}
// 提交审核后仍允许平台人工修正代表箱数,入库单快照必须随当前批次同步。
// 提交审核后仍允许平台人工修正代表箱数,但订单原始总箱数不可被测量汇总覆盖。
if ("MEASURE_PENDING".equals(inbound.getProcessStatus())) {
inbound.setTotalPackageCount(measurement.getTotalPackageCount());
inbound.setActualVolumeCbm(measurement.getTotalVolumeCbm());
inbound.setVolumeWeightKg(measurement.getVolumeWeightKg());
if (!repository.updateInbound(inbound)) {
@@ -125,8 +124,15 @@ public class CartonMeasurementService {
private UpsertOutcome persist(long tenantId, long operatorId,
CartonLabelBindingResolver.Binding binding,
InboundOrderEntity inbound, String boxCode,
String boxCode,
CartonMeasurementCommand command, long imageFileId) {
InboundOrderEntity inbound = repository.lockInbound(tenantId, binding.inboundId());
if (inbound == null) {
throw new BizException(ResultCode.NOT_FOUND, "入库单不存在");
}
if (!DEVICE_WRITABLE_STATUSES.contains(inbound.getProcessStatus())) {
throw new BizException(ResultCode.CONFLICT, "测量已提交,设备不能新增或覆盖数据");
}
InboundMeasurementItemEntity item = repository.findItemByBoxCode(tenantId, boxCode);
boolean created = item == null;
InboundMeasurementEntity measurement;
@@ -0,0 +1,91 @@
package com.baiyue.firstleg.service;
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.PrintPdfClient;
import com.baiyue.firstleg.dto.InboundCartonLabels;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import feign.FeignException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.math.BigDecimal;
import java.util.List;
/** Allocates stable carton codes and forwards immutable label facts to Print. */
@Service
public class InboundCartonLabelPrintService {
private static final BigDecimal PAGE_MM = new BigDecimal("100");
private static final List<String> SAFE_RESPONSE_HEADERS = List.of(
HttpHeaders.CONTENT_DISPOSITION, HttpHeaders.CACHE_CONTROL, HttpHeaders.PRAGMA,
"X-Print-Page-Count", "X-Request-Id", "X-Trace-Id");
private final InboundCartonLabelService labelService;
private final PrintPdfClient printClient;
private final ObjectMapper objectMapper;
@Value("${baiyue.security.internal-service-token}")
private String internalToken;
public InboundCartonLabelPrintService(InboundCartonLabelService labelService,
PrintPdfClient printClient,
ObjectMapper objectMapper) {
this.labelService = labelService;
this.printClient = printClient;
this.objectMapper = objectMapper;
}
public ResponseEntity<byte[]> generate(long tenantId, long inboundId,
List<Integer> cartonIndexes, String requestId) {
InboundCartonLabels.Batch batch = labelService.list(tenantId, inboundId, cartonIndexes);
PrintPdfClient.InboundMarkPdfRequest request = new PrintPdfClient.InboundMarkPdfRequest(
"INBOUND_MARK_V1", new PrintPdfClient.PageSpec(PAGE_MM, PAGE_MM),
batch.inboundCode() + "-" + batch.totalCount() + "件-箱唛.pdf",
batch.labels().stream().map(this::toPrintLabel).toList());
try {
return requirePdf(printClient.generateInboundMarks(internalToken, requestId, request));
} catch (FeignException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "打印服务暂不可用,请稍后重试");
}
}
private PrintPdfClient.Label toPrintLabel(InboundCartonLabels.Label label) {
return new PrintPdfClient.Label(label.topCode(), label.warehouseText(), label.cartonText(),
label.routeText(), label.boxCode());
}
private ResponseEntity<byte[]> requirePdf(ResponseEntity<byte[]> response) {
byte[] body = response == null || response.getBody() == null ? new byte[0] : response.getBody();
MediaType contentType = response == null ? null : response.getHeaders().getContentType();
if (contentType == null || !MediaType.APPLICATION_PDF.isCompatibleWith(contentType) || body.length == 0) {
throw downstreamError(body);
}
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_PDF);
SAFE_RESPONSE_HEADERS.forEach(name -> {
String value = response.getHeaders().getFirst(name);
if (StringUtils.hasText(value)) headers.set(name, value);
});
headers.setContentLength(body.length);
return ResponseEntity.ok().headers(headers).body(body);
}
private BizException downstreamError(byte[] body) {
try {
ApiResponse<Object> error = objectMapper.readValue(body, new TypeReference<>() { });
if (error != null && !error.successful()) {
return new BizException(ResultCode.of(error.code()),
StringUtils.hasText(error.message()) ? error.message() : "打印服务生成失败");
}
} catch (Exception ignored) {
// Non-contract responses are treated as unavailable instead of exposing downstream content.
}
return new BizException(ResultCode.SERVICE_UNAVAILABLE, "打印服务返回了无效响应");
}
}
@@ -273,6 +273,7 @@ public class InboundMeasurementService {
measurement == null ? null : measurement.getOriginalDocumentNo(),
measurement == null ? null : measurement.getOriginalGrossWeightKg(),
measurement == null ? null : measurement.getOriginalVolumeCbm(),
inbound.getTotalPackageCount(),
measurement == null ? inbound.getTotalPackageCount() : measurement.getTotalPackageCount(),
inbound.getActualGrossWeightKg(),
measurement == null ? inbound.getActualVolumeCbm() : measurement.getTotalVolumeCbm(),
@@ -68,6 +68,17 @@ public class MeasurementReviewService {
if (!"SUBMITTED".equals(measurement.getStatus())) {
throw new BizException(ResultCode.CONFLICT, "测量批次不是待审核状态");
}
if (approved) {
long confirmed = repository.listMeasurementItems(tenantId, measurement.getId()).stream()
.map(com.baiyue.firstleg.entity.InboundMeasurementItemEntity::getPackageCount)
.filter(java.util.Objects::nonNull)
.mapToLong(Integer::longValue)
.sum();
if (order.getTotalPackageCount() == null || confirmed != order.getTotalPackageCount()) {
throw new BizException(ResultCode.VALIDATION_FAILED,
"人工确认箱数合计与入库单总箱数不一致,不能审核通过");
}
}
if (repository.transitionReview(tenantId, inboundId, operatorId,
request.expectedVersion(), approved) != 1) {