This commit is contained in:
2026-09-11 16:17:16 +08:00
parent 2d928d667b
commit 51ce609ab1
57 changed files with 942 additions and 139 deletions
@@ -60,4 +60,5 @@ public interface PrintPdfClient {
record DropshipPalletSkuRow(String inboundCode, String skuCode, String cargoName,
int cartonCount, int skuQuantity) {}
}
@@ -1,12 +1,16 @@
package com.baiyue.firstleg.domain;
import java.util.List;
import java.util.Locale;
import java.util.Set;
/** 首版固定渠道目录;上传名称只能映射到这些稳定编码。 */
public final class PriceBookChannelCatalog {
private PriceBookChannelCatalog() {}
private static final Set<String> SEA_RAIL_CHANNEL_CODES = Set.of(
"EXX_RAIL_NY", "NYEXX", "OA_CHICAGO_RAIL", "CPHT");
public static final List<ChannelDefinition> CHANNELS = List.of(
fba("MATSON_CLX_TRUCK", "美森正班海卡", "美森正班海卡", "ZBYHK", "ZBYHWK"),
fba("MATSON_MAX_TRUCK", "美森加班海卡", "美森加班海卡", "BYHK", "BYHWK"),
@@ -37,6 +41,11 @@ public final class PriceBookChannelCatalog {
inboundCodePrefix, overseasInboundCodePrefix);
}
public static boolean isSeaRail(String channelCode) {
return channelCode != null
&& SEA_RAIL_CHANNEL_CODES.contains(channelCode.trim().toUpperCase(Locale.ROOT));
}
public record ChannelDefinition(
String channelCode,
String displayName,
@@ -23,6 +23,7 @@ public interface LoadingListQueryMapper {
p.id AS plan_id, p.plan_no, p.container_booking_id, p.plan_status, p.remark,
p.created_by, p.created_by_name, p.created_at, p.version,
b.container_no, b.cabinet_no, b.warehouse_id, b.warehouse_name,
b.channel_code, b.outbound_confirmed_at, b.actual_empty_reported_at,
b.vessel_name, b.voyage_no, b.container_type,
(SELECT COUNT(*) FROM fl_preload_loading_plan_link preload_link
WHERE preload_link.loading_plan_id = p.id) AS preload_link_count,
@@ -51,6 +52,7 @@ public interface LoadingListQueryMapper {
p.id, p.plan_no, p.container_booking_id, p.plan_status, p.remark,
p.created_by, p.created_by_name, p.created_at, p.version,
b.container_no, b.cabinet_no, b.warehouse_id, b.warehouse_name,
b.channel_code, b.outbound_confirmed_at, b.actual_empty_reported_at,
b.vessel_name, b.voyage_no, b.container_type
""";
@@ -394,6 +396,9 @@ public interface LoadingListQueryMapper {
private String cabinetNo;
private Long warehouseId;
private String warehouseName;
private String channelCode;
private LocalDateTime outboundConfirmedAt;
private LocalDateTime actualEmptyReportedAt;
private String vesselName;
private String voyageNo;
private String containerType;
@@ -15,7 +15,6 @@ import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.format.DateTimeFormatter;
import java.util.LinkedHashMap;
import java.util.List;
@@ -47,7 +46,7 @@ public class OceanBookingPrintService {
String fileName = booking.getBookingNo() + "-" + suffix + ".pdf";
PrintPdfClient.FreightA4PdfRequest request = new PrintPdfClient.FreightA4PdfRequest(
template, A4, fileName, List.of(new PrintPdfClient.FreightDocument(
fields(template, detail), List.of(cargo(detail.containerRequirements())))));
fields(template, detail), List.of(emptyCargo()))));
try {
ResponseEntity<byte[]> response = print.generateFreightA4Documents(internalToken, requestId, request);
MediaType contentType = response.getHeaders().getContentType();
@@ -78,12 +77,12 @@ public class OceanBookingPrintService {
fields.put("placeOfDelivery", text(booking.getPlaceOfDelivery()));
fields.put("serviceTerm", text(booking.getServiceTerm()));
fields.put("freightTerm", text(booking.getFreightTerm()));
fields.put("totalPackagesInWords", Integer.toString(totalContainers(detail.containerRequirements())));
fields.put("totalPackagesInWords", "");
if (SHIPPING_INSTRUCTION.equals(template)) {
fields.put("toCompany", text(booking.getBookingAgentName()));
fields.put("jobNumber", text(booking.getBookingNo()));
fields.put("etdDate", date(booking.getEstimatedSailingAt()));
fields.put("cargoQuantity", Integer.toString(totalContainers(detail.containerRequirements())));
fields.put("cargoQuantity", "");
fields.put("carrier", text(booking.getShippingCompanyName()));
fields.put("bookingAgent", text(booking.getBookingAgentName()));
fields.put("remark", text(booking.getRemark()));
@@ -99,18 +98,8 @@ public class OceanBookingPrintService {
return fields;
}
private static PrintPdfClient.CargoRow cargo(List<OceanBookingContainerRequirementEntity> requirements) {
BigDecimal gross = requirements.stream().map(value -> value.getMaxWeightKg()
.multiply(BigDecimal.valueOf(value.getQuantity()))).reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal volume = requirements.stream().map(value -> value.getCapacityCbm()
.multiply(BigDecimal.valueOf(value.getQuantity()))).reduce(BigDecimal.ZERO, BigDecimal::add);
return new PrintPdfClient.CargoRow("N/M", Integer.toString(totalContainers(requirements)),
"CONTAINERS", requirementSummary(requirements), decimal(gross, 3) + " KGS",
decimal(volume, 4) + " CBM");
}
private static int totalContainers(List<OceanBookingContainerRequirementEntity> values) {
return values.stream().mapToInt(OceanBookingContainerRequirementEntity::getQuantity).sum();
private static PrintPdfClient.CargoRow emptyCargo() {
return new PrintPdfClient.CargoRow("—", "—", "—", "—", "—", "—");
}
private static String requirementSummary(List<OceanBookingContainerRequirementEntity> values) {
@@ -128,9 +117,6 @@ public class OceanBookingPrintService {
private static String date(java.time.LocalDateTime value) {
return value == null ? "" : value.format(DateTimeFormatter.ISO_LOCAL_DATE);
}
private static String decimal(BigDecimal value, int scale) {
return value.setScale(scale, RoundingMode.HALF_UP).toPlainString();
}
private static String first(String first, String fallback) { return !text(first).isEmpty() ? first.trim() : text(fallback); }
private static String join(String first, String second) { return List.of(text(first), text(second)).stream().filter(value -> !value.isEmpty()).collect(Collectors.joining(" / ")); }
private static String text(String value) { return value == null ? "" : value.trim(); }
@@ -261,7 +261,7 @@ public class InboundMeasurementService {
ConfirmedWeightTotals confirmedTotals = confirmedWeightTotals(weights);
if (confirmedTotals.palletCount() == 0) {
throw new BizException(ResultCode.VALIDATION_FAILED,
"请至少上传并确认一张称重图片后再提交测量审核");
"即使只有一箱,也必须上传并确认至少一张称重图片后再提交测量审核");
}
requireConfirmedPackageTotal(inbound, items);
@@ -26,6 +26,7 @@ import com.baiyue.firstleg.repository.LoadingListRepository;
import com.baiyue.firstleg.repository.LoadingPickRepository;
import com.baiyue.firstleg.repository.ContainerRepository;
import com.baiyue.firstleg.domain.Iso6346ContainerNumber;
import com.baiyue.firstleg.domain.PriceBookChannelCatalog;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -138,7 +139,9 @@ public class LoadingListService {
}
public BusinessDocument containerLogisticsDocument(long bookingId,String type){ requireLogisticsType(type); requireBookingPlan(bookingId); return document(repository.document(bookingId,type)); }
public BusinessDocument uploadContainerLogisticsDocument(long bookingId,String type,long operatorId,String operatorName,String key,int expectedVersion,String reason,MultipartFile file){
requireLogisticsType(type); String businessType=fileType(type); return uploadDocument(requireBookingPlan(bookingId),type,businessType,bookingId,Long.toString(bookingId),operatorId,operatorName,key,expectedVersion,reason,file);
requireLogisticsType(type); String businessType=fileType(type); AggregateRow plan=requireBookingPlan(bookingId);
requireDeliveryOrderUploadAllowed(plan);
return uploadDocument(plan,type,businessType,bookingId,Long.toString(bookingId),operatorId,operatorName,key,expectedVersion,reason,file);
}
public DocumentDownload downloadContainerDocument(long bookingId,String type,long fileId){
requireLogisticsType(type); String businessType=fileType(type); requireBookingPlan(bookingId); ContainerDocumentEntity d=requireDocumentEntity(bookingId,type); if(!Long.valueOf(fileId).equals(d.getFileId())) throw notFound("货柜资料不存在");
@@ -190,6 +193,17 @@ public class LoadingListService {
public record DocumentDownload(String fileName,MediaType contentType,byte[] content) {}
private static void requireLogisticsType(String type){ if(!DELIVERY_ORDER.equals(type)) throw badRequest("documentType只能是DELIVERY_ORDER"); }
private static String fileType(String type){ if (DELIVERY_ORDER.equals(type)) return DO_FILE_TYPE; throw badRequest("documentType只能是DELIVERY_ORDER"); }
private static void requireDeliveryOrderUploadAllowed(AggregateRow plan) {
if (!"COMPLETED".equals(plan.getPlanStatus()) || plan.getOutboundConfirmedAt() == null) {
throw conflict("货柜进入海外履约后才可上传DO资料");
}
if (plan.getActualEmptyReportedAt() != null) {
throw conflict("货柜已还柜,不可再上传DO资料");
}
if (PriceBookChannelCatalog.isSeaRail(plan.getChannelCode())) {
throw conflict("海铁渠道不适用DO资料");
}
}
@Transactional
@BusinessAudit(event = "CONTAINER_CLEARANCE_DOCUMENT_ATTACH", resourceType = "CONTAINER",
@@ -92,14 +92,18 @@ public class MeasurementReviewService {
"人工确认箱数合计与入库单总箱数不一致,不能审核通过");
}
var weights = measurements.listWeights(tenantId, inboundId);
if (weights.isEmpty() || weights.stream().anyMatch(weight ->
if (weights.isEmpty()) {
throw new BizException(ResultCode.VALIDATION_FAILED,
"该票测量已提交但没有称重图片;请先退回测量,上传并确认至少一张称重图片后重新提交(即使只有一箱也必须称重)");
}
if (weights.stream().anyMatch(weight ->
!"CONFIRMED".equals(weight.getRecognitionStatus())
|| weight.getGrossWeightKg() == null
|| weight.getGrossWeightKg().signum() <= 0
|| weight.getNetWeightKg() == null
|| weight.getNetWeightKg().signum() <= 0)) {
throw new BizException(ResultCode.VALIDATION_FAILED,
"所有称重结果必须已确认且包含大于零的毛重和货物净重,才能审核通过");
"该票存在未确认或重量无效的称重图片;请先在审核弹框逐张修正并确认后再审核");
}
BigDecimal grossWeight = weights.stream()
.map(com.baiyue.firstleg.entity.InboundWeightResultEntity::getGrossWeightKg)
@@ -1224,7 +1224,7 @@ paths:
/api/platform/v1/inbounds/{inboundId}/measurements/approve:
post:
summary: 审核通过测量
description: 需要 firstleg:inbound:measurement-review 权限,且 Order 权威订单必须已审核通过。订单待审核期间可保留并提交到仓测量数据,但不能审核测量。标准头程审核通过后进入 WAIT_SPLIT;一件代发进入 WAIT_PALLETIZE,完成逐箱清点和封托后再进入 WAIT_SPLIT。审核时按该次测量数据尽力生成 CALCULATED 初算费用;价格未覆盖或初算异常只返回非阻断提示,不影响审核结果和后续履约状态。
description: 需要 firstleg:inbound:measurement-review 权限,且 Order 权威订单必须已审核通过。订单待审核期间可保留并提交到仓测量数据,但不能审核测量。审核通过前必须存在至少一张已确认的称重图片;历史异常数据若已提交但没有称重图片,必须先退回测量后补录。标准头程审核通过后进入 WAIT_SPLIT;一件代发进入 WAIT_PALLETIZE,完成逐箱清点和封托后再进入 WAIT_SPLIT。审核时按该次测量数据尽力生成 CALCULATED 初算费用;价格未覆盖或初算异常只返回非阻断提示,不影响审核结果和后续履约状态。
parameters:
- {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
@@ -1245,7 +1245,7 @@ paths:
/api/platform/v1/inbounds/{inboundId}/measurements/submit:
post:
summary: 提交测量完成
description: 提交只校验并复用称重图片确认阶段已经 OCCUPIED 且绑定完整的权威整票库位块,然后进入客服待审核;不会首次分配、重建或更换已显示槽位。Order 仍待审核时允许提交并冻结到仓测量证据,但不得继续审核测量或进入计费、分柜和装柜。
description: 即使只有一箱,也必须先上传并确认至少一张称重图片。提交只校验并复用称重图片确认阶段已经 OCCUPIED 且绑定完整的权威整票库位块,然后进入客服待审核;不会首次分配、重建或更换已显示槽位。Order 仍待审核时允许提交并冻结到仓测量证据,但不得继续审核测量或进入计费、分柜和装柜。
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]*$'}}
@@ -89,7 +89,7 @@ paths:
/api/platform/v1/ocean-bookings/{oceanBookingId}/prints/shipping-instruction:
get:
summary: Render the current saved booking as an export shipping instruction PDF
description: Firstleg maps the authoritative saved aggregate (DRAFT or RELEASED, with missing fields displayed as —) to SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1 and synchronously proxies the generated PDF.
description: Firstleg maps the authoritative saved aggregate (DRAFT or RELEASED, with missing fields displayed as —) to SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1 and synchronously proxies the generated PDF. Container requirements are not treated as actual cargo quantity, weight, volume, marks, packaging, or goods descriptions.
x-permission: platform:firstleg:ocean-booking:list
parameters: [{$ref: '#/components/parameters/OceanBookingId'}]
responses:
@@ -97,7 +97,7 @@ paths:
/api/platform/v1/ocean-bookings/{oceanBookingId}/prints/bill-of-lading-confirmation:
get:
summary: Render the current saved booking as a bill-of-lading confirmation PDF
description: Firstleg maps the authoritative saved aggregate (DRAFT or RELEASED, with missing fields displayed as —) to BILL_OF_LADING_CONFIRMATION_A4_V1 and synchronously proxies the generated PDF.
description: Firstleg maps the authoritative saved aggregate (DRAFT or RELEASED, with missing fields displayed as —) to BILL_OF_LADING_CONFIRMATION_A4_V1 and synchronously proxies the generated PDF. Container requirements remain a container summary and are not treated as actual cargo facts.
x-permission: platform:firstleg:ocean-booking:list
parameters: [{$ref: '#/components/parameters/OceanBookingId'}]
responses:
@@ -39,10 +39,10 @@ paths:
/api/platform/v1/containers/{id}/documents/{documentType}/upload:
post:
operationId: uploadContainerDocument
description: '权限:platform:firstleg:terminal-drayage:document:edit;仅国外工作台DO,无完成动作'
description: '权限:platform:firstleg:terminal-drayage:document:edit;仅已进入海外履约且未还柜的非海铁渠道可上传DO,无完成动作'
parameters: [{$ref: '#/components/parameters/ContainerId'}, {$ref: '#/components/parameters/DocumentType'}, {name: Idempotency-Key, in: header, required: true, schema: {type: string}}]
requestBody: {required: true, content: {multipart/form-data: {schema: {type: object, required: [file, expectedVersion], properties: {file: {type: string, format: binary}, expectedVersion: {type: integer, minimum: 0}, operationReason: {type: string, maxLength: 500}}}}}}
responses: {'200': {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/DocumentEnvelope'}}}}}
responses: {'200': {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/DocumentEnvelope'}}}}, '409': {description: '尚未进入海外履约、海铁渠道不适用DO或货柜已还柜'}}
/api/platform/v1/containers/{id}/documents/{documentType}/{fileId}/download:
get:
operationId: downloadContainerDocument
@@ -27,7 +27,11 @@ class InboundMeasurementEvidenceOpenApiContractTest {
Map<String, Object> submitOperation = stringMap(stringMap(paths.get(
"/api/platform/v1/inbounds/{inboundId}/measurements/submit")).get("post"));
assertThat(submitOperation.get("description").toString())
.contains("只校验并复用", "不会首次分配");
.contains("即使只有一箱", "上传并确认至少一张称重图片", "只校验并复用", "不会首次分配");
Map<String, Object> approveOperation = stringMap(stringMap(paths.get(
"/api/platform/v1/inbounds/{inboundId}/measurements/approve")).get("post"));
assertThat(approveOperation.get("description").toString())
.contains("已确认的称重图片", "历史异常数据", "先退回测量");
Map<String, Object> schemas = stringMap(stringMap(document.get("components")).get("schemas"));
Map<String, Object> detailProperties = properties(schemas, "InboundMeasurementDetail");
@@ -31,7 +31,7 @@ class OceanBookingPrintServiceTest {
@BeforeEach void setUp() { MockitoAnnotations.openMocks(this); ReflectionTestUtils.setField(service, "internalToken", "token"); }
@Test void shippingInstructionUsesOnlyAuthoritativeReleasedAggregate() {
@Test void shippingInstructionDoesNotTreatContainerCapacityAsActualCargo() {
when(bookings.detail(10L)).thenReturn(detail("RELEASED"));
HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_PDF);
headers.set("X-Print-Page-Count", "1");
@@ -49,12 +49,17 @@ class OceanBookingPrintServiceTest {
assertThat(document.fields()).containsEntry("jobNumber", "OB-1")
.containsEntry("shipper", "Shipper\nAddress")
.containsEntry("oceanVesselVoyage", "Vessel / V001")
.containsEntry("cargoQuantity", "—")
.containsEntry("totalPackagesInWords", "—")
.containsKeys("toCompany", "consignee", "notifyParty", "cargoQuantity",
"portOfLoading", "portOfDischarge", "placeOfDelivery", "totalPackagesInWords");
assertThat(document.cargoRows()).singleElement().satisfies(row -> {
assertThat(row.quantity()).isEqualTo("2");
assertThat(row.descriptionOfGoods()).isEqualTo("40HQ DRY x 2");
assertThat(row.grossWeight()).isEqualTo("56000.000 KGS");
assertThat(row.marksAndNos()).isEqualTo("—");
assertThat(row.quantity()).isEqualTo("—");
assertThat(row.packaging()).isEqualTo("—");
assertThat(row.descriptionOfGoods()).isEqualTo("—");
assertThat(row.grossWeight()).isEqualTo("—");
assertThat(row.measurement()).isEqualTo("—");
});
}
@@ -85,7 +90,11 @@ class OceanBookingPrintServiceTest {
.containsEntry("serviceTerm", "—")
.containsEntry("freightTerm", "—");
}
assertThat(request.getValue().documents().get(0).fields()).containsEntry("bookingNumber", "—");
assertThat(request.getValue().documents().get(0).fields())
.containsEntry("bookingNumber", "—")
.containsEntry("containerSpec", "40HQ DRY x 2")
.containsEntry("containerSummary", "40HQ DRY x 2")
.containsEntry("totalPackagesInWords", "—");
}
private static Detail detail(String status) {
@@ -1188,7 +1188,8 @@ class InboundMeasurementServiceTest {
assertThatThrownBy(() -> service.submit(1L, 10L, 9L,
new InboundMeasurementRequests.SubmitRequest(30L, 0)))
.isInstanceOf(BizException.class)
.hasMessageContaining("至少上传并确认一张称重图片");
.hasMessageContaining("即使只有一箱")
.hasMessageContaining("上传并确认至少一张称重图片");
verify(repository, never()).updateMeasurement(any());
verify(repository, never()).updateInbound(any());
@@ -1212,7 +1213,8 @@ class InboundMeasurementServiceTest {
assertThatThrownBy(() -> service.submit(1L, 10L, 9L,
new InboundMeasurementRequests.SubmitRequest(30L, 0)))
.isInstanceOf(BizException.class)
.hasMessageContaining("至少上传并确认一张称重图片");
.hasMessageContaining("即使只有一箱")
.hasMessageContaining("上传并确认至少一张称重图片");
verify(repository, never()).updateMeasurement(any());
verify(repository, never()).updateInbound(any());
@@ -681,6 +681,48 @@ class LoadingListServiceTest {
eq("COMPLETED"), eq("COMPLETED"), anyString(), anyString(), eq(9L), eq("operator"), isNull());
}
@Test
void seaRailContainerCannotUploadDeliveryOrder() {
AggregateRow plan = row("COMPLETED", 4);
plan.setChannelCode("OA_CHICAGO_RAIL");
var file = new MockMultipartFile("file", "do.pdf", "application/pdf", "do".getBytes());
when(repository.aggregateByBooking(30L)).thenReturn(plan);
assertThatThrownBy(() -> service.uploadContainerLogisticsDocument(30L,
LoadingListService.DELIVERY_ORDER, 9L, "operator", "do-rail", 0, null, file))
.isInstanceOf(BizException.class)
.hasMessageContaining("海铁渠道不适用DO资料");
verifyNoInteractions(documentFiles);
}
@Test
void domesticStageContainerCannotUploadDeliveryOrder() {
AggregateRow plan = row("COMPLETED", 4);
plan.setOutboundConfirmedAt(null);
var file = new MockMultipartFile("file", "do.pdf", "application/pdf", "do".getBytes());
when(repository.aggregateByBooking(30L)).thenReturn(plan);
assertThatThrownBy(() -> service.uploadContainerLogisticsDocument(30L,
LoadingListService.DELIVERY_ORDER, 9L, "operator", "do-domestic", 0, null, file))
.isInstanceOf(BizException.class)
.hasMessageContaining("进入海外履约后");
verifyNoInteractions(documentFiles);
}
@Test
void returnedContainerCannotUploadDeliveryOrder() {
AggregateRow plan = row("COMPLETED", 4);
plan.setActualEmptyReportedAt(java.time.LocalDateTime.now());
var file = new MockMultipartFile("file", "do.pdf", "application/pdf", "do".getBytes());
when(repository.aggregateByBooking(30L)).thenReturn(plan);
assertThatThrownBy(() -> service.uploadContainerLogisticsDocument(30L,
LoadingListService.DELIVERY_ORDER, 9L, "operator", "do-returned", 0, null, file))
.isInstanceOf(BizException.class)
.hasMessageContaining("已还柜");
verifyNoInteractions(documentFiles);
}
@Test
void staleDeliveryOrderVersionReturnsConflictBeforeUploadingAnotherFile() {
AggregateRow plan = row("COMPLETED", 4);
@@ -918,6 +960,8 @@ class LoadingListServiceTest {
row.setContainerBookingId(30L);
row.setPlanStatus(status);
row.setWarehouseId(100L);
row.setChannelCode("OA_LA_TRUCK");
row.setOutboundConfirmedAt(java.time.LocalDateTime.now());
row.setVersion(version);
return row;
}
@@ -135,6 +135,41 @@ class MeasurementReviewServiceTest {
verify(fixture.repository, never()).transitionReview(anyLong(), anyLong(), anyLong(), anyInt(), anyBoolean(), anyLong());
}
@Test
@DisplayName("历史异常测量已提交但没有称重图片时提示退回补录")
void shouldExplainHowToRecoverSubmittedMeasurementWithoutWeightImage() {
Fixture fixture = fixture(false);
when(measurements.listWeights(10001L, 10L)).thenReturn(List.of());
assertThatThrownBy(() -> fixture.service.approve(10001L, 9L, "审核员", 10L,
"approve-without-weight", new MeasurementReviewRequest(20L, 3, null)))
.isInstanceOf(BizException.class)
.hasMessageContaining("已提交但没有称重图片")
.hasMessageContaining("先退回测量")
.hasMessageContaining("即使只有一箱也必须称重");
verify(fixture.repository, never()).transitionReview(
anyLong(), anyLong(), anyLong(), anyInt(), anyBoolean(), anyLong());
}
@Test
@DisplayName("已上传但未确认的称重图片提示在审核弹框修正")
void shouldExplainHowToConfirmInvalidWeightImageDuringReview() {
Fixture fixture = fixture(false);
InboundWeightResultEntity pending = confirmedWeight("357.500", "349.500");
pending.setRecognitionStatus("PENDING_CONFIRM");
when(measurements.listWeights(10001L, 10L)).thenReturn(List.of(pending));
assertThatThrownBy(() -> fixture.service.approve(10001L, 9L, "审核员", 10L,
"approve-pending-weight", new MeasurementReviewRequest(20L, 3, null)))
.isInstanceOf(BizException.class)
.hasMessageContaining("未确认或重量无效")
.hasMessageContaining("在审核弹框逐张修正并确认");
verify(fixture.repository, never()).transitionReview(
anyLong(), anyLong(), anyLong(), anyInt(), anyBoolean(), anyLong());
}
@Test
@DisplayName("多SKU测量行的箱数、重量、体积和体积重守恒后才能进入WAIT_SPLIT")
void shouldApproveMultipleCargoLinesOnlyWhenAllFrozenTotalsMatch() {