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
@@ -3189,8 +3189,12 @@ CREATE TABLE IF NOT EXISTS fl_price_book_channel (
reference_transit VARCHAR(128) NULL COMMENT '参考时效',
surcharge_description VARCHAR(2000) NULL COMMENT '附加费说明,仅展示',
remark VARCHAR(2000) NULL COMMENT '渠道备注',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_price_book_channel (price_book_version_id, channel_code),
KEY idx_price_book_channel_code (channel_code, price_book_version_id)
@@ -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) {
@@ -33,6 +33,8 @@ baiyue:
url: ${ORDER_SERVICE_URL:}
file:
url: ${FILE_SERVICE_URL:}
print:
url: ${PRINT_SERVICE_URL:}
firstleg:
inbound:
allow-rejected-measurement: false
@@ -454,8 +454,12 @@ CREATE TABLE IF NOT EXISTS fl_price_book_channel (
reference_transit VARCHAR(128) NULL COMMENT '参考时效',
surcharge_description VARCHAR(2000) NULL COMMENT '附加费说明,仅展示',
remark VARCHAR(2000) NULL COMMENT '渠道备注',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_price_book_channel (price_book_version_id, channel_code),
KEY idx_price_book_channel_code (channel_code, price_book_version_id)
@@ -9,6 +9,7 @@ import org.springframework.web.bind.annotation.RequestParam;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@@ -19,6 +20,8 @@ class InboundOrderControllerContractTest {
AuthenticatedUser.class, Long.class, InboundOrderQuery.class);
assertAuthority("detail", "hasAuthority('platform:firstleg:inbound:view')",
AuthenticatedUser.class, long.class, long.class);
assertAuthority("printCartonLabels", "hasAuthority('platform:firstleg:inbound:view')",
AuthenticatedUser.class, long.class, long.class, List.class, String.class);
Method page = PlatformInboundOrderController.class.getDeclaredMethod(
"page", AuthenticatedUser.class, Long.class, InboundOrderQuery.class);
@@ -0,0 +1,22 @@
package com.baiyue.firstleg.mapper;
import org.apache.ibatis.annotations.Delete;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Method;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("价格版本渠道 Mapper 契约")
class PriceBookChannelMapperTest {
@Test
@DisplayName("重新生成版本快照时使用物理删除避免唯一键冲突")
void shouldPhysicallyDeleteVersionChannels() throws Exception {
Method method = PriceBookChannelMapper.class
.getMethod("physicallyDeleteByPriceBookVersionId", long.class);
assertThat(method.getAnnotation(Delete.class).value())
.containsExactly("DELETE FROM fl_price_book_channel WHERE price_book_version_id = #{versionId}");
}
}
@@ -17,6 +17,10 @@ class PriceBookSqlContractTest {
.getContentAsString(StandardCharsets.UTF_8);
String migration = new ClassPathResource("db/phase2-fba-pricing-migration.sql")
.getContentAsString(StandardCharsets.UTF_8);
String channelTable = schema.substring(
schema.indexOf("CREATE TABLE IF NOT EXISTS fl_price_book_channel"),
schema.indexOf("ENGINE=InnoDB COMMENT='价格版本渠道及船期快照'")
);
assertThat(schema)
.contains("CREATE TABLE IF NOT EXISTS fl_price_channel_catalog")
.contains("CREATE TABLE IF NOT EXISTS fl_price_origin_alias")
@@ -32,6 +36,11 @@ class PriceBookSqlContractTest {
.contains("destination_name VARCHAR(128)")
.contains("parse_issues_json JSON NULL")
.contains("DEFAULT 'PENDING_CONFIRMATION'");
assertThat(channelTable)
.contains("created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID'")
.contains("updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID'")
.contains("deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记'")
.contains("version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'");
assertThat(migration)
.doesNotContain("fl_price_book_version")
.doesNotContain("'pricing_tax_mode'")
@@ -50,6 +50,7 @@ class CartonMeasurementServiceTest {
when(resolver.resolveByBoxCode(10001L, "RK-001#5-1"))
.thenReturn(new CartonLabelBindingResolver.Binding(70001L, 80001L, "RK-001", 1, 5, "50x40x30cm"));
when(repository.findInbound(10001L, 70001L)).thenReturn(inbound("WAIT_MEASURE"));
when(repository.lockInbound(10001L, 70001L)).thenReturn(inbound("WAIT_MEASURE"));
when(images.upload(any(), any(Long.TYPE), any(Long.TYPE), any(Long.TYPE), any()))
.thenReturn(ApiResponse.success(new MeasurementImageClient.UploadResult(
99001L, "measure.jpg", "image/jpeg", 10L, "NOT_REQUIRED", null, null, null, null)));
@@ -130,7 +131,7 @@ class CartonMeasurementServiceTest {
}
@Test
void confirmedPackageCountCanBeCorrectedAfterSubmissionAndSyncsInboundSnapshot() {
void confirmedPackageCountCanBeCorrectedAfterSubmissionWithoutChangingOriginalPackageCount() {
InboundOrderEntity inbound = inbound("MEASURE_PENDING");
InboundMeasurementEntity measurement = measurement(30001L, "SUBMITTED", 2);
measurement.setTotalPackageCount(1);
@@ -0,0 +1,89 @@
package com.baiyue.firstleg.service;
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.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class InboundCartonLabelPrintServiceTest {
private final InboundCartonLabelService labels = mock(InboundCartonLabelService.class);
private final PrintPdfClient print = mock(PrintPdfClient.class);
private InboundCartonLabelPrintService service;
@BeforeEach
void setUp() {
service = new InboundCartonLabelPrintService(labels, print, new ObjectMapper());
ReflectionTestUtils.setField(service, "internalToken", "internal-token");
}
@Test
void forwardsStableLabelsAndOnlySafePdfHeaders() {
when(labels.list(10001L, 70001L, List.of(1))).thenReturn(batch());
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_PDF);
headers.setContentDispositionFormData("attachment", "labels.pdf");
headers.set("X-Print-Page-Count", "1");
headers.set("X-Internal-Service-Token", "must-not-leak");
when(print.generateInboundMarks(org.mockito.ArgumentMatchers.anyString(),
org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any()))
.thenReturn(new ResponseEntity<>(new byte[] {1, 2, 3}, headers,
org.springframework.http.HttpStatus.OK));
ResponseEntity<byte[]> response = service.generate(10001L, 70001L, List.of(1), "request-1");
ArgumentCaptor<PrintPdfClient.InboundMarkPdfRequest> request =
ArgumentCaptor.forClass(PrintPdfClient.InboundMarkPdfRequest.class);
verify(print).generateInboundMarks(org.mockito.ArgumentMatchers.eq("internal-token"),
org.mockito.ArgumentMatchers.eq("request-1"), request.capture());
assertThat(request.getValue().downloadFileName()).isEqualTo("PCHK2608040174-19件-箱唛.pdf");
assertThat(request.getValue().labels()).singleElement().satisfies(label -> {
assertThat(label.qrContent()).isEqualTo("PCHK2608040174#19-1");
assertThat(label.cartonText()).isEqualTo("CTNS:19-1");
});
assertThat(response.getBody()).containsExactly(1, 2, 3);
assertThat(response.getHeaders().getFirst("X-Print-Page-Count")).isEqualTo("1");
assertThat(response.getHeaders()).doesNotContainKey("X-Internal-Service-Token");
}
@Test
void convertsPrintJsonBusinessFailureInsteadOfReturningItAsPdf() throws Exception {
when(labels.list(10001L, 70001L, null)).thenReturn(batch());
byte[] error = "{\"code\":\"A0422\",\"message\":\"标签字段超出模板限制\","
.concat("\"data\":null,\"requestId\":\"req-2\",\"traceId\":\"trace-2\"}")
.getBytes(java.nio.charset.StandardCharsets.UTF_8);
when(print.generateInboundMarks(org.mockito.ArgumentMatchers.anyString(),
org.mockito.ArgumentMatchers.isNull(), org.mockito.ArgumentMatchers.any()))
.thenReturn(ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body(error));
assertThatThrownBy(() -> service.generate(10001L, 70001L, null, null))
.isInstanceOf(BizException.class)
.hasMessageContaining("标签字段超出模板限制")
.extracting(value -> ((BizException) value).getResultCode())
.isEqualTo(ResultCode.VALIDATION_FAILED);
}
private InboundCartonLabels.Batch batch() {
InboundCartonLabels.Label label = new InboundCartonLabels.Label(
1L, 2L, 1, "音箱", 19, "PCHK2608040174#19-1", 19, 1,
"50x40x30cm", new BigDecimal("50"), new BigDecimal("40"), new BigDecimal("30"),
"PCHK2608040174", "MCC1", "CTNS:19-1", "洛杉矶普船海卡");
return new InboundCartonLabels.Batch(70001L, 80001L, "PCHK2608040174", 19, List.of(label));
}
}
@@ -6,6 +6,7 @@ import com.baiyue.firstleg.config.InboundProperties;
import com.baiyue.firstleg.dto.ExceptionReleaseRequest;
import com.baiyue.firstleg.dto.MeasurementReviewRequest;
import com.baiyue.firstleg.entity.InboundMeasurementEntity;
import com.baiyue.firstleg.entity.InboundMeasurementItemEntity;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.entity.InboundStatusHistoryEntity;
import com.baiyue.firstleg.repository.InboundWorkflowRepository;
@@ -79,6 +80,23 @@ class MeasurementReviewServiceTest {
assertThat(history.getValue().getToStatus()).isEqualTo("WAIT_SPLIT");
}
@Test
@DisplayName("确认箱数合计不等于订单总箱数时不能审核通过")
void shouldRejectApprovalWhenConfirmedPackageCountDoesNotMatchOrder() {
Fixture fixture = fixture(false);
InboundMeasurementItemEntity item = new InboundMeasurementItemEntity();
item.setPackageCount(4);
when(fixture.repository.listMeasurementItems(10001L, 20L)).thenReturn(List.of(item));
assertThatThrownBy(() -> fixture.service.approve(10001L, 9L, "审核员", 10L, "approve-mismatch",
new MeasurementReviewRequest(20L, 3, null)))
.isInstanceOf(BizException.class)
.extracting(error -> ((BizException) error).getResultCode())
.isEqualTo(ResultCode.VALIDATION_FAILED);
verify(fixture.repository, never()).transitionReview(anyLong(), anyLong(), anyLong(), anyInt(), anyBoolean());
}
@Test
@DisplayName("驳回原因必填且状态推进为测量驳回")
void shouldRequireRejectReason() {
@@ -175,10 +193,15 @@ class MeasurementReviewServiceTest {
InboundOrderEntity order = new InboundOrderEntity();
order.setProcessStatus("MEASURE_PENDING");
order.setMeasureReviewStatus("PENDING");
order.setTotalPackageCount(5);
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
measurement.setId(20L);
measurement.setStatus("SUBMITTED");
InboundMeasurementItemEntity item = new InboundMeasurementItemEntity();
item.setPackageCount(5);
when(repository.findOwned(10001L, 10L)).thenReturn(order);
when(repository.findMeasurement(10001L, 10L, 20L)).thenReturn(measurement);
when(repository.listMeasurementItems(10001L, 20L)).thenReturn(List.of(item));
return new Fixture(service, repository, idempotency, order, measurement);
}