This commit is contained in:
2026-08-19 14:02:11 +08:00
590 changed files with 23946 additions and 5168 deletions
@@ -0,0 +1,26 @@
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.RequestHeader;
import org.springframework.web.bind.annotation.RequestParam;
import java.lang.reflect.Method;
import static org.assertj.core.api.Assertions.assertThat;
class IamCustomerSnapshotClientContractTest {
@Test
void usesInternalIamCustomerQueryWithTokenAndExactCustomerFilter() throws Exception {
FeignClient annotation = IamCustomerSnapshotClient.class.getAnnotation(FeignClient.class);
assertThat(annotation.path()).isEqualTo("/internal/iam");
Method method = IamCustomerSnapshotClient.class.getDeclaredMethod("page", String.class,
int.class, int.class, String.class, String.class, Long.class);
assertThat(method.getAnnotation(GetMapping.class).value()).containsExactly("/finance-customers");
assertThat(((RequestHeader) method.getParameterAnnotations()[0][0]).value())
.isEqualTo("X-Internal-Service-Token");
assertThat(((RequestParam) method.getParameterAnnotations()[4][0]).value()).isEqualTo("status");
assertThat(((RequestParam) method.getParameterAnnotations()[5][0]).value()).isEqualTo("customerId");
}
}
@@ -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"));
}
}
@@ -0,0 +1,42 @@
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.RequestHeader;
import org.springframework.web.bind.annotation.RequestParam;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class WarehouseSupplierClientContractTest {
@Test
void usesWarehouseInternalSupplierEndpointAndToken() throws Exception {
FeignClient annotation = WarehouseSupplierClient.class.getAnnotation(FeignClient.class);
assertThat(annotation.path()).isEqualTo("/internal/warehouse/v1/suppliers");
Method method = WarehouseSupplierClient.class.getDeclaredMethod("get", String.class, long.class,
boolean.class, String.class);
assertThat(method.getAnnotation(GetMapping.class).value()).containsExactly("/{supplierId}");
assertThat(method.getParameterAnnotations()[0][0]).isInstanceOf(RequestHeader.class);
assertThat(((RequestHeader) method.getParameterAnnotations()[0][0]).value())
.isEqualTo("X-Internal-Service-Token");
assertThat(method.getParameterAnnotations()[3][0]).isInstanceOf(RequestParam.class);
assertThat(method.getParameterCount()).isEqualTo(4);
}
@Test
void openApiDocumentsServerResolvedSupplierSnapshots() throws Exception {
String ocean = Files.readString(Path.of("src/main/resources/openapi/ocean-bookings-v1.yaml"), StandardCharsets.UTF_8);
String truck = Files.readString(Path.of("src/main/resources/openapi/truck-appointments-v1.yaml"), StandardCharsets.UTF_8);
String customs = Files.readString(Path.of("src/main/resources/openapi/customs-v1.yaml"), StandardCharsets.UTF_8);
assertThat(ocean).contains("bookingAgentId:", "category BOOKING",
"do not use domestic/international scope", "persisted snapshot always comes from Warehouse")
.doesNotContain("BOOKING with INTERNATIONAL scope");
assertThat(truck).contains("carrierId:", "TRUCKING", "persisted snapshot comes from Warehouse");
assertThat(customs).contains("customsAgentId", "CUSTOMS", "free text cannot bypass supplier validation");
}
}
@@ -4,6 +4,8 @@ import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.core.io.ClassPathResource;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
@@ -19,10 +21,13 @@ class ContainerControllerContractTest {
assertPermission("page", "platform:firstleg:container:list");
assertPermission("detail", "platform:firstleg:container:list");
assertPermission("create", "platform:firstleg:container:create");
assertPermission("linkOceanBooking", "platform:firstleg:container:edit");
assertPermission("unlinkOceanBooking", "platform:firstleg:container:edit");
assertPermission("updateDraft", "platform:firstleg:container:edit");
assertPermission("updateTransport", "platform:firstleg:container:edit");
assertPermission("uploadClearance", "platform:firstleg:customs:manage");
assertPermission("overview", "platform:firstleg:container:list");
assertPermission("downloadPdaPhoto", "platform:firstleg:container:list");
assertPermission("markException", "platform:firstleg:container:edit");
assertPermission("cancelException", "platform:firstleg:container:edit");
assertPermission("logisticsDocument", "platform:firstleg:container:list");
@@ -36,7 +41,10 @@ class ContainerControllerContractTest {
assertThat(openapi).contains("/api/platform/v1/containers/{id}/overview:",
"/documents/{documentType}/{fileId}/download:", "ExceptionRequest",
"enum: [DELIVERY_ORDER, PICKUP_ORDER]", "Idempotency-Key", "photoCounts:",
"downloadPath:", "version: {type: integer, minimum: 0}")
"/pda/photos/{fileId}/download:", "downloadPath:", "workflowStatus:",
"AWAITING_REVIEW", "version: {type: integer, minimum: 0}",
"downloadPath: {type: string, nullable: true",
"仅 ACTIVE 照片提供鉴权下载路径;VOID 历史照片为空")
.doesNotContain("?containerNo", "containerNo in: query");
}
@@ -50,6 +58,14 @@ class ContainerControllerContractTest {
assertThat(section)
.contains("/api/platform/v1/containers/{id}/transport:")
.contains("/api/platform/v1/containers/{id}/ocean-booking-link:")
.contains("首次关联允许 WAIT_LOADING 或已有预排货的 EDITING 模拟柜")
.contains("RELEASED oceanBookingId")
.contains("未满配 requirementId")
.contains("唯一 containerNo")
.contains("解绑或换绑仅允许完全空的 WAIT_LOADING 模拟柜")
.contains("任何排货、PDA、有效装柜证据或业务单证事实均拒绝")
.doesNotContain("realContainerBookingId")
.contains("/api/platform/v1/containers/{id}/clearance/document/upload:")
.contains("multipart/form-data")
.contains("ContainerDocumentUploadRequest")
@@ -60,6 +76,19 @@ class ContainerControllerContractTest {
.contains("COMPLETED")
.doesNotContain("tenantId")
.doesNotContain("accountId");
assertThat(openapi)
.contains("required: [oceanBookingId, requirementId, containerNo, expectedVersion]")
.contains("required: [id, bookingNo, cabinetNo, sourceType, recordStatus")
.contains("sourceType: {type: string, enum: [SIMULATION, OCEAN_BOOKING]")
.contains("recordStatus: {type: string, enum: [ACTIVE, MERGED]")
.contains("mergedIntoContainerBookingId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true");
}
@Test
@DisplayName("multipart 文本字段使用请求参数绑定")
void multipartScalarFieldsUseRequestParameters() {
assertMultipartBinding("uploadLogisticsDocument", 4, 5, 6);
assertMultipartBinding("uploadClearance", 3, 4, 5);
}
private static void assertPermission(String methodName, String authority) {
@@ -70,4 +99,16 @@ class ContainerControllerContractTest {
.isEqualTo("hasAuthority('" + authority + "')");
}
private static void assertMultipartBinding(String methodName, int fileIndex,
int versionIndex, int reasonIndex) {
Method method = Arrays.stream(PlatformContainerController.class.getDeclaredMethods())
.filter(candidate -> candidate.getName().equals(methodName))
.findFirst().orElseThrow();
assertThat(method.getParameters()[fileIndex].getAnnotation(RequestPart.class)).isNotNull();
assertThat(method.getParameters()[versionIndex].getAnnotation(RequestParam.class)).isNotNull();
assertThat(method.getParameters()[versionIndex].getAnnotation(RequestPart.class)).isNull();
assertThat(method.getParameters()[reasonIndex].getAnnotation(RequestParam.class)).isNotNull();
assertThat(method.getParameters()[reasonIndex].getAnnotation(RequestPart.class)).isNull();
}
}
@@ -36,7 +36,7 @@ class FbaPricingContractTest {
}
@Test
@DisplayName("契约声明二期边界计费、已发布费用调整和幂等头")
@DisplayName("契约声明二期边界计费、人工费用调整和幂等头")
void documentsPricingLifecycle() throws Exception {
String openapi = new ClassPathResource("openapi/fba-pricing-v1.yaml")
.getContentAsString(StandardCharsets.UTF_8);
@@ -49,10 +49,15 @@ class FbaPricingContractTest {
.contains("/api/platform/v1/inbounds/{inboundId}/fees/published:")
.contains("Idempotency-Key")
.contains("主流程已到 WAIT_SPLIT,也可独立试算且不回退主流程状态")
.contains("该动作涉及后续仓储流程,本轮二期验收不调用")
.contains("区域阶梯渠道可按平台 FBA 仓或海外中转仓的邮编区域")
.contains("不再以 carrierId、业务 customerId 或客户计费规则作为前置")
.contains("仅此动作将费用置为 SENT")
.contains("写入 Finance Outbox")
.contains("不回退 WAIT_SPLIT 状态")
.doesNotContain("应收")
.doesNotContain("应付")
.contains("expectedFeeVersion")
.contains("chargeableQuantity")
.contains("unitPrice")
.doesNotContain("required: [subtotalAmount, additionalAmount, reason]")
.doesNotContain("待客户确认");
String customerRules = openapi.substring(
openapi.indexOf("/api/platform/v1/fba-pricing/customer-rules:"),
@@ -41,14 +41,25 @@ class FeeManagementContractTest {
}
@Test
@DisplayName("柜次费用新增和调整请求均携带往来单位快照")
void containerFeeRequestsCarryCounterpartySnapshot() {
for (Class<?> requestType : List.of(
com.baiyue.firstleg.dto.ContainerFeeDetailDtos.SaveRequest.class,
com.baiyue.firstleg.dto.ContainerFeeDetailDtos.AdjustRequest.class)) {
assertThat(Arrays.stream(requestType.getRecordComponents()).map(RecordComponent::getName))
.contains("partnerId", "partnerName");
}
@DisplayName("柜次费用调整只能修改版本、业务日期和金额")
void containerFeeAdjustmentCannotReplaceSourceIdentity() {
assertThat(Arrays.stream(com.baiyue.firstleg.dto.ContainerFeeDetailDtos.AdjustRequest.class
.getRecordComponents()).map(RecordComponent::getName))
.containsExactly("expectedSourceVersion", "businessDate", "amount");
}
@Test
@DisplayName("柜次费用调整OpenAPI冻结三字段请求和快照不漂移语义")
void containerFeeAdjustmentOpenApiIsNarrowAndVersioned() throws Exception {
String source = new ClassPathResource("openapi/firstleg-v1.yaml")
.getContentAsString(StandardCharsets.UTF_8);
assertThat(source)
.contains("/api/platform/v1/loading-plans/{loadingPlanId}/fee-details/{feeId}/adjust:")
.contains("ContainerFeeAdjustRequest:")
.contains("required: [expectedSourceVersion, businessDate, amount]")
.contains("additionalProperties: false")
.contains("费用方向、费用项目、供应商、币种和备注保持原快照")
.contains("旧版本异参、已作废或非已审核状态返回 A0409 且零写入");
}
@Test
@@ -96,6 +107,13 @@ class FeeManagementContractTest {
assertThat(stringMap(properties(schemas, "FeeItemSaveRequest").get("feeCode")))
.containsEntry("maxLength", 50)
.containsEntry("pattern", "^[A-Za-z0-9_-]+$");
Map<String, Object> supplierCategory = stringMap(
properties(schemas, "FeeItemSaveRequest").get("supplierCategory"));
assertThat(supplierCategory)
.containsEntry("nullable", true)
.containsEntry("enum", List.of("BOOKING", "CUSTOMS", "TRUCKING", "CERTIFICATE"));
assertThat(list(stringMap(schemas.get("FeeItemSaveRequest")).get("required")))
.doesNotContain("supplierCategory");
}
private static void assertPermission(String methodName, String permission) {
@@ -0,0 +1,80 @@
package com.baiyue.firstleg.controller;
import com.baiyue.firstleg.dto.FbaPricingDtos;
import com.baiyue.firstleg.dto.InboundFeeDetailDtos;
import com.baiyue.firstleg.entity.InboundFeeDetailEntity;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import org.yaml.snakeyaml.Yaml;
import java.lang.reflect.Field;
import java.lang.reflect.RecordComponent;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
class InboundFeeOpenApiContractTest {
@Test
void documentsEveryInboundFeeControllerPathAndReadOnlySourceContract() throws Exception {
String source = new ClassPathResource("openapi/fba-pricing-v1.yaml")
.getContentAsString(StandardCharsets.UTF_8);
Map<String, Object> document = map(new Yaml().load(source));
Map<String, Object> paths = map(document.get("paths"));
assertThat(paths).containsKeys(
"/api/platform/v1/inbounds/{inboundId}/fees/calculate",
"/api/platform/v1/inbounds/{inboundId}/fees/confirm",
"/api/platform/v1/inbounds/{inboundId}/fees/send",
"/api/platform/v1/inbounds/{inboundId}/fees/latest",
"/api/platform/v1/inbounds/{inboundId}/fees/published",
"/api/platform/v1/inbounds/{inboundId}/fee-details",
"/api/platform/v1/inbounds/{inboundId}/fee-details/{detailId}",
"/api/platform/v1/inbounds/{inboundId}/fee-details/{detailId}/approve",
"/api/platform/v1/inbounds/{inboundId}/fee-details/{detailId}/reject",
"/api/platform/v1/inbounds/{inboundId}/fee-details/{detailId}/adjust",
"/api/platform/v1/inbounds/{inboundId}/fee-details/{detailId}/void");
assertThat(source).contains("includeHistory", "FBA_PRICING", "MANUAL", "只读派生",
"Finance Outbox", "CALCULATED", "CONFIRMED", "SENT", "SUPERSEDED");
}
@Test
void responseAndRequestSchemasTrackActualDtoAndEntityFields() throws Exception {
Map<String, Object> document = map(new Yaml().load(new ClassPathResource(
"openapi/fba-pricing-v1.yaml").getContentAsString(StandardCharsets.UTF_8)));
Map<String, Object> schemas = map(map(document.get("components")).get("schemas"));
assertThat(properties(schemas, "FeeSnapshotResponse").keySet())
.containsExactlyInAnyOrder(recordFields(FbaPricingDtos.FeeSnapshotResponse.class).toArray(String[]::new));
assertThat(properties(schemas, "InboundFeeDetailSaveRequest").keySet())
.containsExactlyInAnyOrder(recordFields(InboundFeeDetailDtos.SaveRequest.class).toArray(String[]::new));
assertThat(properties(schemas, "InboundFeeDetailVersionRequest").keySet())
.containsExactlyInAnyOrder(recordFields(InboundFeeDetailDtos.VersionRequest.class).toArray(String[]::new));
assertThat(properties(schemas, "InboundFeeDetailRejectRequest").keySet())
.containsExactlyInAnyOrder(recordFields(InboundFeeDetailDtos.RejectRequest.class).toArray(String[]::new));
Set<String> entityFields = new LinkedHashSet<>();
for (Class<?> type = InboundFeeDetailEntity.class; type != Object.class; type = type.getSuperclass()) {
Arrays.stream(type.getDeclaredFields()).map(Field::getName).forEach(entityFields::add);
}
assertThat(properties(schemas, "InboundFeeDetailResource").keySet())
.containsExactlyInAnyOrder(entityFields.toArray(String[]::new));
}
private static Set<String> recordFields(Class<?> type) {
Set<String> fields = new LinkedHashSet<>();
Arrays.stream(type.getRecordComponents()).map(RecordComponent::getName).forEach(fields::add);
return fields;
}
private static Map<String, Object> properties(Map<String, Object> schemas, String schema) {
return map(map(schemas.get(schema)).get("properties"));
}
@SuppressWarnings("unchecked")
private static Map<String, Object> map(Object value) {
return (Map<String, Object>) value;
}
}
@@ -0,0 +1,63 @@
package com.baiyue.firstleg.controller;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import org.yaml.snakeyaml.Yaml;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class InboundMeasurementEvidenceOpenApiContractTest {
@Test
void measurementDetailSeparatesDimensionAndWeightEvidenceByStableRecord() throws Exception {
String source = new ClassPathResource("openapi/firstleg-v1.yaml")
.getContentAsString(StandardCharsets.UTF_8);
Map<String, Object> document = stringMap(new Yaml().load(source));
Map<String, Object> paths = stringMap(document.get("paths"));
Map<String, Object> detailPath = stringMap(paths.get(
"/api/platform/v1/first-leg/inbounds/{inboundId}/measurement"));
assertThat(detailPath).containsKey("get");
Map<String, Object> schemas = stringMap(stringMap(document.get("components")).get("schemas"));
Map<String, Object> detailProperties = properties(schemas, "InboundMeasurementDetail");
assertThat(detailProperties).containsKeys(
"actualGrossWeightKg", "actualNetWeightKg", "items", "weightResults");
assertThat(stringMap(detailProperties.get("items")))
.containsEntry("description", "DIMENSION 尺寸测量明细及其图片证据");
assertThat(stringMap(detailProperties.get("weightResults")))
.containsEntry("description", "WEIGHT 称重结果及其图片、确认状态和服务端重量事实");
assertThat(detailProperties).doesNotContainKey("images");
Map<String, Object> draftProperties = properties(schemas, "InboundMeasurementSaveDraftRequest");
assertThat(draftProperties).containsKeys("measurementId", "version", "actualGoodsName", "items")
.doesNotContainKeys("originalDocumentNo", "originalGrossWeightKg", "originalVolumeCbm");
assertThat(paths).containsKey(
"/api/platform/v1/first-leg/inbounds/{inboundId}/measurement/draft");
assertThat(stringMap(stringMap(draftProperties.get("items")).get("items")))
.containsEntry("$ref", "#/components/schemas/InboundMeasurementItemRequest");
assertThat(properties(schemas, "InboundMeasurementItemRequest")).containsKeys(
"inboundDetailId", "itemNo", "boxSpecification", "packageCount", "lengthCm",
"widthCm", "heightCm", "intelligentImageFileId", "remark");
Map<String, Object> itemProperties = properties(schemas, "InboundMeasurementItem");
assertThat(itemProperties).containsKeys("id", "intelligentImageFileId");
Map<String, Object> weightProperties = properties(schemas, "InboundWeightResult");
assertThat(weightProperties).containsKeys(
"id", "fileId", "recognitionStatus", "grossWeightKg", "netWeightKg", "confirmedAt");
assertThat(stringMap(detailProperties.get("actualGrossWeightKg")).get("description").toString())
.contains("CONFIRMED", "grossWeightKg", "含托盘毛重");
assertThat(stringMap(detailProperties.get("actualNetWeightKg")).get("description").toString())
.contains("CONFIRMED", "netWeightKg", "审核与计费以此为准");
}
private static Map<String, Object> properties(Map<String, Object> schemas, String schemaName) {
return stringMap(stringMap(schemas.get(schemaName)).get("properties"));
}
@SuppressWarnings("unchecked")
private static Map<String, Object> stringMap(Object value) {
return (Map<String, Object>) value;
}
}
@@ -1,10 +1,9 @@
package com.baiyue.firstleg.controller;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.ExceptionReleaseRequest;
import com.baiyue.firstleg.dto.MeasurementApprovalResponse;
import com.baiyue.firstleg.dto.MeasurementReviewRequest;
import com.baiyue.firstleg.service.AutomaticInboundCompletionService;
import com.baiyue.firstleg.service.InitialInboundPricingService;
import com.baiyue.firstleg.service.MeasurementReviewService;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
@@ -24,14 +23,10 @@ import static org.mockito.Mockito.when;
@DisplayName("入库测量审核控制器契约")
class InboundMeasurementReviewContractTest {
@Test
@DisplayName("审核、放行和历史接口声明独立按钮权限")
@DisplayName("审核和历史接口声明独立按钮权限")
void shouldDeclareButtonPermissions() throws Exception {
assertAuthority("approve", "hasAuthority('firstleg:inbound:measurement-review')",
AuthenticatedUser.class, long.class, long.class, String.class, MeasurementReviewRequest.class);
assertAuthority("reject", "hasAuthority('firstleg:inbound:measurement-review')",
AuthenticatedUser.class, long.class, long.class, String.class, MeasurementReviewRequest.class);
assertAuthority("exceptionRelease", "hasAuthority('firstleg:inbound:exception-release')",
AuthenticatedUser.class, long.class, long.class, String.class, ExceptionReleaseRequest.class);
assertAuthority("history", "hasAuthority('firstleg:inbound:measurement-review-history')",
AuthenticatedUser.class, long.class, long.class, long.class, long.class);
}
@@ -43,53 +38,42 @@ class InboundMeasurementReviewContractTest {
.getContentAsString(StandardCharsets.UTF_8);
assertThat(openapi)
.contains("/api/platform/v1/inbounds/{inboundId}/measurements/approve:")
.contains("/api/platform/v1/inbounds/{inboundId}/measurements/reject:")
.contains("firstleg:inbound:exception-release")
.contains("Idempotency-Key")
.contains("审核事务提交后服务端自动执行计费")
.contains("接口不会伪造 WAIT_SPLIT")
.contains("成功 data 为 FeeSnapshotResponse")
.contains("nextStatus=WAIT_SPLIT")
.contains("初算异常只返回非阻断提示")
.contains("data.processStatus 固定为 WAIT_SPLIT")
.contains("feeStatus=UNPRICED 且主流程仍成功")
.contains("name: inboundId")
.contains("schema: {type: string, pattern:")
.doesNotContain("/api/platform/v1/inbounds/{inboundId}/measurements/reject:")
.doesNotContain("/api/platform/v1/inbounds/{inboundId}/measurements/exception-release:")
.doesNotContain("待客户确认");
}
@Test
@DisplayName("审核事务完成后才启动自动编排并返回真实费用快照")
void shouldCompleteApprovedReviewAfterReviewTransactionReturns() {
@DisplayName("审核先提交待分柜,再执行不阻塞的初算")
void shouldApproveBeforeBestEffortInitialPricing() {
MeasurementReviewService review = mock(MeasurementReviewService.class);
AutomaticInboundCompletionService completion = mock(AutomaticInboundCompletionService.class);
InitialInboundPricingService initialPricing = mock(InitialInboundPricingService.class);
PlatformInboundMeasurementReviewController controller = new PlatformInboundMeasurementReviewController();
ReflectionTestUtils.setField(controller, "service", review);
ReflectionTestUtils.setField(controller, "automaticCompletion", completion);
ReflectionTestUtils.setField(controller, "initialPricing", initialPricing);
AuthenticatedUser user = new AuthenticatedUser(9L, 0L, 0L, null,
"客服", "PLATFORM", "WEB", Set.of("firstleg:inbound:measurement-review"));
MeasurementReviewRequest request = new MeasurementReviewRequest(30L, 4, "通过");
var fee = mock(com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse.class);
when(completion.completeApproved(10001L, 88L, 9L, "客服", 30L, 5)).thenReturn(fee);
var result = new MeasurementApprovalResponse(88L, 30L, "WAIT_SPLIT", "UNPRICED",
false, "待人工维护费用", null);
when(initialPricing.tryCalculate(10001L, 9L, 88L, 30L, 5)).thenReturn(result);
var first = controller.approve(user, 10001L, 88L, "approve-1", request);
var replay = controller.approve(user, 10001L, 88L, "approve-1", request);
assertThat(first.data()).isSameAs(fee);
assertThat(replay.data()).isSameAs(fee);
var ordered = inOrder(review, completion);
assertThat(first.data()).isSameAs(result);
assertThat(replay.data()).isSameAs(result);
var ordered = inOrder(review, initialPricing);
ordered.verify(review).approve(10001L, 9L, "客服", 88L, "approve-1", request);
ordered.verify(completion).completeApproved(10001L, 88L, 9L, "客服", 30L, 5);
ordered.verify(initialPricing).tryCalculate(10001L, 9L, 88L, 30L, 5);
ordered.verify(review).approve(10001L, 9L, "客服", 88L, "approve-1", request);
ordered.verify(completion).completeApproved(10001L, 88L, 9L, "客服", 30L, 5);
}
@Test
void shouldAuditAutomaticInboundCompletion() throws Exception {
Method method = AutomaticInboundCompletionService.class.getDeclaredMethod("completeApproved",
long.class, long.class, long.class, String.class, long.class, int.class);
BusinessAudit audit = method.getAnnotation(BusinessAudit.class);
assertThat(audit.event()).isEqualTo("INBOUND_AUTO_COMPLETE");
assertThat(audit.resourceType()).isEqualTo("INBOUND");
assertThat(audit.resourceId()).isEqualTo("#inboundId");
assertThat(audit.captureBefore()).isFalse();
ordered.verify(initialPricing).tryCalculate(10001L, 9L, 88L, 30L, 5);
}
private void assertAuthority(String name, String expression, Class<?>... types) throws Exception {
@@ -51,6 +51,11 @@ class InboundOrderControllerContractTest {
.contains("required: false")
.contains("InboundOrderSummary:")
.contains("required: [id, tenantId]")
.contains("actualNetWeightKg: {type: number, nullable: true")
.contains("只读取 fl_inbound_order.actual_gross_weight_kg,不以净重回退")
.contains("只读取 fl_inbound_order.actual_net_weight_kg,不以毛重回退")
.contains("chargeableVolumeCbm: {type: number, nullable: true}")
.contains("measureCompletedAt: {type: string, format: date-time, nullable: true}")
.contains("pricingTaxMode: {type: string, enum: [TAX_INCLUDED, TAX_EXCLUDED]")
.contains("tenantId: {type: string, pattern: '^[1-9][0-9]*$', description:")
.contains("[WAIT_MEASURE, WAIT_REVIEW, WAIT_FEE_SEND, WAIT_SPLIT]")
@@ -3,8 +3,11 @@ package com.baiyue.firstleg.controller;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.service.FinanceFeeSourceSnapshotService;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import org.springframework.test.util.ReflectionTestUtils;
import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
@@ -16,19 +19,19 @@ class InternalFinanceFeeSourceControllerTest {
@Test
void rejectsMissingOrWrongInternalTokenBeforeSnapshotLookup() {
assertThatThrownBy(() -> controller.inbound(null, 21L, 31L))
assertThatThrownBy(() -> controller.inbound(null, 21L, 31L, 2))
.isInstanceOf(BizException.class).hasMessageContaining("令牌无效");
assertThatThrownBy(() -> controller.container("wrong", 41L, 51L))
assertThatThrownBy(() -> controller.container("wrong", 41L, 51L, 2))
.isInstanceOf(BizException.class).hasMessageContaining("令牌无效");
}
@Test
void routesAuthorizedSnapshotLookup() {
controller.inbound("finance-token", 21L, 31L);
controller.container("finance-token", 41L, 51L);
controller.inbound("finance-token", 21L, 31L, 2);
controller.container("finance-token", 41L, 51L, 3);
verify(service).inbound(21L, 31L);
verify(service).container(41L, 51L);
verify(service).inbound(21L, 31L, 2);
verify(service).container(41L, 51L, 3);
}
@Test
@@ -40,6 +43,18 @@ class InternalFinanceFeeSourceControllerTest {
assertThat(methodPath("container")).isEqualTo("/containers/{containerId}/fees/{feeId}/source-snapshot");
}
@Test
void openApiRequiresExactSourceVersionAndDocumentsSupplierConstraint() throws Exception {
String source = new ClassPathResource("openapi/firstleg-v1.yaml")
.getContentAsString(StandardCharsets.UTF_8);
assertThat(source)
.contains("/internal/firstleg/v1/inbounds/{inboundId}/fees/{feeId}/source-snapshot:")
.contains("/internal/firstleg/v1/containers/{containerId}/fees/{feeId}/source-snapshot:")
.contains("{name: sourceVersion, in: query, required: true")
.contains("supplierCategory: {type: string, enum: [BOOKING, CUSTOMS, TRUCKING, CERTIFICATE], nullable: true")
.contains("为空时仅要求供应商ACTIVE");
}
private InternalFinanceFeeSourceController controller() {
InternalFinanceFeeSourceController value = new InternalFinanceFeeSourceController(service);
ReflectionTestUtils.setField(value, "expectedToken", "finance-token");
@@ -4,6 +4,7 @@ import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.InboundOrderReference;
import com.baiyue.firstleg.dto.OrderApprovedInboundCommand;
import com.baiyue.firstleg.service.InboundOrderCreationService;
import com.baiyue.firstleg.service.InboundCartonLabelService;
import com.baiyue.firstleg.service.UnifiedInboundQueryService;
import com.baiyue.firstleg.dto.UnifiedInboundQuery;
import com.baiyue.common.core.api.PageResult;
@@ -27,6 +28,8 @@ class InternalInboundOrderReferenceControllerTest {
@MockBean
private InboundOrderCreationService service;
@MockBean
private InboundCartonLabelService cartonLabels;
@MockBean
private UnifiedInboundQueryService unifiedQueryService;
@Resource
private InternalInboundOrderController controller;
@@ -79,6 +82,7 @@ class InternalInboundOrderReferenceControllerTest {
assertThat(controller.createFromApprovedOrder("internal-token", command).data()).isEqualTo(88L);
verify(service).createFromApprovedOrder(command);
verify(cartonLabels).getOrCreate(10001L, 88L);
}
private static OrderApprovedInboundCommand approvedCommand(String inboundCode, Long carrierId) {
@@ -5,6 +5,7 @@ import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedQuery;
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery;
import com.baiyue.firstleg.dto.LoadingListDtos.TransportUpdateRequest;
import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmPreloadRequest;
import com.baiyue.firstleg.dto.LoadingListDtos.DocumentAttachRequest;
import com.baiyue.firstleg.dto.LoadingListDtos.DocumentCompleteRequest;
import com.baiyue.firstleg.service.LoadingListService;
@@ -13,6 +14,8 @@ import org.junit.jupiter.api.Test;
import org.yaml.snakeyaml.Yaml;
import org.springframework.core.io.ClassPathResource;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.multipart.MultipartFile;
import java.lang.reflect.Method;
@@ -32,6 +35,8 @@ class LoadingListControllerContractTest {
authority("preloadDetail", "platform:firstleg:preload:list", AuthenticatedUser.class, long.class, long.class);
authority("updateTransport", "platform:firstleg:preload:edit", AuthenticatedUser.class,
long.class, long.class, String.class, TransportUpdateRequest.class);
authority("confirmPreload", "platform:firstleg:preload:edit", AuthenticatedUser.class,
long.class, String.class, ConfirmPreloadRequest.class);
authority("confirmedPage", "platform:firstleg:loading-list:list", AuthenticatedUser.class,
ConfirmedQuery.class);
authority("confirmedDetail", "platform:firstleg:loading-list:list", AuthenticatedUser.class, long.class, long.class);
@@ -48,6 +53,8 @@ class LoadingListControllerContractTest {
void writesAudited() throws Exception {
audit("updateTransport", "PRELOAD_TRANSPORT_UPDATE", long.class, long.class, long.class, String.class,
String.class, TransportUpdateRequest.class);
audit("confirmPreload", "PRELOAD_PLAN_CONFIRM", long.class, long.class, String.class,
String.class, ConfirmPreloadRequest.class);
}
@Test
@@ -72,6 +79,16 @@ class LoadingListControllerContractTest {
.contains("beforeSnapshot")
.contains("containerNo")
.contains("voyageNo")
.contains("已推送且仍为 EDITING")
.contains("不能清空已创建 PDA 任务的柜号")
.contains("/loading-plans/{planId}/confirm")
.contains("confirmationStatus")
.contains("canConfirm")
.contains("blockingReasons")
.contains("oceanBookingStatus")
.contains("pdaTaskId")
.contains("cabinetNo")
.doesNotContain("internalContainerNo")
.doesNotContain("rollback")
.doesNotContain("delete:")
.doesNotContain("/items/{itemId}");
@@ -89,6 +106,18 @@ class LoadingListControllerContractTest {
assertThat(component(ConfirmedQuery.class, "warehouseId").getAnnotation(NotNull.class)).isNull();
}
@Test
void multipartScalarFieldsUseRequestParameters() throws Exception {
Method method = PlatformLoadingListController.class.getDeclaredMethod("uploadDeclaration",
AuthenticatedUser.class, long.class, long.class, String.class,
MultipartFile.class, int.class, String.class);
assertThat(method.getParameters()[4].getAnnotation(RequestPart.class)).isNotNull();
assertThat(method.getParameters()[5].getAnnotation(RequestParam.class)).isNotNull();
assertThat(method.getParameters()[5].getAnnotation(RequestPart.class)).isNull();
assertThat(method.getParameters()[6].getAnnotation(RequestParam.class)).isNotNull();
assertThat(method.getParameters()[6].getAnnotation(RequestPart.class)).isNull();
}
@Test
void yamlParses() throws Exception {
String yaml = new ClassPathResource("openapi/loading-lists-v1.yaml")
@@ -100,6 +129,7 @@ class LoadingListControllerContractTest {
"/api/platform/v1/first-leg/loading-plans",
"/api/platform/v1/first-leg/loading-plans/{planId}",
"/api/platform/v1/first-leg/loading-plans/{planId}/transport",
"/api/platform/v1/first-leg/loading-plans/{planId}/confirm",
"/api/platform/v1/first-leg/loading-lists",
"/api/platform/v1/first-leg/loading-lists/{planId}",
"/api/platform/v1/first-leg/loading-lists/{planId}/customs",
@@ -111,7 +141,9 @@ class LoadingListControllerContractTest {
assertThat(schemas).containsKeys(
"LongId", "PreloadPage", "ConfirmedPage", "Customer", "PreloadSummary",
"ConfirmedSummary", "LoadingItem", "OperationHistory", "PreloadDetail",
"ConfirmedDetail", "TransportUpdateRequest", "BusinessDocumentEnvelope",
"ConfirmedDetail", "TransportUpdateRequest", "PreloadReadiness",
"ConfirmPreloadRequest", "PreloadConfirmation", "PreloadConfirmationEnvelope",
"BusinessDocumentEnvelope",
"DocumentAttachRequest", "DocumentUploadRequest", "DocumentCompleteRequest", "BusinessDocument");
assertThat(properties(schemas, "DocumentUploadRequest"))
.containsOnlyKeys("file", "expectedVersion", "operationReason");
@@ -120,7 +152,9 @@ class LoadingListControllerContractTest {
assertThat(stringMap(schemas.get("LongId"))).containsEntry("type", "string");
assertThat(properties(schemas, "PreloadPage")).containsKeys("records", "page", "size", "total", "pages");
assertThat(properties(schemas, "PreloadSummary"))
.containsKeys("planId", "containerBookingId", "customers", "skuCount");
.containsKeys("planId", "containerBookingId", "containerNo", "cabinetNo",
"customers", "skuCount", "readiness")
.doesNotContainKey("internalContainerNo");
assertThat(properties(schemas, "ConfirmedSummary"))
.containsKeys("planId", "containerBookingId", "loadingListNo");
assertThat(properties(schemas, "LoadingItem")).containsKeys(
@@ -30,6 +30,33 @@ class PdaConvergenceOpenApiContractTest {
.contains("InventorySnapshot")
.contains("changing it never resynchronizes the task or item versions")
.contains("expectedTaskVersion")
.contains("advances every associated inbound from WAIT_SPLIT to LOADING with optimistic locking")
.contains("workflowStatus may later become AWAITING_REVIEW")
.contains("without using containerNo as an association key")
.contains("evidence identity is taskId+inboundId+loadingPlanId+containerBookingId")
.contains("Current fl_inbound_order.version for inboundId")
.contains("Compatibility alias of inboundVersion from the same fl_inbound_order row")
.contains("sourceType", "recordStatus", "mergedIntoContainerBookingId")
.contains("Task detail is served only from the ACTIVE container booking")
.contains("Enforced by the server-side pending-task query")
.contains("assigned LOADING tasks with pendingItemCount=0 remain visible as AWAITING_REVIEW")
.contains("CompleteLoadingRequest");
String taskLookup = yaml.substring(
yaml.indexOf(" /api/platform/v1/first-leg/pda-loading/tasks:"),
yaml.indexOf(" /api/platform/v1/first-leg/pda-loading/tasks/pending:"));
String batchRequest = yaml.substring(
yaml.indexOf(" BatchRequest:"),
yaml.indexOf(" BatchItemRequest:"));
assertThat(taskLookup)
.contains("name: cabinetNo", "name: loadingPlanId")
.doesNotContain("name: containerNo");
String taskDetail = yaml.substring(
yaml.indexOf(" /api/platform/v1/first-leg/pda-loading/tasks/{taskId}:"),
yaml.indexOf(" /api/platform/v1/first-leg/pda-loading/tasks/{taskId}/claim:"));
assertThat(taskDetail).contains("#/components/schemas/Task", "ACTIVE container lifecycle fields");
assertThat(batchRequest)
.contains("loadingPlanId:", "containerBookingId:")
.doesNotContain("containerNo:");
}
}
@@ -51,12 +51,12 @@ class PlatformCartonScanControllerTest {
.thenReturn(new CartonMeasurementResult(30001L, 1, 40001L, "RK-001#5-1",
"FBA-SCAN-001", 1,
new BigDecimal("60.1"), new BigDecimal("40.2"), new BigDecimal("30.3"),
new BigDecimal("12.5"), 99001L, "SIM-01",
99001L, "SIM-01",
LocalDateTime.parse("2026-08-07T10:30:00"), true));
MockPart data = new MockPart("data", "data", ("""
{"boxCode":"RK-001#5-1","scannedShipmentId":"FBA-SCAN-001",
"length":60.1,"width":40.2,"height":30.3,
"weight":12.5,"machineNo":"SIM-01","measuredAt":"2026-08-07T10:30:00"}
"machineNo":"SIM-01","measuredAt":"2026-08-07T10:30:00"}
""").getBytes(java.nio.charset.StandardCharsets.UTF_8));
data.getHeaders().setContentType(MediaType.APPLICATION_JSON);
MockMultipartFile image = new MockMultipartFile(
@@ -91,11 +91,11 @@ class PlatformCartonScanControllerTest {
when(cartonMeasurements.upsertManualForInbound(eq(10001L), eq(90001L), eq(70001L), any()))
.thenReturn(new CartonMeasurementResult(30001L, 1, 40001L, "RK-001#5-1",
null, 1, new BigDecimal("60.1"), new BigDecimal("40.2"), new BigDecimal("30.3"),
new BigDecimal("12.5"), 99001L, "MANUAL-ADMIN",
99001L, "MANUAL-ADMIN",
LocalDateTime.parse("2026-08-12T14:00:00"), true));
MockPart data = new MockPart("data", "data", ("""
{"boxCode":"RK-001#5-1","length":60.1,"width":40.2,"height":30.3,
"weight":12.5,"machineNo":"MANUAL-ADMIN","measuredAt":"2026-08-12T14:00:00"}
"machineNo":"MANUAL-ADMIN","measuredAt":"2026-08-12T14:00:00"}
""").getBytes(java.nio.charset.StandardCharsets.UTF_8));
data.getHeaders().setContentType(MediaType.APPLICATION_JSON);
MockMultipartFile image = new MockMultipartFile(
@@ -2,21 +2,46 @@ package com.baiyue.firstleg.controller;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.InboundMeasurementRequests;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.multipart.MultipartFile;
import java.lang.reflect.Method;
import java.lang.reflect.RecordComponent;
import java.util.Arrays;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@DisplayName("平台入库测量接口权限契约")
class PlatformInboundMeasurementControllerContractTest {
@Test
void draftRequestOnlyAcceptsEditableGoodsNameAndMeasurementItems() {
assertThat(Arrays.stream(InboundMeasurementRequests.SaveDraftRequest.class.getRecordComponents())
.map(RecordComponent::getName)).containsExactly(
"measurementId", "version", "actualGoodsName", "items");
}
@Test
void draftRequestRejectsRemovedDeclarationFieldsAtDeserialization() {
String json = """
{"version":0,"originalGrossWeightKg":2000,"actualGoodsName":"货物","items":[]}
""";
assertThatThrownBy(() -> new ObjectMapper().readValue(
json, InboundMeasurementRequests.SaveDraftRequest.class))
.hasMessageContaining("originalGrossWeightKg");
}
@Test
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);
@@ -12,7 +12,7 @@ import static org.assertj.core.api.Assertions.assertThat;
class PlatformLoadingPdaControllerContractTest {
@Test
void exposesAssignmentWorkAndReviewWithDedicatedAuthorities() throws Exception {
authority("task", "platform:firstleg:pda:view", AuthenticatedUser.class, String.class, String.class, Long.class, long.class);
authority("task", "platform:firstleg:pda:view", AuthenticatedUser.class, String.class, Long.class, long.class);
authority("pendingTasks", "platform:firstleg:pda:view", AuthenticatedUser.class, String.class);
authority("inventorySnapshot", "platform:firstleg:pda:view", AuthenticatedUser.class, long.class, long.class);
authority("claim", "platform:firstleg:pda:work", AuthenticatedUser.class, long.class, ClaimRequest.class);
@@ -23,8 +23,45 @@ class PlatformSmartLoadingControllerContractTest {
authority("assign","platform:firstleg:smart-loading:assign",AuthenticatedUser.class,long.class,AssignRequest.class);
authority("recommend","platform:firstleg:smart-loading:assign",AuthenticatedUser.class,long.class,RecommendRequest.class);
authority("adjust","platform:firstleg:smart-loading:assign",AuthenticatedUser.class,long.class,long.class,AdjustRequest.class);
authority("warehouseRemark","platform:firstleg:preload:edit",AuthenticatedUser.class,long.class,long.class,WarehouseRemarkRequest.class);
authority("delete","platform:firstleg:smart-loading:delete",AuthenticatedUser.class,long.class,long.class,DeleteRequest.class);
authority("push","platform:firstleg:smart-loading:push",AuthenticatedUser.class,long.class,String.class,PushRequest.class);
}
@Test void openApiPushDoesNotRequireContainerNumberOrCreatePda() throws Exception {
String yaml=java.nio.file.Files.readString(java.nio.file.Path.of("src/main/resources/openapi/firstleg-v1.yaml"));
assertThat(yaml).contains("/api/platform/v1/first-leg/smart-loading/plans/{planId}/push-preload:")
.contains("不要求真实柜号")
.contains("不创建 PDA 作业任务")
.contains("已关联 RELEASED 订舱时才创建待领取 PDA 任务")
.contains("领取后才进入 LOADING")
.doesNotContain("已关联 CONFIRMED/RELEASED 订舱")
.doesNotContain("真实柜号必须已录入");
}
@Test void openApiFreezesIncrementalMutationAndCapabilityContract() throws Exception {
String yaml=java.nio.file.Files.readString(java.nio.file.Path.of("src/main/resources/openapi/firstleg-v1.yaml"));
assertThat(yaml)
.contains("/api/platform/v1/first-leg/smart-loading/plans/{planId}/assignments:")
.contains("/api/platform/v1/first-leg/smart-loading/plans/{planId}/items/{itemId}:")
.contains("SmartLoadingAssignRequest:")
.contains("SmartLoadingAdjustRequest:")
.contains("SmartLoadingDeleteRequest:")
.contains("SmartLoadingWarehouseRemarkRequest:")
.contains("/items/{itemId}/warehouse-remark:")
.contains("需要 platform:firstleg:preload:edit 权限")
.contains("warehouseRemark: {type: string, nullable: true, maxLength: 1000")
.contains("成功响应中的 version 是递增后的 planVersion")
.contains("items[].version 是递增后的 itemVersion")
.contains("数量调整或动态增量不会覆盖已有 warehouseRemark")
.contains("canAssign: {type: boolean}")
.contains("canEdit: {type: boolean}")
.contains("pickedPackageCount: {type: integer, minimum: 0}")
.contains("minPackageCount: {type: integer, minimum: 1}")
.contains("canAdjust: {type: boolean}")
.contains("canRemove: {type: boolean}")
.contains("原 inventorySnapshotToken 随即失效")
.contains("PDA task 行锁 -> plan 乐观锁")
.contains("回到 IN_PROGRESS")
.contains("COMPLETED 永久拒绝增量");
}
private void authority(String method,String permission,Class<?>...types)throws Exception{Method m=PlatformSmartLoadingController.class.getDeclaredMethod(method,types);assertThat(m.getAnnotation(PreAuthorize.class).value()).isEqualTo("hasAuthority('"+permission+"')");}
}
@@ -16,6 +16,7 @@ import java.nio.charset.StandardCharsets;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.groups.Tuple.tuple;
@DisplayName("船期价格表公共契约")
class PriceBookContractTest {
@@ -23,6 +24,24 @@ class PriceBookContractTest {
@DisplayName("固定十五个渠道且海派使用区域重量模型")
void keepsStableChannelCatalog() {
assertThat(PriceBookChannelCatalog.CHANNELS).hasSize(15);
assertThat(PriceBookChannelCatalog.CHANNELS)
.extracting("channelCode", "inboundCodePrefix", "overseasInboundCodePrefix")
.containsExactly(
tuple("MATSON_CLX_TRUCK", "ZBYHK", "ZBYHWK"),
tuple("MATSON_MAX_TRUCK", "BYHK", "BYHWK"),
tuple("EXX_TRUCK", "EXXHK", "EXXHWK"),
tuple("HEDE_TRUCK", "HDHK", "HDHWK"),
tuple("EXX_RAIL_NY", "NYEXX", null),
tuple("OA_LA_TRUCK", "PCHK", "PCHWK"),
tuple("OA_LA_FULL_TRUCK", "PCKK", null),
tuple("OA_CHICAGO_RAIL", "CPHT", null),
tuple("OA_HOUSTON_TRUCK", "HPHK", null),
tuple("OA_NEW_YORK_TRUCK", "NPHK", "NPHWK"),
tuple("OA_SAVANNAH_TRUCK", "SPHK", null),
tuple("MATSON_CLX_PARCEL", "ZBYHP", null),
tuple("MATSON_MAX_PARCEL", "BYHP", null),
tuple("HEDE_PARCEL", "HDHP", null),
tuple("OA_PARCEL", "PCHP", null));
assertThat(PriceBookChannelCatalog.CHANNELS.stream()
.map(PriceBookChannelCatalog.ChannelDefinition::channelCode))
.containsExactly(
@@ -96,5 +115,12 @@ class PriceBookContractTest {
.contains("/api/platform/v1/fba-pricing/carrier-rates/price-books/{versionId}/source")
.contains("确认并立即生效")
.contains("SUPERSEDED");
String firstlegOpenapi = new ClassPathResource("openapi/firstleg-v1.yaml")
.getContentAsString(StandardCharsets.UTF_8);
assertThat(firstlegOpenapi)
.contains("/internal/firstleg/v1/pricing/channels/validate:")
.contains("required: [tenantId, originWarehouseId, channelCode, destinationType, requireInboundCodePrefix]")
.contains("LEGACY_MANUAL 模式仅校验渠道可用性");
}
}
@@ -10,6 +10,8 @@ import org.springframework.web.bind.annotation.RequestHeader;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.util.Map;
@@ -17,8 +19,19 @@ import static org.assertj.core.api.Assertions.assertThat;
class TruckAppointmentControllerContractTest {
@Test void exposesDedicatedEndpointAndPermissions() {String p=PlatformTruckAppointmentController.class.getAnnotation(org.springframework.web.bind.annotation.RequestMapping.class).value()[0];assertThat(p).isEqualTo("/api/platform/v1/truck-appointments"); for(String n:new String[]{"page","detail","history","attachments","attach","upload","deleteAttachment","create","edit","action","reschedule"}) assertThat(java.util.Arrays.stream(PlatformTruckAppointmentController.class.getDeclaredMethods()).anyMatch(m->m.getName().equals(n))).isTrue();}
@Test void schemaHasAggregateTablesStatesAndActualEventTimes() throws Exception {String s=new ClassPathResource("db/phase3-truck-appointment-schema.sql").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("fl_truck_appointment","fl_truck_appointment_history","fl_truck_appointment_attachment","fl_truck_appointment_operation","PENDING_DISPATCH","PICKUP_IN_PROGRESS","ARRIVED_WAREHOUSE","RESCHEDULED","actual_arrived_warehouse_at","actual_loading_at","actual_departed_at","CALL phase3_truck_add_column");}
@Test void schemaHasAggregateTablesStatesAndActualEventTimes() throws Exception {String s=new ClassPathResource("db/phase3-truck-appointment-schema.sql").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("fl_truck_appointment","fl_truck_appointment_history","fl_truck_appointment_attachment","fl_truck_appointment_operation","bill_of_lading_no VARCHAR(128)","chk_truck_attachment_type","BILL_OF_LADING","PENDING_DISPATCH","PICKUP_IN_PROGRESS","ARRIVED_WAREHOUSE","RESCHEDULED","actual_arrived_warehouse_at","actual_loading_at","actual_departed_at","CALL phase3_truck_add_column");}
@Test void existingAppointmentTableReceivesAllLateColumns() throws Exception {
String moduleSql=new ClassPathResource("db/phase3-truck-appointment-schema.sql").getContentAsString(StandardCharsets.UTF_8);
Path direct=Path.of("deploy","sql","头程数据库统一增量-MySQL8.sql");
Path unifiedPath=Files.exists(direct)?direct:Path.of("..","deploy","sql","头程数据库统一增量-MySQL8.sql");
String unifiedSql=Files.readString(unifiedPath,StandardCharsets.UTF_8);
for(String sql:new String[]{moduleSql,unifiedSql}) {
assertThat(sql).contains("'bill_of_lading_no'","'actual_arrived_warehouse_at'","'actual_loading_at'","'actual_departed_at'");
}
}
@Test void openApiDocumentsLifecycleIdempotencyAndActualTimes() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("/api/platform/v1/truck-appointments/{id}/{action}:","/api/platform/v1/truck-appointments/{id}/reschedule:","name: Idempotency-Key","required: true","actual event timestamps");}
@Test void openApiFreezesIndependentCreateAndOptionalAssociations() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("required: [customerId, carrierId, plannedLoadingAt, expectedVersion]","independent operational records","no RELEASED-state prerequisite","It may be supplied only with containerBookingId","ACTIVE IAM customer","ACTIVE Warehouse supplier ID validated as TRUCKING","Fulfillment associations remain null","schema: {$ref: '#/components/schemas/DetailEnvelope'}","required: [code, message, data, timestamp]","allOf: [{$ref: '#/components/schemas/DetailStableReferences'}]","nullable: true");}
@Test void openApiFreezesAttachmentRequestAndOwnershipContract() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("AttachmentRequest:","required: [fileId, attachmentType]","required: [attachmentType, file]","BILL_OF_LADING","TRUCK_APPOINTMENT_ATTACHMENT","Deletes the appointment reference only","DetailStableReferences:","oceanBookingNo:","shippingCompanyName:","bookingAgentName:","cabinetNo:","billOfLadingNo:");}
@Test void methodsHavePlatformPermissionsAndAuthenticatedScope() {
Map<String,String> expected=Map.ofEntries(
Map.entry("page","platform:firstleg:truck-appointment:list"), Map.entry("detail","platform:firstleg:truck-appointment:list"),
@@ -40,8 +40,9 @@ class UnifiedInboundOpenApiContractTest {
"businessType", "channelCode", "pricingTaxMode", "containerMode", "destinationType",
"holdFlag", "actualGrossWeightKg", "actualNetWeightKg", "actualVolumeCbm",
"volumeWeightKg", "chargeableWeightKg", "chargeableVolumeCbm", "palletCount",
"inboundedAt", "expressCompanyCode", "expressCompanyName", "loadingStatus", "containerNo",
"cabinetNo", "sailingAt", "portArrivalAt", "createdAt", "updatedAt");
"inboundedAt", "requestedPickupAt", "expressCompanyCode", "expressCompanyName", "containerNo",
"cabinetNo", "estimatedSailingAt", "actualSailingAt", "estimatedPortArrivalAt",
"actualPortArrivalAt", "createdByName", "createdAt", "updatedAt");
}
@SuppressWarnings("unchecked")
@@ -30,7 +30,7 @@ class CustomerTransitStatisticsContractTest {
.findFirst().orElseThrow().getAnnotation(org.apache.ibatis.annotations.Select.class).value();
var statement = String.join("\n", sql);
assertThat(statement).contains("actual_sailing_at IS NOT NULL", "actual_warehouse_arrival_at IS NULL",
"status NOT IN ('CANCELLED', 'FAILED')", "DISTINCT x.customer_id, x.container_booking_id",
"status <> 'CANCELLED'", "DISTINCT x.customer_id, x.container_booking_id",
"b.customer_id", "i.account_id", "i.deleted = 0", "NOT EXISTS",
"x.customer_id = #{customerId}", "LIKE '20%'", "THEN 1.0",
"LIKE '40%'", "THEN 2.0", "LIKE '45%'", "THEN 2.25", "ELSE NULL");
@@ -6,6 +6,7 @@ import com.baiyue.firstleg.customs.client.CustomsFileClient;
import com.baiyue.firstleg.customs.dto.CustomsDtos.*;
import com.baiyue.firstleg.customs.entity.*;
import com.baiyue.firstleg.customs.repository.CustomsRepository;
import com.baiyue.firstleg.service.WarehouseSupplierSnapshotService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -24,6 +25,7 @@ import static org.mockito.Mockito.*;
class CustomsServiceTest {
@Mock CustomsRepository repository;
@Mock CustomsFileClient files;
@Mock WarehouseSupplierSnapshotService suppliers;
@InjectMocks CustomsService service;
@BeforeEach
@@ -31,6 +33,8 @@ class CustomsServiceTest {
MockitoAnnotations.openMocks(this);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper());
ReflectionTestUtils.setField(service, "internalToken", "token");
when(suppliers.customs(anyLong(), anyString()))
.thenReturn(new com.baiyue.firstleg.client.WarehouseSupplierClient.Snapshot(88L, "线下报关代理", "CUSTOMS", "ACTIVE"));
when(repository.completeOperation(anyLong(), anyString(), anyLong())).thenReturn(1);
}
@@ -84,9 +88,11 @@ class CustomsServiceTest {
when(repository.transition(41L, 4, "RECEIPT_PENDING", 7L, "admin")).thenReturn(1);
CustomsSubmissionBatchEntity result = service.submitBatch(41L, 7L, "admin", "batch-1",
new SubmitBatchRequest("线下报关代理", "EXT-1", "提交", 4));
new SubmitBatchRequest(88L, "线下报关代理", "EXT-1", "提交", 4));
assertThat(result.getFileSnapshotJson()).contains("documentVersion").contains("INVOICE");
assertThat(result.getCustomsAgentId()).isEqualTo(88L);
assertThat(result.getCustomsAgentName()).isEqualTo("线下报关代理");
verify(repository).freeze(51L);
verify(repository).insert(any(CustomsSubmissionBatchEntity.class));
}
@@ -0,0 +1,24 @@
package com.baiyue.firstleg.domain;
import com.baiyue.common.core.exception.BizException;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class ContainerSpecificationCatalogTest {
@Test
void exposesCanonicalSpecificationsAndRejectsAliases() {
var specification = ContainerSpecificationCatalog.requireMatch(
"40HC", new BigDecimal("76.000"), new BigDecimal("28000.000"));
assertThat(specification.containerType()).isEqualTo("40HC");
assertThatThrownBy(() -> ContainerSpecificationCatalog.require("40HQ"))
.isInstanceOf(BizException.class).hasMessageContaining("固定规格");
assertThatThrownBy(() -> ContainerSpecificationCatalog.requireMatch(
"40HC", new BigDecimal("68"), new BigDecimal("28000")))
.isInstanceOf(BizException.class).hasMessageContaining("容量和限重");
}
}
@@ -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"));
}
}
@@ -0,0 +1,35 @@
package com.baiyue.firstleg.dto;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import static org.assertj.core.api.Assertions.assertThat;
class InboundOrderWeightSemanticsTest {
@Test
void summaryKeepsGrossAndNetWeightAsIndependentDatabaseFacts() {
InboundOrderEntity inbound = new InboundOrderEntity();
inbound.setActualGrossWeightKg(new BigDecimal("1787.500"));
inbound.setActualNetWeightKg(new BigDecimal("1747.500"));
InboundOrderSummary result = InboundOrderSummary.from(
inbound, (CurrentContainerTransport) null, null, null);
assertThat(result.actualGrossWeightKg()).isEqualByComparingTo("1787.500");
assertThat(result.actualNetWeightKg()).isEqualByComparingTo("1747.500");
}
@Test
void summaryDoesNotFallbackBetweenGrossAndNetWeight() {
InboundOrderEntity inbound = new InboundOrderEntity();
inbound.setActualGrossWeightKg(new BigDecimal("1787.500"));
InboundOrderSummary result = InboundOrderSummary.from(
inbound, (CurrentContainerTransport) null, null, null);
assertThat(result.actualGrossWeightKg()).isEqualByComparingTo("1787.500");
assertThat(result.actualNetWeightKg()).isNull();
}
}
@@ -4,6 +4,8 @@ import com.baiyue.firstleg.loading.bridge.dto.LoadingModelBridgeDtos.BindRequest
import com.baiyue.firstleg.loading.bridge.dto.LoadingModelBridgeDtos.Link;
import com.baiyue.firstleg.loading.bridge.mapper.LoadingModelBridgeMapper.LoadingPlanSnapshot;
import com.baiyue.firstleg.loading.bridge.mapper.LoadingModelBridgeMapper.PreloadSnapshot;
import com.baiyue.firstleg.loading.bridge.mapper.LoadingModelBridgeMapper.AllocationScope;
import com.baiyue.firstleg.loading.bridge.entity.PreloadLoadingPlanLinkEntity;
import com.baiyue.firstleg.loading.bridge.repository.LoadingModelBridgeRepository;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -42,6 +44,101 @@ class LoadingModelBridgeServiceTest {
verify(repository).touchLoadingPlan(40, 3, 7, "admin");
}
@Test
void bindsEveryAllocatedInboundBeforeLoadingStarts() {
when(repository.loadingPlan(40)).thenReturn(new LoadingPlanSnapshot(40L, 50L, "C1", "EDITING", 3));
when(repository.allocatedScopes(40)).thenReturn(List.of(new AllocationScope(10L, 20L, 8)));
when(repository.preload(10, 20)).thenReturn(new PreloadSnapshot(30L, 10L, 20L, "PENDING", 2));
when(repository.allocatedPackages(10, 20, 40)).thenReturn(8);
when(repository.touchPreload(30, 10, 20, 2, 7, "admin")).thenReturn(1);
when(repository.touchLoadingPlan(40, 3, 7, "admin")).thenReturn(1);
Link linked = link("PENDING", 3, "EDITING", 4, 8);
when(repository.byInbound(10, 20)).thenReturn(List.of(linked));
when(repository.byLoadingPlan(40)).thenReturn(List.of(linked));
List<Link> result = service.bindAllocatedBeforeLoading(40, 7, "admin", "push-1");
assertThat(result).containsExactly(linked);
verify(repository).insert(any());
verify(repository).touchPreload(30, 10, 20, 2, 7, "admin");
verify(repository).touchLoadingPlan(40, 3, 7, "admin");
}
@Test
void editingReplayWithEveryPairExistingIsReadOnly() {
var plan = new LoadingPlanSnapshot(40L, 50L, "C1", "EDITING", 9);
when(repository.loadingPlan(40)).thenReturn(plan);
when(repository.allocatedScopes(40)).thenReturn(List.of(
new AllocationScope(10L, 20L, 8), new AllocationScope(11L, 21L, 4)));
when(repository.byPair(10, 20, 40)).thenReturn(new PreloadLoadingPlanLinkEntity());
when(repository.byPair(11, 21, 40)).thenReturn(new PreloadLoadingPlanLinkEntity());
Link first = link("PENDING", 3, "EDITING", 9, 8);
when(repository.byLoadingPlan(40)).thenReturn(List.of(first));
assertThat(service.bindAllocatedBeforeLoading(40, 7, "admin", "push-1"))
.containsExactly(first);
verify(repository, never()).preload(anyLong(), anyLong());
verify(repository, never()).insert(any());
verify(repository, never()).touchPreload(anyLong(), anyLong(), anyLong(), anyInt(), anyLong(), anyString());
verify(repository, never()).touchLoadingPlan(anyLong(), anyInt(), anyLong(), anyString());
}
@Test
void editingReplaySkipsExistingPairAndCreatesOnlyMissingPair() {
when(repository.loadingPlan(40)).thenReturn(new LoadingPlanSnapshot(40L, 50L, "C1", "EDITING", 3));
when(repository.allocatedScopes(40)).thenReturn(List.of(
new AllocationScope(10L, 20L, 8), new AllocationScope(11L, 21L, 4)));
when(repository.byPair(10, 20, 40)).thenReturn(new PreloadLoadingPlanLinkEntity());
when(repository.byPair(11, 21, 40)).thenReturn(null);
when(repository.preload(11, 21)).thenReturn(new PreloadSnapshot(31L, 11L, 21L, "PENDING", 5));
when(repository.loadingPlan(40)).thenReturn(
new LoadingPlanSnapshot(40L, 50L, "C1", "EDITING", 3),
new LoadingPlanSnapshot(40L, 50L, "C1", "EDITING", 3));
when(repository.allocatedPackages(11, 21, 40)).thenReturn(4);
when(repository.touchPreload(31, 11, 21, 5, 7, "admin")).thenReturn(1);
when(repository.touchLoadingPlan(40, 3, 7, "admin")).thenReturn(1);
Link linked = link("PENDING", 6, "EDITING", 4, 4);
when(repository.byInbound(11, 21)).thenReturn(List.of(linked));
when(repository.byLoadingPlan(40)).thenReturn(List.of(linked));
assertThat(service.bindAllocatedBeforeLoading(40, 7, "admin", "push-1"))
.containsExactly(linked);
verify(repository, never()).preload(10, 20);
verify(repository, times(2)).preload(11, 21);
verify(repository, times(1)).insert(any());
verify(repository, times(1)).touchPreload(31, 11, 21, 5, 7, "admin");
verify(repository, times(1)).touchLoadingPlan(40, 3, 7, "admin");
}
@Test
void refusesPostHocBindingAfterLoadingStarts() {
when(repository.loadingPlan(40)).thenReturn(new LoadingPlanSnapshot(40L, 50L, "C1", "LOADING", 4));
when(repository.allocatedScopes(40)).thenReturn(List.of(new AllocationScope(10L, 20L, 8)));
assertThatThrownBy(() -> service.bindAllocatedBeforeLoading(40, 7, "admin", "push-1"))
.hasMessageContaining("禁止在LOADING后补关联");
verify(repository, never()).insert(any());
}
@Test
void loadingReplayOnlyReadsAlreadyCompleteLinks() {
when(repository.loadingPlan(40)).thenReturn(new LoadingPlanSnapshot(40L, 50L, "C1", "LOADING", 4));
when(repository.allocatedScopes(40)).thenReturn(List.of(new AllocationScope(10L, 20L, 8)));
PreloadLoadingPlanLinkEntity existing = new PreloadLoadingPlanLinkEntity();
when(repository.byPair(10, 20, 40)).thenReturn(existing);
Link linked = link("PENDING", 3, "LOADING", 4, 8);
when(repository.byLoadingPlan(40)).thenReturn(List.of(linked));
assertThat(service.bindAllocatedBeforeLoading(40, 7, "admin", "push-1"))
.containsExactly(linked);
verify(repository, never()).insert(any());
verify(repository, never()).touchLoadingPlan(anyLong(), anyInt(), anyLong(), anyString());
}
@Test
void secondVersionConflictDoesNotProceedAsSuccess() {
when(repository.preload(10, 20)).thenReturn(new PreloadSnapshot(30L, 10L, 20L, "PENDING", 2));
@@ -55,6 +152,33 @@ class LoadingModelBridgeServiceTest {
verify(repository, never()).byInbound(10, 20);
}
@Test
void publicBindSameKeyWithChangedVersionsStillConflicts() {
var prior = new PreloadLoadingPlanLinkEntity();
prior.setRequestHash("prior-request-hash");
when(repository.byIdempotency(7, "manual-key")).thenReturn(prior);
assertThatThrownBy(() -> service.bind(10, 20, 40, 7, "admin", "manual-key",
new BindRequest(3, 4)))
.hasMessageContaining("Idempotency-Key已用于不同关联请求");
verify(repository, never()).byPair(anyLong(), anyLong(), anyLong());
verify(repository, never()).insert(any());
}
@Test
void confirmationGateRejectsIncompleteAllocatedLinks() {
when(repository.loadingPlan(40)).thenReturn(new LoadingPlanSnapshot(
40L, 50L, "C1", "EDITING", 3, LocalDateTime.now()));
when(repository.allocatedScopes(40)).thenReturn(List.of(
new AllocationScope(10L, 20L, 8), new AllocationScope(11L, 21L, 4)));
when(repository.byLoadingPlan(40)).thenReturn(List.of(
link("PENDING", 2, "EDITING", 3, 8)));
assertThatThrownBy(() -> service.requireAllocatedLinksForConfirmation(40))
.hasMessageContaining("关联不完整");
}
@Test
void terminalGateRequiresEveryLinkedContainerPlanCompleted() {
when(repository.byInbound(10, 20)).thenReturn(List.of(
@@ -16,15 +16,19 @@ class LoadingDeliveryOpenApiContractTest {
assertThat(yaml)
.contains("/api/platform/v1/inbounds/{inboundId}/loading/evidence:")
.contains("/api/platform/v1/inbounds/{inboundId}/loading/evidence/pda:")
.contains("/api/platform/v1/inbounds/{inboundId}/loading/evidence/{evidenceId}/content/pda:")
.contains("/api/platform/v1/inbounds/{inboundId}/loading/evidence/{evidenceId}/void:")
.contains("/api/platform/v1/inbounds/{inboundId}/loading/picking/start:")
.contains("/api/platform/v1/inbounds/{inboundId}/loading/picking/complete:")
.contains("/api/platform/v1/inbounds/{inboundId}/loading/complete:")
.contains("required: [expectedVersion, evidenceType, fileId]")
.contains("previewUrl:")
.contains("enum: [PHOTO_STEP_1, PHOTO_STEP_2, PHOTO_STEP_3, PHOTO_STEP_4, SEAL]")
.contains("同类型可多次追加")
.contains("Required when evidenceType is SEAL")
.contains("compatibility projection")
.contains("associated inbound process status to be LOADING")
.contains("workflowStatus AWAITING_REVIEW does not bypass this gate")
.contains("must exactly match the server-derived container-task aggregate")
.contains("cannot complete canonical picking");
assertThat(yaml).doesNotContain("/delivery/complete").doesNotContain("DELIVERED");
@@ -30,6 +30,11 @@ class PlatformLoadingDeliveryControllerContractTest {
"platform:firstleg:pda:work", UpsertEvidenceRequest.class);
get("pdaEvidence", "/{inboundId}/loading/evidence/pda",
"platform:firstleg:pda:work");
Method pdaContent = PlatformLoadingDeliveryController.class.getDeclaredMethod("pdaEvidenceContent",
AuthenticatedUser.class, long.class, long.class, long.class);
assertThat(pdaContent.getAnnotation(GetMapping.class).value())
.containsExactly("/{inboundId}/loading/evidence/{evidenceId}/content/pda");
assertAuthority(pdaContent, "platform:firstleg:pda:work");
Method pdaVoid = PlatformLoadingDeliveryController.class.getDeclaredMethod("pdaVoidEvidence",
AuthenticatedUser.class, long.class, long.class, long.class, String.class, VoidEvidenceRequest.class);
assertThat(pdaVoid.getAnnotation(PostMapping.class).value())
@@ -7,7 +7,7 @@ import static org.assertj.core.api.Assertions.assertThat;
class LoadingCompletionGateSqlContractTest {
@Test
void requiresEveryLinkedContainerToPassBookingAndTruckGates() throws Exception {
void requiresEveryLinkedContainerToHaveReleasedBookingWithoutTruckPrerequisite() throws Exception {
Select select = LoadingCompletionGateMapper.class
.getDeclaredMethod("snapshot", long.class, long.class).getAnnotation(Select.class);
String sql = String.join(" ", select.value()).replaceAll("\\s+", " ");
@@ -17,10 +17,8 @@ class LoadingCompletionGateSqlContractTest {
.contains("JOIN fl_loading_plan p ON p.id=l.loading_plan_id")
.contains("fl_ocean_booking_container_allocation")
.contains("b.status='RELEASED'")
.contains("fl_truck_appointment")
.contains("t.status='COMPLETED'")
.doesNotContain("fl_truck_appointment", "truckReady", "t.status='COMPLETED'")
.contains("l.tenant_id=#{tenantId} AND l.inbound_id=#{inboundId}")
.doesNotContain("truckReady, FROM")
.doesNotContain("fl_customs_case", "fl_customs_receipt", "customsReady")
.doesNotContain("&lt;", "&gt;");
}
@@ -24,6 +24,7 @@ import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.ResponseEntity;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
@@ -54,6 +55,24 @@ class LoadingDeliveryServiceTest {
token.set(service, "token");
}
@Test
void returnsAuthenticatedPreviewUrlAndProxiesOwnedEvidenceContent() {
when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 3));
LoadingEvidenceEntity target = evidence("PHOTO_STEP_1", 11L, null);
target.setId(99L); target.setInboundId(20L); target.setTenantId(10L); target.setStatus("ACTIVE");
when(repository.listEvidence(10, 20)).thenReturn(List.of(target));
when(files.download("token", 10, 20, 11)).thenReturn(ResponseEntity.ok(new byte[]{1, 2, 3}));
var listed = service.evidence(10, 20);
var content = service.evidenceContent(10, 20, 99);
assertThat(listed.evidences()).singleElement().satisfies(item ->
assertThat(item.previewUrl()).isEqualTo(
"/api/platform/v1/inbounds/20/loading/evidence/99/content/pda?tenantId=10"));
assertThat(content.getBody()).containsExactly(1, 2, 3);
verify(files).download("token", 10, 20, 11);
}
@Test
void completesLoadingOnlyAfterPickingAndFiveEvidenceFiles() {
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
@@ -79,10 +98,48 @@ class LoadingDeliveryServiceTest {
LoadingActionResponse result = service.completeLoading(10, 20, 7, "admin", "k1", new CompleteLoadingRequest(3, null));
assertThat(result.mainStatus()).isEqualTo("LOADED");
verify(loadingModelBridge).requireCompleted(10, 20);
verify(completionGate).requireBookingAndTransport(10, 20);
verify(completionGate).requireReleasedBooking(10, 20);
verify(histories).insert(any(InboundStatusHistoryEntity.class));
}
@Test
void pdaCompletionPreflightChecksStartedPlanTransportAndEvidenceWithoutWriting() {
when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 3));
when(repository.listEvidence(10, 20)).thenReturn(List.of(
evidence("PHOTO_STEP_1", 1L, null), evidence("PHOTO_STEP_2", 2L, null),
evidence("PHOTO_STEP_3", 3L, null), evidence("PHOTO_STEP_4", 4L, null),
evidence("SEAL", 5L, "LOCK-001")));
when(files.validate(eq("token"), eq(10L), eq(20L), anyLong())).thenAnswer(invocation -> {
long fileId = invocation.getArgument(3);
String type = fileId == 5L ? "SEAL" : "PHOTO_STEP_" + fileId;
return ApiResponse.success(new LoadingEvidenceFileClient.FileMetadata(
fileId, type.toLowerCase() + ".png", "LOADING_" + type, "image/png"));
});
service.requirePdaCompletionReady(10, 20);
verify(loadingModelBridge).requireStarted(10, 20);
verify(completionGate).requireReleasedBooking(10, 20);
verify(loadingModelBridge, never()).requireCompleted(anyLong(), anyLong());
verify(repository, never()).completeLoading(anyLong(), anyLong(), anyLong(), anyInt());
verify(repository, never()).claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString());
}
@Test
void pdaCompletionPreflightReportsBookingBlockerBeforeEvidenceOrWrites() {
when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 3));
doThrow(new com.baiyue.common.core.exception.BizException(
com.baiyue.common.core.api.ResultCode.CONFLICT,
"LOADED门禁未通过:订舱尚未完成(必须为 RELEASED)"))
.when(completionGate).requireReleasedBooking(10, 20);
assertThatThrownBy(() -> service.requirePdaCompletionReady(10, 20))
.hasMessageContaining("订舱尚未完成");
verify(repository, never()).listEvidence(anyLong(), anyLong());
verify(repository, never()).completeLoading(anyLong(), anyLong(), anyLong(), anyInt());
}
@Test
void completesLoadedFulfillmentSliceBeforeAggregatingRoot() {
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
@@ -113,7 +170,7 @@ class LoadingDeliveryServiceTest {
assertThat(result.mainStatus()).isEqualTo("LOADED");
verify(loadingModelBridge).requireCompleted(10, 20);
verify(completionGate).requireBookingAndTransport(10, 20);
verify(completionGate).requireReleasedBooking(10, 20);
verify(repository).completeLoadedChild(10, 20, 7, 3);
verify(repository).aggregateRootLoaded(10, 20, 7);
verify(histories).insert(argThat((InboundStatusHistoryEntity history) ->
@@ -121,6 +178,62 @@ class LoadingDeliveryServiceTest {
verify(repository, never()).completeLoading(anyLong(), anyLong(), anyLong(), anyInt());
}
@Test
void pdaFinalizeUsesSourceVersionIncrementedByFullSettlement() {
readyForPdaFinalize();
CompletionTarget settled = new CompletionTarget(20L, null, 20L,
"LOADING", 7, "LOADING", 7);
when(repository.completionTarget(10, 20)).thenReturn(settled);
when(repository.findInboundForUpdate(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 7));
when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 7));
when(repository.completeLoadedChild(10, 20, 7, 7)).thenReturn(1);
LoadingActionResponse result = service.completeAfterPdaSettlement(
10, 20, 7, "admin", "pda-full", "完成");
assertThat(result.mainStatus()).isEqualTo("LOADED");
verify(repository).findInboundForUpdate(10, 20);
verify(repository).completeLoadedChild(10, 20, 7, 7);
verify(repository, never()).completeLoadedChild(10, 20, 7, 6);
}
@Test
void pdaFinalizeLocksAndUsesLoadedSliceVersionAfterPartialSettlement() {
readyForPdaFinalize();
CompletionTarget settled = new CompletionTarget(20L, 21L, 20L,
"LOADING", 7, "LOADING", 2);
when(repository.completionTarget(10, 20)).thenReturn(settled);
when(repository.findInboundForUpdate(10, 21)).thenReturn(new Snapshot(21L, 10L, "LOADING", 2));
when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 7));
when(repository.completeLoadedChild(10, 20, 7, 2)).thenReturn(1);
LoadingActionResponse result = service.completeAfterPdaSettlement(
10, 20, 7, "admin", "pda-partial", "完成");
assertThat(result.mainStatus()).isEqualTo("LOADED");
verify(repository).findInboundForUpdate(10, 21);
verify(repository).completeLoadedChild(10, 20, 7, 2);
}
@Test
void pdaFinalizeKeepsOptimisticConflictForRealConcurrentChange() {
readyForPdaFinalize();
CompletionTarget settled = new CompletionTarget(20L, null, 20L,
"LOADING", 7, "LOADING", 7);
when(repository.completionTarget(10, 20)).thenReturn(settled);
when(repository.findInboundForUpdate(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 7));
when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 7));
when(repository.completeLoadedChild(10, 20, 7, 7)).thenReturn(0);
assertThatThrownBy(() -> service.completeAfterPdaSettlement(
10, 20, 7, "admin", "pda-concurrent", "完成"))
.hasMessageContaining("数据已被其他人修改");
verify(histories, never()).insert(any(InboundStatusHistoryEntity.class));
verify(repository, never()).completeOperation(anyLong(), anyLong(), anyLong(),
anyString(), anyString(), anyString());
}
@Test
void completesSingleRemainderSourceOnceAndReplaysSameKeyWithoutRepeatingSideEffects() {
AtomicInteger claims = new AtomicInteger();
@@ -172,7 +285,7 @@ class LoadingDeliveryServiceTest {
verify(repository).completeLoadedChild(10, 20, 7, 5);
verify(repository).aggregateRootLoaded(10, 10, 7);
verify(loadingModelBridge).requireCompleted(10, 20);
verify(completionGate).requireBookingAndTransport(10, 20);
verify(completionGate).requireReleasedBooking(10, 20);
verify(histories).insert(any(InboundStatusHistoryEntity.class));
verify(repository).completeOperation(10L, 20L, 7L, "COMPLETE_LOADING", "slice-replay", resultJson.get());
}
@@ -297,10 +410,33 @@ class LoadingDeliveryServiceTest {
private static LoadingEvidenceEntity evidence(String type, Long fileId, String sealNo) {
LoadingEvidenceEntity value = new LoadingEvidenceEntity();
value.setId(fileId); value.setInboundId(20L); value.setTenantId(10L);
value.setEvidenceType(type); value.setFileId(fileId); value.setSealNo(sealNo);
return value;
}
private void readyForPdaFinalize() {
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString()))
.thenReturn(true);
LoadingPickItemMapper.InboundProgress progress = mock(LoadingPickItemMapper.InboundProgress.class);
when(progress.getExpectedCount()).thenReturn(5);
when(progress.getPickedCount()).thenReturn(5);
when(progress.getTaskCount()).thenReturn(1);
when(progress.getCompletedTaskCount()).thenReturn(1);
when(progress.getUnresolvedCount()).thenReturn(0);
when(loadingPicks.inboundProgress(10, 20)).thenReturn(progress);
when(repository.listEvidence(10, 20)).thenReturn(List.of(
evidence("PHOTO_STEP_1", 1L, null), evidence("PHOTO_STEP_2", 2L, null),
evidence("PHOTO_STEP_3", 3L, null), evidence("PHOTO_STEP_4", 4L, null),
evidence("SEAL", 5L, "LOCK-001")));
when(files.validate(eq("token"), eq(10L), eq(20L), anyLong())).thenAnswer(invocation -> {
long fileId = invocation.getArgument(3);
String type = fileId == 5L ? "SEAL" : "PHOTO_STEP_" + fileId;
return ApiResponse.success(new LoadingEvidenceFileClient.FileMetadata(
fileId, type.toLowerCase() + ".png", "LOADING_" + type, "image/png"));
});
}
private static LoadingOperationEntity successfulOperation(String requestHash, String resultJson) {
LoadingOperationEntity value = new LoadingOperationEntity();
value.setRequestHash(requestHash);
@@ -8,6 +8,8 @@ import org.springframework.web.bind.annotation.RequestMapping;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.Map;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
@@ -18,23 +20,17 @@ class PlatformOceanBookingControllerContractTest {
.containsExactly("/api/platform/v1/ocean-bookings");
Map<String, String> permissions = Map.ofEntries(
Map.entry("page", "platform:firstleg:ocean-booking:list"),
Map.entry("channels", "platform:firstleg:ocean-booking:list"),
Map.entry("detail", "platform:firstleg:ocean-booking:list"),
Map.entry("create", "platform:firstleg:ocean-booking:create"),
Map.entry("updateDraft", "platform:firstleg:ocean-booking:edit"),
Map.entry("submit", "platform:firstleg:ocean-booking:submit"),
Map.entry("confirm", "platform:firstleg:ocean-booking:confirm"),
Map.entry("fail", "platform:firstleg:ocean-booking:confirm"),
Map.entry("release", "platform:firstleg:ocean-booking:release"),
Map.entry("cancel", "platform:firstleg:ocean-booking:cancel"),
Map.entry("requestChange", "platform:firstleg:ocean-booking:change"),
Map.entry("confirmChange", "platform:firstleg:ocean-booking:change"),
Map.entry("failChange", "platform:firstleg:ocean-booking:change"),
Map.entry("allocate", "platform:firstleg:ocean-booking:allocate"));
Map.entry("deleteDraft", "platform:firstleg:ocean-booking:delete"),
Map.entry("release", "platform:firstleg:ocean-booking:release"));
permissions.forEach((name, permission) -> {
Method method = method(name);
assertThat(method.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('" + permission + "')");
if (!name.equals("page") && !name.equals("detail")) {
if (!name.equals("page") && !name.equals("detail") && !name.equals("channels")) {
assertThat(method.getAnnotation(BusinessAudit.class)).isNotNull();
}
});
@@ -49,6 +45,18 @@ class PlatformOceanBookingControllerContractTest {
.allMatch(parameter -> parameter.getName().equals("oceanBookingId"))).isTrue();
}
@Test
void openApiFreezesServerResolvedChannelContract() throws Exception {
String yaml = Files.readString(Path.of("src/main/resources/openapi/ocean-bookings-v1.yaml"));
assertThat(yaml).contains("/api/platform/v1/ocean-bookings/channels:",
"originWarehouseId", "platform:firstleg:ocean-booking:list", "ChannelOption")
.contains("Create an editable DRAFT", "Replace a DRAFT", "Delete a DRAFT")
.contains("Validate and permanently release a DRAFT")
.contains("containerRequirements:", "ContainerRequirementRequest:")
.contains("never requires or creates a real container number")
.doesNotContain("change-request", "change-confirm", "/cancel:", "containers[].containerNo");
}
private static Method method(String name) {
return Arrays.stream(PlatformOceanBookingController.class.getDeclaredMethods())
.filter(method -> method.getName().equals(name)).findFirst().orElseThrow();
@@ -16,10 +16,12 @@ class OceanBookingMapperContractTest {
com.baomidou.mybatisplus.extension.plugins.pagination.Page.class,
com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.Query.class)
.getAnnotation(Select.class).value());
assertThat(sql).contains("customer_id=#{query.customerId}")
.contains("status=#{query.status}")
.contains("booking_no LIKE")
.contains("ORDER BY updated_at DESC,id DESC");
assertThat(sql).contains("booking.customer_id=#{query.customerId}")
.contains("booking.status=#{query.status}")
.contains("booking.booking_no LIKE")
.contains("party.party_type='SHIPPER'", "party.party_type='CONSIGNEE'")
.contains("fl_ocean_booking_container_allocation", "container.record_status='ACTIVE'", "container_count")
.contains("ORDER BY booking.updated_at DESC,booking.id DESC");
}
@Test
@@ -30,10 +32,8 @@ class OceanBookingMapperContractTest {
.flatMap(annotation -> Arrays.stream(annotation.value()))
.reduce("", (left, right) -> left + '\n' + right);
assertThat(sql)
.contains("status='DRAFT' AND version=#{expectedVersion}")
.contains("status=#{fromStatus} AND version=#{expectedVersion}")
.contains("status='CONFIRMED' AND version=#{expectedVersion}")
.contains("status='CHANGE_PENDING' AND version=#{expectedVersion}")
.contains("status IN ('SUBMITTED','CONFIRMED','CHANGE_PENDING')");
.contains("status='DRAFT' AND version=#{version}")
.doesNotContain("CONFIRMED", "CHANGE_PENDING", "CANCELLED", "SUBMITTED", "FAILED");
}
}
@@ -18,22 +18,38 @@ class OceanBookingSqlContractTest {
.contains("CREATE TABLE IF NOT EXISTS fl_ocean_booking_container_allocation")
.contains("CREATE TABLE IF NOT EXISTS fl_ocean_booking_status_history")
.contains("CREATE TABLE IF NOT EXISTS fl_ocean_booking_operation")
.contains("customer_id BIGINT UNSIGNED NULL COMMENT '可选客户ID快照'")
.contains("customer_name VARCHAR(128) NULL COMMENT '可选客户名称快照'")
.contains("MODIFY COLUMN customer_id BIGINT UNSIGNED NULL COMMENT '可选客户ID快照'")
.contains("MODIFY COLUMN customer_name VARCHAR(128) NULL COMMENT '可选客户名称快照'")
.contains("service_term VARCHAR(32) NULL COMMENT '运输服务条款快照;发布前必填'")
.contains("freight_term VARCHAR(32) NULL COMMENT '海运费支付条款快照;发布前必填'")
.contains("container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '明确引用现有柜次ID'")
.contains("REFERENCES fl_container_booking(id)")
.contains("REFERENCES fl_ocean_booking(id) ON DELETE CASCADE")
.doesNotContain("ALTER TABLE fl_container_booking");
}
@Test
void enforcesStateIdempotencyAndAuditContracts() throws Exception {
assertThat(sql())
.contains("OLD.status='DRAFT' AND NEW.status IN ('SUBMITTED','CANCELLED')")
.contains("OLD.status='CONFIRMED' AND NEW.status IN ('RELEASED','CHANGE_PENDING','CANCELLED')")
.contains("OLD.status='CHANGE_PENDING' AND NEW.status IN ('CONFIRMED','FAILED','CANCELLED')")
.contains("OLD.status IN ('RELEASED','CANCELLED')")
.contains("OLD.status='DRAFT' AND NEW.status='RELEASED'")
.contains("OLD.status = 'RELEASED'")
.contains("UNIQUE KEY uk_ocean_operation (operator_id,operation,idempotency_key)")
.contains("request_hash CHAR(64) NOT NULL")
.contains("<> 'RELEASED' THEN")
.contains("订舱状态历史不可修改")
.contains("UNIQUE KEY uk_ocean_allocation_container (container_booking_id)");
.contains("UNIQUE KEY uk_ocean_allocation_container (container_booking_id)")
.doesNotContain("CONFIRMED", "CHANGE_PENDING", "CANCELLED");
}
@Test
void reconcilesExistingDatabasesWithoutGuessingLegacyBusinessData() throws Exception {
assertThat(sql())
.contains("DROP CHECK chk_ocean_booking_status")
.contains("ADD CONSTRAINT chk_ocean_booking_status CHECK")
.contains("DROP COLUMN failure_reason")
.doesNotContain("UPDATE fl_ocean_booking SET status=");
}
@Test
@@ -42,13 +58,11 @@ class OceanBookingSqlContractTest {
.contains("platform:firstleg:ocean-booking:list")
.contains("platform:firstleg:ocean-booking:create")
.contains("platform:firstleg:ocean-booking:edit")
.contains("platform:firstleg:ocean-booking:submit")
.contains("platform:firstleg:ocean-booking:confirm")
.contains("platform:firstleg:ocean-booking:delete")
.contains("platform:firstleg:ocean-booking:release")
.contains("platform:firstleg:ocean-booking:cancel")
.contains("platform:firstleg:ocean-booking:change")
.contains("platform:firstleg:ocean-booking:allocate")
.doesNotContain("platform:firstleg:ocean-booking:view");
.doesNotContain("platform:firstleg:ocean-booking:view",
"platform:firstleg:ocean-booking:cancel", "platform:firstleg:ocean-booking:change");
}
private static String sql() throws Exception {
@@ -1,27 +1,30 @@
package com.baiyue.firstleg.oceanbooking.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.ActionRequest;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.ChangeRequest;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.Detail;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.AllocationRequest;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity;
import com.baiyue.firstleg.client.WarehouseMasterDataClient;
import com.baiyue.firstleg.client.WarehouseSupplierClient;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.*;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerRequirementEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingOperationEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingStatusHistoryEntity;
import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
import com.baiyue.firstleg.repository.PriceBookQueryRepository;
import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow;
import com.baiyue.firstleg.service.WarehouseSupplierSnapshotService;
import com.baiyue.firstleg.service.FirstlegDocumentNumberGenerator;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.ArgumentCaptor;
import org.mockito.MockitoAnnotations;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -30,137 +33,227 @@ import static org.mockito.Mockito.*;
class OceanBookingServiceTest {
@Mock private OceanBookingRepository repository;
@Mock private PriceBookQueryRepository priceBooks;
@Mock private WarehouseSupplierSnapshotService suppliers;
@Mock private WarehouseMasterDataClient warehouses;
@Mock private FirstlegDocumentNumberGenerator documentNumbers;
@InjectMocks private OceanBookingService service;
private ObjectMapper objectMapper;
@BeforeEach
void setUp() throws Exception {
MockitoAnnotations.openMocks(this);
objectMapper = new ObjectMapper().findAndRegisterModules();
lenient().when(documentNumbers.next("OB")).thenReturn("OB-260818-0001");
var field = OceanBookingService.class.getDeclaredField("objectMapper");
field.setAccessible(true); field.set(service, new ObjectMapper().findAndRegisterModules());
field.setAccessible(true);
field.set(service, objectMapper);
when(repository.parties(anyLong())).thenReturn(List.of());
when(repository.requirements(anyLong())).thenReturn(List.of());
when(repository.allocations(anyLong())).thenReturn(List.of());
when(repository.containers(anyLong())).thenReturn(List.of());
when(repository.histories(anyLong())).thenReturn(List.of());
when(repository.claimOperation(any(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
when(repository.completeOperation(anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(1);
when(repository.completeOperation(any(), anyLong(), anyString(), anyString(), anyString())).thenReturn(1);
}
@Test
void advancesDraftToSubmittedWithVersionAndHistory() {
when(repository.find(1L)).thenReturn(booking("DRAFT", 2), booking("SUBMITTED", 3));
var shipper = new com.baiyue.firstleg.oceanbooking.entity.OceanBookingPartyEntity(); shipper.setPartyType("SHIPPER");
var consignee = new com.baiyue.firstleg.oceanbooking.entity.OceanBookingPartyEntity(); consignee.setPartyType("CONSIGNEE");
var notify = new com.baiyue.firstleg.oceanbooking.entity.OceanBookingPartyEntity(); notify.setPartyType("NOTIFY_PARTY");
when(repository.parties(1L)).thenReturn(List.of(shipper, consignee, notify));
when(repository.requirements(1L)).thenReturn(List.of(new com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerRequirementEntity()));
when(repository.transition(1L, "DRAFT", "SUBMITTED", null, null, 2, 9L, "admin")).thenReturn(1);
void savesBlankDraftWithoutCreatingContainersOrAllocations() {
AtomicReference<OceanBookingEntity> inserted = stubCreatedDraft();
Detail result = service.create(9L, "admin", "draft-empty",
new CreateRequest(0, partialDraft(null)));
Detail result = service.submit(1L, 9L, "admin", "submit-1", new ActionRequest(2, "提交"));
assertThat(result.oceanBooking().getStatus()).isEqualTo("SUBMITTED");
ArgumentCaptor<OceanBookingStatusHistoryEntity> history =
ArgumentCaptor.forClass(OceanBookingStatusHistoryEntity.class);
verify(repository).insert(history.capture());
assertThat(history.getValue().getFromStatus()).isEqualTo("DRAFT");
assertThat(history.getValue().getToStatus()).isEqualTo("SUBMITTED");
assertThat(history.getValue().getAction()).isEqualTo("SUBMIT");
assertThat(result.oceanBooking().getStatus()).isEqualTo("DRAFT");
assertThat(result.oceanBooking().getBookingNo()).isEqualTo("OB-260818-0001");
assertThat(inserted.get().getCustomerId()).isNull();
verify(repository).replaceRequirements(inserted.get().getId(), List.of());
verify(repository, never()).insertContainer(any());
verify(repository, never()).insert(any(com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity.class));
verifyNoInteractions(priceBooks, suppliers, warehouses);
}
@Test
void rejectsCancellationAfterRelease() {
when(repository.find(1L)).thenReturn(booking("RELEASED", 4));
void freezesServerSnapshotsAndPersistsQuantityRequirementsOnly() {
stubMasterData();
AtomicReference<OceanBookingEntity> inserted = stubCreatedDraft();
Detail result = service.create(9L, "admin", "draft-complete", new CreateRequest(0, completeDraft()));
assertThatThrownBy(() -> service.cancel(1L, 9L, "admin", "cancel-1",
new ActionRequest(4, "错误"))).isInstanceOf(BizException.class)
.hasMessageContaining("不可逆");
verify(repository, never()).transition(anyLong(), anyString(), anyString(), any(), any(), anyInt(), anyLong(), anyString());
assertThat(result.oceanBooking().getStatus()).isEqualTo("DRAFT");
assertThat(inserted.get().getChannelName()).isEqualTo("美西海运");
assertThat(inserted.get().getWarehouseName()).isEqualTo("深圳中转仓");
assertThat(inserted.get().getBookingAgentName()).isEqualTo("订舱代理");
@SuppressWarnings("unchecked")
ArgumentCaptor<List<OceanBookingContainerRequirementEntity>> captor = ArgumentCaptor.forClass(List.class);
verify(repository).replaceRequirements(eq(inserted.get().getId()), captor.capture());
assertThat(captor.getValue()).singleElement().satisfies(value -> {
assertThat(value.getContainerType()).isEqualTo("40HC");
assertThat(value.getQuantity()).isEqualTo(2);
});
verify(repository, never()).insertContainer(any());
verify(repository, never()).insert(any(com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity.class));
}
@Test
void keepsCurrentVoyagePendingUntilChangeConfirmation() {
OceanBookingEntity current = booking("CONFIRMED", 5);
current.setChannelCode("CHANNEL-A");
current.setVesselName("OLD"); current.setVoyageNo("V1");
OceanBookingEntity pending = booking("CHANGE_PENDING", 6);
pending.setVesselName("OLD"); pending.setVoyageNo("V1"); pending.setPendingVesselName("NEW"); pending.setPendingVoyageNo("V2");
when(repository.find(1L)).thenReturn(current, pending);
when(repository.requestChange(eq(1L), any(), eq("改船"), eq(5), eq(9L), eq("admin"))).thenReturn(1);
void updatesOnlyDraftWithExpectedVersion() {
when(repository.find(1L)).thenReturn(booking("DRAFT", 3), booking("DRAFT", 4));
when(repository.updateDraft(any(), eq(3), eq(9L), eq("admin"))).thenReturn(1);
Detail result = service.updateDraft(1L, 9L, "admin", "update-1",
new UpdateRequest(3, partialDraft("备注")));
Detail result = service.requestChange(1L, 9L, "admin", "change-1",
new ChangeRequest(5, "NEW", "V2", LocalDateTime.of(2026, 9, 1, 0, 0),
LocalDateTime.of(2026, 9, 20, 0, 0), "改船"));
assertThat(result.oceanBooking().getStatus()).isEqualTo("CHANGE_PENDING");
assertThat(result.oceanBooking().getVesselName()).isEqualTo("OLD");
assertThat(result.oceanBooking().getPendingVesselName()).isEqualTo("NEW");
assertThat(result.oceanBooking().getVersion()).isEqualTo(4);
verify(repository).replaceParties(1L, List.of());
verify(repository).replaceRequirements(1L, List.of());
}
@Test
void replaysCompletedRequestBeforeVersionValidation() throws Exception {
OceanBookingEntity submitted = booking("SUBMITTED", 3);
Detail replay = new Detail(submitted, List.of(), List.of(), List.of(), List.of());
void rejectsReleaseWhenPersistedAggregateIsIncompleteWithoutWriting() {
when(repository.find(1L)).thenReturn(booking("DRAFT", 3));
assertThatThrownBy(() -> service.release(1L, 9L, "admin", "release-missing",
new ReleaseRequest(3, "确认")))
.isInstanceOf(BizException.class).hasMessageContaining("尚未填写完整");
verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt(), anyLong(), anyString());
}
@Test
void releasesCompleteAggregateAndReplaysFrozenResult() throws Exception {
OceanBookingEntity draft = completeBooking("DRAFT", 3);
OceanBookingEntity released = completeBooking("RELEASED", 4);
when(repository.find(1L)).thenReturn(draft, released);
when(repository.parties(1L)).thenReturn(parties());
when(repository.requirements(1L)).thenReturn(List.of(requirement()));
when(repository.transition(1L, "DRAFT", "RELEASED", 3, 9L, "admin")).thenReturn(1);
ReleaseRequest request = new ReleaseRequest(3, "确认放舱");
Detail first = service.release(1L, 9L, "admin", "release-1", request);
assertThat(first.oceanBooking().getStatus()).isEqualTo("RELEASED");
OceanBookingOperationEntity operation = new OceanBookingOperationEntity();
operation.setRequestHash(hashForSubmit()); operation.setStatus("SUCCESS");
operation.setResultJson(new ObjectMapper().findAndRegisterModules().writeValueAsString(replay));
when(repository.claimOperation(eq(1L), eq(9L), eq("SUBMIT"), eq("submit-replay"), anyString())).thenReturn(false);
when(repository.operation(9L, "SUBMIT", "submit-replay")).thenReturn(operation);
operation.setRequestHash(hash("RELEASE", 1L, request));
operation.setStatus("SUCCESS");
operation.setResultJson(objectMapper.writeValueAsString(first));
when(repository.claimOperation(1L, 9L, "RELEASE", "release-1", operation.getRequestHash())).thenReturn(false);
when(repository.operation(9L, "RELEASE", "release-1")).thenReturn(operation);
Detail result = service.submit(1L, 9L, "admin", "submit-replay", new ActionRequest(2, "提交"));
assertThat(result.oceanBooking().getStatus()).isEqualTo("SUBMITTED");
verify(repository, never()).find(anyLong());
assertThat(service.release(1L, 9L, "admin", "release-1", request).oceanBooking().getStatus())
.isEqualTo("RELEASED");
verify(repository, times(1)).transition(1L, "DRAFT", "RELEASED", 3, 9L, "admin");
}
@Test
void allocatesContainerWithExplicitTimestampAndVersionGuard() {
when(repository.find(1L)).thenReturn(booking("CONFIRMED", 5), booking("CONFIRMED", 6));
OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity();
requirement.setId(11L); requirement.setOceanBookingId(1L); requirement.setContainerType("40HQ");
requirement.setQuantity(1);
ContainerBookingEntity container = new ContainerBookingEntity();
container.setChannelCode("CHANNEL-A");
container.setId(12L); container.setContainerType("40HQ");
when(repository.requirement(1L, 11L)).thenReturn(requirement);
when(repository.containerBooking(12L)).thenReturn(container);
when(repository.touchForAllocation(1L, 5, 9L, "admin")).thenReturn(1);
Detail result = service.allocate(1L, 9L, "admin", "allocate-1",
new AllocationRequest(11L, 12L, 5));
ArgumentCaptor<OceanBookingContainerAllocationEntity> allocation =
ArgumentCaptor.forClass(OceanBookingContainerAllocationEntity.class);
verify(repository).insert(allocation.capture());
assertThat(allocation.getValue().getOceanBookingId()).isEqualTo(1L);
assertThat(allocation.getValue().getRequirementId()).isEqualTo(11L);
assertThat(allocation.getValue().getContainerBookingId()).isEqualTo(12L);
assertThat(allocation.getValue().getAllocatedAt()).isNotNull();
assertThat(result.oceanBooking().getVersion()).isEqualTo(6);
void releasedAggregateRejectsEditDeleteAndRepeatRelease() {
when(repository.find(1L)).thenReturn(booking("RELEASED", 4));
assertThatThrownBy(() -> service.updateDraft(1L, 9L, "admin", "edit-released",
new UpdateRequest(4, partialDraft(null)))).isInstanceOf(BizException.class);
assertThatThrownBy(() -> service.deleteDraft(1L, 9L, "admin", "delete-released",
new ActionRequest(4, null))).isInstanceOf(BizException.class);
assertThatThrownBy(() -> service.release(1L, 9L, "admin", "release-again",
new ReleaseRequest(4, null))).isInstanceOf(BizException.class);
verify(repository, never()).deleteDraft(anyLong(), anyInt());
verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt(), anyLong(), anyString());
}
@Test
void rejectsAllocationAfterReleaseBeforeMutation() {
when(repository.find(1L)).thenReturn(booking("RELEASED", 5));
void deleteDraftKeepsIdempotencyDetachedFromDeletedAggregate() {
when(repository.find(1L)).thenReturn(booking("DRAFT", 2));
when(repository.deleteDraft(1L, 2)).thenReturn(1);
service.deleteDraft(1L, 9L, "admin", "delete-1", new ActionRequest(2, "作废草稿"));
assertThatThrownBy(() -> service.allocate(1L, 9L, "admin", "allocate-released",
new AllocationRequest(11L, 12L, 5)))
.isInstanceOf(BizException.class)
.hasMessageContaining("当前订舱状态不可分配柜次");
verify(repository, never()).touchForAllocation(anyLong(), anyInt(), anyLong(), anyString());
verify(repository, never()).insert(any(OceanBookingContainerAllocationEntity.class));
verify(repository).claimOperation(isNull(), eq(9L), eq("DELETE_DRAFT"), eq("delete-1"), anyString());
verify(repository).completeOperation(isNull(), eq(9L), eq("DELETE_DRAFT"), eq("delete-1"), anyString());
}
private String hashForSubmit() throws Exception {
var method = OceanBookingService.class.getDeclaredMethod("hash", String.class, Long.class, Object.class);
method.setAccessible(true);
return (String) method.invoke(service, "SUBMIT", 1L, new ActionRequest(2, "提交"));
@Test
void supportsOnlyDraftAndReleasedListStatuses() {
service.page(new Query(null, "DRAFT", null, 1, 20));
service.page(new Query(null, "RELEASED", null, 1, 20));
assertThatThrownBy(() -> service.page(new Query(null, "CONFIRMED", null, 1, 20)))
.isInstanceOf(BizException.class).hasMessageContaining("状态");
verify(repository, times(2)).page(any());
}
@Test
void releaseRejectsInvalidScheduleWithoutTransition() {
OceanBookingEntity draft = completeBooking("DRAFT", 3);
draft.setEstimatedArrivalAt(draft.getEstimatedSailingAt().minusDays(1));
when(repository.find(1L)).thenReturn(draft);
when(repository.parties(1L)).thenReturn(parties());
when(repository.requirements(1L)).thenReturn(List.of(requirement()));
assertThatThrownBy(() -> service.release(1L, 9L, "admin", "release-range",
new ReleaseRequest(3, null))).isInstanceOf(BizException.class).hasMessageContaining("不能早于");
verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt(), anyLong(), anyString());
}
private AtomicReference<OceanBookingEntity> stubCreatedDraft() {
AtomicReference<OceanBookingEntity> inserted = new AtomicReference<>();
doAnswer(invocation -> { inserted.set(invocation.getArgument(0)); return null; })
.when(repository).insert(any(OceanBookingEntity.class));
when(repository.find(anyLong())).thenAnswer(invocation -> inserted.get());
return inserted;
}
private void stubMasterData() {
ChannelRow row = new ChannelRow();
row.setPriceBookVersionId(51L); row.setChannelCode("SEA-US"); row.setChannelName("美西海运");
row.setPriceModel("FBA_WAREHOUSE_RATE");
when(priceBooks.activeVersionId()).thenReturn(51L);
when(priceBooks.channels(51L, 701L)).thenReturn(List.of(row));
when(warehouses.active(any(), eq(0L), eq(701L))).thenReturn(ApiResponse.success(
new WarehouseMasterDataClient.Detail(new WarehouseMasterDataClient.Warehouse(
701L, 0L, "SZX", "深圳中转仓", "DOMESTIC", "ACTIVE"))));
when(suppliers.booking(81L, null)).thenReturn(
new WarehouseSupplierClient.Snapshot(81L, "订舱代理", "BOOKING", "ACTIVE"));
}
private static DraftData partialDraft(String remark) {
return new DraftData(null, null, null, null, null, null, null, null, null,
null, null, null, null, null, null, null, null, null, null, null, remark, null, null);
}
private static DraftData completeDraft() {
return new DraftData(100L, "客户", "SEA-US", 701L, "BL-001", 71L, "船公司",
81L, null, "VESSEL", "V001", "CNSZX", "深圳", "USLAX", "洛杉矶",
"LAX", "CY-CY", "PREPAID", LocalDateTime.of(2026, 9, 1, 0, 0),
LocalDateTime.of(2026, 9, 20, 0, 0), null,
List.of(new PartyRequest("SHIPPER", "发货人", null, null, null),
new PartyRequest("CONSIGNEE", "收货人", null, null, null),
new PartyRequest("NOTIFY_PARTY", "通知人", null, null, null)),
List.of(new ContainerRequirementRequest("40HC", "DRY", 2,
new BigDecimal("76"), new BigDecimal("28000"))));
}
private static OceanBookingEntity completeBooking(String status, int version) {
OceanBookingEntity value = booking(status, version);
value.setCustomerId(100L); value.setCustomerName("客户"); value.setChannelCode("SEA-US");
value.setWarehouseId(701L); value.setBillOfLadingNo("BL-001"); value.setShippingCompanyId(71L);
value.setBookingAgentId(81L); value.setVesselName("VESSEL"); value.setVoyageNo("V001");
value.setServiceTerm("CY-CY"); value.setFreightTerm("PREPAID");
value.setEstimatedSailingAt(LocalDateTime.of(2026, 9, 1, 0, 0));
value.setEstimatedArrivalAt(LocalDateTime.of(2026, 9, 20, 0, 0));
return value;
}
private static OceanBookingEntity booking(String status, int version) {
OceanBookingEntity value = new OceanBookingEntity(); value.setId(1L); value.setBookingNo("OB1");
value.setStatus(status); value.setCustomerId(100L); value.setCustomerName("客户");
value.setChannelCode("CHANNEL-A"); value.setVersion(version);
OceanBookingEntity value = new OceanBookingEntity();
value.setId(1L); value.setBookingNo("OB1"); value.setStatus(status); value.setVersion(version);
return value;
}
private static List<com.baiyue.firstleg.oceanbooking.entity.OceanBookingPartyEntity> parties() {
return List.of("SHIPPER", "CONSIGNEE", "NOTIFY_PARTY").stream().map(type -> {
var value = new com.baiyue.firstleg.oceanbooking.entity.OceanBookingPartyEntity();
value.setPartyType(type); value.setPartyName(type); return value;
}).toList();
}
private static OceanBookingContainerRequirementEntity requirement() {
var value = new OceanBookingContainerRequirementEntity();
value.setId(11L); value.setContainerType("40HC"); value.setContainerKind("DRY");
value.setQuantity(1); value.setCapacityCbm(new BigDecimal("76"));
value.setMaxWeightKg(new BigDecimal("28000")); return value;
}
private String hash(String action, Long id, Object request) throws Exception {
var method = OceanBookingService.class.getDeclaredMethod("hash", String.class, Long.class, Object.class);
method.setAccessible(true);
return (String) method.invoke(service, action, id, request);
}
}
@@ -7,6 +7,14 @@ import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class OutboundDispatchReadMapperContractTest {
@Test
void bindingProjectsStableCabinetAndRealContainerNumbersSeparately() throws Exception {
String sql = String.join("\n", OutboundDispatchReadMapper.class
.getMethod("bindings", Long.class, Long.class, Long.class)
.getAnnotation(Select.class).value());
assertThat(sql).contains("booking.cabinet_no cabinetNo", "booking.container_no containerNo");
}
@Test
void pickTaskQueryOrdersByTheSchemaStartedAtColumn() throws Exception {
String sql = OutboundDispatchReadMapper.class.getMethod("pick", long.class)
@@ -129,7 +129,7 @@ class OutboundDispatchServiceTest {
private static OutboundDispatchReadMapper.Binding binding() {
return new OutboundDispatchReadMapper.Binding(10L, "O-10", 11L, "I-11", 7L, 8L, "客户A",
"LOADED", 22L, "C-22", 44L, "COMPLETED");
"LOADED", 22L, "CAB-22", "C-22", 44L, "COMPLETED");
}
private static OutboundDispatchGateContributor passing(String code) {
@@ -24,6 +24,10 @@ class ContainerBookingMapperContractTest {
assertThat(select)
.contains("fl_loading_plan")
.contains("p.id AS plan_id")
.contains("p.plan_no AS plan_no")
.contains("fl_container_exception_history")
.contains("destination_clearance_document_status")
.contains("delivery_order_document_status")
.contains("CASE WHEN p.id IS NULL THEN 'WAIT_LOADING'")
.contains("plan_status")
.contains("p.plan_status != 'COMPLETED'")
@@ -32,6 +36,31 @@ class ContainerBookingMapperContractTest {
.doesNotContain("account_id");
}
@Test
@DisplayName("货柜分页按装柜明细客户归属精确筛选")
void filtersPageByStableCustomerId() throws Exception {
String page = String.join("\n", ContainerBookingMapper.class
.getDeclaredMethod("selectBookingPage", com.baomidou.mybatisplus.extension.plugins.pagination.Page.class,
com.baiyue.firstleg.dto.ContainerDtos.Query.class)
.getAnnotation(Select.class).value());
assertThat(page)
.contains("fl_ocean_booking_container_allocation customer_allocation")
.contains("customer_allocation.container_booking_id = b.id")
.contains("customer_booking.customer_id = #{query.customerId}")
.contains("customer_item.loading_plan_id = p.id")
.contains("customer_item.deleted = 0")
.contains("customer_item.account_id = #{query.customerId}");
}
@Test
@DisplayName("首次关联客户隔离查询使用原生不等号")
void customerMismatchQueryUsesNativeSqlOperator() throws Exception {
String sql = ContainerBookingMapper.class
.getDeclaredMethod("hasMismatchedCustomerItems", long.class, long.class)
.getAnnotation(Select.class).value()[0];
assertThat(sql).contains("i.account_id<>#{customerId}").doesNotContain("&lt;&gt;");
}
@Test
@DisplayName("草稿完整更新和预装运输白名单都带版本与状态门禁")
void guardsUpdatesInSql() {
@@ -0,0 +1,27 @@
package com.baiyue.firstleg.repository;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class ContainerFeeDetailRepositoryTest {
@Test
void adjustmentUpdateOnlyTouchesAmountDateVersionAndReviewProjection() throws Exception {
String source = Files.readString(Path.of(
"src/main/java/com/baiyue/firstleg/repository/ContainerFeeDetailRepository.java"),
StandardCharsets.UTF_8);
String method = source.substring(source.indexOf("public int adjust("),
source.indexOf("public int voidApproved("));
assertThat(method).contains("getBusinessDate", "getAmount", "getSourceVersion",
"getReviewStatus", "getReviewerId", "getReviewerName", "getReviewedAt",
"getReviewReason", "getUpdatedBy", "getUpdatedAt")
.doesNotContain("getFeeItemId", "getFeeItemCodeSnapshot", "getFeeItemNameSnapshot",
"getDirection", "getSupplierId", "getSupplierName", "getSupplierCategory", "getCurrency",
"getRemark");
}
}
@@ -1,6 +1,8 @@
package com.baiyue.firstleg.repository;
import org.junit.jupiter.api.Test;
import org.apache.ibatis.annotations.Select;
import com.baiyue.firstleg.mapper.ContainerOverviewMapper;
import org.springframework.core.io.ClassPathResource;
import java.nio.charset.StandardCharsets;
@@ -10,6 +12,24 @@ import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class ContainerOverviewSqlContractTest {
@Test void pdaSummarySelectsRecordFieldsExplicitlyIncludingTaskVersion() throws Exception {
String sql = String.join(" ", ContainerOverviewMapper.class.getDeclaredMethod("pickTask", long.class)
.getAnnotation(Select.class).value());
assertThat(sql).contains("loading_plan_id AS loadingPlanId",
"container_booking_id AS containerBookingId", "task_status AS taskStatus", "version")
.doesNotContain("SELECT *");
}
@Test void exceptionQueriesSelectEveryExceptionRowConstructorField() throws Exception {
for (String method : java.util.List.of("exceptions", "latestException")) {
String sql = String.join(" ", ContainerOverviewMapper.class.getDeclaredMethod(method, long.class)
.getAnnotation(Select.class).value());
assertThat(sql).contains("SELECT id,action,reason", "operator_id operatorId",
"operator_name operatorName", "occurred_at occurredAt", "request_hash requestHash")
.doesNotContain("SELECT *");
}
}
@Test void developmentAndUnifiedSchemasContainImmutableIdempotentExceptionHistory() throws Exception {
String development = new ClassPathResource("db/phase3-smart-loading-schema.sql")
.getContentAsString(StandardCharsets.UTF_8);
@@ -22,6 +22,9 @@ class FeeItemMigrationSqlContractTest {
.contains("LEFT JOIN tmp_fee_item_seed matched_seed")
.contains("matched_seed.fee_code = NULLIF(TRIM(f.fee_code), '')")
.contains("matched_seed.trade_scope = f.trade_scope")
.contains("193000000001000111, 0, 'FBA_HEAD_FREIGHT'")
.contains("fee_code = 'FBA_HEAD_FREIGHT'")
.contains("warehouse_receivable = 1")
.doesNotContain("LEFT JOIN tmp_fee_item_seed by_code")
.doesNotContain("LEFT JOIN tmp_fee_item_seed by_semantic")
.doesNotContain("OR (SELECT COUNT(*) FROM tmp_fee_item_seed")
@@ -9,19 +9,21 @@ import static org.assertj.core.api.Assertions.assertThat;
class FinanceFeeSourceMigrationContractTest {
@Test
void freshSchemaAndMigrationExposeInboundFeePartnerSnapshotsUsedByAllFeeFlows() throws Exception {
void freshSchemaAndMigrationExposeInboundFeeSupplierSnapshotsUsedByAllFeeFlows() throws Exception {
String baseline = new ClassPathResource("db/phase2-fee-item-and-inbound-detail.sql")
.getContentAsString(StandardCharsets.UTF_8);
String migration = new ClassPathResource("db/finance-fee-source-migration.sql")
.getContentAsString(StandardCharsets.UTF_8);
assertThat(baseline).contains(
"partner_id BIGINT UNSIGNED NULL",
"partner_name VARCHAR(200) NULL");
"supplier_id BIGINT UNSIGNED NULL",
"supplier_name VARCHAR(200) NULL",
"supplier_category VARCHAR(16) NULL");
assertThat(migration).contains(
"firstleg_finance_add_column('fl_inbound_fee_detail', 'partner_id'",
"firstleg_finance_add_column('fl_inbound_fee_detail', 'partner_name'",
"Firstleg Finance迁移失败:入库费用往来单位快照字段不完整");
"firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_id'",
"firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_name'",
"firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_category'",
"Firstleg Finance迁移失败:入库费用供应商快照字段不完整");
}
@Test
@@ -32,10 +34,12 @@ class FinanceFeeSourceMigrationContractTest {
assertThat(sql).contains("information_schema.columns", "event_key", "payload_hash",
"uk_inbound_outbox_event_key", "next_retry_at", "locked_at", "locked_by",
"idx_inbound_outbox_finance_relay", "CREATE TABLE IF NOT EXISTS fl_container_fee_detail",
"settlement_included", "source_version", "partner_id", "partner_name",
"firstleg_finance_add_column('fl_container_fee_detail', 'partner_id'", "RECEIVABLE", "PAYABLE",
"firstleg_finance_add_column('fl_inbound_fee_detail', 'partner_id'",
"Firstleg Finance迁移失败:入库费用往来单位快照字段不完整",
"source_version", "supplier_id", "supplier_name", "supplier_category",
"CREATE TABLE IF NOT EXISTS fl_container_fee_source_snapshot",
"uk_container_fee_source_version",
"firstleg_finance_add_column('fl_container_fee_detail', 'supplier_id'", "RECEIVABLE", "PAYABLE",
"firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_id'",
"Firstleg Finance迁移失败:入库费用供应商快照字段不完整",
"chk_container_fee_currency", "currency = 'CNY'", "firstleg_finance_assert_schema");
}
}
@@ -0,0 +1,20 @@
package com.baiyue.firstleg.repository;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat;
class InboundCodeGlobalUniquenessContractTest {
@Test
void inboundSchemaUsesTheSamePlatformWideInboundCodeIndex() throws Exception {
String sql = new ClassPathResource("db/phase2-inbound-schema.sql")
.getContentAsString(StandardCharsets.UTF_8);
assertThat(sql)
.contains("UNIQUE KEY uk_inbound_code (inbound_code)")
.doesNotContain("UNIQUE KEY uk_inbound_tenant_code (tenant_id, inbound_code)");
}
}
@@ -16,6 +16,7 @@ import com.baiyue.firstleg.mapper.InboundOrderMapper;
import com.baiyue.firstleg.mapper.InboundPalletAllocationMapper;
import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
import com.baiyue.firstleg.mapper.InboundTransportSnapshotMapper;
import com.baiyue.firstleg.mapper.InboundPickupSnapshotMapper;
import org.junit.jupiter.api.Test;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.mockito.ArgumentCaptor;
@@ -37,6 +38,7 @@ import static org.mockito.Mockito.when;
class InboundOrderRepositoryTest {
@MockBean private InboundOrderMapper orders;
@MockBean private InboundTransportSnapshotMapper transports;
@MockBean private InboundPickupSnapshotMapper pickupSnapshots;
@MockBean private InboundPalletAllocationMapper pallets;
@MockBean private InboundFileMapper files;
@MockBean private InboundInternalNoteMapper notes;
@@ -62,7 +64,9 @@ class InboundOrderRepositoryTest {
String sql = captor.getValue().getCustomSqlSegment();
assertThat(sql).contains("inbound_code", "order_status_snapshot", "expected_arrival_at",
"channel_name", "container_mode", "measure_review_status", "order_id IN",
"fl_inbound_fee", "fl_inbound_transport_snapshot", "sailing_at", "loading_status");
"fl_inbound_fee", "fl_loading_plan_item", "fl_loading_plan", "fl_container_booking",
"actual_sailing_at", "estimated_sailing_at", "plan_status");
assertThat(sql).doesNotContain("fl_inbound_transport_snapshot", "loading_status");
LambdaQueryWrapper<InboundOrderEntity> wrapper =
(LambdaQueryWrapper<InboundOrderEntity>) captor.getValue();
assertThat(wrapper.getParamNameValuePairs().values())
@@ -162,8 +166,6 @@ class InboundOrderRepositoryTest {
assertThat(result.records()).extracting(record -> record.tenantId())
.containsExactly(10001L, 10002L);
assertThat(result.records()).extracting(record -> record.loadingStatus())
.containsExactly("T1", "T2");
assertThat(result.records()).extracting(record -> record.locationCodes())
.containsExactly("A1", "B1");
ArgumentCaptor<Wrapper<InboundOrderEntity>> orderCaptor = ArgumentCaptor.forClass(Wrapper.class);
@@ -11,16 +11,28 @@ import static org.assertj.core.api.Assertions.assertThat;
class InboundOrderStatusSqlContractTest {
@Test
void advancesThroughPricingBeforeOpeningWaitSplit() {
void opensWaitSplitImmediatelyAfterMeasurementApproval() {
assertThat(sql("approveMeasurement"))
.contains("process_status = 'WAIT_PRICING'")
.doesNotContain("inbounded_at =");
.contains("process_status = 'WAIT_SPLIT'")
.contains("inbounded_at = CURRENT_TIMESTAMP(3)")
.doesNotContain("process_status = 'WAIT_PRICING'");
assertThat(sql("completeInbound"))
.contains("process_status = 'WAIT_SPLIT'")
.contains("inbounded_at = CURRENT_TIMESTAMP(3)")
.contains("process_status = 'INBOUNDING'");
}
@Test
void keepsConfirmedGrossAndNetSumsSeparateWhenMeasurementIsApproved() {
assertThat(sql("approveMeasurement"))
.contains("FROM fl_inbound_weight_result weight")
.contains("weight.recognition_status = 'CONFIRMED'")
.contains("weight.deleted = 0")
.contains("actual_gross_weight_kg = (SELECT SUM(weight.gross_weight_kg)")
.contains("actual_net_weight_kg = (SELECT SUM(weight.net_weight_kg)")
.doesNotContain("item.total_weight_kg");
}
private static String sql(String methodName) {
Method method = Arrays.stream(InboundOrderMapper.class.getDeclaredMethods())
.filter(candidate -> candidate.getName().equals(methodName))
@@ -2,6 +2,7 @@ package com.baiyue.firstleg.repository;
import com.baiyue.firstleg.entity.InboundPalletAllocationEntity;
import com.baiyue.firstleg.mapper.InboundPalletAllocationMapper;
import com.baiyue.firstleg.client.WarehousePalletLocationClient;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
@@ -9,10 +10,14 @@ import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import java.math.BigDecimal;
import org.mockito.ArgumentCaptor;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.verify;
@ExtendWith(MockitoExtension.class)
class InboundPalletAllocationRepositoryTest {
@@ -42,6 +47,30 @@ class InboundPalletAllocationRepositoryTest {
.doesNotThrowAnyException();
}
@Test
void restoresReservationNumbersByReservationIdWhenPalletIsReused() {
InboundPalletAllocationEntity first = allocation(1L, 91L, "RESERVED", 1, 0);
first.setPalletId(501L);
first.setReservationNo("LR-91");
InboundPalletAllocationEntity second = allocation(2L, 92L, "RESERVED", 1, 0);
second.setPalletId(501L);
second.setReservationNo("LR-92");
when(mapper.selectList(any())).thenReturn(List.of(first, second));
var result = new WarehousePalletLocationClient.BatchResult(2, List.of(
new WarehousePalletLocationClient.AllocationResult(91L, null, 1, 501L, "P1", new BigDecimal("1.2"), 11L, "A1",
new BigDecimal("1.2"), "OCCUPIED"),
new WarehousePalletLocationClient.AllocationResult(92L, null, 2, 501L, "P1", new BigDecimal("1.2"), 12L, "A2",
new BigDecimal("1.2"), "OCCUPIED")));
repository.replace(10001L, 31L, 88L, 0L, 2, result);
ArgumentCaptor<InboundPalletAllocationEntity> inserted = ArgumentCaptor.forClass(InboundPalletAllocationEntity.class);
verify(mapper, org.mockito.Mockito.times(2)).insert(inserted.capture());
assertThat(inserted.getAllValues()).extracting(InboundPalletAllocationEntity::getReservationNo)
.containsExactly("LR-91", "LR-92");
}
private static InboundPalletAllocationEntity allocation(long id, long reservationId, String status,
int allocated, int picked) {
InboundPalletAllocationEntity entity = new InboundPalletAllocationEntity();
@@ -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("同票托盘唯一约束仍阻止托盘主数据复用");
}
}
@@ -37,29 +37,50 @@ class LoadingListQueryMapperContractTest {
.contains("p.preload_pushed_at IS NOT NULL")
.contains("p.plan_status IN ('EDITING', 'LOADING')")
.contains("active_item.deleted = 0")
.contains("fl_preload_loading_plan_link")
.contains("fl_ocean_booking_container_allocation")
.contains("fl_loading_pick_task")
.contains("pda_task_status")
.contains("customer_item.account_id = #{query.customerId}")
.contains("b.warehouse_id = #{query.warehouseId}")
.doesNotContain("query.accountId")
.doesNotContain("PRELOAD_PUSHED");
assertThat(confirmed)
.contains("FROM fl_loading_plan p")
.contains("p.plan_status = 'COMPLETED'")
.contains("customer_item.account_id = #{query.customerId}")
.contains("b.warehouse_id = #{query.warehouseId}")
.doesNotContain("query.accountId")
.doesNotContain("fl_loading_list");
}
@Test
void detailItemsExposeFrozenOrderAndActiveContainerFacts() throws Exception {
String items = sql("selectItems", Select.class);
assertThat(items)
.contains("i.order_id", "i.order_no", "b.container_no")
.contains("b.record_status='ACTIVE'")
.contains("i.loading_plan_id = #{planId}")
.contains("i.deleted = 0");
}
@Test
void mutationsShouldUseStatusAndOptimisticVersionConditions() throws Exception {
String touch = sql("touchEditable", Update.class);
String transport = sql("updateTransport", Update.class);
String confirmPreload = sql("confirmLinkedPreload", Update.class);
String confirmPlan = sql("touchForPreloadConfirmation", Update.class);
String advance = sql("advance", Update.class);
assertThat(touch)
.contains("plan_status = 'LOADING'")
.contains("plan_status IN ('EDITING', 'LOADING')")
.contains("preload_pushed_at IS NOT NULL")
.contains("version = #{expectedVersion}")
.contains("version = version + 1");
assertThat(transport)
.contains("SET b.container_no = #{containerNo}, b.voyage_no = #{voyageNo}")
.contains("plan_status IN ('EDITING', 'LOADING')")
.contains("preload_pushed_at IS NOT NULL")
.contains("#{containerNo} IS NOT NULL")
.contains("fl_loading_pick_task")
.doesNotContain("DELETE")
@@ -69,6 +90,16 @@ class LoadingListQueryMapperContractTest {
.contains("plan_status = #{toStatus}")
.contains("version = #{expectedVersion}")
.doesNotContain("ROLLED_BACK");
assertThat(confirmPreload)
.contains("status = 'CONFIRMED'")
.contains("status = #{fromStatus}")
.contains("version = #{expectedVersion}")
.contains("version = version + 1");
assertThat(confirmPlan)
.contains("plan_status = 'EDITING'")
.contains("preload_pushed_at IS NOT NULL")
.contains("version = #{expectedVersion}")
.contains("version = version + 1");
}
@Test
@@ -39,6 +39,13 @@ class LoadingListRepositoryTest {
row.setPlanNo("LP-41");
row.setPlanStatus("LOADING");
row.setVersion(1);
row.setContainerNo("MSCU-001");
row.setCabinetNo("CAB-001");
row.setPreloadPushedAt(java.time.LocalDateTime.now());
row.setPreloadLinkCount(2L);
row.setConfirmedPreloadCount(1L);
row.setOceanBookingId(81L);
row.setOceanBookingStatus("RELEASED");
Page<AggregateRow> preloadPage = new Page<>(1, 20);
preloadPage.setRecords(List.of(row));
@@ -51,13 +58,17 @@ class LoadingListRepositoryTest {
confirmedPage.setTotal(1);
when(mapper.selectConfirmedPage(any(), any())).thenReturn(confirmedPage);
var preload = repository.preloadPage(new PreloadQuery(null, null, null, null, null, 1, 20))
var preload = repository.preloadPage(new PreloadQuery(null, null, null, null, null, null, 1, 20))
.records().get(0);
var confirmed = repository.confirmedPage(new ConfirmedQuery(null, null, null, null, 1, 20))
var confirmed = repository.confirmedPage(new ConfirmedQuery(null, null, null, null, null, 1, 20))
.records().get(0);
assertThat(preload.planId()).isEqualTo(41L);
assertThat(preload.containerBookingId()).isEqualTo(61L);
assertThat(preload.containerNo()).isEqualTo("MSCU-001");
assertThat(preload.cabinetNo()).isEqualTo("CAB-001");
assertThat(preload.readiness().confirmationStatus()).isEqualTo("PARTIALLY_CONFIRMED");
assertThat(preload.readiness().oceanBookingStatus()).isEqualTo("RELEASED");
assertThat(confirmed.planId()).isEqualTo(41L);
assertThat(confirmed.containerBookingId()).isEqualTo(61L);
}
@@ -28,6 +28,15 @@ class LoadingPickPdaTaskSqlContractTest {
assertThat(sql)
.contains("task.task_status = 'LOADING'")
.contains("booking.record_status = 'ACTIVE'")
.contains("booking.source_type AS sourceType")
.contains("booking.record_status AS recordStatus")
.contains("booking.merged_into_container_booking_id AS mergedIntoContainerBookingId")
.contains("WHEN task.task_status = 'COMPLETED' THEN 'COMPLETED'")
.contains("WHEN assignment.pick_task_id IS NULL THEN 'UNASSIGNED'")
.contains("pick_item.item_status IN ('PENDING', 'PICKING')")
.contains("THEN 'AWAITING_REVIEW'")
.contains("ELSE 'IN_PROGRESS' END AS workflowStatus")
.contains("assignment.pick_task_id IS NULL OR assignment.assignee_id = #{operatorId}")
.contains("booking.warehouse_id AS warehouseId")
.doesNotContain("tenantId");
@@ -46,4 +55,17 @@ class LoadingPickPdaTaskSqlContractTest {
.contains("label.inbound_detail_id = plan_item.inbound_detail_id")
.contains("label.box_code AS boxCode");
}
@Test
void taskDetailResolvesOnlyTheActiveBookingOwnedByItsCurrentPlan() throws Exception {
String sql = LoadingPickTaskMapper.class
.getDeclaredMethod("selectActiveBookingByTask", long.class)
.getAnnotation(Select.class).value()[0];
assertThat(sql)
.contains("plan.id=task.loading_plan_id")
.contains("booking.id=plan.container_booking_id")
.contains("task.id=#{taskId}")
.contains("booking.record_status='ACTIVE'");
}
}
@@ -14,8 +14,11 @@ class LoadingPickStableLookupContractTest {
.getAnnotation(Select.class).value()[0];
String legacy = LoadingPickTaskMapper.class.getMethod("selectLoadingByContainerAndAccount", String.class, long.class)
.getAnnotation(Select.class).value()[0];
assertThat(cabinet).contains("booking.cabinet_no=#{cabinetNo}").doesNotContain("task.container_no");
assertThat(plan).contains("task.loading_plan_id=#{loadingPlanId}").doesNotContain("task.container_no");
assertThat(legacy).contains("booking.container_no = #{containerNo}").doesNotContain("task.container_no =");
assertThat(cabinet).contains("booking.cabinet_no=#{cabinetNo}", "booking.record_status='ACTIVE'")
.doesNotContain("task.container_no");
assertThat(plan).contains("task.loading_plan_id=#{loadingPlanId}", "booking.record_status='ACTIVE'")
.doesNotContain("task.container_no");
assertThat(legacy).contains("booking.container_no = #{containerNo}",
"booking.record_status = 'ACTIVE'").doesNotContain("task.container_no =");
}
}
@@ -76,7 +76,14 @@ class PdaSplitSettlementSqlContractTest {
.getAnnotation(Insert.class).value()[0];
assertThat(clone)
.contains("#{childOrderId},#{childOrderNo}")
.contains("CASE WHEN #{splitPurpose}='LOADED' THEN 'LOADING' ELSE 'WAIT_SPLIT' END");
.contains("CASE WHEN #{splitPurpose}='LOADED' THEN 'LOADING' ELSE 'WAIT_SPLIT' END")
.contains("actual_gross_weight_kg * #{packageCount}")
.contains("actual_net_weight_kg * #{packageCount}");
String cloneItem = InboundSplitSettlementMapper.class.getMethod("cloneMeasurementItem",
long.class, long.class, long.class, long.class, long.class, long.class, int.class, int.class)
.getAnnotation(Insert.class).value()[0];
assertThat(cloneItem).doesNotContain("unit_weight_kg").doesNotContain("total_weight_kg");
}
@Test
@@ -22,15 +22,19 @@ class Phase3BookingTruckPermissionSqlContractTest {
private static final Pattern OCEAN_CODE = Pattern.compile("platform:firstleg:ocean-booking:[a-z-]+");
private static final Pattern TRUCK_CODE = Pattern.compile("platform:firstleg:truck-appointment:[a-z-]+");
private static final Set<String> OCEAN_PERMISSIONS = Set.of(
private static final Set<String> OCEAN_CONTROLLER_PERMISSIONS = Set.of(
"platform:firstleg:ocean-booking:list",
"platform:firstleg:ocean-booking:create",
"platform:firstleg:ocean-booking:edit",
"platform:firstleg:ocean-booking:submit",
"platform:firstleg:ocean-booking:confirm",
"platform:firstleg:ocean-booking:release",
"platform:firstleg:ocean-booking:cancel",
"platform:firstleg:ocean-booking:change",
"platform:firstleg:ocean-booking:edit",
"platform:firstleg:ocean-booking:delete");
private static final Set<String> OCEAN_SQL_PERMISSIONS = Set.of(
"platform:firstleg:ocean-booking:list",
"platform:firstleg:ocean-booking:create",
"platform:firstleg:ocean-booking:release",
"platform:firstleg:ocean-booking:edit",
"platform:firstleg:ocean-booking:delete",
"platform:firstleg:ocean-booking:allocate");
private static final Set<String> TRUCK_PERMISSIONS = Set.of(
@@ -40,16 +44,16 @@ class Phase3BookingTruckPermissionSqlContractTest {
"platform:firstleg:truck-appointment:operate");
@Test
void controllerAuthoritiesExactlyMatchModuleAndUnifiedPermissionCodes() throws Exception {
assertThat(authorities(PlatformOceanBookingController.class)).isEqualTo(OCEAN_PERMISSIONS);
void controllerAuthoritiesAndPersistedPermissionCodesMatchTheirActiveContracts() throws Exception {
assertThat(authorities(PlatformOceanBookingController.class)).isEqualTo(OCEAN_CONTROLLER_PERMISSIONS);
assertThat(authorities(PlatformTruckAppointmentController.class)).isEqualTo(TRUCK_PERMISSIONS);
String oceanModule = resource("db/phase3-ocean-booking-schema.sql");
String truckModule = resource("db/phase3-truck-appointment-schema.sql");
String unified = unifiedSql();
assertThat(codes(oceanModule, OCEAN_CODE)).isEqualTo(OCEAN_PERMISSIONS);
assertThat(codes(oceanModule, OCEAN_CODE)).isEqualTo(OCEAN_SQL_PERMISSIONS);
assertThat(codes(truckModule, TRUCK_CODE)).isEqualTo(TRUCK_PERMISSIONS);
assertThat(codes(unified, OCEAN_CODE)).isEqualTo(OCEAN_PERMISSIONS);
assertThat(codes(unified, OCEAN_CODE)).isEqualTo(OCEAN_SQL_PERMISSIONS);
assertThat(codes(unified, TRUCK_CODE)).isEqualTo(TRUCK_PERMISSIONS);
}
@@ -69,6 +73,8 @@ class Phase3BookingTruckPermissionSqlContractTest {
.contains("(5102149,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:allocate'")
.contains("(5102150,0,'PLATFORM','MENU',5102000,'platform:firstleg:truck-appointment:list'")
.contains("(5102154,0,'PLATFORM','BUTTON',5102150,'platform:firstleg:truck-appointment:operate'")
.contains("permission_code LIKE 'platform:firstleg:ocean-booking:%') <> 6")
.contains("p.permission_code LIKE 'platform:firstleg:ocean-booking:%') <> 12")
.contains("role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')")
.doesNotContain("assert_phase3_booking_truck_permission_ids_available")
.doesNotContain("/trucking");
@@ -60,12 +60,16 @@ class Phase3ChannelContainerMigrationContractTest {
.contains("@phase3_channel_message = MESSAGE_TEXT")
.contains("渠道柜迁移失败:货柜只读触发器未恢复")
.contains("渠道柜迁移失败:PDA任务只读触发器未恢复")
.contains("装柜完成后货柜全部只读,不允许修改")
.contains("current_plan_status IN ('LOADING', 'COMPLETED')")
.contains("装柜作业开始后仅允许修改集装箱号与放舱运输数据")
.contains("已完成PDA拣货任务不可修改")
.contains("NOT (OLD.channel_code <=> NEW.channel_code)")
.contains("NOT (OLD.channel_name <=> NEW.channel_name)")
.contains("NOT (OLD.ocean_booking_id <=> NEW.ocean_booking_id)")
.contains("NOT (OLD.ocean_booking_requirement_id <=> NEW.ocean_booking_requirement_id)")
.doesNotContain("NOT (OLD.bill_of_lading_no <=> NEW.bill_of_lading_no)")
.doesNotContain("NOT (OLD.vessel_name <=> NEW.vessel_name)")
.doesNotContain("NOT (OLD.estimated_sailing_at <=> NEW.estimated_sailing_at)")
.doesNotContain("p.plan_status='COMPLETED'");
assertThat(call).isGreaterThan(0);
assertThat(containerRestore).isGreaterThan(call);
@@ -30,6 +30,10 @@ class Phase3FinalUnifiedSqlContractTest {
.contains("CREATE TABLE IF NOT EXISTS fl_customs_case")
.contains("CREATE TABLE IF NOT EXISTS fl_customs_document")
.contains("CREATE TABLE IF NOT EXISTS fl_customs_submission_batch")
.contains("customs_agent_id BIGINT UNSIGNED NULL")
.contains("terminal_trucking_company_id BIGINT UNSIGNED NULL")
.contains("phase3_container_supplier_snapshot_add_column")
.contains("phase3_customs_supplier_snapshot_add_column")
.contains("CREATE TABLE IF NOT EXISTS fl_customs_receipt")
.contains("CREATE TABLE IF NOT EXISTS fl_port_schedule")
.contains("CREATE TABLE IF NOT EXISTS fl_port_schedule_history")
@@ -54,6 +58,20 @@ class Phase3FinalUnifiedSqlContractTest {
.contains("trg_loading_pick_operation_before_delete");
}
@Test
void unifiedOceanBookingBlockIncludesExistingSchemaReconciliation() throws Exception {
String sql = normalise(Files.readString(UNIFIED));
assertThat(sql)
.contains("DROP CHECK chk_ocean_booking_status")
.contains("ADD CONSTRAINT chk_ocean_booking_status CHECK")
.contains("DROP COLUMN failure_reason")
.contains("(status IN ('DRAFT','RELEASED'))")
.contains("platform:firstleg:ocean-booking:edit")
.contains("platform:firstleg:ocean-booking:delete")
.doesNotContain("reconcile_ocean_booking_legacy")
.doesNotContain("UPDATE fl_ocean_booking SET status='CONFIRMED'");
}
@Test
void permissionRangesDoNotOverlapAndBothPlatformRolesAreGranted() throws Exception {
String sql = Files.readString(UNIFIED, StandardCharsets.UTF_8);
@@ -134,8 +152,9 @@ class Phase3FinalUnifiedSqlContractTest {
assertThat(count(sql, "CALL queue_table_comment(")).isEqualTo(150);
assertThat(count(sql, "CALL queue_column_comment(")).isGreaterThanOrEqualTo(2459);
assertThat(sql)
.contains("CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'partner_id'")
.contains("CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'partner_name'");
.contains("CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'supplier_id'")
.contains("CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'supplier_name'")
.contains("CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'supplier_category'");
assertThat(sql.indexOf("CALL apply_queued_comments();",
sql.indexOf("-- END GENERATED NINE-SCHEMA COMMENT UPGRADE CALLS")))
.isGreaterThan(sql.indexOf("-- END GENERATED NINE-SCHEMA COMMENT UPGRADE CALLS"));
@@ -42,6 +42,8 @@ class PriceBookSqlContractTest {
.contains("destination_name VARCHAR(128)")
.contains("parse_issues_json JSON NULL")
.contains("DEFAULT 'PENDING_CONFIRMATION'");
assertThat(schema)
.contains("customer_id BIGINT UNSIGNED NULL COMMENT '可选业务客户快照;平台公共渠道计费不依赖该字段'");
assertThat(schema)
.contains("'ORIGIN_CODE', 'CN-YIWU-01', 193000000000200001")
.contains("'ORIGIN_CODE', 'CN-DONGGUAN-01', 193000000000200002")
@@ -22,6 +22,11 @@ class SmartLoadingCandidateMapperContractTest {
.contains("i.inbound_detail_id IS NOT NULL")
.contains("o.warehouse_id=#{warehouseId}")
.contains("o.process_status='WAIT_SPLIT'")
.contains("unitGrossWeightKg")
.contains("unitNetWeightKg")
.contains("o.actual_gross_weight_kg")
.contains("o.actual_net_weight_kg")
.doesNotContain("i.unit_weight_kg")
.contains("SELECT SUM(li.package_count)")
.contains("li.deleted=0")
.contains("ORDER BY o.inbounded_at ASC, o.id ASC, i.id ASC");
@@ -39,6 +44,9 @@ class SmartLoadingCandidateMapperContractTest {
.contains("SELECT MAX(m2.id)")
.contains("o.warehouse_id=#{warehouseId}")
.contains("o.process_status='WAIT_SPLIT'")
.contains("unitGrossWeightKg")
.contains("unitNetWeightKg")
.doesNotContain("i.unit_weight_kg")
.contains("SELECT SUM(li.package_count)")
.contains("FOR UPDATE");
}
@@ -31,6 +31,7 @@ class SmartLoadingSqlContractTest {
@DisplayName("平台聚合与客户明细边界及原型快照字段完整")
void shouldProvidePlatformAggregatesAndAccountScopedSnapshots() throws Exception {
assertThat(migration())
.contains("CREATE TABLE IF NOT EXISTS fl_document_sequence")
.contains("CREATE TABLE IF NOT EXISTS fl_container_booking")
.contains("CREATE TABLE IF NOT EXISTS fl_loading_plan")
.contains("CREATE TABLE IF NOT EXISTS fl_loading_plan_item")
@@ -61,6 +62,8 @@ class SmartLoadingSqlContractTest {
@Test
@DisplayName("活动明细唯一锁、乐观锁和双层幂等字段完整")
void shouldProvideConcurrencyAndIdempotencyBoundaries() throws Exception {
assertContainerLifecycleSchema(migration());
assertContainerLifecycleSchema(unifiedMigration());
assertThat(migration())
.contains("active_inbound_detail_id BIGINT UNSIGNED GENERATED ALWAYS AS")
.contains("CASE WHEN deleted = 0 THEN inbound_detail_id ELSE NULL END")
@@ -73,6 +76,46 @@ class SmartLoadingSqlContractTest {
.contains("version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'");
}
@Test
@DisplayName("仓库备注使用计划先行与明细单表CAS并允许已拣明细仅改备注")
void shouldUpdateWarehouseRemarkWithoutMultiTableTriggerConflict() throws Exception {
String mapper = Files.readString(Path.of(
"src/main/java/com/baiyue/firstleg/mapper/LoadingPlanItemMapper.java"), StandardCharsets.UTF_8);
assertThat(mapper)
.contains("UPDATE fl_loading_plan_item i")
.contains("p.version=#{touchedPlanVersion}")
.contains("p.plan_status IN ('EDITING','LOADING')")
.contains("i.frozen_at IS NULL")
.doesNotContain("UPDATE fl_loading_plan_item i JOIN fl_loading_plan p")
.doesNotContain("p.version=p.version+1");
assertWarehouseRemarkTriggerContract(migration());
assertWarehouseRemarkTriggerContract(unifiedMigration());
}
private static void assertContainerLifecycleSchema(String sql) {
assertThat(sql)
.contains("source_type VARCHAR(24) NOT NULL DEFAULT 'SIMULATION'")
.contains("record_status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE'")
.contains("merged_into_container_booking_id BIGINT UNSIGNED NULL")
.contains("UNIQUE KEY uk_container_booking_container_no (container_no)")
.contains("KEY idx_container_booking_merge (record_status,merged_into_container_booking_id)")
.contains("CHECK (source_type IN ('SIMULATION','OCEAN_BOOKING'))")
.contains("CHECK (record_status IN ('ACTIVE','MERGED'))")
.contains("column_name='source_type'")
.contains("column_name='record_status'")
.contains("column_name='merged_into_container_booking_id'");
}
private static void assertWarehouseRemarkTriggerContract(String sql) {
assertThat(sql)
.contains("IF NOT (NEW.warehouse_remark <=> OLD.warehouse_remark) THEN")
.contains("IF NEW.package_count <> OLD.package_count OR NEW.deleted <> OLD.deleted")
.contains("已有PDA拣货记录的装柜明细修改仓库备注时不得变更其他业务字段")
.contains("ELSEIF NOT EXISTS (")
.contains("IF OLD.frozen_at IS NOT NULL THEN")
.contains("装柜完成后的明细永久冻结");
}
@Test
@DisplayName("清关与报关采用柜次级文件、一键完成和真实文件哈希")
void shouldProvideSimpleCustomsDocuments() throws Exception {
@@ -81,7 +124,8 @@ class SmartLoadingSqlContractTest {
assertThat(Files.readString(Path.of("src/main/resources/db/phase3-simple-customs-document-migration.sql"),
StandardCharsets.UTF_8))
.contains("CREATE TRIGGER trg_container_booking_before_update")
.contains("IF current_plan_status='COMPLETED' THEN")
.contains("current_plan_status IN ('LOADING','COMPLETED')")
.contains("装柜作业开始后仅允许修改集装箱号与放舱运输数据")
.contains("NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION')")
.contains("OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION')");
}
@@ -145,8 +189,11 @@ class SmartLoadingSqlContractTest {
.contains("SET frozen_at = COALESCE(frozen_at, NEW.confirmed_at, CURRENT_TIMESTAMP(3))")
.contains("已有PDA拣货记录的装柜明细仅允许收敛部分拣货实际箱数")
.contains("装柜完成后的明细永久冻结")
.contains("装柜中仅允许修改集装箱号与航次")
.contains("装柜完成后货柜全部只读,不允许修改")
.contains("current_plan_status IN ('LOADING', 'COMPLETED')")
.contains("装柜作业开始后仅允许修改集装箱号与放舱运输数据")
.doesNotContain("NOT (OLD.bill_of_lading_no <=> NEW.bill_of_lading_no)")
.doesNotContain("NOT (OLD.vessel_name <=> NEW.vessel_name)")
.doesNotContain("NOT (OLD.estimated_sailing_at <=> NEW.estimated_sailing_at)")
.doesNotContain("preload_status")
.doesNotContain("PRELOAD_PUSHED")
.doesNotContain("PRELOADING")
@@ -13,12 +13,24 @@ class TruckAppointmentMapperContractTest {
@Test void mapsToPhase3TableAndUsesOptimisticGuards() throws Exception {
assertThat(TruckAppointmentEntity.class.getAnnotation(TableName.class).value()).isEqualTo("fl_truck_appointment");
String page = TruckAppointmentMapper.class.getMethod("page", com.baomidou.mybatisplus.extension.plugins.pagination.Page.class, com.baiyue.firstleg.dto.TruckAppointmentDtos.Query.class).getAnnotation(Select.class).value()[0];
assertThat(page).contains("fl_truck_appointment", "appointment_no", "vehicle_no", "status=#{q.status}", "container_booking_id=#{q.containerBookingId}");
assertThat(page).contains("fl_truck_appointment", "appointment_no", "vehicle_no", "status=#{q.status}", "customer_id=#{q.customerId}", "container_booking_id=#{q.containerBookingId}",
"fl_ocean_booking", "fl_container_booking", "record_status='ACTIVE'", "cabinet_no", "container_no", "fl_truck_appointment_attachment", "attachment_count");
String transition = TruckAppointmentMapper.class.getMethod("transition", long.class, String.class, String.class, int.class).getAnnotation(Update.class).value()[0];
assertThat(transition).contains("status=#{toStatus}", "status=#{fromStatus}", "version=#{version}", "revision=revision+1", "version=version+1");
String dispatch = TruckAppointmentMapper.class.getMethod("dispatch", long.class, String.class, String.class, int.class).getAnnotation(Update.class).value()[0];
assertThat(dispatch).contains("status='DISPATCHED'", "bill_of_lading_no=#{billOfLadingNo}", "status=#{fromStatus}", "version=#{version}", "revision=revision+1");
String active = TruckAppointmentMapper.class.getMethod("activeForUpdate", long.class).getAnnotation(Select.class).value()[0];
assertThat(active).contains("container_booking_id=#{containerBookingId}", "FOR UPDATE")
.contains("'COMPLETED','CANCELLED','EXCEPTION'");
String pdaComplete = TruckAppointmentMapper.class.getMethod("completeLoadingFromPda", long.class, String.class, int.class).getAnnotation(Update.class).value()[0];
assertThat(pdaComplete).contains("status='COMPLETED'", "actual_loading_at=COALESCE", "status=#{fromStatus}",
"version=#{version}", "revision=revision+1", "version=version+1");
String reschedule = TruckAppointmentMapper.class.getMethod("reschedule", long.class, java.time.LocalDateTime.class, String.class, int.class).getAnnotation(Update.class).value()[0];
assertThat(reschedule).contains("status='RESCHEDULED'", "version=#{version}", "revision=revision+1");
String edit = TruckAppointmentMapper.class.getMethod("updateEdit", TruckAppointmentEntity.class, int.class).getAnnotation(Update.class).value()[0];
assertThat(edit).contains("WHERE id=#{e.id} AND version=#{version}", "version=version+1", "revision=revision+1");
assertThat(edit).contains("ocean_booking_id=#{e.oceanBookingId}", "container_booking_id=#{e.containerBookingId}",
"loading_plan_id=#{e.loadingPlanId}", "customer_id=#{e.customerId}", "carrier_id=#{e.carrierId}",
"planned_loading_at=#{e.plannedLoadingAt}", "bill_of_lading_no=#{e.billOfLadingNo}", "WHERE id=#{e.id} AND version=#{version}",
"version=version+1", "revision=revision+1");
}
}
@@ -0,0 +1,71 @@
package com.baiyue.firstleg.repository;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.CurrentContainerTransport;
import com.baiyue.firstleg.mapper.UnifiedInboundTransportMapper;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
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;
import static org.mockito.ArgumentMatchers.anyList;
class UnifiedInboundTransportRepositoryTest {
private final UnifiedInboundTransportMapper mapper = mock(UnifiedInboundTransportMapper.class);
private final UnifiedInboundReadRepository repository = repository();
@Test
void batchKeepsParentChildAndDepartedUnitOnTheirOwnContainer() {
var oldDeparted = fact(11L, 101L, 501L, "OLD", null, LocalDateTime.parse("2026-08-01T08:00"));
var splitChild = fact(12L, 101L, 502L, "NEW", LocalDateTime.parse("2026-08-20T08:00"), null);
when(mapper.findCurrentByInboundIds(anyList())).thenReturn(List.of(oldDeparted, splitChild));
var result = repository.findTransports(Map.of(11L, 101L, 12L, 101L));
assertThat(result.get(11L).containerBookingId()).isEqualTo(501L);
assertThat(result.get(11L).actualSailingAt()).isEqualTo(LocalDateTime.parse("2026-08-01T08:00"));
assertThat(result.get(12L).containerBookingId()).isEqualTo(502L);
assertThat(result.get(12L).estimatedSailingAt()).isEqualTo(LocalDateTime.parse("2026-08-20T08:00"));
verify(mapper).findCurrentByInboundIds(anyList());
}
@Test
void noContainerReturnsNoFact() {
when(mapper.findCurrentByInboundIds(List.of(11L))).thenReturn(List.of());
assertThat(repository.findTransports(Map.of(11L, 101L))).isEmpty();
}
@Test
void multipleEffectiveContainersAreAnInvariantConflict() {
when(mapper.findCurrentByInboundIds(List.of(11L))).thenReturn(List.of(
fact(11L, 101L, 501L, "A", null, null), fact(11L, 101L, 502L, "B", null, null)));
assertThatThrownBy(() -> repository.findTransports(Map.of(11L, 101L)))
.isInstanceOf(BizException.class).hasMessageContaining("多个有效柜次");
}
@Test
void filtersRowsOutsideInboundTenantOwnership() {
when(mapper.findCurrentByInboundIds(List.of(11L))).thenReturn(List.of(
fact(11L, 999L, 501L, "OTHER", null, null)));
assertThat(repository.findTransports(Map.of(11L, 101L))).isEmpty();
}
private UnifiedInboundReadRepository repository() {
var value = new UnifiedInboundReadRepository();
ReflectionTestUtils.setField(value, "transports", mapper);
return value;
}
private CurrentContainerTransport fact(Long inboundId, Long accountId, Long bookingId, String containerNo,
LocalDateTime estimatedSailingAt, LocalDateTime actualSailingAt) {
return new CurrentContainerTransport(inboundId, accountId, bookingId, containerNo, "CAB", "VESSEL", "V1",
estimatedSailingAt, actualSailingAt, null, null);
}
}
@@ -0,0 +1,20 @@
package com.baiyue.firstleg.repository;
import com.baiyue.firstleg.mapper.UnifiedInboundTransportMapper;
import org.apache.ibatis.annotations.Select;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class UnifiedInboundTransportSqlContractTest {
@Test
void queriesCurrentBusinessUnitContainersInOneBatchFromAuthoritativeBooking() throws Exception {
String sql = String.join(" ", UnifiedInboundTransportMapper.class
.getMethod("findCurrentByInboundIds", java.util.List.class).getAnnotation(Select.class).value());
assertThat(sql).contains("fl_loading_plan_item", "i.deleted = 0",
"p.plan_status IN ('LOADING', 'COMPLETED')", "fl_container_booking",
"estimated_sailing_at", "actual_sailing_at", "estimated_port_arrival_at",
"actual_port_arrival_at", "<foreach");
assertThat(sql).doesNotContain("fl_inbound_transport_snapshot", "LIMIT 1");
}
}
@@ -1,92 +0,0 @@
package com.baiyue.firstleg.service;
import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.repository.InboundWorkflowRepository;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@SpringJUnitConfig(AutomaticInboundCompletionService.class)
class AutomaticInboundCompletionServiceTest {
@MockBean private FbaPricingService pricing;
@MockBean private InboundWorkflowRepository workflow;
@MockBean private InboundCompletionOrchestrator completion;
@Resource private AutomaticInboundCompletionService service;
@Test
void shouldHandleCase01() {
FeeSnapshotResponse fee = fee(501L);
when(pricing.latest(10001L, 88L)).thenReturn(fee);
when(workflow.findOwned(10001L, 88L)).thenReturn(
order("WAIT_PRICING", "UNPRICED", 4),
order("FEE_CONFIRMING", "CALCULATED", 5),
order("FEE_CONFIRMING", "CONFIRMED", 6),
order("INBOUNDING", "CONFIRMED", 7),
order("WAIT_SPLIT", "SENT", 8));
FeeSnapshotResponse result = service.completeApproved(10001L, 88L, 9L, "客服", 30L, 4);
assertThat(result).isSameAs(fee);
verify(pricing).calculate(10001L, 9L, 88L, null, "auto-88-30-4-calculate");
verify(pricing).confirm(10001L, 9L, 88L, "auto-88-30-4-confirm");
verify(completion).beginSendFeeAndInbound(10001L, 9L, "客服", 88L, 501L, 6,
"auto-88-30-4-begin");
verify(completion).completeSendFeeAndInbound(10001L, 9L, "客服", 88L, 501L, 7,
"auto-88-30-4-complete");
}
@Test
void shouldHandleCase02() {
FeeSnapshotResponse fee = fee(501L);
when(workflow.findOwned(10001L, 88L)).thenReturn(order("WAIT_SPLIT", "SENT", 8));
when(pricing.latest(10001L, 88L)).thenReturn(fee);
assertThat(service.completeApproved(10001L, 88L, 9L, "客服", 30L, 4)).isSameAs(fee);
verify(pricing, never()).calculate(anyLong(), anyLong(), anyLong(),
org.mockito.ArgumentMatchers.any(), anyString());
verify(pricing, never()).confirm(anyLong(), anyLong(), anyLong(), anyString());
verify(completion, never()).beginSendFeeAndInbound(anyLong(), anyLong(), anyString(),
anyLong(), anyLong(), org.mockito.ArgumentMatchers.anyInt(), anyString());
}
@Test
void shouldHandleCase03() {
FeeSnapshotResponse fee = fee(501L);
when(pricing.latest(10001L, 88L)).thenReturn(fee);
when(workflow.findOwned(10001L, 88L)).thenReturn(
order("INBOUNDING", "CONFIRMED", 7), order("WAIT_SPLIT", "SENT", 8));
service.completeApproved(10001L, 88L, 9L, "客服", 30L, 4);
verify(completion).completeSendFeeAndInbound(10001L, 9L, "客服", 88L, 501L, 7,
"auto-88-30-4-complete");
verify(pricing, never()).calculate(anyLong(), anyLong(), anyLong(),
org.mockito.ArgumentMatchers.any(), anyString());
verify(pricing, never()).confirm(anyLong(), anyLong(), anyLong(), anyString());
}
private static InboundOrderEntity order(String process, String feeStatus, int version) {
InboundOrderEntity order = new InboundOrderEntity();
order.setProcessStatus(process);
order.setFeeStatus(feeStatus);
order.setVersion(version);
return order;
}
private static FeeSnapshotResponse fee(long feeId) {
FeeSnapshotResponse fee = mock(FeeSnapshotResponse.class);
when(fee.feeId()).thenReturn(feeId);
return fee;
}
}
@@ -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;
@@ -113,6 +114,32 @@ class CartonMeasurementServiceTest {
assertThat(measurement.getSourceCode()).isEqualTo("PLATFORM");
}
@Test
void measuringInboundWithoutMeasurementAllowsManualEntryBeforeSubmission() {
InboundOrderEntity measuring = inbound("MEASURING");
when(repository.findInbound(10001L, 70001L)).thenReturn(measuring);
when(repository.lockInbound(10001L, 70001L)).thenReturn(measuring);
when(repository.findItemByBoxCode(10001L, "RK-001#5-1")).thenReturn(null);
when(repository.findLatestEditableMeasurement(10001L, 70001L)).thenReturn(null);
when(repository.listItems(org.mockito.ArgumentMatchers.eq(10001L),
org.mockito.ArgumentMatchers.anyLong())).thenAnswer(invocation -> List.of(insertedItem.get()));
when(repository.updateMeasurement(any())).thenReturn(true);
CartonMeasurementResult result = service.upsertManualForInbound(
10001L, 90001L, 70001L, acceptanceCommand());
ArgumentCaptor<InboundMeasurementEntity> measurement =
ArgumentCaptor.forClass(InboundMeasurementEntity.class);
verify(repository).insertMeasurement(measurement.capture());
assertThat(result.created()).isTrue();
assertThat(result.measurementId()).isEqualTo(measurement.getValue().getId());
assertThat(measurement.getValue().getSourceType()).isEqualTo(MeasurementBatchService.SOURCE_MANUAL);
assertThat(measurement.getValue().getSourceCode()).isEqualTo("PLATFORM");
assertThat(measurement.getValue().getStatus()).isEqualTo(MeasurementBatchService.STATUS_RECEIVED);
assertThat(insertedItem.get().getMeasurementId()).isEqualTo(measurement.getValue().getId());
verify(images).upload(any(), any(Long.TYPE), any(Long.TYPE), any(Long.TYPE), any());
}
@Test
void repeatedScanReplacesDeviceValuesButPreservesManualCountAndOldImageIsDeleted() {
InboundMeasurementEntity measurement = measurement(30001L, "RECEIVED", 4);
@@ -145,7 +172,7 @@ class CartonMeasurementServiceTest {
when(repository.updateMeasurement(measurement)).thenReturn(true);
CartonMeasurementCommand oversize = new CartonMeasurementCommand(
"RK-001#5-1", new BigDecimal("60.100"), new BigDecimal("50.100"),
new BigDecimal("50.100"), new BigDecimal("12.500"), "MJ-01",
new BigDecimal("50.100"), "MJ-01",
LocalDateTime.parse("2026-08-07T10:30:00"),
new MockMultipartFile("image", "measure.jpg", "image/jpeg", "image".getBytes()),
"FBA-SCAN-001");
@@ -248,14 +275,13 @@ class CartonMeasurementServiceTest {
}
@Test
void confirmedPackageCountSynchronizesActualWeightAndVolumeForPricingAfterSubmission() {
void confirmedPackageCountDoesNotUseCartonWeightAsWeightFallback() {
InboundOrderEntity inbound = inbound("MEASURE_PENDING");
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"));
@@ -266,13 +292,52 @@ 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);
service.updateConfirmedPackageCount(10001L, 70001L, 40001L, 2, 5);
assertThat(inbound.getTotalPackageCount()).isEqualTo(5);
assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("54.400");
assertThat(inbound.getActualGrossWeightKg()).isNull();
assertThat(inbound.getActualNetWeightKg()).isNull();
assertThat(inbound.getActualVolumeCbm()).isEqualByComparingTo("0.7480");
assertThat(inbound.getVolumeWeightKg()).isEqualByComparingTo("124.916");
verify(repository).updateInbound(inbound);
}
@Test
void cartonMeasurementAfterConfirmedWeightKeepsNetWeightAndUpdatesVolume() {
InboundOrderEntity inbound = inbound("MEASURE_PENDING");
inbound.setActualGrossWeightKg(new BigDecimal("30.000"));
inbound.setActualNetWeightKg(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.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.setGrossWeightKg(new BigDecimal("30.000"));
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("30.000");
assertThat(inbound.getActualNetWeightKg()).isEqualByComparingTo("11.500");
assertThat(inbound.getActualVolumeCbm()).isEqualByComparingTo("0.7480");
assertThat(inbound.getVolumeWeightKg()).isEqualByComparingTo("124.916");
verify(repository).updateInbound(inbound);
@@ -280,7 +345,7 @@ class CartonMeasurementServiceTest {
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"),
new BigDecimal("40.200"), new BigDecimal("30.300"),
"MJ-01", LocalDateTime.parse("2026-08-07T10:30:00"),
new MockMultipartFile("image", "measure.jpg", "image/jpeg", "image".getBytes()),
"FBA-SCAN-001");
@@ -289,7 +354,7 @@ class CartonMeasurementServiceTest {
private CartonMeasurementCommand acceptanceCommand() {
return new CartonMeasurementCommand(
"RK-001#5-1", new BigDecimal("59.500"), new BigDecimal("41.200"),
new BigDecimal("31.300"), new BigDecimal("13.600"), "MJ-ADMIN-E2E-02",
new BigDecimal("31.300"), "MJ-ADMIN-E2E-02",
LocalDateTime.parse("2026-08-08T10:01:00"),
new MockMultipartFile("image", "measure.jpg", "image/jpeg", "image".getBytes()),
"FBA-SCAN-002");
@@ -314,7 +379,7 @@ class CartonMeasurementServiceTest {
item.setId(id); item.setTenantId(10001L); item.setMeasurementId(measurementId);
item.setBoxCode("RK-001#5-1"); item.setPackageCount(packageCount);
item.setLengthCm(new BigDecimal("50")); item.setWidthCm(new BigDecimal("40"));
item.setHeightCm(new BigDecimal("30")); item.setUnitWeightKg(new BigDecimal("10"));
item.setHeightCm(new BigDecimal("30"));
item.setSourceImageFileId(imageId); item.setItemNo("B1"); item.setBoxSpecification("50x40x30cm");
return item;
}
@@ -2,11 +2,13 @@ package com.baiyue.firstleg.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.ContainerFeeDetailDtos;
import com.baiyue.firstleg.dto.ContainerFeeDetailDtos.AdjustRequest;
import com.baiyue.firstleg.entity.ContainerFeeDetailEntity;
import com.baiyue.firstleg.entity.FeeItemEntity;
import com.baiyue.firstleg.entity.LoadingPlanEntity;
import com.baiyue.firstleg.entity.LoadingPlanItemEntity;
import com.baiyue.firstleg.repository.ContainerFeeDetailRepository;
import com.baiyue.firstleg.repository.ContainerFeeSourceSnapshotRepository;
import com.baiyue.firstleg.repository.FeeItemRepository;
import com.baiyue.firstleg.repository.SmartLoadingRepository;
import org.junit.jupiter.api.Test;
@@ -20,6 +22,8 @@ import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
@@ -31,8 +35,10 @@ class ContainerFeeDetailServiceTest {
private final SmartLoadingRepository loading = mock(SmartLoadingRepository.class);
private final FeeItemRepository feeItems = mock(FeeItemRepository.class);
private final FinanceFeeSourceOutboxService outbox = mock(FinanceFeeSourceOutboxService.class);
private final WarehouseSupplierSnapshotService suppliers = mock(WarehouseSupplierSnapshotService.class);
private final ContainerFeeSourceSnapshotRepository sourceSnapshots = mock(ContainerFeeSourceSnapshotRepository.class);
private final ContainerFeeDetailService service =
new ContainerFeeDetailService(repository, loading, feeItems, outbox);
new ContainerFeeDetailService(repository, loading, feeItems, outbox, suppliers, sourceSnapshots);
@Test
void rejectsContainerThatDoesNotContainCustomerOrders() {
@@ -55,9 +61,13 @@ class ContainerFeeDetailServiceTest {
approved.setId(51L);
approved.setTenantId(10001L);
approved.setLoadingPlanId(41L);
approved.setFeeItemId(11L);
approved.setDirection("RECEIVABLE");
approved.setSourceVersion(1);
approved.setSettlementIncluded(true);
when(repository.find(10001L, 41L, 51L)).thenReturn(approved);
when(feeItems.findItem(11L)).thenReturn(item());
when(repository.updateSupplierSnapshot(eq(10001L), eq(41L), eq(51L),
eq(null), eq(null), eq(null), eq(9L))).thenReturn(1);
when(repository.review(10001L, 41L, 51L, "APPROVED", 9L, "admin", null)).thenReturn(1);
service.approve(10001L, 41L, 51L, 9L, "admin");
@@ -69,37 +79,107 @@ class ContainerFeeDetailServiceTest {
@Test
void adjustmentIncrementsSourceVersionAndReturnsToReview() {
ownedPlan();
FeeItemEntity item = item();
when(feeItems.findItem(11L)).thenReturn(item);
ContainerFeeDetailEntity current = new ContainerFeeDetailEntity();
current.setId(51L);
current.setTenantId(10001L);
current.setLoadingPlanId(41L);
current.setSourceVersion(1);
ContainerFeeDetailEntity current = approvedFee(1);
ContainerFeeDetailEntity adjusted = approvedFee(2);
adjusted.setBusinessDate(LocalDate.of(2026, 8, 12));
adjusted.setAmount(new BigDecimal("325.00"));
adjusted.setReviewStatus("PENDING");
when(repository.find(10001L, 41L, 51L)).thenReturn(current);
when(repository.adjust(any(), eq(1))).thenReturn(1);
when(repository.adjust(eq(10001L), eq(41L), eq(51L), eq(1),
eq(LocalDate.of(2026, 8, 12)), eq(new BigDecimal("325.00")), eq(9L), any()))
.thenReturn(1);
when(repository.find(10001L, 41L, 51L)).thenReturn(current, adjusted);
service.adjust(10001L, 41L, 51L, 1, 9L, request(true));
ContainerFeeDetailEntity result = service.adjust(10001L, 41L, 51L, 9L,
adjustment(1, "325.00"));
ArgumentCaptor<ContainerFeeDetailEntity> captor = ArgumentCaptor.forClass(ContainerFeeDetailEntity.class);
verify(repository).adjust(captor.capture(), eq(1));
assertThat(captor.getValue().getSourceVersion()).isEqualTo(2);
assertThat(captor.getValue().getReviewStatus()).isEqualTo("PENDING");
assertThat(captor.getValue().getPartnerId()).isEqualTo(71L);
assertThat(captor.getValue().getPartnerName()).isEqualTo("现有往来单位");
assertThat(result.getSourceVersion()).isEqualTo(2);
assertThat(result.getReviewStatus()).isEqualTo("PENDING");
assertThat(result.getFeeItemId()).isEqualTo(11L);
assertThat(result.getDirection()).isEqualTo("PAYABLE");
assertThat(result.getSupplierId()).isEqualTo(71L);
assertThat(result.getCurrency()).isEqualTo("CNY");
assertThat(result.getRemark()).isEqualTo("原费用备注");
verify(feeItems, never()).findItem(anyLong());
}
@Test
void payableRequiresCounterpartySnapshot() {
void exactAdjustmentReplayReturnsCurrentPendingVersionWithoutWriting() {
ownedPlan();
ContainerFeeDetailEntity replay = approvedFee(2);
replay.setReviewStatus("PENDING");
replay.setBusinessDate(LocalDate.of(2026, 8, 12));
replay.setAmount(new BigDecimal("325.00"));
when(repository.find(10001L, 41L, 51L)).thenReturn(replay);
assertThat(service.adjust(10001L, 41L, 51L, 9L, adjustment(1, "325.0")))
.isSameAs(replay);
verify(repository, never()).adjust(anyLong(), anyLong(), anyLong(),
anyInt(), any(), any(), anyLong(), any());
}
@Test
void staleOrDifferentReplayIsRejectedWithoutWriting() {
ownedPlan();
ContainerFeeDetailEntity current = approvedFee(2);
current.setReviewStatus("PENDING");
current.setBusinessDate(LocalDate.of(2026, 8, 12));
current.setAmount(new BigDecimal("325.00"));
when(repository.find(10001L, 41L, 51L)).thenReturn(current);
assertThatThrownBy(() -> service.adjust(10001L, 41L, 51L, 9L,
adjustment(1, "326.00"))).isInstanceOf(BizException.class)
.hasMessageContaining("当前版本的已审核");
verify(repository, never()).adjust(anyLong(), anyLong(), anyLong(),
anyInt(), any(), any(), anyLong(), any());
}
@Test
void concurrentVersionChangeReturnsConflictWithoutPublishing() {
ownedPlan();
when(repository.find(10001L, 41L, 51L)).thenReturn(approvedFee(1));
when(repository.adjust(eq(10001L), eq(41L), eq(51L), eq(1), any(), any(), eq(9L), any()))
.thenReturn(0);
assertThatThrownBy(() -> service.adjust(10001L, 41L, 51L, 9L,
adjustment(1, "325.00"))).isInstanceOf(BizException.class)
.hasMessageContaining("当前版本的已审核");
verify(outbox, never()).publishContainer(any(), anyLong(), anyLong(), anyLong(),
anyInt(), any());
}
@Test
void voidedOrUnapprovedFeeCannotBeAdjusted() {
ownedPlan();
ContainerFeeDetailEntity voided = approvedFee(1);
voided.setSourceStatus("VOIDED");
when(repository.find(10001L, 41L, 51L)).thenReturn(voided);
assertThatThrownBy(() -> service.adjust(10001L, 41L, 51L, 9L,
adjustment(1, "325.00"))).isInstanceOf(BizException.class);
ContainerFeeDetailEntity pending = approvedFee(1);
pending.setReviewStatus("PENDING");
when(repository.find(10001L, 41L, 51L)).thenReturn(pending);
assertThatThrownBy(() -> service.adjust(10001L, 41L, 51L, 9L,
adjustment(1, "325.00"))).isInstanceOf(BizException.class);
verify(repository, never()).adjust(anyLong(), anyLong(), anyLong(),
anyInt(), any(), any(), anyLong(), any());
}
@Test
void payableRequiresSupplierSnapshot() {
ownedPlan();
when(feeItems.findItem(11L)).thenReturn(item());
var missingPartner = new ContainerFeeDetailDtos.SaveRequest(11L, "PAYABLE",
var missingSupplier = new ContainerFeeDetailDtos.SaveRequest(11L, "PAYABLE",
LocalDate.of(2026, 8, 11), new BigDecimal("300.00"), "CNY",
true, null, null, "整柜拖车费");
null, "整柜拖车费");
assertThatThrownBy(() -> service.create(10001L, 41L, 9L, missingPartner))
.isInstanceOf(BizException.class).hasMessageContaining("必须选择有效往来单位");
assertThatThrownBy(() -> service.create(10001L, 41L, 9L, missingSupplier))
.isInstanceOf(BizException.class).hasMessageContaining("必须选择有效供应商");
verify(repository, never()).insert(any());
}
@@ -110,13 +190,13 @@ class ContainerFeeDetailServiceTest {
var request = new ContainerFeeDetailDtos.SaveRequest(11L, "RECEIVABLE",
LocalDate.of(2026, 8, 11), new BigDecimal("300.00"), "CNY",
true, 71L, "不应保留", "整柜操作费");
71L, "整柜操作费");
service.create(10001L, 41L, 9L, request);
ArgumentCaptor<ContainerFeeDetailEntity> captor = ArgumentCaptor.forClass(ContainerFeeDetailEntity.class);
verify(repository).insert(captor.capture());
assertThat(captor.getValue().getPartnerId()).isNull();
assertThat(captor.getValue().getPartnerName()).isNull();
assertThat(captor.getValue().getSupplierId()).isNull();
assertThat(captor.getValue().getSupplierName()).isNull();
}
private void ownedPlan() {
@@ -139,9 +219,34 @@ class ContainerFeeDetailServiceTest {
return value;
}
private static ContainerFeeDetailDtos.SaveRequest request(boolean settlementIncluded) {
private static ContainerFeeDetailEntity approvedFee(int version) {
ContainerFeeDetailEntity value = new ContainerFeeDetailEntity();
value.setId(51L);
value.setTenantId(10001L);
value.setLoadingPlanId(41L);
value.setFeeItemId(11L);
value.setFeeItemCodeSnapshot("TRUCKING");
value.setFeeItemNameSnapshot("拖车费");
value.setDirection("PAYABLE");
value.setSupplierId(71L);
value.setSupplierName("现有供应商");
value.setCurrency("CNY");
value.setRemark("原费用备注");
value.setBusinessDate(LocalDate.of(2026, 8, 11));
value.setAmount(new BigDecimal("300.00"));
value.setSourceStatus("ACTIVE");
value.setReviewStatus("APPROVED");
value.setSourceVersion(version);
return value;
}
private static AdjustRequest adjustment(int version, String amount) {
return new AdjustRequest(version, LocalDate.of(2026, 8, 12), new BigDecimal(amount));
}
private static ContainerFeeDetailDtos.SaveRequest request(boolean ignored) {
return new ContainerFeeDetailDtos.SaveRequest(11L, "PAYABLE",
LocalDate.of(2026, 8, 11), new BigDecimal("300.00"), "CNY",
settlementIncluded, 71L, " 现有往来单位 ", "整柜拖车费");
71L, "整柜拖车费");
}
}
@@ -5,6 +5,10 @@ import com.baiyue.firstleg.dto.ContainerOverviewDtos.ExceptionRequest;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.mapper.ContainerOverviewMapper;
import com.baiyue.firstleg.repository.ContainerRepository;
import com.baiyue.firstleg.client.LoadingEvidenceFileClient;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.test.util.ReflectionTestUtils;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
@@ -13,6 +17,7 @@ import org.mockito.MockitoAnnotations;
import java.time.LocalDateTime;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@@ -20,17 +25,98 @@ import static org.mockito.Mockito.*;
class ContainerOverviewServiceTest {
@Mock ContainerRepository containers;
@Mock ContainerOverviewMapper mapper;
@Mock LoadingEvidenceFileClient evidenceFiles;
@InjectMocks ContainerOverviewService service;
@BeforeEach void setUp() { MockitoAnnotations.openMocks(this); }
@BeforeEach void setUp() {
MockitoAnnotations.openMocks(this);
ReflectionTestUtils.setField(service, "internalToken", "internal-token");
}
@Test void overviewExposesAwaitingReviewWorkflowAndFirstlegPhotoDownloadPath() {
ContainerBookingEntity booking = new ContainerBookingEntity();
booking.setId(41L); booking.setVersion(3); booking.setPlanId(52L);
when(containers.find(41L)).thenReturn(booking);
when(mapper.pickTask(41L)).thenReturn(new ContainerOverviewMapper.PickTaskRow(
61L, 52L, 41L, "LOADING", 7));
when(mapper.itemCount(61L)).thenReturn(2);
when(mapper.completedItemCount(61L)).thenReturn(2);
when(mapper.unresolvedItemCount(61L)).thenReturn(0);
when(mapper.photos(52L)).thenReturn(java.util.List.of(
new ContainerOverviewMapper.PhotoRow(
71L, 81L, 91L, "LOADING_DOOR_OPEN", 101L, "door.jpg", "ACTIVE", LocalDateTime.now()),
new ContainerOverviewMapper.PhotoRow(
72L, 81L, 91L, "LOADING_DOOR_OPEN", 102L, "void.jpg", "VOID", LocalDateTime.now())));
when(mapper.documents(41L)).thenReturn(java.util.List.of());
when(mapper.exceptions(41L)).thenReturn(java.util.List.of());
when(mapper.transport(41L)).thenReturn(java.util.List.of());
when(mapper.pickTimeline(61L)).thenReturn(java.util.List.of());
when(mapper.pickExceptionReasons(61L)).thenReturn(java.util.List.of());
var overview = service.overview(41L);
assertThat(overview.pda().taskStatus()).isEqualTo("LOADING");
assertThat(overview.pda().workflowStatus()).isEqualTo("AWAITING_REVIEW");
assertThat(overview.pda().photos().get(0).downloadPath())
.isEqualTo("/api/platform/v1/containers/41/pda/photos/101/download");
assertThat(overview.pda().photos()).hasSize(2);
assertThat(overview.pda().photos().get(1).status()).isEqualTo("VOID");
assertThat(overview.pda().photos().get(1).downloadPath()).isNull();
assertThat(overview.pda().photoCounts()).singleElement().satisfies(count -> {
assertThat(count.evidenceType()).isEqualTo("LOADING_DOOR_OPEN");
assertThat(count.count()).isEqualTo(1);
});
}
@Test void downloadsOnlyActivePhotoOwnedByContainerLoadingPlan() {
ContainerBookingEntity booking = new ContainerBookingEntity(); booking.setId(41L);
when(containers.find(41L)).thenReturn(booking);
when(mapper.pickTask(41L)).thenReturn(new ContainerOverviewMapper.PickTaskRow(
61L, 52L, 41L, "LOADING", 7));
when(mapper.photos(52L)).thenReturn(java.util.List.of(new ContainerOverviewMapper.PhotoRow(
71L, 81L, 91L, "LOADING_DOOR_OPEN", 101L, "door.jpg", "ACTIVE", LocalDateTime.now())));
when(evidenceFiles.download("internal-token", 81L, 91L, 101L)).thenReturn(ResponseEntity.ok()
.contentType(MediaType.IMAGE_JPEG).body(new byte[]{1, 2, 3}));
var download = service.downloadPhoto(41L, 101L);
assertThat(download.fileName()).isEqualTo("door.jpg");
assertThat(download.contentType()).isEqualTo(MediaType.IMAGE_JPEG);
assertThat(download.content()).containsExactly(1, 2, 3);
}
@Test void rejectsPhotoFromAnotherContainerBeforeCallingFileService() {
ContainerBookingEntity booking = new ContainerBookingEntity(); booking.setId(41L);
when(containers.find(41L)).thenReturn(booking);
when(mapper.pickTask(41L)).thenReturn(new ContainerOverviewMapper.PickTaskRow(
61L, 52L, 41L, "LOADING", 7));
when(mapper.photos(52L)).thenReturn(java.util.List.of());
assertThatThrownBy(() -> service.downloadPhoto(41L, 999L))
.isInstanceOf(BizException.class).hasMessageContaining("不属于该货柜");
verifyNoInteractions(evidenceFiles);
}
@Test void rejectsVoidedPhotoBeforeCallingFileService() {
ContainerBookingEntity booking = new ContainerBookingEntity(); booking.setId(41L);
when(containers.find(41L)).thenReturn(booking);
when(mapper.pickTask(41L)).thenReturn(new ContainerOverviewMapper.PickTaskRow(
61L, 52L, 41L, "LOADING", 7));
when(mapper.photos(52L)).thenReturn(java.util.List.of(new ContainerOverviewMapper.PhotoRow(
71L, 81L, 91L, "LOADING_DOOR_OPEN", 101L, "void.jpg", "VOID", LocalDateTime.now())));
assertThatThrownBy(() -> service.downloadPhoto(41L, 101L))
.isInstanceOf(BizException.class).hasMessageContaining("不属于该货柜");
verifyNoInteractions(evidenceFiles);
}
@Test void markLocksContainerAndPersistsIdempotentImmutableHistory() {
ContainerBookingEntity booking = new ContainerBookingEntity(); booking.setId(41L); booking.setVersion(3);
when(containers.findForUpdate(41L)).thenReturn(booking);
when(containers.findActiveForUpdate(41L)).thenReturn(booking);
service.mark(41L, 7L, "operator", "mark-1", new ExceptionRequest(3, "broken seal"));
verify(containers).findForUpdate(41L);
verify(containers).findActiveForUpdate(41L);
verify(mapper).insertException(anyLong(), eq(41L), eq("MARK"), eq("broken seal"), eq(7L),
eq("operator"), eq("mark-1"), matches("[0-9a-f]{64}"));
}
@@ -3,8 +3,12 @@ package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.WarehouseMasterDataClient;
import com.baiyue.firstleg.client.WarehouseSupplierClient;
import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest;
import com.baiyue.firstleg.dto.ContainerDtos.SaveRequest;
import com.baiyue.firstleg.dto.ContainerDtos.SimulationCreateRequest;
import com.baiyue.firstleg.dto.ContainerDtos.OceanBookingLinkRequest;
import com.baiyue.firstleg.dto.ContainerDtos.OceanBookingUnlinkRequest;
import com.baiyue.firstleg.dto.ContainerDtos.TransportUpdateRequest;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.entity.LoadingPlanEntity;
@@ -13,6 +17,12 @@ import com.baiyue.firstleg.mapper.ContainerTransportHistoryMapper;
import com.baiyue.firstleg.repository.ContainerRepository;
import com.baiyue.firstleg.repository.LoadingPickRepository;
import com.baiyue.firstleg.repository.SmartLoadingRepository;
import com.baiyue.firstleg.repository.PriceBookQueryRepository;
import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow;
import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerRequirementEntity;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.BeforeEach;
@@ -23,14 +33,14 @@ import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@DisplayName("货柜基础资料服务")
@@ -41,58 +51,83 @@ class ContainerServiceTest {
@MockBean private SmartLoadingRepository smartLoadingRepository;
@MockBean private LoadingPickRepository loadingPickRepository;
@MockBean private LoadingPickService loadingPickService;
@MockBean private PriceBookQueryRepository priceBooks;
@MockBean private OceanBookingRepository oceanBookings;
@MockBean private WarehouseSupplierSnapshotService suppliers;
@MockBean private FirstlegDocumentNumberGenerator documentNumbers;
@MockBean private ContainerTransportHistoryMapper transportHistories;
@Resource private ContainerService service;
@BeforeEach
void setUpWarehouse() {
when(documentNumbers.next("CB")).thenReturn("CB-260818-0001");
when(documentNumbers.next("CAB")).thenReturn("CAB-260818-0001");
when(warehouses.active(anyString(), anyLong(), anyLong())).thenAnswer(invocation ->
activeWarehouse(invocation.getArgument(2, Long.class), "DOMESTIC", "上海中转仓"));
when(suppliers.trucking(202L, "拖车公司A"))
.thenReturn(new WarehouseSupplierClient.Snapshot(202L, "拖车公司A", "TRUCKING", "ACTIVE"));
}
@Test
@DisplayName("新增货柜保存最终数据库契约字段并生成平台预订单号")
void createsContainerBooking() {
ContainerBookingEntity created = service.create(9L, "admin", request("柜-001"));
@DisplayName("新增模拟柜从有效渠道冻结快照并生成稳定内部编号")
void createsSimulationContainerBooking() {
ChannelRow channel = channel();
when(priceBooks.activeVersionId()).thenReturn(51L);
when(priceBooks.channels(51L, 101L)).thenReturn(java.util.List.of(channel));
ContainerBookingEntity created = service.createSimulation(9L, "admin", simulation());
ArgumentCaptor<ContainerBookingEntity> captor = ArgumentCaptor.forClass(ContainerBookingEntity.class);
verify(repository).insert(captor.capture());
ContainerBookingEntity saved = captor.getValue();
assertThat(created).isSameAs(saved);
assertThat(saved.getBookingNo()).startsWith("CB");
assertThat(saved.getCabinetNo()).isEqualTo("柜-001");
assertThat(saved.getBookingNo()).isEqualTo("CB-260818-0001");
assertThat(saved.getCabinetNo()).isEqualTo("CAB-260818-0001");
assertThat(saved.getContainerNo()).isNull();
assertThat(saved.getOceanBookingId()).isNull();
assertThat(saved.getChannelName()).isEqualTo("美西海运");
assertThat(saved.getWarehouseId()).isEqualTo(101L);
assertThat(saved.getWarehouseName()).isEqualTo("上海中转仓");
assertThat(saved.getDeparturePortCode()).isEqualTo("CNSHA");
assertThat(saved.getCapacityCbm()).isEqualByComparingTo("68.0000");
assertThat(saved.getCapacityCbm()).isEqualByComparingTo("76.0000");
assertThat(saved.getPlanStatus()).isEqualTo("WAIT_LOADING");
}
@Test
@DisplayName("智能装柜草稿阶段允许完整编辑")
@DisplayName("新增模拟柜拒绝柜型与固定规格不匹配")
void rejectsSimulationWithNonCanonicalSpecification() {
SimulationCreateRequest invalid = new SimulationCreateRequest(
"SEA-US", 101L, "40HC", new BigDecimal("100"), new BigDecimal("28000"));
assertThatThrownBy(() -> service.createSimulation(9L, "admin", invalid))
.isInstanceOf(BizException.class)
.hasMessageContaining("容量和限重");
verify(repository, never()).insert(any());
}
@Test
@DisplayName("智能装柜草稿阶段完整编辑时保留稳定柜次号")
void updatesAllFieldsWhileEditing() {
ContainerBookingEntity current = booking("EDITING", 2);
when(repository.find(11L)).thenReturn(current);
when(repository.updateDraft(any(), org.mockito.ArgumentMatchers.eq(2),
org.mockito.ArgumentMatchers.eq(9L), org.mockito.ArgumentMatchers.eq("admin"))).thenReturn(1);
service.updateDraft(11L, 9L, "admin", new FullUpdateRequest(2, request("柜-NEW")));
service.updateDraft(11L, 9L, "admin", new FullUpdateRequest(2, request()));
assertThat(current.getCabinetNo()).isEqualTo("柜-NEW");
assertThat(current.getCabinetNo()).isEqualTo("柜-001");
verify(repository).updateDraft(current, 2, 9L, "admin");
}
@Test
@DisplayName("待装柜阶段允许完整编辑")
@DisplayName("待装柜阶段完整编辑时保留稳定柜次号")
void updatesAllFieldsWhileWaitingForLoading() {
ContainerBookingEntity current = booking("WAIT_LOADING", 2);
when(repository.find(11L)).thenReturn(current);
when(repository.updateDraft(any(), org.mockito.ArgumentMatchers.eq(2),
org.mockito.ArgumentMatchers.eq(9L), org.mockito.ArgumentMatchers.eq("admin"))).thenReturn(1);
service.updateDraft(11L, 9L, "admin", new FullUpdateRequest(2, request("柜 NEW")));
service.updateDraft(11L, 9L, "admin", new FullUpdateRequest(2, request()));
assertThat(current.getCabinetNo()).isEqualTo("柜 NEW");
assertThat(current.getCabinetNo()).isEqualTo("柜-001");
verify(repository).updateDraft(current, 2, 9L, "admin");
}
@@ -102,7 +137,7 @@ class ContainerServiceTest {
when(repository.find(11L)).thenReturn(booking("LOADING", 2));
assertThatThrownBy(() -> service.updateDraft(
11L, 9L, "admin", new FullUpdateRequest(2, request("柜-NEW"))))
11L, 9L, "admin", new FullUpdateRequest(2, request())))
.isInstanceOf(BizException.class)
.hasMessageContaining("只能修改集装箱号和航次");
@@ -143,9 +178,7 @@ class ContainerServiceTest {
service.updateTransport(11L, 9L, "admin",
new TransportUpdateRequest("MSCU1234567", "V002", 2));
verify(loadingPickService).begin(org.mockito.ArgumentMatchers.eq(31L),
org.mockito.ArgumentMatchers.eq("MSCU1234567"), org.mockito.ArgumentMatchers.eq(9L),
org.mockito.ArgumentMatchers.eq("admin"), any());
verify(loadingPickService).beginIfReady(11L, 9L, "admin");
}
@Test
@@ -165,8 +198,7 @@ class ContainerServiceTest {
service.updateTransport(11L, 9L, "admin",
new TransportUpdateRequest("MSCU7654321", "V002", 2));
verify(loadingPickService, never()).begin(anyLong(), any(), anyLong(), any(), any());
verify(smartLoadingRepository, never()).items(31L);
verify(loadingPickService).beginIfReady(11L, 9L, "admin");
}
@Test
@@ -208,30 +240,18 @@ class ContainerServiceTest {
when(repository.find(11L)).thenReturn(booking("EDITING", 3));
assertThatThrownBy(() -> service.updateDraft(
11L, 9L, "admin", new FullUpdateRequest(2, request("柜-NEW"))))
11L, 9L, "admin", new FullUpdateRequest(2, request())))
.isInstanceOf(BizException.class)
.hasMessageContaining("刷新后重试");
}
@Test
@DisplayName("新增时校验重复集装箱号和内部柜号")
void rejectsDuplicateNumbers() {
when(repository.containerNoExists("MSCU1234567", null)).thenReturn(true);
assertThatThrownBy(() -> service.create(9L, "admin", request("柜-001")))
.isInstanceOf(BizException.class)
.hasMessageContaining("集装箱号已存在");
verify(repository, never()).insert(any());
}
@Test
@DisplayName("虚拟柜拒绝选择非国内中转仓")
void rejectsNonDomesticWarehouse() {
when(warehouses.active(anyString(), anyLong(), anyLong()))
.thenReturn(activeWarehouse(101L, "FBA", "美国目的仓"));
assertThatThrownBy(() -> service.create(9L, "admin", request("柜-001")))
assertThatThrownBy(() -> service.createSimulation(9L, "admin", simulation()))
.isInstanceOf(BizException.class)
.hasMessageContaining("国内中转仓");
@@ -241,7 +261,9 @@ class ContainerServiceTest {
@Test
@DisplayName("虚拟柜仓库名称以仓库主数据为准")
void replacesRequestedWarehouseName() {
service.create(9L, "admin", request("柜-001"));
when(priceBooks.activeVersionId()).thenReturn(51L);
when(priceBooks.channels(51L, 101L)).thenReturn(java.util.List.of(channel()));
service.createSimulation(9L, "admin", simulation());
ArgumentCaptor<ContainerBookingEntity> captor = ArgumentCaptor.forClass(ContainerBookingEntity.class);
verify(repository).insert(captor.capture());
@@ -254,13 +276,107 @@ class ContainerServiceTest {
when(warehouses.active(anyString(), anyLong(), anyLong()))
.thenThrow(new IllegalStateException("warehouse service unavailable"));
assertThatThrownBy(() -> service.create(9L, "admin", request("柜-001")))
assertThatThrownBy(() -> service.createSimulation(9L, "admin", simulation()))
.isInstanceOf(BizException.class)
.hasMessageContaining("暂时不可用");
verify(repository, never()).insert(any());
}
@Test
@DisplayName("首次关联允许已有预排货的编辑计划且保持内部柜和计划身份")
void linksReleasedBookingAfterPreloading() {
ContainerBookingEntity container = booking("EDITING", 2);
container.setWarehouseId(101L); container.setChannelCode("SEA-US"); container.setContainerType("40HC");
container.setCapacityCbm(new BigDecimal("76.0000")); container.setMaxWeightKg(new BigDecimal("28000.000"));
OceanBookingEntity booking = new OceanBookingEntity();
booking.setId(21L); booking.setStatus("RELEASED"); booking.setWarehouseId(101L); booking.setChannelCode("SEA-US");
OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity();
requirement.setId(31L); requirement.setQuantity(1); requirement.setContainerType("40HC"); requirement.setCapacityCbm(new BigDecimal("76.0000")); requirement.setMaxWeightKg(new BigDecimal("28000.000"));
when(repository.find(11L)).thenReturn(container, container);
when(oceanBookings.find(21L)).thenReturn(booking);
when(oceanBookings.requirement(21L, 31L)).thenReturn(requirement);
when(oceanBookings.allocations(21L)).thenReturn(List.of());
when(repository.linkReleasedBooking(eq(11L), eq(21L), eq(31L), eq("MSCU7654321"),
same(booking), eq(2), eq(9L), eq("admin"))).thenReturn(1);
ContainerBookingEntity result = service.linkOceanBooking(11L, 9L, "admin",
new OceanBookingLinkRequest(21L, 31L, "MSCU7654321", 2));
assertThat(result).isSameAs(container);
assertThat(container.getCabinetNo()).isEqualTo("柜-001");
verify(repository, never()).hasActiveLoadingItems(11L);
verify(repository).linkReleasedBooking(eq(11L), eq(21L), eq(31L), eq("MSCU7654321"),
same(booking), eq(2), eq(9L), eq("admin"));
verify(oceanBookings).insert(any(OceanBookingContainerAllocationEntity.class));
}
@Test
@DisplayName("未发布订舱不可关联")
void rejectsDraftBooking() {
ContainerBookingEntity container = booking("WAIT_LOADING", 2);
OceanBookingEntity booking = new OceanBookingEntity();
booking.setId(21L); booking.setStatus("DRAFT");
when(repository.find(11L)).thenReturn(container);
when(oceanBookings.find(21L)).thenReturn(booking);
assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin",
new OceanBookingLinkRequest(21L, 31L, "MSCU7654321", 2)))
.isInstanceOf(BizException.class).hasMessageContaining("已发布");
}
@Test
@DisplayName("订舱箱型需求达到数量上限时拒绝关联")
void rejectsRequirementOverAllocation() {
ContainerBookingEntity container = booking("WAIT_LOADING", 2);
OceanBookingEntity booking = new OceanBookingEntity();
booking.setId(21L); booking.setStatus("RELEASED");
OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity();
requirement.setId(31L); requirement.setQuantity(1);
OceanBookingContainerAllocationEntity allocation = new OceanBookingContainerAllocationEntity();
allocation.setRequirementId(31L);
when(repository.find(11L)).thenReturn(container);
when(oceanBookings.find(21L)).thenReturn(booking);
when(oceanBookings.requirement(21L, 31L)).thenReturn(requirement);
when(oceanBookings.allocations(21L)).thenReturn(List.of(allocation));
assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin",
new OceanBookingLinkRequest(21L, 31L, "MSCU7654321", 2)))
.isInstanceOf(BizException.class).hasMessageContaining("数量上限");
verify(repository, never()).linkReleasedBooking(anyLong(), anyLong(), anyLong(), anyString(),
any(), anyInt(), anyLong(), anyString());
}
@Test
@DisplayName("完全空的模拟柜直接解绑并删除当前allocation,不依赖MERGED来源柜")
void unlinksEmptySimulationDirectly() {
ContainerBookingEntity linked = booking("WAIT_LOADING", 2);
linked.setOceanBookingId(21L);
ContainerBookingEntity detached = booking("WAIT_LOADING", 3);
when(repository.find(11L)).thenReturn(linked, detached);
when(repository.unlinkReleasedBooking(11L, 2, 9L, "admin")).thenReturn(1);
ContainerBookingEntity result = service.unlinkOceanBooking(11L, 9L, "admin",
new OceanBookingUnlinkRequest(2, "换绑"));
assertThat(result).isSameAs(detached);
verify(repository).unlinkReleasedBooking(11L, 2, 9L, "admin");
verify(oceanBookings).deleteAllocation(11L);
}
@Test
@DisplayName("首次关联遇到有效装柜证据时拒绝")
void rejectsLinkWhenEvidenceExists() {
ContainerBookingEntity container = booking("EDITING", 2);
when(repository.find(11L)).thenReturn(container);
when(repository.hasEvidence(11L)).thenReturn(true);
assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin",
new OceanBookingLinkRequest(21L, 31L, "MSCU7654321", 2)))
.isInstanceOf(BizException.class).hasMessageContaining("证据");
verifyNoInteractions(oceanBookings);
}
private static ApiResponse<WarehouseMasterDataClient.Detail> activeWarehouse(long warehouseId,
String type,
String name) {
@@ -278,12 +394,20 @@ class ContainerServiceTest {
return value;
}
private static SaveRequest request(String cabinetNo) {
return new SaveRequest("MSCU1234567", cabinetNo, 101L, "上海仓", "BOL-001", "柏悦轮",
"V001", "40HQ", 201L, "船司A", "CNSHA", "上海港", "USLAX", "洛杉矶港",
"ONT8", "ONT8", new BigDecimal("68.0000"), new BigDecimal("26000.000"), "拖车公司A",
private static SaveRequest request() {
return new SaveRequest("MSCU1234567", 101L, "上海仓", "BOL-001", "柏悦轮",
"V001", "40HC", 201L, "船司A", "CNSHA", "上海港", "USLAX", "洛杉矶港",
"ONT8", "ONT8", new BigDecimal("76.0000"), new BigDecimal("28000.000"), 202L, "拖车公司A",
LocalDateTime.of(2026, 8, 10, 8, 0), LocalDateTime.of(2026, 8, 12, 8, 0), null,
LocalDateTime.of(2026, 8, 25, 8, 0), null,
LocalDateTime.of(2026, 8, 27, 8, 0), null);
}
private static SimulationCreateRequest simulation() {
return new SimulationCreateRequest("SEA-US", 101L, "40HC", new BigDecimal("76.0000"), new BigDecimal("28000.000"));
}
private static ChannelRow channel() {
ChannelRow channel = new ChannelRow(); channel.setChannelCode("SEA-US"); channel.setChannelName("美西海运"); return channel;
}
}
@@ -31,6 +31,7 @@ import java.util.concurrent.atomic.AtomicReference;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
@@ -90,6 +91,23 @@ class FbaPricingServiceTest {
assertThat(fee.getValue().getPricingTaxMode()).isEqualTo("TAX_INCLUDED");
assertThat(fee.getValue().getChargeRuleId()).isNull();
assertThat(fee.getValue().getCarrierId()).isNull();
assertThat(fee.getValue().getCustomerId()).isEqualTo(11L);
}
@Test
@DisplayName("缺少订单客户归属时拒绝生成对客费用")
void rejectsMissingOrderCustomerOwnership() {
readyRepository();
InboundPricingEntity inbound = inbound("200.000", "0.2000");
inbound.setCustomerId(null);
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
assertThatThrownBy(() -> service.calculate(
TENANT, OPERATOR, INBOUND, NOW, "calc-no-customer"))
.isInstanceOf(BizException.class)
.hasMessageContaining("缺少订单客户归属");
verify(priceBookEstimator, never()).estimate(any());
verify(repository, never()).insertFee(any());
}
@Test
@@ -125,6 +143,8 @@ class FbaPricingServiceTest {
assertThat(response.nextProcessStatus()).isEqualTo("WAIT_SPLIT");
verify(repository).markInboundCalculated(TENANT, INBOUND, "WAIT_SPLIT", "KG",
new BigDecimal("167.000"));
verify(feeDetails).materializeCalculatedFreight(TENANT, INBOUND, 501L, OPERATOR, NOW);
verify(financeOutbox, never()).publishInbound(any(), anyLong(), anyLong(), anyLong(), anyInt(), any());
}
@Test
@@ -284,7 +304,9 @@ class FbaPricingServiceTest {
doAnswer(invocation -> { ((InboundPricingIdempotencyEntity) invocation.getArgument(0)).setId(901L); return null; })
.when(repository).insertIdempotency(any());
when(repository.nextFeeVersion(TENANT, INBOUND)).thenReturn(2);
when(repository.supersedeSent(TENANT, 501L)).thenReturn(1);
when(repository.supersedeCurrent(TENANT, 501L, "SENT")).thenReturn(1);
when(repository.markInboundAdjusted(eq(TENANT), eq(INBOUND), eq("SENT"), eq("KG"), any()))
.thenReturn(1);
doAnswer(invocation -> {
InboundFeeEntity fee = invocation.getArgument(0);
fee.setId(502L);
@@ -296,18 +318,81 @@ class FbaPricingServiceTest {
FeeSnapshotResponse response = service.adjustPublished(TENANT, OPERATOR, INBOUND,
new com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest(
new BigDecimal("100.00"), new BigDecimal("20.00"), "客服修正"), "adjust-1");
1, new BigDecimal("12.5"), new BigDecimal("8.00"),
new BigDecimal("20.00"), "客服修正"), "adjust-1");
assertThat(response.feeVersion()).isEqualTo(2);
assertThat(response.feeStatus()).isEqualTo("SENT");
assertThat(response.totalAmount()).isEqualByComparingTo("120.00");
assertThat(response.nextProcessStatus()).isEqualTo("WAIT_SPLIT");
assertThat(inbound.getProcessStatus()).isEqualTo("WAIT_SPLIT");
verify(repository).supersedeSent(TENANT, 501L);
assertThat(inserted.get().getChargeableQuantity()).isEqualByComparingTo("12.5000");
verify(repository).supersedeCurrent(TENANT, 501L, "SENT");
verify(financeOutbox).publishInbound(eq(FinanceFeeSourceOutboxService.ADJUSTED),
eq(TENANT), eq(INBOUND), eq(502L), eq(2), eq(NOW));
}
@Test
@DisplayName("初算费用调整后保持初算状态且不发布财务来源")
void adjustsCalculatedFeeWithoutPublishingIt() {
InboundPricingEntity inbound = inbound("10", "1");
inbound.setProcessStatus("WAIT_SPLIT");
inbound.setFeeStatus("CALCULATED");
InboundFeeEntity current = savedFee("CALCULATED");
AtomicReference<InboundFeeEntity> inserted = new AtomicReference<>();
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
when(repository.findLatestFee(TENANT, INBOUND)).thenReturn(current);
when(repository.findIdempotency(TENANT, OPERATOR, "FEE_ADJUST", "adjust-calculated")).thenReturn(null);
doAnswer(invocation -> { ((InboundPricingIdempotencyEntity) invocation.getArgument(0)).setId(902L); return null; })
.when(repository).insertIdempotency(any());
when(repository.nextFeeVersion(TENANT, INBOUND)).thenReturn(2);
when(repository.supersedeCurrent(TENANT, 501L, "CALCULATED")).thenReturn(1);
when(repository.markInboundAdjusted(eq(TENANT), eq(INBOUND), eq("CALCULATED"), eq("KG"), any()))
.thenReturn(1);
doAnswer(invocation -> {
InboundFeeEntity fee = invocation.getArgument(0);
fee.setId(503L);
inserted.set(fee);
return null;
}).when(repository).insertFee(any());
when(repository.completeIdempotency(any(), eq(503L), eq(NOW))).thenReturn(1);
when(repository.findFee(TENANT, 503L)).thenAnswer(invocation -> inserted.get());
FeeSnapshotResponse response = service.adjustPublished(TENANT, OPERATOR, INBOUND,
new com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest(
1, new BigDecimal("12.5"), new BigDecimal("8.00"),
new BigDecimal("20.00"), "客服修正"), "adjust-calculated");
assertThat(response.feeStatus()).isEqualTo("CALCULATED");
assertThat(response.totalAmount()).isEqualByComparingTo("120.00");
assertThat(response.nextProcessStatus()).isEqualTo("WAIT_SPLIT");
verify(repository).supersedeCurrent(TENANT, 501L, "CALCULATED");
verify(feeDetails).materializeCalculatedFreight(TENANT, INBOUND, 503L, OPERATOR, NOW);
verify(financeOutbox, never()).publishInbound(any(), anyLong(), anyLong(), anyLong(), anyInt(), any());
}
@Test
@DisplayName("费用版本已变化时拒绝覆盖当前版本")
void rejectsAdjustmentWithStaleFeeVersion() {
InboundPricingEntity inbound = inbound("10", "1");
inbound.setProcessStatus("WAIT_SPLIT");
InboundFeeEntity current = savedFee("CALCULATED");
current.setFeeVersion(2);
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
when(repository.findLatestFee(TENANT, INBOUND)).thenReturn(current);
assertThatThrownBy(() -> service.adjustPublished(TENANT, OPERATOR, INBOUND,
new com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest(
1, new BigDecimal("12.5"), new BigDecimal("8.00"),
BigDecimal.ZERO, "客服修正"), "stale-adjust"))
.isInstanceOf(BizException.class)
.extracting(error -> ((BizException) error).getResultCode())
.isEqualTo(ResultCode.CONFLICT);
verify(repository, never()).insertIdempotency(any());
verify(repository, never()).supersedeCurrent(anyLong(), anyLong(), any());
}
private void readyRepository() {
when(repository.findIdempotency(anyLong(), anyLong(), any(), any())).thenReturn(null);
doAnswer(invocation -> { ((InboundPricingIdempotencyEntity) invocation.getArgument(0)).setId(900L); return null; })
@@ -336,7 +421,8 @@ class FbaPricingServiceTest {
value.setChannelCode("MATSON_TRUCK"); value.setCarrierId(31L); value.setCarrierName("船司A");
value.setTaxMode("BUY_DECLARATION"); value.setPricingTaxMode("TAX_INCLUDED");
value.setDestinationType("FBA"); value.setDestinationCode("ONT8");
value.setActualGrossWeightKg(new BigDecimal(weight)); value.setActualVolumeCbm(new BigDecimal(volume));
value.setActualGrossWeightKg(new BigDecimal(weight).add(new BigDecimal("8.000")));
value.setActualNetWeightKg(new BigDecimal(weight)); value.setActualVolumeCbm(new BigDecimal(volume));
return value;
}
@@ -43,6 +43,8 @@ class FeeManagementServiceTest {
private FbaPricingRepository pricingRepository;
@MockBean
private FinanceFeeSourceOutboxService financeOutbox;
@MockBean
private WarehouseSupplierSnapshotService suppliers;
@Resource
private FeeItemService feeItems;
@Resource
@@ -96,6 +98,8 @@ class FeeManagementServiceTest {
assertThat(created.getId()).isEqualTo(11L);
assertThat(created.getAllocationFee()).isTrue();
assertThat(created.getWarehouseReceivable()).isTrue();
assertThat(created.getWarehousePayable()).isTrue();
assertThat(created.getSupplierCategory()).isNull();
assertThat(created.getFeeCode()).startsWith("MANUAL_");
assertThat(created.getTenantId()).isZero();
assertThat(created.getCreatedByName()).isEqualTo("admin");
@@ -183,13 +187,10 @@ class FeeManagementServiceTest {
inbound.setCompanyName("客户A有限公司");
when(repository.findInbound(10001L, 21L)).thenReturn(inbound);
when(repository.findBySourceFee(10001L, "FBA_PRICING", 501L)).thenReturn(null);
when(repository.findItemByCode("FBA_HEAD_FREIGHT")).thenReturn(null);
doAnswer(invocation -> {
FeeItemEntity value = invocation.getArgument(0);
assertThat(value.getTenantId()).isZero();
value.setId(11L);
return null;
}).when(repository).insertItem(any());
FeeItemEntity freightItem = item(true, false);
freightItem.setId(11L);
freightItem.setFeeCode("FBA_HEAD_FREIGHT");
when(repository.findItemByCode("FBA_HEAD_FREIGHT")).thenReturn(freightItem);
InboundFeeEntity fee = new InboundFeeEntity();
fee.setId(501L);
@@ -223,7 +224,57 @@ class FeeManagementServiceTest {
assertThat(saved.getUnitPrice()).isEqualByComparingTo("2.1198");
assertThat(saved.getReviewStatus()).isEqualTo("APPROVED");
assertThat(saved.getPublicationStatus()).isEqualTo("PUBLISHED");
assertThat(saved.getSettlementIncluded()).isTrue();
}
@Test
@DisplayName("查询费用明细会幂等补齐历史初算缺失的派生行")
void materializesMissingLatestCalculatedFreightOnList() {
InboundOrderEntity inbound = inbound();
inbound.setCustomerId(101L);
inbound.setCustomerName("客户A");
when(repository.findInbound(10001L, 21L)).thenReturn(inbound);
when(repository.findBySourceFee(10001L, "FBA_PRICING", 501L)).thenReturn(null);
when(repository.listDetails(10001L, 21L, false)).thenReturn(List.of());
FeeItemEntity freightItem = item(true, false);
freightItem.setFeeCode("FBA_HEAD_FREIGHT");
when(repository.findItemByCode("FBA_HEAD_FREIGHT")).thenReturn(freightItem);
InboundFeeEntity fee = new InboundFeeEntity();
fee.setId(501L); fee.setInboundId(21L); fee.setFeeNo("FBAF-21-2");
fee.setFeeVersion(2); fee.setFeeStatus("CALCULATED");
fee.setActualWeightKg(new BigDecimal("100.0000"));
fee.setActualVolumeCbm(new BigDecimal("1.000000"));
fee.setChargeableQuantity(new BigDecimal("167.0000"));
fee.setChargeUnit("KG"); fee.setCurrency("USD");
fee.setTotalAmount(new BigDecimal("354.00"));
fee.setCalculatedAt(LocalDateTime.of(2026, 8, 17, 12, 0));
when(pricingRepository.findLatestFee(10001L, 21L)).thenReturn(fee);
when(pricingRepository.findFee(10001L, 501L)).thenReturn(fee);
details.list(10001L, 21L, false);
verify(repository).insertDetail(any(InboundFeeDetailEntity.class));
verify(repository).listDetails(10001L, 21L, false);
}
@Test
@DisplayName("系统头程运费项目缺失时拒绝运行期补建")
void rejectsMissingSystemFreightItem() {
when(repository.findInbound(10001L, 21L)).thenReturn(inbound());
when(repository.findBySourceFee(10001L, "FBA_PRICING", 501L)).thenReturn(null);
when(repository.findItemByCode("FBA_HEAD_FREIGHT")).thenReturn(null);
InboundFeeEntity fee = new InboundFeeEntity();
fee.setId(501L);
fee.setInboundId(21L);
fee.setFeeStatus("SENT");
when(pricingRepository.findFee(10001L, 501L)).thenReturn(fee);
assertThatThrownBy(() -> details.publishFreight(
10001L, 21L, 501L, 9L, "admin", LocalDateTime.of(2026, 8, 5, 12, 0)))
.isInstanceOf(BizException.class)
.hasMessageContaining("FBA头程运费未初始化");
verify(repository, never()).insertItem(any());
}
@Test
@@ -276,26 +327,40 @@ class FeeManagementServiceTest {
@Test
@DisplayName("审核只更新待审核明细")
void approvesPendingDetail() {
when(repository.findDetail(10001L, 21L, 31L)).thenReturn(new InboundFeeDetailEntity());
InboundFeeDetailEntity pending = new InboundFeeDetailEntity();
pending.setId(31L);
pending.setTenantId(10001L);
pending.setInboundId(21L);
pending.setFeeItemId(11L);
pending.setDirection("RECEIVABLE");
pending.setSourceType("MANUAL");
pending.setSourceFeeVersion(1);
when(repository.findDetail(10001L, 21L, 31L)).thenReturn(pending);
when(repository.findItem(11L)).thenReturn(item(true, true));
when(repository.updateDetail(pending)).thenReturn(1);
when(repository.review(10001L, 21L, 31L, "APPROVED", 9L, "admin", null)).thenReturn(1);
details.approve(10001L, 21L, 31L, 9L, "admin");
verify(repository).review(10001L, 21L, 31L, "APPROVED", 9L, "admin", null);
verify(financeOutbox).publishInbound(eq(FinanceFeeSourceOutboxService.PUBLISHED),
eq(10001L), eq(21L), eq(31L), eq(1), any(LocalDateTime.class));
}
@Test
@DisplayName("审核通过且参与结算的人工费用写入Finance事件")
@DisplayName("审核通过的人工费用自动写入Finance事件")
void publishesApprovedSettlementFee() {
InboundFeeDetailEntity approved = new InboundFeeDetailEntity();
approved.setId(31L);
approved.setTenantId(10001L);
approved.setInboundId(21L);
approved.setFeeItemId(11L);
approved.setDirection("RECEIVABLE");
approved.setSourceType("MANUAL");
approved.setSourceFeeVersion(1);
approved.setSettlementIncluded(true);
when(repository.findDetail(10001L, 21L, 31L)).thenReturn(approved);
when(repository.findItem(11L)).thenReturn(item(true, true));
when(repository.updateDetail(approved)).thenReturn(1);
when(repository.review(10001L, 21L, 31L, "APPROVED", 9L, "admin", null)).thenReturn(1);
details.approve(10001L, 21L, 31L, 9L, "admin");
@@ -304,6 +369,53 @@ class FeeManagementServiceTest {
eq(10001L), eq(21L), eq(31L), eq(1), any(LocalDateTime.class));
}
@Test
@DisplayName("自动头程运费派生明细不能在费用明细中二次审核")
void rejectsReviewingDerivedFreight() {
InboundFeeDetailEntity derived = new InboundFeeDetailEntity();
derived.setSourceType("FBA_PRICING");
derived.setReviewStatus("PENDING");
when(repository.findDetail(10001L, 21L, 31L)).thenReturn(derived);
assertThatThrownBy(() -> details.approve(10001L, 21L, 31L, 9L, "admin"))
.isInstanceOf(BizException.class)
.hasMessageContaining("只读派生");
verify(repository, never()).review(10001L, 21L, 31L, "APPROVED", 9L, "admin", null);
}
@Test
@DisplayName("已审核人工费用调整保留旧版本并创建待审核新版本")
void versionsApprovedManualAdjustment() {
InboundFeeDetailEntity approved = new InboundFeeDetailEntity();
approved.setId(31L);
approved.setTenantId(10001L);
approved.setInboundId(21L);
approved.setSourceType("MANUAL");
approved.setSourceFeeVersion(1);
approved.setReviewStatus("APPROVED");
approved.setPublicationStatus("PUBLISHED");
when(repository.findDetail(10001L, 21L, 31L)).thenReturn(approved);
when(repository.findInbound(10001L, 21L)).thenReturn(inbound());
when(repository.findItem(11L)).thenReturn(item(true, true));
when(repository.supersedeApprovedManual(eq(10001L), eq(21L), eq(31L),
eq(1), eq(9L), any(LocalDateTime.class))).thenReturn(1);
doAnswer(invocation -> {
((InboundFeeDetailEntity) invocation.getArgument(0)).setId(32L);
return null;
}).when(repository).insertDetail(any());
InboundFeeDetailEntity next = details.adjust(
10001L, 21L, 31L, 1, 9L, detailRequest("RECEIVABLE"));
assertThat(next.getId()).isEqualTo(32L);
assertThat(next.getSourceFeeId()).isEqualTo(31L);
assertThat(next.getSourceFeeVersion()).isEqualTo(2);
assertThat(next.getReviewStatus()).isEqualTo("PENDING");
assertThat(next.getPublicationStatus()).isEqualTo("DRAFT");
verify(repository).insertDetail(next);
}
@Test
@DisplayName("作废已入池人工费用引用当前版本并发布VOIDED")
void voidsPublishedSettlementFee() {
@@ -314,7 +426,6 @@ class FeeManagementServiceTest {
current.setFeeItemId(11L);
current.setSourceType("MANUAL");
current.setSourceFeeVersion(1);
current.setSettlementIncluded(true);
InboundFeeDetailEntity voided = new InboundFeeDetailEntity();
voided.setId(31L);
voided.setTenantId(10001L);
@@ -322,7 +433,6 @@ class FeeManagementServiceTest {
voided.setFeeItemId(11L);
voided.setSourceType("MANUAL");
voided.setSourceFeeVersion(1);
voided.setSettlementIncluded(true);
when(repository.findDetail(10001L, 21L, 31L)).thenReturn(current, voided);
when(repository.voidApproved(10001L, 21L, 31L, 1, 9L)).thenReturn(1);
@@ -364,6 +474,7 @@ class FeeManagementServiceTest {
inbound.setDestinationName("ONT8");
inbound.setTotalPackageCount(10);
inbound.setActualGrossWeightKg(new BigDecimal("10.5000"));
inbound.setActualNetWeightKg(new BigDecimal("10.5000"));
inbound.setActualVolumeCbm(new BigDecimal("1.250000"));
inbound.setVolumeWeightKg(new BigDecimal("208.3333"));
inbound.setChargeableWeightKg(new BigDecimal("208.3333"));
@@ -3,10 +3,10 @@ package com.baiyue.firstleg.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.FinanceFeeSourceSnapshot;
import com.baiyue.firstleg.entity.FeeItemEntity;
import com.baiyue.firstleg.entity.ContainerFeeDetailEntity;
import com.baiyue.firstleg.entity.ContainerFeeSourceSnapshotEntity;
import com.baiyue.firstleg.entity.InboundFeeDetailEntity;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.repository.ContainerFeeDetailRepository;
import com.baiyue.firstleg.repository.ContainerFeeSourceSnapshotRepository;
import com.baiyue.firstleg.repository.FeeItemRepository;
import org.junit.jupiter.api.Test;
@@ -19,7 +19,7 @@ import static org.mockito.Mockito.when;
class FinanceFeeSourceSnapshotServiceTest {
private final FeeItemRepository inboundFees = mock(FeeItemRepository.class);
private final ContainerFeeDetailRepository containerFees = mock(ContainerFeeDetailRepository.class);
private final ContainerFeeSourceSnapshotRepository containerFees = mock(ContainerFeeSourceSnapshotRepository.class);
private final FinanceFeeSourceSnapshotService service =
new FinanceFeeSourceSnapshotService(inboundFees, containerFees);
@@ -37,8 +37,7 @@ class FinanceFeeSourceSnapshotServiceTest {
detail.setCurrency("CNY");
detail.setReviewStatus("APPROVED");
detail.setPublicationStatus("PUBLISHED");
detail.setSettlementIncluded(true);
when(inboundFees.findDetail(21L, 31L)).thenReturn(detail);
when(inboundFees.findSourceVersion(21L, 31L, 2)).thenReturn(detail);
InboundOrderEntity inbound = new InboundOrderEntity();
inbound.setId(21L);
inbound.setOrderId(81L);
@@ -50,53 +49,53 @@ class FinanceFeeSourceSnapshotServiceTest {
item.setFeeCode("STORAGE");
when(inboundFees.findItem(11L)).thenReturn(item);
FinanceFeeSourceSnapshot snapshot = service.inbound(21L, 31L);
FinanceFeeSourceSnapshot snapshot = service.inbound(21L, 31L, 2);
assertThat(snapshot.customerId()).isEqualTo(10001L);
assertThat(snapshot.orderId()).isEqualTo(81L);
assertThat(snapshot.feeVersion()).isEqualTo(2);
assertThat(snapshot.sourceHash()).matches("[0-9a-f]{64}");
detail.setPublicationStatus("VOIDED");
assertThat(service.inbound(21L, 31L).sourceHash()).isEqualTo(snapshot.sourceHash());
assertThat(service.inbound(21L, 31L, 2).sourceHash()).isEqualTo(snapshot.sourceHash());
}
@Test
void rejectsFeeThatDoesNotBelongToInboundPath() {
InboundFeeDetailEntity detail = new InboundFeeDetailEntity();
detail.setInboundId(22L);
when(inboundFees.findDetail(21L, 31L)).thenReturn(detail);
when(inboundFees.findSourceVersion(21L, 31L, 1)).thenReturn(detail);
assertThatThrownBy(() -> service.inbound(21L, 31L))
assertThatThrownBy(() -> service.inbound(21L, 31L, 1))
.isInstanceOf(BizException.class).hasMessageContaining("不存在");
}
@Test
void containerSnapshotPublishesPayableCounterpartyIdentity() {
ContainerFeeDetailEntity detail = new ContainerFeeDetailEntity();
detail.setId(51L);
ContainerFeeSourceSnapshotEntity detail = new ContainerFeeSourceSnapshotEntity();
detail.setSourceFeeId(51L);
detail.setTenantId(10001L);
detail.setLoadingPlanId(41L);
detail.setContainerBookingId(61L);
detail.setContainerNo("CONT-41");
detail.setFeeItemId(11L);
detail.setFeeItemCodeSnapshot("TRUCKING");
detail.setFeeItemNameSnapshot("拖车费");
detail.setFeeItemCode("TRUCKING");
detail.setFeeItemName("拖车费");
detail.setDirection("PAYABLE");
detail.setPartnerId(71L);
detail.setPartnerName("现有往来单位");
detail.setSupplierId(71L);
detail.setSupplierName("现有供应商");
detail.setSupplierCategory("TRUCKING");
detail.setBusinessDate(java.time.LocalDate.of(2026, 8, 12));
detail.setAmount(new BigDecimal("300.00"));
detail.setCurrency("CNY");
detail.setReviewStatus("APPROVED");
detail.setSourceStatus("ACTIVE");
detail.setSourceVersion(2);
detail.setSettlementIncluded(true);
when(containerFees.find(41L, 51L)).thenReturn(detail);
when(containerFees.find(41L, 51L, 2)).thenReturn(detail);
FinanceFeeSourceSnapshot snapshot = service.container(41L, 51L);
FinanceFeeSourceSnapshot snapshot = service.container(41L, 51L, 2);
assertThat(snapshot.partnerId()).isEqualTo(71L);
assertThat(snapshot.partnerName()).isEqualTo("现有往来单位");
assertThat(snapshot.supplierId()).isEqualTo(71L);
assertThat(snapshot.supplierName()).isEqualTo("现有供应商");
assertThat(snapshot.supplierCategory()).isEqualTo("TRUCKING");
assertThat(snapshot.sourceHash()).matches("[0-9a-f]{64}");
}
}
@@ -0,0 +1,61 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.exception.BizException;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.jdbc.core.JdbcTemplate;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneId;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class FirstlegDocumentNumberGeneratorTest {
private static final Clock CLOCK = Clock.fixed(
Instant.parse("2026-08-18T02:00:00Z"), ZoneId.of("Asia/Shanghai"));
@Test
void startsAsASpringBeanWhenTheTestClockConstructorAlsoExists() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) {
context.registerBean(JdbcTemplate.class, () -> jdbc);
context.register(FirstlegDocumentNumberGenerator.class);
context.refresh();
assertThat(context.getBean(FirstlegDocumentNumberGenerator.class)).isNotNull();
}
}
@Test
void formatsAllOperationalDocumentPrefixes() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
when(jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class))
.thenReturn(1L, 1L, 1L, 1L, 1L);
FirstlegDocumentNumberGenerator generator = new FirstlegDocumentNumberGenerator(jdbc, CLOCK);
assertThat(generator.next("OB")).isEqualTo("OB-260818-0001");
assertThat(generator.next("TA")).isEqualTo("TA-260818-0001");
assertThat(generator.next("CB")).isEqualTo("CB-260818-0001");
assertThat(generator.next("CAB")).isEqualTo("CAB-260818-0001");
assertThat(generator.next("LP")).isEqualTo("LP-260818-0001");
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("INSERT INTO fl_document_sequence"),
eq("OB:20260818"));
}
@Test
void rejectsUnsupportedTypeAndDailyOverflow() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
FirstlegDocumentNumberGenerator generator = new FirstlegDocumentNumberGenerator(jdbc, CLOCK);
assertThatThrownBy(() -> generator.next("MSCU")).isInstanceOf(IllegalArgumentException.class);
when(jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class)).thenReturn(10_000L);
assertThatThrownBy(() -> generator.next("CAB")).isInstanceOf(BizException.class);
}
}
@@ -0,0 +1,59 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.IamCustomerSnapshotClient;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.springframework.test.util.ReflectionTestUtils;
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.verify;
import static org.mockito.Mockito.when;
class IamCustomerSnapshotServiceTest {
@Mock IamCustomerSnapshotClient client;
private IamCustomerSnapshotService service;
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
service = new IamCustomerSnapshotService();
ReflectionTestUtils.setField(service, "client", client);
ReflectionTestUtils.setField(service, "internalToken", "internal-token");
}
@Test
void resolvesExactlyOneActiveCustomerAndUsesServerName() {
var customer = new IamCustomerSnapshotClient.Customer(
7L, "C-7", "Canonical Customer", "ACTIVE", null, null);
when(client.page("internal-token", 1, 2, null, "ACTIVE", 7L))
.thenReturn(ApiResponse.success(new PageResult<>(List.of(customer), 1, 2, 1, 1)));
var result = service.active(7L, "Canonical Customer");
assertThat(result.tenantName()).isEqualTo("Canonical Customer");
verify(client).page("internal-token", 1, 2, null, "ACTIVE", 7L);
}
@Test
void rejectsMissingInactiveOrClientSuppliedDifferentName() {
when(client.page("internal-token", 1, 2, null, "ACTIVE", 7L))
.thenReturn(ApiResponse.success(new PageResult<>(List.of(), 1, 2, 0, 0)));
assertThatThrownBy(() -> service.active(7L, null))
.isInstanceOf(BizException.class).hasMessageContaining("不存在或未启用");
var customer = new IamCustomerSnapshotClient.Customer(
7L, "C-7", "Canonical Customer", "ACTIVE", null, null);
when(client.page("internal-token", 1, 2, null, "ACTIVE", 7L))
.thenReturn(ApiResponse.success(new PageResult<>(List.of(customer), 1, 2, 1, 1)));
assertThatThrownBy(() -> service.active(7L, "Forged Customer"))
.isInstanceOf(BizException.class).hasMessageContaining("不一致");
}
}
@@ -60,8 +60,8 @@ class InboundCartonLabelServiceTest {
assertThat(batch.totalCount()).isEqualTo(5);
assertThat(batch.labels()).extracting(label -> label.boxCode())
.containsExactly("PCHK2608040174#5-1", "PCHK2608040174#5-2", "PCHK2608040174#5-3",
"PCHK2608040174#5-4", "PCHK2608040174#5-5");
.containsExactly("PCHK2608140001#5-1", "PCHK2608140001#5-2", "PCHK2608140001#5-3",
"PCHK2608140001#5-4", "PCHK2608140001#5-5");
assertThat(batch.labels()).extracting(label -> label.inboundDetailId())
.containsExactly(80001L, 80001L, 80001L, 80002L, 80002L);
assertThat(batch.labels().get(0).boxSpecification()).isEqualTo("60x40x30cm");
@@ -102,8 +102,8 @@ class InboundCartonLabelServiceTest {
.thenReturn(ApiResponse.success(List.of(cargo(80001L, 1, 2, "60", "40", "30"))));
var result = service.getOrCreate(10001L, 70001L);
assertThat(result.labels()).extracting(label -> label.boxCode())
.containsExactly("PCHK2608040174#5-1", "PCHK2608040174#5-2", "PCHK2608040174#5-3",
"PCHK2608040174#5-4", "PCHK2608040174#5-5");
.containsExactly("PCHK2608140001#5-1", "PCHK2608140001#5-2", "PCHK2608140001#5-3",
"PCHK2608140001#5-4", "PCHK2608140001#5-5");
verify(repository, never()).insert(any());
}
@@ -122,7 +122,7 @@ class InboundCartonLabelServiceTest {
assertThat(result.totalCount()).isEqualTo(2);
assertThat(result.labels()).extracting(label -> label.boxCode())
.containsExactly("PCHK2608040174#2-1", "PCHK2608040174#2-2");
.containsExactly("PCHK2608140001#2-1", "PCHK2608140001#2-2");
}
@Test
@@ -154,35 +154,35 @@ class InboundCartonLabelServiceTest {
var result = service.getOrCreate(10001L, 70001L);
assertThat(result.labels()).extracting(label -> label.boxCode())
.containsExactly("PCHK2608040174#1-1");
.containsExactly("PCHK2608140001#1-1");
verify(repository, never()).insert(any());
}
@Test
void resolvesBoxCodeInsideCustomerBoundary() {
InboundCartonLabelEntity label = labels(inbound(), 1).get(0);
when(repository.findByBoxCode(10001L, "PCHK2608040174#1-1")).thenReturn(label);
when(repository.findByBoxCode(10001L, "PCHK2608140001#1-1")).thenReturn(label);
var resolved = service.resolveByBoxCode(10001L, " PCHK2608040174#1-1 ");
var resolved = service.resolveByBoxCode(10001L, " PCHK2608140001#1-1 ");
assertThat(resolved.inboundId()).isEqualTo(70001L);
assertThat(resolved.inboundDetailId()).isEqualTo(80001L);
assertThat(resolved.inboundCode()).isEqualTo("PCHK2608040174");
assertThat(resolved.inboundCode()).isEqualTo("PCHK2608140001");
assertThat(resolved.cartonIndex()).isEqualTo(1);
assertThat(resolved.totalCartons()).isEqualTo(1);
assertThat(resolved.boxSpecification()).isEqualTo("60x40x30cm");
verify(repository).findByBoxCode(10001L, "PCHK2608040174#1-1");
verify(repository).findByBoxCode(10001L, "PCHK2608140001#1-1");
}
@Test
void exposesDetailedResolutionForFirstlegConsumers() {
InboundCartonLabelEntity label = labels(inbound(), 1).get(0);
when(repository.findByBoxCode(10001L, "PCHK2608040174#1-1")).thenReturn(label);
when(repository.findByBoxCode(10001L, "PCHK2608140001#1-1")).thenReturn(label);
var resolved = service.resolveDetailsByBoxCode(10001L, "PCHK2608040174#1-1");
var resolved = service.resolveDetailsByBoxCode(10001L, "PCHK2608140001#1-1");
assertThat(resolved.orderId()).isEqualTo(60001L);
assertThat(resolved.boxCode()).isEqualTo("PCHK2608040174#1-1");
assertThat(resolved.boxCode()).isEqualTo("PCHK2608140001#1-1");
assertThat(resolved.cargoItemNo()).isEqualTo(1);
}
@@ -190,9 +190,9 @@ class InboundCartonLabelServiceTest {
void rejectsInactiveBoxCodeDuringScanResolution() {
InboundCartonLabelEntity label = labels(inbound(), 1).get(0);
label.setBatchStatus("VOID");
when(repository.findByBoxCode(10001L, "PCHK2608040174#1-1")).thenReturn(label);
when(repository.findByBoxCode(10001L, "PCHK2608140001#1-1")).thenReturn(label);
assertThatThrownBy(() -> service.resolveByBoxCode(10001L, "PCHK2608040174#1-1"))
assertThatThrownBy(() -> service.resolveByBoxCode(10001L, "PCHK2608140001#1-1"))
.isInstanceOf(BizException.class)
.hasMessage("箱码已失效,请重新打印");
}
@@ -233,7 +233,7 @@ class InboundCartonLabelServiceTest {
@Test
void repairsMissingHistoricalInboundFromOwnedOrderSnapshot() {
var snapshot = new com.baiyue.firstleg.dto.OrderApprovedInboundCommand(
10001L, 60001L, "FL60001", "PCHK2608040174", 1L, "Customer",
10001L, 60001L, "FL60001", "PCHK2608140001", 1L, "Customer",
41L, "Warehouse", null, null, null, "EXPORT_SEA", null, null,
null, null, null, null, null, false, "FBA", null, null, null,
null, null, null, 2);
@@ -251,7 +251,7 @@ class InboundCartonLabelServiceTest {
inbound.setId(70001L);
inbound.setTenantId(10001L);
inbound.setOrderId(60001L);
inbound.setInboundCode("PCHK2608040174");
inbound.setInboundCode("PCHK2608140001");
inbound.setProcessStatus("WAIT_MEASURE");
inbound.setWarehouseName("MCC1");
inbound.setChannelName("洛杉矶普船海卡");
@@ -1,150 +0,0 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.entity.InboundFeeEntity;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.repository.InboundWorkflowRepository;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@DisplayName("发送费用并入库状态编排")
@SpringJUnitConfig(InboundCompletionOrchestrator.class)
class InboundCompletionOrchestratorTest {
@MockBean private FinanceFeeSourceOutboxService financeOutbox;
@MockBean
private InboundWorkflowRepository repository;
@MockBean
private InboundIdempotencyService idempotency;
@MockBean
private InboundFeeDetailService feeDetails;
@Resource
private InboundCompletionOrchestrator service;
@Test
@DisplayName("审核通过且费用和库位引用已准备时进入INBOUNDING")
void shouldBeginInboundAfterAllGuardsPass() {
Fixture fixture = fixture("APPROVED", false);
when(fixture.repository.beginInbound(10001L, 10L, 9L, 4)).thenReturn(1);
fixture.service.beginSendFeeAndInbound(10001L, 9L, "操作员", 10L, 30L, 4, "send-1");
verify(fixture.repository).beginInbound(10001L, 10L, 9L, 4);
verify(fixture.repository).insertHistory(any());
}
@Test
@DisplayName("测量驳回且无异常放行记录时禁止发送费用")
void shouldBlockRejectedMeasurementWithoutRelease() {
Fixture fixture = fixture("REJECTED", true);
when(fixture.repository.hasExceptionRelease(10001L, 10L)).thenReturn(false);
assertThatThrownBy(() -> fixture.service.beginSendFeeAndInbound(
10001L, 9L, "操作员", 10L, 30L, 4, "send-1"))
.isInstanceOf(BizException.class)
.extracting(error -> ((BizException) error).getResultCode())
.isEqualTo(ResultCode.CONFLICT);
verify(fixture.repository, never()).beginInbound(anyLong(), anyLong(), anyLong(), anyInt());
}
@Test
@DisplayName("异常放行标记必须与费用快照一致")
void shouldRequireFeeSnapshotReleaseFlag() {
Fixture fixture = fixture("REJECTED", false);
when(fixture.repository.hasExceptionRelease(10001L, 10L)).thenReturn(true);
assertThatThrownBy(() -> fixture.service.beginSendFeeAndInbound(
10001L, 9L, "操作员", 10L, 30L, 4, "send-1"))
.isInstanceOf(BizException.class)
.hasMessageContaining("异常放行标记");
}
@Test
@DisplayName("完成阶段原子发送费用、完成入库并写待投递事件")
void shouldCompleteInboundAndWriteOutbox() {
Fixture fixture = fixture("APPROVED", false);
fixture.order.setProcessStatus("INBOUNDING");
when(fixture.repository.completeInbound(10001L, 10L, 9L, 5)).thenReturn(1);
when(fixture.repository.markFeeSent(10001L, 10L, 30L, 9L)).thenReturn(1);
com.baiyue.firstleg.entity.InboundFeeDetailEntity published =
new com.baiyue.firstleg.entity.InboundFeeDetailEntity();
published.setSourceFeeVersion(1);
when(feeDetails.publishFreight(eq(10001L), eq(10L), eq(30L), eq(9L), eq("操作员"),
org.mockito.ArgumentMatchers.any(java.time.LocalDateTime.class))).thenReturn(published);
fixture.service.completeSendFeeAndInbound(
10001L, 9L, "操作员", 10L, 30L, 5, "complete-1");
verify(fixture.repository).completeInbound(10001L, 10L, 9L, 5);
verify(fixture.repository).markFeeSent(10001L, 10L, 30L, 9L);
verify(feeDetails).publishFreight(eq(10001L), eq(10L), eq(30L), eq(9L), eq("操作员"),
org.mockito.ArgumentMatchers.any(java.time.LocalDateTime.class));
verify(financeOutbox).publishInbound(eq(FinanceFeeSourceOutboxService.PUBLISHED),
eq(10001L), eq(10L), eq(30L), eq(1),
org.mockito.ArgumentMatchers.any(java.time.LocalDateTime.class));
}
@Test
@DisplayName("条件更新失败时报告并发冲突且不发送费用")
void shouldRejectConcurrentCompletion() {
Fixture fixture = fixture("APPROVED", false);
fixture.order.setProcessStatus("INBOUNDING");
when(fixture.repository.completeInbound(10001L, 10L, 9L, 5)).thenReturn(0);
assertThatThrownBy(() -> fixture.service.completeSendFeeAndInbound(
10001L, 9L, "操作员", 10L, 30L, 5, "complete-1"))
.isInstanceOf(BizException.class)
.extracting(error -> ((BizException) error).getResultCode())
.isEqualTo(ResultCode.CONFLICT);
verify(fixture.repository, never()).markFeeSent(anyLong(), anyLong(), anyLong(), anyLong());
verify(financeOutbox, never()).publishInbound(any(), anyLong(), anyLong(), anyLong(),
org.mockito.ArgumentMatchers.anyInt(),
org.mockito.ArgumentMatchers.any(java.time.LocalDateTime.class));
}
@Test
@DisplayName("只有仓库预占已补偿后才通过显式方法回退")
void shouldRollbackOnlyInboundingState() {
Fixture fixture = fixture("APPROVED", false);
when(fixture.repository.rollbackInbound(10001L, 10L, 9L, 5)).thenReturn(1);
fixture.service.rollbackSendFeeAndInbound(
10001L, 9L, "操作员", 10L, 5, "仓库确认失败且已释放预占", "rollback-1");
verify(fixture.repository).rollbackInbound(10001L, 10L, 9L, 5);
verify(fixture.repository).insertHistory(any());
}
private Fixture fixture(String reviewStatus, boolean feeRelease) {
when(idempotency.begin(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString()))
.thenReturn(new InboundIdempotencyService.Context(10001L, 1L, false));
InboundOrderEntity order = new InboundOrderEntity();
order.setProcessStatus("FEE_CONFIRMING");
order.setFeeStatus("CONFIRMED");
order.setMeasureReviewStatus(reviewStatus);
InboundFeeEntity fee = new InboundFeeEntity();
fee.setExceptionRelease(feeRelease);
when(repository.findOwned(10001L, 10L)).thenReturn(order);
when(repository.findConfirmedFee(10001L, 10L, 30L)).thenReturn(fee);
when(repository.countPreparedAllocations(10001L, 10L)).thenReturn(1L);
return new Fixture(service, repository, order);
}
private record Fixture(InboundCompletionOrchestrator service,
InboundWorkflowRepository repository,
InboundOrderEntity order) {
}
}
@@ -56,7 +56,7 @@ class InboundMeasurementOpenApiServiceTest {
{"eventNo":"M202608010001","warehouseCode":"CN-YW-01","inboundCode":"RK202608010001",
"externalDetailNo":"BOX-001","inboundDetailId":"80001","deviceCode":"DEVICE-01",
"measuredAt":"2026-08-01T10:30:00+08:00","lengthCm":50.20,"widthCm":40.10,
"heightCm":30.00,"packageCount":5,"unitWeightKg":12.5,
"heightCm":30.00,"packageCount":5,
"boxSpecification":"50.2x40.1x30cm"}
""";
private static final byte[] IMAGE = "image-content".getBytes(java.nio.charset.StandardCharsets.UTF_8);
@@ -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;
@@ -25,8 +26,11 @@ import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import org.springframework.test.context.TestPropertySource;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import feign.FeignException;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.Assertions.tuple;
@@ -49,8 +53,19 @@ 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(repository.updateInboundWeightSummary(any())).thenReturn(true);
when(warehousePallets.palletSnapshots("internal-token", 100L)).thenReturn(
ApiResponse.success(List.of(palletSnapshot())));
when(orderCargoClient.measurementDeclaration("internal-token", 1L, 80L)).thenReturn(
ApiResponse.success(new OrderCargoClient.MeasurementDeclarationSnapshot(
"REF-50", new BigDecimal("2000"), new BigDecimal("20"), "123123")));
}
@Test
void customerCannotReadAnotherCustomersMeasurement() {
assertThatThrownBy(() -> service.detail(2L, 10L))
@@ -85,6 +100,32 @@ class InboundMeasurementServiceTest {
assertThat(revision.getValue().getAfterWeightValue()).isEqualByComparingTo("13.200");
}
@Test
void correctingConfirmedWeightImmediatelyRemovesItFromInboundTotal() {
InboundOrderEntity inbound = inbound("MEASURING");
inbound.setActualGrossWeightKg(new BigDecimal("12.500"));
inbound.setActualNetWeightKg(new BigDecimal("12.500"));
InboundWeightResultEntity weight = weight(20L, "12.500", "KG", "CONFIRMED", 4);
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);
when(repository.listWeights(1L, 10L)).thenReturn(List.of(weight));
when(repository.updateInboundWeightSummary(inbound)).thenReturn(true);
service.correctWeightResult(1L, 10L, 20L, 9L, "操作员",
new InboundMeasurementRequests.CorrectWeightResultRequest(
4, new BigDecimal("13.200"), "KG", "秤面显示修正"));
assertThat(weight.getRecognitionStatus()).isEqualTo("PENDING_CONFIRM");
assertThat(weight.getGrossWeightKg()).isNull();
assertThat(weight.getNetWeightKg()).isNull();
assertThat(inbound.getActualGrossWeightKg()).isNull();
assertThat(inbound.getActualNetWeightKg()).isNull();
verify(repository).updateInboundWeightSummary(inbound);
}
@Test
void detailReturnsStandardPalletRecommendationWithoutAllocatingPallet() {
InboundOrderEntity inbound = inbound("MEASURE_PENDING");
@@ -101,9 +142,75 @@ class InboundMeasurementServiceTest {
assertThat(result.recommendedPalletType()).isEqualTo("STANDARD");
assertThat(result.inboundVersion()).isEqualTo(7);
assertThat(result.version()).isEqualTo(4);
assertThat(result.originalDocumentNo()).isEqualTo("REF-50");
assertThat(result.originalGrossWeightKg()).isEqualByComparingTo("2000");
assertThat(result.originalVolumeCbm()).isEqualByComparingTo("20");
assertThat(result.actualGoodsName()).isEqualTo("123123");
verify(repository, never()).updateInbound(any());
}
@Test
void detailReportsOrderContractNotReadyInsteadOfExposingDownstreamBadRequest() {
InboundOrderEntity inbound = inbound("MEASURE_PENDING");
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
measurement.setId(30L);
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
when(repository.findLatestMeasurement(1L, 10L)).thenReturn(measurement);
FeignException downstreamBadRequest = mock(FeignException.class);
when(downstreamBadRequest.status()).thenReturn(400);
when(orderCargoClient.measurementDeclaration("internal-token", 1L, 80L))
.thenThrow(downstreamBadRequest);
assertThatThrownBy(() -> service.detail(1L, 10L))
.isInstanceOfSatisfying(BizException.class, exception -> {
assertThat(exception.getResultCode().code()).isEqualTo("S0503");
assertThat(exception).hasMessageContaining("申报快照契约未就绪")
.hasMessageContaining("order-service")
.hasMessageContaining("firstleg-service");
});
}
@Test
void detailKeepsDimensionAndWeightEvidenceOnTheirStableBusinessRecords() {
InboundOrderEntity inbound = inbound("MEASURE_PENDING");
inbound.setActualGrossWeightKg(new BigDecimal("30.000"));
inbound.setActualNetWeightKg(new BigDecimal("11.500"));
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
measurement.setId(30L);
measurement.setVersion(4);
InboundMeasurementItemEntity item = new InboundMeasurementItemEntity();
item.setId(40L);
item.setTenantId(1L);
item.setMeasurementId(30L);
item.setSourceImageFileId(140L);
InboundWeightResultEntity weight = weight(50L, "30.000", "KG", "CONFIRMED", 2);
weight.setGrossWeightKg(new BigDecimal("30.000"));
weight.setPalletTareWeightKg(new BigDecimal("18.500"));
weight.setNetWeightKg(new BigDecimal("11.500"));
weight.setConfirmedAt(LocalDateTime.of(2026, 8, 17, 10, 30));
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
when(repository.findLatestMeasurement(1L, 10L)).thenReturn(measurement);
when(repository.listItems(1L, 30L)).thenReturn(List.of(item));
when(repository.listWeights(1L, 10L)).thenReturn(List.of(weight));
InboundMeasurementResponses.Detail result = service.detail(1L, 10L);
assertThat(result.actualGrossWeightKg()).isEqualByComparingTo("30.000");
assertThat(result.actualNetWeightKg()).isEqualByComparingTo("11.500");
assertThat(result.items()).singleElement().satisfies(dimension -> {
assertThat(dimension.id()).isEqualTo(40L);
assertThat(dimension.intelligentImageFileId()).isEqualTo(140L);
});
assertThat(result.weightResults()).singleElement().satisfies(confirmedWeight -> {
assertThat(confirmedWeight.id()).isEqualTo(50L);
assertThat(confirmedWeight.fileId()).isEqualTo(150L);
assertThat(confirmedWeight.recognitionStatus()).isEqualTo("CONFIRMED");
assertThat(confirmedWeight.grossWeightKg()).isEqualByComparingTo("30.000");
assertThat(confirmedWeight.netWeightKg()).isEqualByComparingTo("11.500");
assertThat(confirmedWeight.confirmedAt()).isNotNull();
});
}
@Test
void creatingWeightResultNormalizesProviderConfidenceAtBusinessBoundary() {
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
@@ -112,22 +219,56 @@ 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.setGrossWeightKg(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);
@@ -138,12 +279,85 @@ 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.getActualNetWeightKg()).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("30.000");
assertThat(inbound.getActualNetWeightKg()).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 confirmingFifthPalletSeparatelyAggregatesGrossAndCargoNetWeight() {
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
InboundWeightResultEntity current = weight(20L, "357.500", "KG", "PENDING_CONFIRM", 0);
current.setPalletTareWeightKg(new BigDecimal("8.000"));
List<InboundWeightResultEntity> weights = new java.util.ArrayList<>();
weights.add(current);
for (long id = 21L; id <= 24L; id++) {
InboundWeightResultEntity confirmed = weight(id, "357.500", "KG", "CONFIRMED", 1);
confirmed.setGrossWeightKg(new BigDecimal("357.500"));
confirmed.setNetWeightKg(new BigDecimal("349.500"));
weights.add(confirmed);
}
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
when(repository.findWeight(1L, 10L, 20L)).thenReturn(current);
when(repository.updateWeight(current)).thenReturn(true);
when(repository.listWeights(1L, 10L)).thenReturn(weights);
service.confirmWeightResult(1L, 10L, 20L, 9L, "操作员",
new InboundMeasurementRequests.ConfirmWeightResultRequest(0, null, null, null));
assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("1787.500");
assertThat(inbound.getActualNetWeightKg()).isEqualByComparingTo("1747.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",
@@ -168,7 +382,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);
}
@@ -186,7 +401,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");
}
@@ -261,7 +476,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");
@@ -322,7 +538,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);
@@ -341,7 +558,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);
@@ -390,7 +608,7 @@ class InboundMeasurementServiceTest {
verify(repository).deleteWeight(1L, 10L, 20L, 1);
verify(imageClient).delete("internal-token", 9L, 1L, 10L, 120L);
verify(repository, never()).updateInboundWeightSummary(any());
verify(repository).updateInboundWeightSummary(inbound);
}
@Test
@@ -398,7 +616,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);
@@ -410,6 +629,7 @@ class InboundMeasurementServiceTest {
service.deleteWeightResult(1L, 10L, 20L, 2, 9L);
assertThat(inbound.getActualGrossWeightKg()).isNull();
assertThat(inbound.getActualNetWeightKg()).isNull();
verify(repository).updateInboundWeightSummary(inbound);
}
@@ -418,9 +638,11 @@ 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.setGrossWeightKg(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.setGrossWeightKg(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);
@@ -432,6 +654,7 @@ class InboundMeasurementServiceTest {
service.deleteWeightResult(1L, 10L, 20L, 2, 9L);
assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("5.000");
assertThat(inbound.getActualNetWeightKg()).isEqualByComparingTo("5.000");
verify(repository).updateInboundWeightSummary(inbound);
}
@@ -466,7 +689,8 @@ class InboundMeasurementServiceTest {
item.setPackageCount(3);
item.setSourceImageFileId(500L);
InboundWeightResultEntity weight = weight(20L, "20.000", "KG", "CONFIRMED", 1);
weight.setFinalWeightKg(new BigDecimal("20.000"));
weight.setGrossWeightKg(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));
@@ -482,6 +706,8 @@ class InboundMeasurementServiceTest {
assertThat(inbound.getMeasureReviewStatus()).isEqualTo("PENDING");
assertThat(measurement.getStatus()).isEqualTo("SUBMITTED");
assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("20.000");
assertThat(inbound.getActualNetWeightKg()).isEqualByComparingTo("20.000");
assertThat(inbound.getPalletCount()).isEqualTo(1);
assertThat(inbound.getActualVolumeCbm()).isEqualByComparingTo("1.2345");
}
@@ -554,22 +780,48 @@ class InboundMeasurementServiceTest {
when(batchService.saveBatch(any())).thenReturn(saved);
InboundMeasurementResponses.DraftSaved result = service.saveDraft(1L, 10L, 9L,
new InboundMeasurementRequests.SaveDraftRequest(null, 0, null, null, null, null,
new InboundMeasurementRequests.SaveDraftRequest(null, 0, null,
List.of(new InboundMeasurementRequests.ItemRequest(80001L, "001", "A", 2,
new BigDecimal("40"), new BigDecimal("30"), new BigDecimal("20"),
new BigDecimal("3.250"), null, null, null, null, null, null))));
null, null, null, null, null, null))));
assertThat(result.measurementId()).isEqualTo(30L);
ArgumentCaptor<MeasurementBatchCommand> command = ArgumentCaptor.forClass(MeasurementBatchCommand.class);
verify(batchService).saveBatch(command.capture());
assertThat(command.getValue().items().get(0).inboundDetailId()).isEqualTo(80001L);
assertThat(command.getValue().originalDocumentNo()).isEqualTo("REF-50");
assertThat(command.getValue().originalGrossWeightKg()).isEqualByComparingTo("2000");
assertThat(command.getValue().originalVolumeCbm()).isEqualByComparingTo("20");
assertThat(command.getValue().actualGoodsName()).isEqualTo("123123");
verify(orderCargoClient).detail("internal-token", 1L, 80L, 80001L);
}
@Test
void savingDraftRejectsOverlongDefaultGoodsNameBeforeDatabaseWrite() {
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
InboundMeasurementRequests.ItemRequest item = new InboundMeasurementRequests.ItemRequest(
80001L, "001", "A", 2, new BigDecimal("40"), new BigDecimal("30"),
new BigDecimal("20"), null, null, null, null, null, null);
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
when(orderCargoClient.detail("internal-token", 1L, 80L, 80001L)).thenReturn(
ApiResponse.success(new OrderCargoClient.CargoBinding(
80001L, 80L, 1, "Test", 2, new BigDecimal("6.5"), new BigDecimal("0.1"))));
when(orderCargoClient.measurementDeclaration("internal-token", 1L, 80L)).thenReturn(
ApiResponse.success(new OrderCargoClient.MeasurementDeclarationSnapshot(
"REF", new BigDecimal("6.5"), new BigDecimal("0.1"), "A".repeat(256))));
assertThatThrownBy(() -> service.saveDraft(1L, 10L, 9L,
new InboundMeasurementRequests.SaveDraftRequest(null, 0, null, List.of(item))))
.isInstanceOf(BizException.class).hasMessageContaining("超过255字符");
verify(batchService, never()).saveBatch(any());
}
private InboundOrderEntity inbound(String status) {
InboundOrderEntity inbound = new InboundOrderEntity();
inbound.setId(10L);
inbound.setTenantId(1L);
inbound.setOrderId(80L);
inbound.setWarehouseId(100L);
inbound.setProcessStatus(status);
inbound.setTotalPackageCount(3);
inbound.setVersion(0);
@@ -583,10 +835,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"));
}
}
@@ -11,6 +11,7 @@ import com.baiyue.firstleg.entity.InboundStatusHistoryEntity;
import com.baiyue.firstleg.entity.FulfillmentTaskEntity;
import com.baiyue.firstleg.mapper.InboundOrderMapper;
import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
import com.baiyue.firstleg.mapper.InboundPickupSnapshotMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -42,18 +43,20 @@ class InboundOrderCreationServiceTest {
@Mock
private InboundStatusHistoryMapper histories;
@Mock
private InboundPickupSnapshotMapper pickupSnapshots;
@Mock
private FulfillmentTaskProvisioningService fulfillmentTasks;
@InjectMocks
private InboundOrderCreationService service;
@Test
void shouldUseSubmittedInboundCodeAndKeepReplayIdempotentWithoutCarrier() {
OrderApprovedInboundCommand command = approvedCommand("RK-CUSTOM-77");
OrderApprovedInboundCommand command = approvedCommand("PCHK2608140001");
InboundOrderEntity existing = new InboundOrderEntity();
existing.setId(900L);
existing.setTenantId(10001L);
existing.setOrderId(77L);
existing.setInboundCode("RK-CUSTOM-77");
existing.setInboundCode("PCHK2608140001");
existing.setTaskId(700L);
when(orders.selectOne(any(Wrapper.class))).thenReturn(null, existing);
when(fulfillmentTasks.provision(any(), any())).thenReturn(task(700L));
@@ -64,7 +67,7 @@ class InboundOrderCreationServiceTest {
ArgumentCaptor<InboundOrderEntity> inboundCaptor = ArgumentCaptor.forClass(InboundOrderEntity.class);
verify(orders, times(1)).insert(inboundCaptor.capture());
verify(histories, times(1)).insert(any(InboundStatusHistoryEntity.class));
assertThat(inboundCaptor.getValue().getInboundCode()).isEqualTo("RK-CUSTOM-77");
assertThat(inboundCaptor.getValue().getInboundCode()).isEqualTo("PCHK2608140001");
assertThat(inboundCaptor.getValue().getTaskId()).isEqualTo(700L);
assertThat(inboundCaptor.getValue().getCarrierId()).isNull();
assertThat(inboundCaptor.getValue().getTaxMode()).isEqualTo("BUY_DECLARATION");
@@ -88,25 +91,25 @@ class InboundOrderCreationServiceTest {
@Test
void shouldConvergeOnConcurrentDuplicateInsertWinner() {
InboundOrderEntity winner = inbound(901L, "RK-CUSTOM-77", "UNPRICED", null, null);
InboundOrderEntity winner = inbound(901L, "PCHK2608140001", "UNPRICED", null, null);
winner.setTaskId(700L);
when(orders.selectOne(any(Wrapper.class))).thenReturn(null, winner);
when(fulfillmentTasks.provision(any(), any())).thenReturn(task(700L));
doThrow(new org.springframework.dao.DuplicateKeyException("concurrent insert"))
.when(orders).insert(any(InboundOrderEntity.class));
assertThat(service.createFromApprovedOrder(approvedCommand("RK-CUSTOM-77"))).isEqualTo(901L);
assertThat(service.createFromApprovedOrder(approvedCommand("PCHK2608140001"))).isEqualTo(901L);
verify(histories, never()).insert(any(InboundStatusHistoryEntity.class));
}
@Test
void shouldBackfillTaskLinkForInboundCreatedBeforeTaskProvisioning() {
InboundOrderEntity existing = inbound(900L, "RK-CUSTOM-77", "UNPRICED", null, null);
InboundOrderEntity existing = inbound(900L, "PCHK2608140001", "UNPRICED", null, null);
when(orders.selectOne(any(Wrapper.class))).thenReturn(existing);
when(orders.update(any(), any(Wrapper.class))).thenReturn(1);
when(fulfillmentTasks.provision(any(), any())).thenReturn(task(700L));
assertThat(service.createFromApprovedOrder(approvedCommand("RK-CUSTOM-77"))).isEqualTo(900L);
assertThat(service.createFromApprovedOrder(approvedCommand("PCHK2608140001"))).isEqualTo(900L);
assertThat(existing.getTaskId()).isEqualTo(700L);
verify(orders).update(any(), any(Wrapper.class));
@@ -115,14 +118,14 @@ class InboundOrderCreationServiceTest {
@Test
void shouldPrepareDraftIdempotentlyAndUpgradeSameInboundOnApproval() {
InboundOrderEntity prepared = inbound(900L, "RK-CUSTOM-77", "UNPRICED", null, null);
InboundOrderEntity prepared = inbound(900L, "PCHK2608140001", "UNPRICED", null, null);
prepared.setProcessStatus("PREPARING");
when(orders.selectOne(any(Wrapper.class))).thenReturn(null, prepared, prepared);
when(orders.update(any(), any(Wrapper.class))).thenReturn(1);
when(fulfillmentTasks.provision(any(), any())).thenReturn(task(700L));
long preparedId = service.prepareFromDraft(approvedCommand("RK-CUSTOM-77"));
long approvedId = service.createFromApprovedOrder(approvedCommand("RK-CUSTOM-77"));
long preparedId = service.prepareFromDraft(approvedCommand("PCHK2608140001"));
long approvedId = service.createFromApprovedOrder(approvedCommand("PCHK2608140001"));
assertThat(preparedId).isNotNull();
assertThat(approvedId).isEqualTo(900L);
@@ -0,0 +1,46 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class InitialInboundPricingServiceTest {
private final FbaPricingService pricing = mock(FbaPricingService.class);
private final InitialInboundPricingService service = new InitialInboundPricingService();
InitialInboundPricingServiceTest() {
ReflectionTestUtils.setField(service, "pricing", pricing);
}
@Test
void returnsCalculatedDraftWithoutChangingWaitSplit() {
FeeSnapshotResponse fee = mock(FeeSnapshotResponse.class);
when(fee.feeStatus()).thenReturn("CALCULATED");
when(pricing.calculate(10001L, 9L, 88L, null, "initial-88-30-5")).thenReturn(fee);
var result = service.tryCalculate(10001L, 9L, 88L, 30L, 5);
assertThat(result.processStatus()).isEqualTo("WAIT_SPLIT");
assertThat(result.feeStatus()).isEqualTo("CALCULATED");
assertThat(result.initialPricingSucceeded()).isTrue();
assertThat(result.initialFee()).isSameAs(fee);
}
@Test
void keepsWaitSplitWhenNoPriceMatches() {
when(pricing.calculate(10001L, 9L, 88L, null, "initial-88-30-5"))
.thenThrow(new BizException("未匹配有效价格"));
var result = service.tryCalculate(10001L, 9L, 88L, 30L, 5);
assertThat(result.processStatus()).isEqualTo("WAIT_SPLIT");
assertThat(result.feeStatus()).isEqualTo("UNPRICED");
assertThat(result.initialPricingSucceeded()).isFalse();
assertThat(result.initialPricingMessage()).contains("未匹配有效价格");
}
}
@@ -11,6 +11,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.apache.ibatis.annotations.Select;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
@@ -46,7 +47,16 @@ class LoadingCompletedOutboxServiceTest {
assertThat(first.payload().get("orders")).hasSize(2);
assertThat(first.payload().at("/orders/0/sourceInboundId").asText()).isEqualTo("100");
assertThat(first.payload().at("/orders/0/items/0/sourceInboundDetailId").asText()).isEqualTo("101");
assertThat(rows.get(0).getEventKey()).isEqualTo("FIRSTLEG_LOADING_COMPLETED:90:11:1");
assertThat(rows.get(0).getEventKey()).isEqualTo("FIRSTLEG_LOADING_COMPLETED:90:11:2");
assertThat(first.eventVersion()).isEqualTo(2);
}
@Test void snapshotOnlyPublishesFinalizedLoadedInbounds() throws Exception {
String sql = String.join(" ", LoadingCompletedEventMapper.class.getMethod("rows", long.class)
.getAnnotation(Select.class).value()).replaceAll("\\s+", " ");
assertThat(sql).contains("p.plan_status = 'COMPLETED'")
.contains("o.process_status = 'LOADED'");
}
@Test void publishesDropshipPalletSkuAndCartonSnapshots() throws Exception {
@@ -6,6 +6,9 @@ import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedQuery;
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery;
import com.baiyue.firstleg.dto.LoadingListDtos.TransportUpdateRequest;
import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmPreloadRequest;
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadConfirmationResponse;
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadReadiness;
import com.baiyue.firstleg.dto.LoadingListDtos.DocumentAttachRequest;
import com.baiyue.firstleg.dto.LoadingListDtos.DocumentCompleteRequest;
import com.baiyue.firstleg.client.LoadingDocumentFileClient;
@@ -13,6 +16,7 @@ import com.baiyue.firstleg.entity.ContainerDocumentEntity;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.firstleg.mapper.LoadingListQueryMapper.AggregateRow;
import com.baiyue.firstleg.mapper.LoadingListQueryMapper.HistoryRow;
import com.baiyue.firstleg.loading.bridge.dto.LoadingModelBridgeDtos.Link;
import com.baiyue.firstleg.repository.LoadingListRepository;
import com.baiyue.firstleg.repository.LoadingPickRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -31,6 +35,7 @@ import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
@@ -45,6 +50,8 @@ class LoadingListServiceTest {
@MockBean
private LoadingPickRepository pickRepository;
@MockBean
private LoadingPickService loadingPickService;
@MockBean
private LoadingDocumentFileClient documentFiles;
@MockBean
private LoadingCompletedOutboxService loadingCompletedOutboxService;
@@ -61,7 +68,7 @@ class LoadingListServiceTest {
@Test
void shouldAcceptEditingStatusFromPreloadList() {
PreloadQuery query = new PreloadQuery(null, "EDITING", 100L, null, null, 1, 20);
PreloadQuery query = new PreloadQuery(null, "EDITING", 200L, 100L, null, null, 1, 20);
service.preloadPage(query);
@@ -87,11 +94,43 @@ class LoadingListServiceTest {
new TransportUpdateRequest(" CSLU1234567 ", "023E", 3, "补录箱号"));
verify(repository).updateTransport(10L, 3, "CSLU1234567", "023E", 9L, "operator");
verify(loadingPickService).beginIfReady(30L, 9L, "operator");
verify(repository).appendHistory(eq(10L), anyString(), eq("UPDATE_PRELOAD_TRANSPORT"),
eq("LOADING"), eq("LOADING"), anyString(), anyString(), eq(9L),
eq("operator"), eq("补录箱号"));
}
@Test
void pushedEditingPreloadCanReceiveContainerNumber() {
AggregateRow before = row("EDITING", 3);
before.setPreloadPushedAt(java.time.LocalDateTime.now());
AggregateRow after = row("EDITING", 4);
after.setPreloadPushedAt(before.getPreloadPushedAt());
after.setContainerNo("CSLU1234567");
when(repository.aggregate(10L)).thenReturn(before, after);
when(repository.updateTransport(10L, 3, "CSLU1234567", null, 9L, "operator")).thenReturn(1);
when(repository.preloadDetail(after)).thenReturn(
new com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail(null, List.of(), List.of()));
service.updateTransport(10L, 100L, 9L, "operator", "editing-key",
new TransportUpdateRequest("CSLU1234567", null, 3, "补录柜号"));
verify(loadingPickService).beginIfReady(30L, 9L, "operator");
}
@Test
void editingPlanCannotReceiveTransportBeforePush() {
when(repository.aggregate(10L)).thenReturn(row("EDITING", 3));
assertThatThrownBy(() -> service.updateTransport(10L, 100L, 9L, "operator", "editing-key",
new TransportUpdateRequest("CSLU1234567", null, 3, "补录柜号")))
.isInstanceOf(BizException.class).hasMessageContaining("已推送");
verify(repository, never()).updateTransport(eq(10L), eq(3), anyString(),
org.mockito.ArgumentMatchers.isNull(), eq(9L), anyString());
verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString());
}
@Test
void shouldRejectTransportUpdateAfterConfirmation() {
when(repository.history(anyString())).thenReturn(null);
@@ -137,6 +176,10 @@ class LoadingListServiceTest {
verify(repository).appendHistory(eq(10L), eq("PDA-COMPLETE-10-21"),
eq("PDA_COMPLETE_LOADING"), eq("LOADING"), eq("COMPLETED"), anyString(), anyString(),
eq(9L), eq("operator"), eq("PDA已人工确认装柜完成"));
verify(loadingCompletedOutboxService, never()).publish(10L);
service.publishCompletedEvent(10L);
verify(loadingCompletedOutboxService).publish(10L);
}
@@ -184,14 +227,14 @@ class LoadingListServiceTest {
@Test
void confirmedPageAlwaysUsesRepositoryConfirmedProjection() {
ConfirmedQuery query = new ConfirmedQuery(" plan ", 100L, null, null, 1, 20);
ConfirmedQuery query = new ConfirmedQuery(" plan ", 200L, 100L, null, null, 1, 20);
when(repository.confirmedPage(org.mockito.ArgumentMatchers.any())).thenReturn(
new PageResult<>(List.of(), 1, 20, 0, 0));
service.confirmedPage(query);
verify(repository).confirmedPage(org.mockito.ArgumentMatchers.argThat(value ->
"plan".equals(value.keyword())));
"plan".equals(value.keyword()) && Long.valueOf(200L).equals(value.customerId())));
}
@Test
@@ -201,8 +244,8 @@ class LoadingListServiceTest {
when(repository.confirmedPage(org.mockito.ArgumentMatchers.any())).thenReturn(
new PageResult<>(List.of(), 1, 20, 0, 0));
service.preloadPage(new PreloadQuery(null, "LOADING", null, null, null, 1, 20));
service.confirmedPage(new ConfirmedQuery(null, null, null, null, 1, 20));
service.preloadPage(new PreloadQuery(null, "LOADING", null, null, null, null, 1, 20));
service.confirmedPage(new ConfirmedQuery(null, null, null, null, null, 1, 20));
verify(repository).preloadPage(org.mockito.ArgumentMatchers.argThat(value -> value.warehouseId() == null));
verify(repository).confirmedPage(org.mockito.ArgumentMatchers.argThat(value -> value.warehouseId() == null));
@@ -456,10 +499,158 @@ class LoadingListServiceTest {
verify(repository, never()).aggregateByBooking(30L);
}
@Test
void confirmsEveryLinkedPreloadAndCreatesSinglePdaTaskAtomically() {
AggregateRow before = row("EDITING", 3);
before.setPreloadPushedAt(java.time.LocalDateTime.now());
AggregateRow after = row("EDITING", 4);
after.setPreloadPushedAt(before.getPreloadPushedAt());
List<Link> links = List.of(
new Link(1L, 101L, 201L, 301L, "PENDING", 2, 10L, 30L,
"MSCU-001", "EDITING", 3, 4, null),
new Link(2L, 102L, 202L, 302L, "CONFIRMING", 5, 10L, 30L,
"MSCU-001", "EDITING", 3, 6, null));
PreloadReadiness readiness = readiness("CONFIRMED", false, true, 80L);
var detail = org.mockito.Mockito.mock(
com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail.class);
var summary = org.mockito.Mockito.mock(
com.baiyue.firstleg.dto.LoadingListDtos.PreloadSummary.class);
when(detail.summary()).thenReturn(summary);
when(summary.readiness()).thenReturn(readiness);
when(repository.history(anyString())).thenReturn(null);
when(repository.aggregate(10L)).thenReturn(before, after);
when(loadingPickService.requirePreloadConfirmationReady(10L)).thenReturn(links);
when(repository.touchForPreloadConfirmation(10L, 3, 9L, "operator")).thenReturn(1);
when(repository.confirmLinkedPreload(301L, "PENDING", 2, 9L, "operator")).thenReturn(1);
when(repository.confirmLinkedPreload(302L, "CONFIRMING", 5, 9L, "operator")).thenReturn(1);
when(repository.preloadDetail(after)).thenReturn(detail);
PreloadConfirmationResponse response = service.confirmPreload(
10L, 9L, "operator", "confirm-key", new ConfirmPreloadRequest(3));
assertThat(response.confirmationStatus()).isEqualTo("CONFIRMED");
assertThat(response.version()).isEqualTo(4);
assertThat(response.readiness().pdaTaskId()).isEqualTo(80L);
verify(repository).confirmLinkedPreload(301L, "PENDING", 2, 9L, "operator");
verify(repository).confirmLinkedPreload(302L, "CONFIRMING", 5, 9L, "operator");
verify(loadingPickService, times(1)).beginIfReady(30L, 9L, "operator");
verify(repository).appendHistory(eq(10L), anyString(), eq("CONFIRM_PRELOAD"),
eq("EDITING"), eq("EDITING"), anyString(), anyString(), eq(9L),
eq("operator"), org.mockito.ArgumentMatchers.isNull());
}
@Test
void exactConfirmationReplayReturnsFrozenResponseWithoutTouchingPlan() throws Exception {
PreloadConfirmationResponse expected = new PreloadConfirmationResponse(
10L, 30L, "CONFIRMED", 4, readiness("CONFIRMED", false, true, 80L));
String requestHash = sha256("CONFIRM_PRELOAD|10|3");
HistoryRow replay = new HistoryRow();
ObjectMapper mapper = new ObjectMapper();
replay.setAfterSnapshot(mapper.writerWithDefaultPrettyPrinter().writeValueAsString(
java.util.Map.of("response", expected, "requestHash", requestHash)));
when(repository.history(anyString())).thenReturn(replay);
PreloadConfirmationResponse actual = service.confirmPreload(
10L, 9L, "operator", "confirm-key", new ConfirmPreloadRequest(3));
assertThat(actual).isEqualTo(expected);
verify(loadingPickService, never()).requirePreloadConfirmationReady(anyLong());
verify(repository, never()).touchForPreloadConfirmation(anyLong(),
org.mockito.ArgumentMatchers.anyInt(), anyLong(), anyString());
verify(repository, never()).appendHistory(anyLong(), anyString(), anyString(),
anyString(), anyString(), anyString(), anyString(), anyLong(), anyString(),
org.mockito.ArgumentMatchers.isNull());
verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString());
}
@Test
void rejectsConfirmationReplayWhenExpectedVersionDiffers() throws Exception {
PreloadConfirmationResponse expected = new PreloadConfirmationResponse(
10L, 30L, "CONFIRMED", 4, readiness("CONFIRMED", false, true, 80L));
String originalHash = sha256("CONFIRM_PRELOAD|10|3");
HistoryRow replay = new HistoryRow();
replay.setAfterSnapshot(new ObjectMapper().writeValueAsString(
java.util.Map.of("requestHash", originalHash, "response", expected)));
when(repository.history(anyString())).thenReturn(replay);
assertThatThrownBy(() -> service.confirmPreload(
10L, 9L, "operator", "confirm-key", new ConfirmPreloadRequest(4)))
.isInstanceOf(BizException.class)
.extracting(error -> ((BizException) error).getResultCode())
.isEqualTo(ResultCode.CONFLICT);
verify(repository, never()).touchForPreloadConfirmation(anyLong(),
org.mockito.ArgumentMatchers.anyInt(), anyLong(), anyString());
verify(loadingPickService, never()).requirePreloadConfirmationReady(anyLong());
}
@Test
void rejectsNewConfirmationKeyAfterPlanWasAlreadyConfirmed() {
AggregateRow confirmed = row("EDITING", 4);
confirmed.setPreloadPushedAt(java.time.LocalDateTime.now());
when(repository.history(anyString())).thenReturn(null);
when(repository.aggregate(10L)).thenReturn(confirmed);
when(loadingPickService.requirePreloadConfirmationReady(10L))
.thenThrow(new BizException(ResultCode.CONFLICT, "预装计划已确认"));
assertThatThrownBy(() -> service.confirmPreload(
10L, 9L, "operator", "new-confirm-key", new ConfirmPreloadRequest(4)))
.isInstanceOf(BizException.class)
.extracting(error -> ((BizException) error).getResultCode())
.isEqualTo(ResultCode.CONFLICT);
verify(repository, never()).touchForPreloadConfirmation(anyLong(),
org.mockito.ArgumentMatchers.anyInt(), anyLong(), anyString());
verify(repository, never()).appendHistory(anyLong(), anyString(), anyString(),
anyString(), anyString(), anyString(), anyString(), anyLong(), anyString(),
org.mockito.ArgumentMatchers.isNull());
}
@Test
void confirmationGateFailureLeavesPlanAndEveryPreloadUntouched() {
AggregateRow before = row("EDITING", 3);
before.setPreloadPushedAt(java.time.LocalDateTime.now());
when(repository.history(anyString())).thenReturn(null);
when(repository.aggregate(10L)).thenReturn(before);
when(loadingPickService.requirePreloadConfirmationReady(10L))
.thenThrow(new BizException(ResultCode.CONFLICT, "第二票订舱尚未放舱"));
assertThatThrownBy(() -> service.confirmPreload(
10L, 9L, "operator", "confirm-key", new ConfirmPreloadRequest(3)))
.isInstanceOf(BizException.class).hasMessageContaining("第二票");
verify(repository, never()).touchForPreloadConfirmation(anyLong(),
org.mockito.ArgumentMatchers.anyInt(), anyLong(), anyString());
verify(repository, never()).confirmLinkedPreload(anyLong(), anyString(),
org.mockito.ArgumentMatchers.anyInt(), anyLong(), anyString());
verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString());
}
@Test
void reusedConfirmationKeyWithDifferentVersionConflictsBeforeReadiness() {
HistoryRow replay = new HistoryRow();
replay.setAfterSnapshot("{\"requestHash\":\"different\"}");
when(repository.history(anyString())).thenReturn(replay);
assertThatThrownBy(() -> service.confirmPreload(
10L, 9L, "operator", "confirm-key", new ConfirmPreloadRequest(4)))
.isInstanceOf(BizException.class).hasMessageContaining("不同请求");
verify(loadingPickService, never()).requirePreloadConfirmationReady(anyLong());
}
private static PreloadReadiness readiness(String confirmationStatus, boolean canConfirm,
boolean readyForPda, Long taskId) {
return new PreloadReadiness(true, true, 2, 2, 90L, "RELEASED", taskId,
taskId == null ? null : "LOADING", confirmationStatus, canConfirm,
readyForPda, List.of());
}
private static AggregateRow row(String status, int version) {
AggregateRow row = new AggregateRow();
row.setPlanId(10L);
row.setPlanNo("PL-10");
row.setContainerBookingId(30L);
row.setPlanStatus(status);
row.setWarehouseId(100L);
row.setVersion(version);
@@ -17,10 +17,22 @@ import com.baiyue.firstleg.entity.LoadingPickItemEntity;
import com.baiyue.firstleg.entity.LoadingPickOperationEntity;
import com.baiyue.firstleg.entity.LoadingPickTaskEntity;
import com.baiyue.firstleg.entity.LoadingPlanItemEntity;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.entity.InboundStatusHistoryEntity;
import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper;
import com.baiyue.firstleg.loading.service.LoadingDeliveryService;
import com.baiyue.firstleg.loading.bridge.dto.LoadingModelBridgeDtos.Link;
import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService;
import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
import com.baiyue.firstleg.mapper.LoadingPickItemMapper;
import com.baiyue.firstleg.repository.LoadingPickRepository;
import com.baiyue.firstleg.repository.SmartLoadingRepository;
import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.BeforeEach;
import org.mockito.InOrder;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
@@ -31,7 +43,11 @@ import java.util.concurrent.atomic.AtomicReference;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -46,57 +62,244 @@ class LoadingPickServiceTest {
@MockBean
private SmartLoadingRepository smartLoadingRepository;
@MockBean
private OceanBookingRepository oceanBookingRepository;
@MockBean
private LoadingModelBridgeService loadingModelBridge;
@MockBean
private LoadingListService loadingListService;
@MockBean
private LoadingDeliveryService loadingDeliveryService;
@MockBean
private TruckAppointmentService truckAppointmentService;
@MockBean
private InboundStatusHistoryMapper inboundHistories;
@MockBean
private PdaPickSettlementService settlementService;
@MockBean
private WarehousePalletLocationClient warehouse;
@Resource
private LoadingPickService service;
@BeforeEach
void activeOperationalContainer() {
ContainerBookingEntity booking = new ContainerBookingEntity();
booking.setId(99L);
booking.setContainerNo("CSLU1234567");
booking.setSourceType("SIMULATION");
booking.setRecordStatus("ACTIVE");
when(repository.activeBookingByTask(20L)).thenReturn(booking);
}
@Test
void opensTaskOnlyAfterReleasedBookingAndRealContainerNumber() {
var booking = new com.baiyue.firstleg.entity.ContainerBookingEntity();
booking.setId(40L); booking.setContainerNo("MSCU-001");
var plan = new com.baiyue.firstleg.entity.LoadingPlanEntity();
plan.setId(10L); plan.setContainerBookingId(40L); plan.setPlanStatus("EDITING");
plan.setPreloadPushedAt(java.time.LocalDateTime.now());
var allocation = new OceanBookingContainerAllocationEntity(); allocation.setOceanBookingId(90L);
var ocean = new OceanBookingEntity(); ocean.setId(90L); ocean.setStatus("RELEASED");
when(smartLoadingRepository.booking(40L)).thenReturn(booking);
when(smartLoadingRepository.planByBooking(40L)).thenReturn(plan);
when(smartLoadingRepository.plan(10L)).thenReturn(plan);
when(loadingModelBridge.byLoadingPlan(10L)).thenReturn(List.of(
new Link(1L, 1L, 50L, 2L, "CONFIRMED", 1, 10L, 40L, "MSCU-001", "EDITING", 1, 1, null)));
when(oceanBookingRepository.allocationByContainer(40L)).thenReturn(allocation);
when(oceanBookingRepository.find(90L)).thenReturn(ocean);
when(repository.taskByPlan(10L)).thenReturn(null);
when(smartLoadingRepository.items(10L)).thenReturn(List.of());
doAnswer(invocation -> {
invocation.getArgument(0, LoadingPickTaskEntity.class).setId(20L);
return null;
}).when(repository).insert(any(LoadingPickTaskEntity.class));
service.beginIfReady(40L, 7L, "admin");
verify(repository).insert(any(LoadingPickTaskEntity.class));
}
@Test
void doesNotOpenTaskBeforeOceanRelease() {
var booking = new com.baiyue.firstleg.entity.ContainerBookingEntity();
booking.setId(40L); booking.setContainerNo("MSCU-001");
var plan = new com.baiyue.firstleg.entity.LoadingPlanEntity();
plan.setId(10L); plan.setContainerBookingId(40L); plan.setPlanStatus("EDITING");
plan.setPreloadPushedAt(java.time.LocalDateTime.now());
var allocation = new OceanBookingContainerAllocationEntity(); allocation.setOceanBookingId(90L);
var ocean = new OceanBookingEntity(); ocean.setId(90L); ocean.setStatus("CONFIRMED");
when(smartLoadingRepository.booking(40L)).thenReturn(booking);
when(smartLoadingRepository.planByBooking(40L)).thenReturn(plan);
when(loadingModelBridge.byLoadingPlan(10L)).thenReturn(List.of(
new Link(1L, 1L, 50L, 2L, "CONFIRMED", 1, 10L, 40L, "MSCU-001", "EDITING", 1, 1, null)));
when(oceanBookingRepository.allocationByContainer(40L)).thenReturn(allocation);
when(oceanBookingRepository.find(90L)).thenReturn(ocean);
service.beginIfReady(40L, 7L, "admin");
verify(repository, never()).insert(any(LoadingPickTaskEntity.class));
}
@Test
void doesNotOpenTaskBeforeEveryLinkedPreloadIsConfirmed() {
var booking = new com.baiyue.firstleg.entity.ContainerBookingEntity();
booking.setId(40L); booking.setContainerNo("MSCU-001");
var plan = new com.baiyue.firstleg.entity.LoadingPlanEntity();
plan.setId(10L); plan.setContainerBookingId(40L); plan.setPlanStatus("EDITING");
plan.setPreloadPushedAt(java.time.LocalDateTime.now());
when(smartLoadingRepository.booking(40L)).thenReturn(booking);
when(smartLoadingRepository.planByBooking(40L)).thenReturn(plan);
when(loadingModelBridge.byLoadingPlan(10L)).thenReturn(List.of(
new Link(1L, 1L, 50L, 2L, "PENDING", 1, 10L, 40L, "MSCU-001", "EDITING", 1, 1, null)));
service.beginIfReady(40L, 7L, "admin");
verify(repository, never()).insert(any(LoadingPickTaskEntity.class));
verify(oceanBookingRepository, never()).allocationByContainer(40L);
}
@Test
void planConfirmationGateRequiresRealContainerAndReleasedBooking() {
Link link = new Link(1L, 1L, 50L, 2L, "PENDING", 1,
10L, 40L, "MSCU-001", "EDITING", 3, 2, null);
var booking = new com.baiyue.firstleg.entity.ContainerBookingEntity();
booking.setId(40L); booking.setContainerNo("MSCU-001");
var allocation = new OceanBookingContainerAllocationEntity();
allocation.setOceanBookingId(90L);
var ocean = new OceanBookingEntity();
ocean.setId(90L); ocean.setStatus("RELEASED");
when(loadingModelBridge.requireAllocatedLinksForConfirmation(10L)).thenReturn(List.of(link));
when(smartLoadingRepository.booking(40L)).thenReturn(booking);
when(oceanBookingRepository.allocationByContainer(40L)).thenReturn(allocation);
when(oceanBookingRepository.find(90L)).thenReturn(ocean);
assertThat(service.requirePreloadConfirmationReady(10L)).containsExactly(link);
}
@Test
void planConfirmationGateRejectsMissingRealContainerNumber() {
Link link = new Link(1L, 1L, 50L, 2L, "PENDING", 1,
10L, 40L, null, "EDITING", 3, 2, null);
var booking = new com.baiyue.firstleg.entity.ContainerBookingEntity();
booking.setId(40L);
when(loadingModelBridge.requireAllocatedLinksForConfirmation(10L)).thenReturn(List.of(link));
when(smartLoadingRepository.booking(40L)).thenReturn(booking);
assertThatThrownBy(() -> service.requirePreloadConfirmationReady(10L))
.isInstanceOf(BizException.class).hasMessageContaining("真实集装箱号");
verify(oceanBookingRepository, never()).allocationByContainer(anyLong());
}
@Test
void newKeyCannotConfirmAnAlreadyConfirmedPlanAgain() {
Link confirmed = new Link(1L, 1L, 50L, 2L, "CONFIRMED", 2,
10L, 40L, "MSCU-001", "EDITING", 4, 2, null);
when(loadingModelBridge.requireAllocatedLinksForConfirmation(10L))
.thenReturn(List.of(confirmed));
assertThatThrownBy(() -> service.requirePreloadConfirmationReady(10L))
.isInstanceOf(BizException.class).hasMessageContaining("已确认");
verify(smartLoadingRepository, never()).booking(anyLong());
}
@Test
void readinessCreatesAtMostOneTask() {
var booking = new com.baiyue.firstleg.entity.ContainerBookingEntity();
booking.setId(40L); booking.setContainerNo("MSCU-001");
var plan = new com.baiyue.firstleg.entity.LoadingPlanEntity();
plan.setId(10L); plan.setContainerBookingId(40L); plan.setPlanStatus("EDITING");
plan.setPreloadPushedAt(java.time.LocalDateTime.now());
var allocation = new OceanBookingContainerAllocationEntity(); allocation.setOceanBookingId(90L);
var ocean = new OceanBookingEntity(); ocean.setId(90L); ocean.setStatus("RELEASED");
when(smartLoadingRepository.booking(40L)).thenReturn(booking);
when(smartLoadingRepository.planByBooking(40L)).thenReturn(plan);
when(loadingModelBridge.byLoadingPlan(10L)).thenReturn(List.of(
new Link(1L, 1L, 50L, 2L, "CONFIRMED", 1, 10L, 40L, "MSCU-001", "EDITING", 1, 1, null)));
when(oceanBookingRepository.allocationByContainer(40L)).thenReturn(allocation);
when(oceanBookingRepository.find(90L)).thenReturn(ocean);
when(repository.taskByPlan(10L)).thenReturn(new LoadingPickTaskEntity());
service.beginIfReady(40L, 7L, "admin");
verify(repository, never()).insert(any(LoadingPickTaskEntity.class));
verify(smartLoadingRepository, never()).startPicking(
org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyLong(),
org.mockito.ArgumentMatchers.anyString());
}
@Test
void stableCabinetAndPlanQueriesReturnSameTaskWithCurrentContainerNumber() {
LoadingPickTaskEntity task = task();
task.setContainerBookingId(40L); task.setCabinetNo("CAB-001");
var booking = new com.baiyue.firstleg.entity.ContainerBookingEntity();
booking.setId(40L); booking.setContainerNo("MSCU-NEW");
booking.setSourceType("SIMULATION"); booking.setRecordStatus("ACTIVE");
when(repository.taskByCabinet("CAB-001", 101L)).thenReturn(task);
when(repository.taskByPlan(10L, 101L)).thenReturn(task);
when(smartLoadingRepository.booking(40L)).thenReturn(booking);
when(repository.activeBookingByTask(20L)).thenReturn(booking);
when(repository.items(20L, 101L)).thenReturn(List.of());
when(repository.cartonLabels(20L)).thenReturn(List.of());
var byCabinet = service.task(null, "CAB-001", null, 101L);
var byPlan = service.task(null, null, 10L, 101L);
var byCabinet = service.task("CAB-001", null, 101L);
var byPlan = service.task(null, 10L, 101L);
assertThat(byCabinet.id()).isEqualTo(20L);
assertThat(byPlan.id()).isEqualTo(20L);
assertThat(byCabinet.currentContainerNo()).isEqualTo("MSCU-NEW");
assertThat(byCabinet.sourceType()).isEqualTo("SIMULATION");
assertThat(byCabinet.recordStatus()).isEqualTo("ACTIVE");
assertThat(byCabinet.mergedIntoContainerBookingId()).isNull();
}
@Test
void pendingTasksExposeServerFilteredContainerLifecycle() {
var row = new com.baiyue.firstleg.mapper.LoadingPickTaskMapper.PendingTaskRow();
row.setTaskId(20L);
row.setLoadingPlanId(10L);
row.setContainerBookingId(99L);
row.setCabinetNo("CAB-001");
row.setContainerNo("MSCU-001");
row.setSourceType("SIMULATION");
row.setRecordStatus("ACTIVE");
row.setMergedIntoContainerBookingId(null);
row.setWarehouseId(101L);
row.setTaskStatus("LOADING");
row.setWorkflowStatus("AWAITING_REVIEW");
row.setVersion(0);
row.setAssignmentVersion(0);
row.setItemCount(1);
row.setPendingItemCount(0);
when(repository.pendingTasks(9L, null)).thenReturn(List.of(row));
var result = service.pendingTasks(9L, null).get(0);
assertThat(result.sourceType()).isEqualTo("SIMULATION");
assertThat(result.recordStatus()).isEqualTo("ACTIVE");
assertThat(result.mergedIntoContainerBookingId()).isNull();
assertThat(result.workflowStatus()).isEqualTo("AWAITING_REVIEW");
assertThat(result.pendingItemCount()).isZero();
}
@Test
void taskDetailRejectsMergedContainerSource() {
LoadingPickTaskEntity task = task();
when(repository.task(20L)).thenReturn(task);
when(repository.activeBookingByTask(20L)).thenReturn(null);
when(repository.items(20L, 101L)).thenReturn(List.of());
when(repository.cartonLabels(20L)).thenReturn(List.of());
assertThatThrownBy(() -> service.task(20L, 101L))
.isInstanceOf(BizException.class).hasMessageContaining("已非有效存续记录");
}
@Test
void rejectsMissingOrConflictingCompatibilitySelectors() {
assertThatThrownBy(() -> service.task(null, null, null, 101L))
assertThatThrownBy(() -> service.task(null, null, 101L))
.isInstanceOf(BizException.class).hasMessageContaining("必须且只能提供一个");
assertThatThrownBy(() -> service.task("MSCU", "CAB", null, 101L))
assertThatThrownBy(() -> service.task("CAB", 10L, 101L))
.isInstanceOf(BizException.class).hasMessageContaining("必须且只能提供一个");
}
@Test
void legacyContainerLookupReturnsCurrentTransportDisplayWithoutChangingIdentity() {
LoadingPickTaskEntity task = task(); task.setContainerBookingId(40L); task.setCabinetNo("CAB-001");
var booking = new com.baiyue.firstleg.entity.ContainerBookingEntity();
booking.setId(40L); booking.setContainerNo("MSCU-CURRENT");
when(repository.taskByContainer("MSCU-CURRENT", 101L)).thenReturn(task);
when(smartLoadingRepository.booking(40L)).thenReturn(booking);
when(repository.items(20L, 101L)).thenReturn(List.of());
when(repository.cartonLabels(20L)).thenReturn(List.of());
var result = service.task("MSCU-CURRENT", null, null, 101L);
assertThat(result.id()).isEqualTo(20L);
assertThat(result.loadingPlanId()).isEqualTo(10L);
assertThat(result.currentContainerNo()).isEqualTo("MSCU-CURRENT");
}
@Test
void keepsPlanLoadingWhenItemsRemain() {
LoadingPickTaskEntity task = task();
@@ -143,6 +346,76 @@ class LoadingPickServiceTest {
verify(smartLoadingRepository).insert(any(com.baiyue.firstleg.entity.LoadingPlanHistoryEntity.class));
}
@Test
void startsInboundLoadingEvenWhenPlanWasAlreadyStarted() {
LoadingPickTaskEntity task = task();
LoadingPickItemEntity item = item();
preparePick(task, item, "pick-start-inbound");
when(smartLoadingRepository.startPicking(10L, 9L, "operator")).thenReturn(0);
when(repository.inboundOwners(20L)).thenReturn(List.of(
new LoadingPickItemMapper.InboundOwner(101L, 50L)));
when(repository.inbound(101L, 50L)).thenReturn(
new LoadingInboundMapper.Snapshot(50L, 101L, "WAIT_SPLIT", 4));
when(repository.beginInboundLoading(101L, 50L, 9L, 4)).thenReturn(1);
service.pick(20L, 30L, new PickRequest(101L, 2, List.of(), 0, "pick-start-inbound"),
9L, "operator");
verify(repository).beginInboundLoading(101L, 50L, 9L, 4);
verify(inboundHistories).insert(argThat((InboundStatusHistoryEntity history) ->
Long.valueOf(101L).equals(history.getTenantId())
&& Long.valueOf(50L).equals(history.getInboundId())
&& "WAIT_SPLIT".equals(history.getFromStatus())
&& "LOADING".equals(history.getToStatus())
&& "PDA_START_LOADING".equals(history.getAction())));
verify(smartLoadingRepository, never()).insert(any(
com.baiyue.firstleg.entity.LoadingPlanHistoryEntity.class));
}
@Test
void acceptsConcurrentInboundTransitionWithoutDuplicateHistory() {
LoadingPickTaskEntity task = task();
LoadingPickItemEntity item = item();
preparePick(task, item, "pick-concurrent-inbound");
when(repository.inboundOwners(20L)).thenReturn(List.of(
new LoadingPickItemMapper.InboundOwner(101L, 50L)));
when(repository.inbound(101L, 50L)).thenReturn(
new LoadingInboundMapper.Snapshot(50L, 101L, "WAIT_SPLIT", 4),
new LoadingInboundMapper.Snapshot(50L, 101L, "LOADING", 5));
when(repository.beginInboundLoading(101L, 50L, 9L, 4)).thenReturn(0);
service.pick(20L, 30L, new PickRequest(101L, 2, List.of(), 0, "pick-concurrent-inbound"),
9L, "operator");
verify(repository).beginInboundLoading(101L, 50L, 9L, 4);
verify(inboundHistories, never()).insert(any(InboundStatusHistoryEntity.class));
}
@Test
void taskDetailReturnsServerInboundAndLoadingVersions() {
LoadingPickTaskEntity task = task();
when(repository.task(20L)).thenReturn(task);
when(repository.items(20L, 101L)).thenReturn(List.of(item()));
when(repository.cartonLabels(20L)).thenReturn(List.of());
when(repository.assignment(20L)).thenReturn(null);
when(repository.review(20L)).thenReturn(null);
when(repository.allTaskItemsResolved(20L)).thenReturn(false);
when(repository.inboundOwners(20L)).thenReturn(List.of(
new LoadingPickItemMapper.InboundOwner(101L, 50L)));
when(repository.inbound(101L, 50L)).thenReturn(
new LoadingInboundMapper.Snapshot(50L, 101L, "LOADING", 7));
var result = service.task(20L, 101L);
assertThat(result.inboundId()).isEqualTo(50L);
assertThat(result.inboundVersion()).isEqualTo(7);
assertThat(result.loadingVersion()).isEqualTo(7);
assertThat(result.loadingPlanId()).isEqualTo(10L);
assertThat(result.containerBookingId()).isEqualTo(99L);
assertThat(result.sourceType()).isEqualTo("SIMULATION");
assertThat(result.recordStatus()).isEqualTo("ACTIVE");
assertThat(result.mergedIntoContainerBookingId()).isNull();
}
@Test
void claimStartsLoadingAndSameOperatorReentryDoesNotDuplicateStartHistory() {
LoadingPickTaskEntity task = task();
@@ -215,12 +488,18 @@ class LoadingPickServiceTest {
}
@Test
void manualCompletionSettlesResolvedItemsAndCompletesPlan() {
void manualCompletionSettlesResolvedItemsCompletesPlanAndReplaysSameOperation() {
LoadingPickTaskEntity task = task();
LoadingPickItemEntity item = item();
AtomicReference<LoadingPickOperationEntity> operation = new AtomicReference<>();
item.setPlannedPackageCount(5);
item.setPickedPackageCount(3);
item.setItemStatus("PARTIAL");
when(repository.operation("complete-1")).thenAnswer(invocation -> operation.get());
doAnswer(invocation -> {
operation.set(invocation.getArgument(0));
return null;
}).when(repository).insert(any(LoadingPickOperationEntity.class));
when(repository.taskForUpdate(20L)).thenReturn(task);
when(repository.task(20L)).thenReturn(task);
when(repository.items(20L)).thenReturn(List.of(item));
@@ -228,13 +507,82 @@ class LoadingPickServiceTest {
when(repository.allTaskItemsResolved(20L)).thenReturn(true);
when(repository.allPlanItemsPicked(10L)).thenReturn(true);
when(repository.approve(20L, 9L, "operator", "checked")).thenReturn(1);
var owner = new LoadingPickItemMapper.InboundOwner(101L, 50L);
when(repository.inboundOwners(20L)).thenReturn(List.of(owner));
when(repository.inboundForUpdate(101L, 50L)).thenReturn(
new LoadingInboundMapper.Snapshot(50L, 101L, "LOADING", 7));
service.complete(20L, new CompleteLoadingRequest(0, "complete-1", "checked"), 9L, "operator");
service.complete(20L, new CompleteLoadingRequest(0, "complete-1", "checked"), 9L, "operator");
verify(repository).insert(any(LoadingPickOperationEntity.class));
verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator");
verify(settlementService).settle(20L, 9L, "operator");
verify(repository).syncInboundProjections(20L, 9L, "operator");
verify(loadingDeliveryService).requirePdaCompletionReady(101L, 50L);
verify(truckAppointmentService).completeLoadingFromPda(99L, 20L, 9L, "operator");
verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 9L, "operator",
"PDA-complete-1-50", "checked");
verify(loadingListService).publishCompletedEvent(10L);
InOrder finalization = inOrder(loadingListService, loadingDeliveryService);
finalization.verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator");
finalization.verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 9L, "operator",
"PDA-complete-1-50", "checked");
finalization.verify(loadingListService).publishCompletedEvent(10L);
}
@Test
void conflictingTruckAppointmentsDoNotBlockPdaCompletion() {
LoadingPickTaskEntity task = task();
LoadingPickItemEntity item = item();
when(repository.taskForUpdate(20L)).thenReturn(task);
when(repository.task(20L)).thenReturn(task);
when(repository.items(20L)).thenReturn(List.of(item));
when(repository.allTaskItemsResolved(20L)).thenReturn(true);
when(repository.allPlanItemsPicked(10L)).thenReturn(true);
when(repository.approve(20L, 9L, "operator", "checked")).thenReturn(1);
when(repository.review(20L)).thenReturn(null);
var owner = new LoadingPickItemMapper.InboundOwner(101L, 50L);
when(repository.inboundOwners(20L)).thenReturn(List.of(owner));
when(repository.inboundForUpdate(101L, 50L)).thenReturn(
new LoadingInboundMapper.Snapshot(50L, 101L, "LOADING", 7));
doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT,
"同一柜次存在多条有效拖车预约,跳过自动完成"))
.when(truckAppointmentService).completeLoadingFromPda(99L, 20L, 9L, "operator");
service.complete(20L,
new CompleteLoadingRequest(0, "complete-not-blocked", "checked"), 9L, "operator");
verify(settlementService).settle(20L, 9L, "operator");
verify(repository).approve(20L, 9L, "operator", "checked");
verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator");
verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 9L, "operator",
"PDA-complete-not-blocked-50", "checked");
verify(loadingListService).publishCompletedEvent(10L);
}
@Test
void doesNotPublishCompletedEventWhenInboundFinalizationFails() {
LoadingPickTaskEntity task = task();
when(repository.taskForUpdate(20L)).thenReturn(task);
when(repository.allTaskItemsResolved(20L)).thenReturn(true);
when(repository.allPlanItemsPicked(10L)).thenReturn(true);
when(repository.approve(20L, 9L, "operator", "checked")).thenReturn(1);
var owner = new LoadingPickItemMapper.InboundOwner(101L, 50L);
when(repository.inboundOwners(20L)).thenReturn(List.of(owner));
when(repository.inboundForUpdate(101L, 50L)).thenReturn(
new LoadingInboundMapper.Snapshot(50L, 101L, "LOADING", 7));
doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT,
"入库单并发修改"))
.when(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 9L, "operator",
"PDA-complete-finalize-fails-50", "checked");
assertThatThrownBy(() -> service.complete(20L,
new CompleteLoadingRequest(0, "complete-finalize-fails", "checked"), 9L, "operator"))
.isInstanceOf(BizException.class).hasMessageContaining("并发修改");
verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator");
verify(loadingListService, never()).publishCompletedEvent(anyLong());
}
@Test
@@ -271,7 +619,7 @@ class LoadingPickServiceTest {
ApiResponse.success(List.of(new WarehousePalletLocationClient.OccupiedResource(
71L, 72L, 0, 101L, 73L, 74L, "A-01", false, 2))));
String snapshotToken = service.inventorySnapshot(20L, 101L).snapshotToken();
BatchRequest request = new BatchRequest(101L, 10L, 99L, "CSLU1234567", 0, snapshotToken, List.of(
BatchRequest request = new BatchRequest(101L, 10L, 99L, 0, snapshotToken, List.of(
new BatchItemRequest(30L, "PICK", 2, null, "MANUAL", List.of(), 0, null),
new BatchItemRequest(31L, "SKIP", null, null, null, null, 0, "damaged carton")));
@@ -301,7 +649,7 @@ class LoadingPickServiceTest {
when(repository.assignment(20L)).thenReturn(assignment());
when(repository.items(20L)).thenReturn(items);
when(repository.items(20L, 101L)).thenReturn(items);
BatchRequest request = new BatchRequest(101L, 10L, 99L, "CSLU1234567", 0, "stale-token", List.of(
BatchRequest request = new BatchRequest(101L, 10L, 99L, 0, "stale-token", List.of(
new BatchItemRequest(30L, "PICK", 2, null, "MANUAL", List.of(), 0, null),
new BatchItemRequest(31L, "PARTIAL", null, 4, "AUTO", null, 0, null)));
@@ -325,7 +673,7 @@ class LoadingPickServiceTest {
when(warehouse.occupiedResources(any(), any(Long.class), any(), any(Long.class))).thenReturn(
ApiResponse.success(List.of(new WarehousePalletLocationClient.OccupiedResource(
71L, 72L, 0, 101L, 73L, 74L, "A-01", false, 2))));
BatchRequest request = new BatchRequest(101L, 10L, 99L, "CSLU1234567", 0, "0".repeat(64), List.of(
BatchRequest request = new BatchRequest(101L, 10L, 99L, 0, "0".repeat(64), List.of(
new BatchItemRequest(30L, "PICK", 2, null, "MANUAL", List.of(), 0, null)));
assertThatThrownBy(() -> service.batch(20L, "batch-inventory-stale", request, 9L, "operator"))
@@ -343,7 +691,7 @@ class LoadingPickServiceTest {
LoadingPickTaskEntity task = task();
task.setVersion(3);
when(repository.taskForUpdate(20L)).thenReturn(task);
BatchRequest request = new BatchRequest(101L, 10L, 99L, "CSLU1234567", 2, "stale-token", List.of(
BatchRequest request = new BatchRequest(101L, 10L, 99L, 2, "stale-token", List.of(
new BatchItemRequest(30L, "PICK", 2, null, "AUTO", null, 0, null)));
assertThatThrownBy(() -> service.batch(20L, "batch-stale", request, 9L, "operator"))
@@ -373,14 +721,14 @@ class LoadingPickServiceTest {
30L, "PICK", 2, null, "MANUAL", List.of(), 0, null);
assertThatThrownBy(() -> service.batch(20L, "batch-stale", new BatchRequest(
101L, 10L, 99L, "CSLU1234567", 1, snapshotToken, List.of(line)), 9L, "operator"))
101L, 10L, 99L, 1, snapshotToken, List.of(line)), 9L, "operator"))
.isInstanceOf(BizException.class)
.hasMessageContaining("任务版本已变化");
assertThat(item.getVersion()).isZero();
assertThat(item.getPickedPackageCount()).isZero();
service.batch(20L, "batch-valid", new BatchRequest(
101L, 10L, 99L, "CSLU1234567", 0, snapshotToken, List.of(line)), 9L, "operator");
101L, 10L, 99L, 0, snapshotToken, List.of(line)), 9L, "operator");
verify(repository, times(1)).pick(30L, 2, "MANUAL", "[]", 0, 101L);
verify(repository, times(1)).touchTask(20L, 0);
@@ -401,7 +749,7 @@ class LoadingPickServiceTest {
service.begin(10L, "CSLU1234567", 9L, "operator", List.of(planItem));
BatchRequest staleRequest = new BatchRequest(101L, 10L, 99L, "CSLU1234567", 1,
BatchRequest staleRequest = new BatchRequest(101L, 10L, 99L, 1,
"stale-token", List.of(
new BatchItemRequest(30L, "PICK", 2, null, "AUTO", null, 0, null)));
assertThatThrownBy(() -> service.batch(20L, "batch-stale-after-resync", staleRequest,
@@ -70,12 +70,11 @@ class MeasurementBatchServiceTest {
assertThat(items.getValue().get(0).getCalculationRuleVersion())
.isEqualTo("CUSTOMER_DIMENSION_MEDIUM_V0");
assertThat(items.getValue().get(0).getGirthCm()).isEqualByComparingTo("140.000");
assertThat(items.getValue().get(0).getUnitWeightKg()).isNull();
verify(repository, never()).updateInbound(any());
}
@Test
void manualDraftCalculatesWeightVolumeAndVolumeWeightOnServer() {
void manualDraftCalculatesVolumeAndVolumeWeightOnServer() {
MeasurementBatchCommand command = new MeasurementBatchCommand(
1L, 10L, null, 0, "MANUAL", "PLATFORM", null, null, null,
LocalDateTime.now(), null, "CKW-1", new BigDecimal("20"),
@@ -90,7 +89,6 @@ class MeasurementBatchServiceTest {
verify(repository).replaceItems(org.mockito.ArgumentMatchers.eq(1L),
org.mockito.ArgumentMatchers.eq(100L), items.capture());
assertThat(items.getValue().get(0).getInboundDetailId()).isEqualTo(80001L);
assertThat(items.getValue().get(0).getTotalWeightKg()).isEqualByComparingTo("6.500");
}
@Test
@@ -143,7 +141,7 @@ class MeasurementBatchServiceTest {
private MeasurementBatchCommand.Item item(BigDecimal unitWeight) {
return new MeasurementBatchCommand.Item(
80001L, "001", "A", 2, new BigDecimal("40"), new BigDecimal("30"),
new BigDecimal("20"), unitWeight, 500L, null,
new BigDecimal("20"), 500L, null,
new BigDecimal("41"), new BigDecimal("31"), new BigDecimal("21"), "备注");
}
@@ -1,9 +1,12 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.WarehousePalletLocationClient;
import com.baiyue.firstleg.config.InboundProperties;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.entity.InboundWeightResultEntity;
import com.baiyue.firstleg.repository.InboundMeasurementRepository;
import com.baiyue.firstleg.repository.InboundPalletAllocationRepository;
import com.baiyue.firstleg.repository.InboundWorkflowRepository;
import jakarta.annotation.Resource;
@@ -21,6 +24,7 @@ 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.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -39,6 +43,7 @@ class MeasurementLocationOccupationServiceTest {
@MockBean private WarehousePalletLocationClient warehouse;
@MockBean private InboundPalletAllocationRepository allocations;
@MockBean private InboundWorkflowRepository workflow;
@MockBean private InboundMeasurementRepository measurements;
@Resource private InboundProperties properties;
@Resource private MeasurementLocationOccupationService service;
@@ -46,6 +51,7 @@ class MeasurementLocationOccupationServiceTest {
void platformWarehouseResourcesUseZeroWhileSnapshotsRemainInBusinessTenant() {
properties.setWarehouseResourceTenantId(90001L);
when(workflow.findOwned(BUSINESS_TENANT, INBOUND)).thenReturn(inbound());
when(measurements.listWeights(BUSINESS_TENANT, INBOUND)).thenReturn(confirmedWeights());
when(warehouse.reserve(org.mockito.ArgumentMatchers.eq("internal-token"),
org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(batch("RESERVED")));
when(warehouse.confirm(org.mockito.ArgumentMatchers.eq("internal-token"),
@@ -59,6 +65,9 @@ class MeasurementLocationOccupationServiceTest {
assertThat(reserve.getValue().tenantId()).isEqualTo(PLATFORM_RESOURCE_TENANT);
assertThat(reserve.getValue().businessId()).isEqualTo(INBOUND);
assertThat(reserve.getValue().idempotencyKey()).startsWith("measure-88-30-3-");
assertThat(reserve.getValue().pallets())
.extracting(WarehousePalletLocationClient.PalletReference::palletId)
.containsExactly(601L, 601L, 601L);
ArgumentCaptor<WarehousePalletLocationClient.ConfirmRequest> confirm =
ArgumentCaptor.forClass(WarehousePalletLocationClient.ConfirmRequest.class);
@@ -72,7 +81,6 @@ class MeasurementLocationOccupationServiceTest {
org.mockito.ArgumentMatchers.eq(INBOUND),
org.mockito.ArgumentMatchers.eq(WAREHOUSE),
org.mockito.ArgumentMatchers.eq(PLATFORM_RESOURCE_TENANT),
org.mockito.ArgumentMatchers.eq(new BigDecimal("1.200000")),
org.mockito.ArgumentMatchers.eq(20),
org.mockito.ArgumentMatchers.any());
}
@@ -81,6 +89,7 @@ class MeasurementLocationOccupationServiceTest {
void defaultResourceTenantRemainsPlatformZero() {
properties.setWarehouseResourceTenantId(0L);
when(workflow.findOwned(BUSINESS_TENANT, INBOUND)).thenReturn(inbound());
when(measurements.listWeights(BUSINESS_TENANT, INBOUND)).thenReturn(confirmedWeights());
when(warehouse.reserve(org.mockito.ArgumentMatchers.eq("internal-token"),
org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(batch("RESERVED")));
when(warehouse.confirm(org.mockito.ArgumentMatchers.eq("internal-token"),
@@ -104,15 +113,57 @@ class MeasurementLocationOccupationServiceTest {
org.mockito.ArgumentMatchers.eq(INBOUND),
org.mockito.ArgumentMatchers.eq(WAREHOUSE),
org.mockito.ArgumentMatchers.eq(PLATFORM_RESOURCE_TENANT),
org.mockito.ArgumentMatchers.eq(new BigDecimal("1.200000")),
org.mockito.ArgumentMatchers.eq(20),
org.mockito.ArgumentMatchers.any());
}
@Test
void rejectsConfirmedWeightWithoutConfirmationTimeInsteadOfDroppingIt() {
InboundWeightResultEntity weight = confirmedWeights().get(0);
weight.setConfirmedAt(null);
when(workflow.findOwned(BUSINESS_TENANT, INBOUND)).thenReturn(inbound());
when(measurements.listWeights(BUSINESS_TENANT, INBOUND)).thenReturn(List.of(weight));
assertThatThrownBy(() -> service.occupy(BUSINESS_TENANT, INBOUND, OPERATOR, MEASUREMENT, 3))
.isInstanceOf(BizException.class)
.hasMessageContaining("缺少确认时间");
verify(warehouse, never()).reserve(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any());
}
@Test
void rejectsConfirmedWeightWithoutNetWeightInsteadOfDroppingIt() {
InboundWeightResultEntity weight = confirmedWeights().get(0);
weight.setNetWeightKg(null);
when(workflow.findOwned(BUSINESS_TENANT, INBOUND)).thenReturn(inbound());
when(measurements.listWeights(BUSINESS_TENANT, INBOUND)).thenReturn(List.of(weight));
assertThatThrownBy(() -> service.occupy(BUSINESS_TENANT, INBOUND, OPERATOR, MEASUREMENT, 3))
.isInstanceOf(BizException.class)
.hasMessageContaining("缺少有效净重");
verify(warehouse, never()).reserve(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any());
}
@Test
void rejectsConfirmedWeightWithoutPalletInsteadOfDroppingIt() {
InboundWeightResultEntity weight = confirmedWeights().get(0);
weight.setPalletId(null);
when(workflow.findOwned(BUSINESS_TENANT, INBOUND)).thenReturn(inbound());
when(measurements.listWeights(BUSINESS_TENANT, INBOUND)).thenReturn(List.of(weight));
assertThatThrownBy(() -> service.occupy(BUSINESS_TENANT, INBOUND, OPERATOR, MEASUREMENT, 3))
.isInstanceOf(BizException.class)
.hasMessageContaining("缺少有效托盘");
verify(warehouse, never()).reserve(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any());
}
@Test
void confirmFailureReleasesReservationInResourceTenantAndKeepsOriginalFailure() {
properties.setWarehouseResourceTenantId(90001L);
when(workflow.findOwned(BUSINESS_TENANT, INBOUND)).thenReturn(inbound());
when(measurements.listWeights(BUSINESS_TENANT, INBOUND)).thenReturn(confirmedWeights());
when(warehouse.reserve(org.mockito.ArgumentMatchers.eq("internal-token"),
org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(batch("RESERVED")));
IllegalStateException failure = new IllegalStateException("confirm failed");
@@ -130,7 +181,7 @@ class MeasurementLocationOccupationServiceTest {
assertThat(release.getValue().tenantId()).isEqualTo(PLATFORM_RESOURCE_TENANT);
assertThat(release.getValue().businessId()).isEqualTo(INBOUND);
verify(allocations).replace(BUSINESS_TENANT, INBOUND, WAREHOUSE, PLATFORM_RESOURCE_TENANT,
new BigDecimal("1.200000"), 20, batch("RESERVED"));
20, batch("RESERVED"));
}
private static InboundProperties properties() {
@@ -159,9 +210,22 @@ class MeasurementLocationOccupationServiceTest {
}
private static WarehousePalletLocationClient.BatchResult batch(String state) {
return new WarehousePalletLocationClient.BatchResult(1, List.of(
new WarehousePalletLocationClient.AllocationResult(
501L, "RES-001", 1, 601L, "PL-001", 701L, "A-001",
new BigDecimal("1.0000"), state)));
return new WarehousePalletLocationClient.BatchResult(3, java.util.stream.IntStream.rangeClosed(1, 3)
.mapToObj(index -> new WarehousePalletLocationClient.AllocationResult(
500L + index, "RES-00" + index, index, 601L, "PL-001", new BigDecimal("1.200000"),
700L + index, "A-00" + index, new BigDecimal("0.333333"), state))
.toList());
}
private static List<InboundWeightResultEntity> confirmedWeights() {
return java.util.stream.LongStream.rangeClosed(1, 3).mapToObj(index -> {
InboundWeightResultEntity weight = new InboundWeightResultEntity();
weight.setId(800L + index);
weight.setPalletId(601L);
weight.setRecognitionStatus("CONFIRMED");
weight.setConfirmedAt(java.time.LocalDateTime.now());
weight.setNetWeightKg(new BigDecimal("10"));
return weight;
}).toList();
}
}
@@ -9,6 +9,8 @@ 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.entity.InboundWeightResultEntity;
import com.baiyue.firstleg.repository.InboundMeasurementRepository;
import com.baiyue.firstleg.repository.InboundWorkflowRepository;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.DisplayName;
@@ -41,6 +43,7 @@ import static org.mockito.Mockito.when;
@SpringJUnitConfig(MeasurementReviewService.class)
class MeasurementReviewServiceTest {
@MockBean private InboundWorkflowRepository repository;
@MockBean private InboundMeasurementRepository measurements;
@MockBean private InboundIdempotencyService idempotency;
@MockBean private InboundProperties properties;
@Resource private MeasurementReviewService service;
@@ -78,7 +81,24 @@ class MeasurementReviewServiceTest {
ArgumentCaptor.forClass(InboundStatusHistoryEntity.class);
verify(fixture.repository).insertHistory(history.capture());
assertThat(history.getValue().getFromStatus()).isEqualTo("MEASURE_PENDING");
assertThat(history.getValue().getToStatus()).isEqualTo("WAIT_PRICING");
assertThat(history.getValue().getToStatus()).isEqualTo("WAIT_SPLIT");
}
@Test
@DisplayName("审核通过使用订单权威货物总重量,不依赖箱规重量")
void shouldApproveWithActualGrossWeightWhenCartonWeightIsMissing() {
Fixture fixture = fixture(false);
InboundMeasurementItemEntity item = new InboundMeasurementItemEntity();
item.setPackageCount(5);
item.setTotalVolumeCbm(new BigDecimal("0.7480"));
when(fixture.repository.listMeasurementItems(10001L, 20L)).thenReturn(List.of(item));
when(fixture.repository.transitionReview(10001L, 10L, 9L, 3, true, 20L)).thenReturn(1);
when(fixture.repository.transitionMeasurement(10001L, 10L, 20L, "APPROVED")).thenReturn(1);
fixture.service.approve(10001L, 9L, "审核员", 10L, "approve-authoritative-weight",
new MeasurementReviewRequest(20L, 3, null));
verify(fixture.repository).transitionReview(10001L, 10L, 9L, 3, true, 20L);
}
@Test
@@ -195,20 +215,35 @@ class MeasurementReviewServiceTest {
order.setProcessStatus("MEASURE_PENDING");
order.setMeasureReviewStatus("PENDING");
order.setTotalPackageCount(5);
order.setActualGrossWeightKg(new BigDecimal("1787.500"));
order.setActualNetWeightKg(new BigDecimal("1747.500"));
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
measurement.setId(20L);
measurement.setStatus("SUBMITTED");
InboundMeasurementItemEntity item = new InboundMeasurementItemEntity();
item.setPackageCount(5);
item.setTotalWeightKg(new BigDecimal("54.400"));
item.setTotalVolumeCbm(new BigDecimal("0.7480"));
item.setVolumeWeightKg(new BigDecimal("124.916"));
when(repository.findOwned(10001L, 10L)).thenReturn(order);
when(repository.findMeasurement(10001L, 10L, 20L)).thenReturn(measurement);
when(repository.listMeasurementItems(10001L, 20L)).thenReturn(List.of(item));
when(measurements.listWeights(10001L, 10L)).thenReturn(List.of(
confirmedWeight("357.500", "349.500"),
confirmedWeight("357.500", "349.500"),
confirmedWeight("357.500", "349.500"),
confirmedWeight("357.500", "349.500"),
confirmedWeight("357.500", "349.500")));
return new Fixture(service, repository, idempotency, order, measurement);
}
private InboundWeightResultEntity confirmedWeight(String grossWeightKg, String netWeightKg) {
InboundWeightResultEntity weight = new InboundWeightResultEntity();
weight.setRecognitionStatus("CONFIRMED");
weight.setGrossWeightKg(new BigDecimal(grossWeightKg));
weight.setNetWeightKg(new BigDecimal(netWeightKg));
return weight;
}
private record Fixture(MeasurementReviewService service, InboundWorkflowRepository repository,
InboundIdempotencyService idempotency, InboundOrderEntity order,
InboundMeasurementEntity measurement) {
@@ -16,6 +16,7 @@ import com.baiyue.firstleg.pricing.RegionWeightTierEstimator;
import com.baiyue.firstleg.repository.PriceBookQueryRepository;
import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow;
import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelTaxModeRow;
import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelPrefixRow;
import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
@@ -41,6 +42,46 @@ class MerchantPriceBookServiceTest {
@MockBean private PriceBookWarehouseClient warehouses;
@Resource private MerchantPriceBookService service;
@Test
void shouldResolveNormalAndOverseasInboundCodePrefixesFromChannelCatalog() {
when(repository.activeVersionId()).thenReturn(7L);
when(repository.channelAvailable(7L, 10L, "MATSON_CLX_TRUCK")).thenReturn(true);
when(repository.channels(7L, 10L)).thenReturn(List.of(
channel(7L, "MATSON_CLX_TRUCK", PriceModel.FBA_WAREHOUSE_RATE)));
when(repository.channelPrefix("MATSON_CLX_TRUCK"))
.thenReturn(new ChannelPrefixRow("MATSON_CLX_TRUCK", "ZBYHK", "ZBYHWK"));
assertThat(service.requireAvailableChannel(10001L, 10L, "matson_clx_truck", "FBA", true)
.inboundCodePrefix()).isEqualTo("ZBYHK");
assertThat(service.requireAvailableChannel(10001L, 10L, "MATSON_CLX_TRUCK", "OVERSEAS", true)
.inboundCodePrefix()).isEqualTo("ZBYHWK");
}
@Test
void shouldFallBackToNormalPrefixWhenChannelHasNoOverseasVariant() {
when(repository.activeVersionId()).thenReturn(7L);
when(repository.channelAvailable(7L, 10L, "OA_HOUSTON_TRUCK")).thenReturn(true);
when(repository.channels(7L, 10L)).thenReturn(List.of(
channel(7L, "OA_HOUSTON_TRUCK", PriceModel.FBA_WAREHOUSE_RATE)));
when(repository.channelPrefix("OA_HOUSTON_TRUCK"))
.thenReturn(new ChannelPrefixRow("OA_HOUSTON_TRUCK", "HPHK", null));
assertThat(service.requireAvailableChannel(10001L, 10L, "OA_HOUSTON_TRUCK", "OVERSEAS", true)
.inboundCodePrefix()).isEqualTo("HPHK");
}
@Test
void shouldValidateLegacyChannelWithoutRequiringAnInboundCodePrefix() {
when(repository.activeVersionId()).thenReturn(7L);
when(repository.channelAvailable(7L, 10L, "LEGACY_CHANNEL")).thenReturn(true);
var response = service.requireAvailableChannel(
10001L, 10L, "LEGACY_CHANNEL", "FBA", false);
assertThat(response.channelCode()).isEqualTo("LEGACY_CHANNEL");
assertThat(response.inboundCodePrefix()).isNull();
verify(repository, never()).channelPrefix("LEGACY_CHANNEL");
}
@Test
void shouldReturnEmptyChannelsWithoutActiveVersion() {
when(repository.activeVersionId()).thenReturn(null);
@@ -187,6 +228,26 @@ class MerchantPriceBookServiceTest {
verify(repository).regionTiers(8L, 10L, "MATSON_PARCEL", "EAST", "TAX_INCLUDED");
}
@Test
void shouldResolveTierRegionFromPlatformOverseasWarehousePostalCode() {
stubChannel(8L, "MATSON_MAX_PARCEL", PriceModel.REGION_WEIGHT_TIER);
when(repository.regionTiers(8L, 10L, "MATSON_MAX_PARCEL", "WEST", "TAX_INCLUDED"))
.thenReturn(tiers());
var warehouse = new PriceBookWarehouseClient.Warehouse(901L, 0L, "US-LAX-01", "洛杉矶仓",
"OVERSEAS", "US", "ACTIVE");
var address = new PriceBookWarehouseClient.Address("US", "CA", "Los Angeles", null,
"90001", "address", null);
when(warehouses.active("internal-token", 0L, 901L))
.thenReturn(ApiResponse.success(new PriceBookWarehouseClient.Detail(warehouse, List.of(address))));
var result = service.estimate(10001L,
request("MATSON_MAX_PARCEL", PricingDestinationType.OVERSEAS, null, 901L, null,
PricingTaxMode.TAX_INCLUDED, "51", null));
assertThat(result.unitPrice()).isEqualByComparingTo("13");
verify(repository).regionTiers(8L, 10L, "MATSON_MAX_PARCEL", "WEST", "TAX_INCLUDED");
}
@Test
void shouldRejectMissingRequiredEstimateFields() {
stubChannel(7L, "MATSON_TRUCK", PriceModel.FBA_WAREHOUSE_RATE);
@@ -206,7 +267,7 @@ class MerchantPriceBookServiceTest {
request("MATSON_PARCEL", PricingDestinationType.FBA, "ONT8", null, null,
PricingTaxMode.TAX_INCLUDED, "51", null)))
.isInstanceOf(BizException.class)
.hasMessageContaining("目的仓区域或平台FBA仓");
.hasMessageContaining("目的仓区域");
}
@Test
@@ -50,7 +50,7 @@ class MojiaMeasurementOpenApiServiceTest {
}
@Test
void fixedAppKeyJsonIsConvertedToTheExistingCartonMeasurementFlow() throws Exception {
void generatedDailyInboundCartonCodeFlowsThroughMojiaMeasurement() throws Exception {
MeasurementRequestAuthenticator authenticator = mock(MeasurementRequestAuthenticator.class);
CartonMeasurementService cartons = mock(CartonMeasurementService.class);
MeasurementImageClient images = mock(MeasurementImageClient.class);
@@ -62,17 +62,17 @@ class MojiaMeasurementOpenApiServiceTest {
when(authenticator.authenticate("POST", MojiaMeasurementOpenApiService.CALLBACK_PATH,
headers, json(), false)).thenReturn(new AuthenticatedMeasurementApp("mojia", 10001L, 90001L, Map.of()));
when(cartons.upsert(eq(10001L), eq(90001L), any())).thenReturn(new CartonMeasurementResult(
1L, 1, 2L, "RK-001#4-1", "FBA19L5KMY2CU000002", 1,
1L, 1, 2L, "PCHK2608140001#5-1", "FBA19L5KMY2CU000002", 1,
new BigDecimal("60.1"), new BigDecimal("40.2"),
new BigDecimal("30.3"), new BigDecimal("12.5"), 3L, "MJ-01", LocalDateTime.now(), true));
new BigDecimal("30.3"), 3L, "MJ-01", LocalDateTime.now(), true));
UploadResponse result = service.receive(headers, json());
assertThat(result.matched()).isTrue();
assertThat(result.boxCode()).isEqualTo("RK-001#4-1");
assertThat(result.boxCode()).isEqualTo("PCHK2608140001#5-1");
ArgumentCaptor<CartonMeasurementCommand> command = ArgumentCaptor.forClass(CartonMeasurementCommand.class);
verify(cartons).upsert(eq(10001L), eq(90001L), command.capture());
assertThat(command.getValue().boxCode()).isEqualTo("RK-001#4-1");
assertThat(command.getValue().boxCode()).isEqualTo("PCHK2608140001#5-1");
assertThat(command.getValue().scannedShipmentId()).isEqualTo("FBA19L5KMY2CU000002");
assertThat(command.getValue().lengthCm()).isEqualByComparingTo("60.1");
assertThat(command.getValue().image().getContentType()).isEqualTo("image/jpeg");
@@ -108,7 +108,7 @@ class MojiaMeasurementOpenApiServiceTest {
ArgumentCaptor<MojiaMeasurementReceiptEntity> receipt =
ArgumentCaptor.forClass(MojiaMeasurementReceiptEntity.class);
verify(receipts).insert(receipt.capture());
assertThat(receipt.getValue().getBoxCode()).isEqualTo("RK-001#4-1");
assertThat(receipt.getValue().getBoxCode()).isEqualTo("PCHK2608140001#5-1");
assertThat(receipt.getValue().getScannedShipmentId()).isNull();
assertThat(receipt.getValue().getLengthCm()).isEqualByComparingTo("60.1");
assertThat(receipt.getValue().getImageFileId()).isEqualTo(3L);
@@ -117,9 +117,9 @@ class MojiaMeasurementOpenApiServiceTest {
private String json() {
return """
{"boxCode":"RK-001#4-1","shipment_id":"FBA19L5KMY2CU000002",
{"boxCode":"PCHK2608140001#5-1","shipment_id":"FBA19L5KMY2CU000002",
"length":60.1,"width":40.2,"height":30.3,
"weight":12.5,"machineNo":"MJ-01","picture":"data:image/jpeg;base64,/9j/2Q=="}
"machineNo":"MJ-01","picture":"data:image/jpeg;base64,/9j/2Q=="}
""";
}
}
@@ -5,16 +5,20 @@ import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.OrderCargoClient;
import com.baiyue.firstleg.dto.SmartLoadingDtos.AssignRequest;
import com.baiyue.firstleg.dto.SmartLoadingDtos.AdjustRequest;
import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate;
import com.baiyue.firstleg.dto.SmartLoadingDtos.DeleteRequest;
import com.baiyue.firstleg.dto.SmartLoadingDtos.Plan;
import com.baiyue.firstleg.dto.SmartLoadingDtos.PushRequest;
import com.baiyue.firstleg.dto.SmartLoadingDtos.RecommendRequest;
import com.baiyue.firstleg.dto.SmartLoadingDtos.WarehouseRemarkRequest;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.entity.LoadingPlanEntity;
import com.baiyue.firstleg.entity.LoadingPlanHistoryEntity;
import com.baiyue.firstleg.entity.LoadingPlanItemEntity;
import com.baiyue.firstleg.repository.SmartLoadingRepository;
import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService;
import com.baiyue.firstleg.split.service.SplitPreloadService;
import com.fasterxml.jackson.databind.ObjectMapper;
import feign.FeignException;
import jakarta.annotation.Resource;
@@ -24,13 +28,17 @@ import org.mockito.ArgumentCaptor;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Bean;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.sql.SQLException;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.catchThrowableOfType;
@@ -41,6 +49,8 @@ import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
@@ -51,6 +61,9 @@ class SmartLoadingServiceTest {
@MockBean private SmartLoadingRepository repository;
@MockBean private OrderCargoClient cargoClient;
@MockBean private LoadingPickService loadingPickService;
@MockBean private LoadingModelBridgeService loadingModelBridge;
@MockBean private SplitPreloadService splitPreloadService;
@MockBean private FirstlegDocumentNumberGenerator documentNumbers;
@Resource private SmartLoadingService service;
@TestConfiguration
@@ -61,6 +74,9 @@ class SmartLoadingServiceTest {
@BeforeEach
void setUp() {
when(documentNumbers.next("LP")).thenReturn("LP-260818-0001");
when(loadingPickService.mutationSnapshot(anyLong())).thenReturn(
new LoadingPickService.MutationSnapshot(null, null, null, Map.of()));
when(cargoClient.detail(anyString(), anyLong(), anyLong(), anyLong())).thenReturn(
ApiResponse.success(new OrderCargoClient.CargoBinding(20L, 30L, 1, "货物", 5,
new BigDecimal("10"), new BigDecimal("2"))));
@@ -88,6 +104,53 @@ class SmartLoadingServiceTest {
assertThat(service.draftByBooking(99L).status()).isEqualTo("LOADING");
}
@Test
void exposesPickedMinimumAndItemCapabilitiesForRunningTask() {
LoadingPlanEntity plan = plan("LOADING", 3);
LoadingPlanItemEntity line = item(8, 9, 20, 3);
when(repository.booking(99L)).thenReturn(booking());
when(repository.plan(1L)).thenReturn(plan);
when(repository.items(1L)).thenReturn(List.of(line));
when(loadingPickService.mutationSnapshot(1L)).thenReturn(
new LoadingPickService.MutationSnapshot(77L, "LOADING", 6, Map.of(8L, 2)));
Plan result = service.detail(1L);
assertThat(result.canAssign()).isTrue();
assertThat(result.canEdit()).isTrue();
assertThat(result.finalCompletionStatus()).isEqualTo("IN_PROGRESS");
assertThat(result.pdaTaskId()).isEqualTo(77L);
assertThat(result.pdaTaskVersion()).isEqualTo(6);
assertThat(result.items()).singleElement().satisfies(item -> {
assertThat(item.getPickedPackageCount()).isEqualTo(2);
assertThat(item.getMinPackageCount()).isEqualTo(2);
assertThat(item.getCanAdjust()).isTrue();
assertThat(item.getCanRemove()).isFalse();
});
}
@Test
void completedTaskDisablesEveryIncrementalCapability() {
LoadingPlanEntity plan = plan("COMPLETED", 4);
LoadingPlanItemEntity line = item(8, 9, 20, 3);
when(repository.booking(99L)).thenReturn(booking());
when(repository.plan(1L)).thenReturn(plan);
when(repository.items(1L)).thenReturn(List.of(line));
when(loadingPickService.mutationSnapshot(1L)).thenReturn(
new LoadingPickService.MutationSnapshot(77L, "COMPLETED", 7, Map.of(8L, 3)));
Plan result = service.detail(1L);
assertThat(result.canAssign()).isFalse();
assertThat(result.canEdit()).isFalse();
assertThat(result.blockedReason()).contains("最终完成");
assertThat(result.finalCompletionStatus()).isEqualTo("COMPLETED");
assertThat(result.items()).singleElement().satisfies(item -> {
assertThat(item.getCanAdjust()).isFalse();
assertThat(item.getCanRemove()).isFalse();
});
}
@Test
void doesNotRestoreCompletedPlanInTheLoadingWorkbench() {
when(repository.booking(99L)).thenReturn(booking());
@@ -113,6 +176,22 @@ class SmartLoadingServiceTest {
verify(repository, never()).insert(any(LoadingPlanEntity.class));
}
@Test
void createsPlanWithShortStableNumber() {
when(repository.booking(99L)).thenReturn(booking());
when(repository.planByBooking(99L)).thenReturn(null);
doAnswer(invocation -> {
LoadingPlanEntity value = invocation.getArgument(0);
value.setId(1L);
return 1;
}).when(repository).insert(any(LoadingPlanEntity.class));
when(repository.items(1L)).thenReturn(List.of());
Plan result = service.create(99L, "备注", 7L, "u");
assertThat(result.planNo()).isEqualTo("LP-260818-0001");
}
@Test
void rejectsExcessBoxes() {
LoadingPlanEntity plan = plan("EDITING", 0);
@@ -128,6 +207,22 @@ class SmartLoadingServiceTest {
verify(repository, never()).touch(anyLong(), anyInt(), anyLong(), any());
}
@Test
void rejectsManualAssignmentOutsideLinkedBookingCustomerScope() {
LoadingPlanEntity plan = plan("EDITING", 0);
ContainerBookingEntity linked = booking();
linked.setCustomerId(88L);
when(repository.plan(1L)).thenReturn(plan);
when(repository.booking(99L)).thenReturn(linked);
when(repository.candidate(100L, 10L, 20L)).thenReturn(candidate(5));
BizException error = catchThrowableOfType(() -> service.assign(1L,
new AssignRequest(10L, 20L, 1, 0, "assign-customer-mismatch"), 7L, "u"), BizException.class);
assertThat(error.getResultCode()).isEqualTo(ResultCode.FORBIDDEN);
verify(repository, never()).touch(anyLong(), anyInt(), anyLong(), any());
}
@Test
void allowsVolumeAndWeightWithinOneHundredTenPercent() {
stubAssignment();
@@ -138,6 +233,7 @@ class SmartLoadingServiceTest {
service.assign(1, new AssignRequest(10L, 20L, 2, 0, "assign-110-ok"), 7, "u");
verify(splitPreloadService).ensurePendingForLoadingAllocation(9L, 10L, 7L, "u");
verify(repository).touch(1, 0, 7, "u");
}
@@ -163,7 +259,7 @@ class SmartLoadingServiceTest {
when(cargoClient.detail(anyString(), eq(9L), eq(30L), eq(20L))).thenReturn(ApiResponse.success(
new OrderCargoClient.CargoBinding(20L, 30L, 1, "中文品名", 10,
new BigDecimal("20"), new BigDecimal("2"), "SKU-1", "English name",
4, 100, new BigDecimal("10"), "货物备注")));
4, 100, new BigDecimal("10"), "货物备注", "客户备注", "最新内部备注")));
doAnswer(invocation -> {
Object value = invocation.getArgument(0);
if (value instanceof LoadingPlanItemEntity item) item.setId(88L);
@@ -180,8 +276,10 @@ class SmartLoadingServiceTest {
assertThat(saved.getProductNameEn()).isEqualTo("English name");
assertThat(saved.getUnitsPerCarton()).isEqualTo(4);
assertThat(saved.getProductQuantity()).isEqualTo(20);
assertThat(saved.getNetWeightKg()).isEqualByComparingTo("5");
assertThat(saved.getInternalRemark()).isEqualTo("货物备注");
assertThat(saved.getNetWeightKg()).isEqualByComparingTo("7.5");
assertThat(saved.getCustomerRemark()).isEqualTo("客户备注");
assertThat(saved.getInternalRemark()).isEqualTo("最新内部备注");
assertThat(saved.getInternalRemark()).isNotEqualTo("货物备注");
ArgumentCaptor<LoadingPlanHistoryEntity> historyCaptor = ArgumentCaptor.forClass(LoadingPlanHistoryEntity.class);
verify(repository).insert(historyCaptor.capture());
@@ -203,13 +301,12 @@ class SmartLoadingServiceTest {
}
@Test
void recyclesLegacyLoadingPlanBeforeAddingFirstItem() {
LoadingPlanEntity stale = plan("LOADING", 5);
LoadingPlanEntity after = plan("EDITING", 7);
when(repository.plan(1)).thenReturn(stale, after);
void addsItemToLoadingPlanWithoutChangingStableTaskOrPlanStatus() {
LoadingPlanEntity loading = plan("LOADING", 5);
LoadingPlanEntity after = plan("LOADING", 6);
when(repository.plan(1)).thenReturn(loading, after);
when(repository.booking(99)).thenReturn(booking());
when(repository.recycleUnpicked(1, 5, 7, "u")).thenReturn(1);
when(repository.touch(1, 6, 7, "u")).thenReturn(1);
when(repository.touch(1, 5, 7, "u")).thenReturn(1);
when(repository.items(1)).thenReturn(List.of());
when(repository.candidate(100, 10, 20)).thenReturn(candidate(10));
when(repository.activeItem(1L, 9L, 20L)).thenReturn(null);
@@ -217,11 +314,15 @@ class SmartLoadingServiceTest {
new OrderCargoClient.CargoBinding(20L, 30L, 1, "货物", 1,
BigDecimal.ONE, BigDecimal.ONE, null, null, null, null, null, null)));
Plan result = service.assign(1, new AssignRequest(10L, 20L, 1, 5, "assign-recycle"), 7, "u");
Plan result = service.assign(1, new AssignRequest(10L, 20L, 1, 5, "assign-loading"), 7, "u");
assertThat(result.status()).isEqualTo("EDITING");
verify(repository).recycleUnpicked(1, 5, 7, "u");
verify(repository).touch(1, 6, 7, "u");
assertThat(result.status()).isEqualTo("LOADING");
var order = inOrder(loadingPickService, repository, loadingModelBridge);
order.verify(loadingPickService).lockMutable(1L);
order.verify(repository).touch(1, 5, 7, "u");
order.verify(loadingModelBridge).bindIncrementalAllocation(1L, 9L, 10L,
7L, "u", "assign-loading");
order.verify(loadingPickService).sync(eq(1L), any(LoadingPlanItemEntity.class));
}
@Test
@@ -301,6 +402,154 @@ class SmartLoadingServiceTest {
verify(repository).touch(1, 2, 7, "u");
}
@Test
void updatesAndClearsBlankWarehouseRemarkWithBothVersions() {
LoadingPlanEntity plan = plan("LOADING", 2);
LoadingPlanItemEntity line = item(8, 9, 20, 1);
line.setWarehouseRemark("旧仓库备注");
when(repository.plan(1L)).thenReturn(plan, plan("LOADING", 3));
when(repository.item(1L, 8L)).thenReturn(line);
when(repository.booking(99L)).thenReturn(booking());
when(repository.items(1L)).thenReturn(List.of(line));
when(repository.touch(1L, 2, 7L, "u")).thenReturn(1);
when(repository.updateWarehouseRemark(1L, 8L, 3, 0, null, 7L, "u")).thenReturn(1);
Plan result = service.updateWarehouseRemark(1L, 8L,
new WarehouseRemarkRequest(" ", 2, 0, "warehouse-clear"), 7L, "u");
assertThat(result.status()).isEqualTo("LOADING");
verify(loadingPickService).lockMutable(1L);
var order = inOrder(loadingPickService, repository);
order.verify(loadingPickService).lockMutable(1L);
order.verify(repository).touch(1L, 2, 7L, "u");
order.verify(repository).updateWarehouseRemark(1L, 8L, 3, 0, null, 7L, "u");
ArgumentCaptor<LoadingPlanHistoryEntity> history = ArgumentCaptor.forClass(LoadingPlanHistoryEntity.class);
verify(repository).insert(history.capture());
assertThat(history.getValue().getActionType()).isEqualTo("WAREHOUSE_REMARK");
assertThat(history.getValue().getBeforeSnapshot()).contains("旧仓库备注");
}
@Test
void updatesWarehouseRemarkWhilePlanIsEditing() {
LoadingPlanEntity plan = plan("EDITING", 1);
LoadingPlanItemEntity line = item(8, 9, 20, 1);
when(repository.plan(1L)).thenReturn(plan, plan("EDITING", 2));
when(repository.item(1L, 8L)).thenReturn(line);
when(repository.booking(99L)).thenReturn(booking());
when(repository.items(1L)).thenReturn(List.of(line));
when(repository.touch(1L, 1, 7L, "u")).thenReturn(1);
when(repository.updateWarehouseRemark(1L, 8L, 2, 0, "待核对外箱", 7L, "u")).thenReturn(1);
Plan result = service.updateWarehouseRemark(1L, 8L,
new WarehouseRemarkRequest("待核对外箱", 1, 0, "warehouse-editing"), 7L, "u");
assertThat(result.status()).isEqualTo("EDITING");
assertThat(result.version()).isEqualTo(2);
verify(repository).updateWarehouseRemark(1L, 8L, 2, 0, "待核对外箱", 7L, "u");
}
@Test
void warehouseRemarkConflictProducesNoHistory() {
LoadingPlanEntity plan = plan("LOADING", 2);
LoadingPlanItemEntity line = item(8, 9, 20, 1);
when(repository.plan(1L)).thenReturn(plan);
when(repository.item(1L, 8L)).thenReturn(line);
when(repository.touch(1L, 2, 7L, "u")).thenReturn(1);
when(repository.updateWarehouseRemark(1L, 8L, 3, 0, "新备注", 7L, "u")).thenReturn(0);
BizException error = catchThrowableOfType(() -> service.updateWarehouseRemark(1L, 8L,
new WarehouseRemarkRequest("新备注", 2, 0, "warehouse-conflict"), 7L, "u"), BizException.class);
assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT);
verify(repository).touch(1L, 2, 7L, "u");
verify(repository, never()).insert(any(LoadingPlanHistoryEntity.class));
}
@Test
void warehouseRemarkMapsTriggerSqlStateToConflict() {
LoadingPlanEntity plan = plan("LOADING", 2);
LoadingPlanItemEntity line = item(8, 9, 20, 1);
when(repository.plan(1L)).thenReturn(plan);
when(repository.item(1L, 8L)).thenReturn(line);
when(repository.touch(1L, 2, 7L, "u")).thenReturn(1);
when(repository.updateWarehouseRemark(1L, 8L, 3, 0, "新备注", 7L, "u"))
.thenThrow(new DataIntegrityViolationException("trigger",
new SQLException("状态冲突", "45000")));
BizException error = catchThrowableOfType(() -> service.updateWarehouseRemark(1L, 8L,
new WarehouseRemarkRequest("新备注", 2, 0, "warehouse-trigger"), 7L, "u"), BizException.class);
assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT);
assertThat(error.getMessage()).contains("仓库备注未保存").contains("刷新后重试");
verify(repository, never()).insert(any(LoadingPlanHistoryEntity.class));
}
@Test
void stalePlanVersionRejectsWarehouseRemarkBeforeItemWrite() {
when(repository.plan(1L)).thenReturn(plan("LOADING", 3));
BizException error = catchThrowableOfType(() -> service.updateWarehouseRemark(1L, 8L,
new WarehouseRemarkRequest("新备注", 2, 0, "warehouse-stale-plan"), 7L, "u"), BizException.class);
assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT);
verify(repository, never()).touch(anyLong(), anyInt(), anyLong(), anyString());
verify(repository, never()).updateWarehouseRemark(anyLong(), anyLong(), anyInt(), anyInt(),
any(), anyLong(), anyString());
}
@Test
void completedPlanRejectsWarehouseRemarkBeforeAnyWrite() {
when(repository.plan(1L)).thenReturn(plan("COMPLETED", 4));
BizException error = catchThrowableOfType(() -> service.updateWarehouseRemark(1L, 8L,
new WarehouseRemarkRequest("新备注", 4, 0, "warehouse-completed"), 7L, "u"), BizException.class);
assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT);
verify(loadingPickService, never()).lockMutable(anyLong());
verify(repository, never()).item(anyLong(), anyLong());
verify(repository, never()).updateWarehouseRemark(anyLong(), anyLong(), anyInt(), anyInt(),
any(), anyLong(), anyString());
verify(repository, never()).insert(any(LoadingPlanHistoryEntity.class));
}
@Test
void exactWarehouseRemarkReplayIsReadOnly() {
String requestHash = sha256("WAREHOUSE_REMARK|1|8|备注|2|0");
when(repository.operation("warehouse-replay"))
.thenReturn(history(1L, 8L, "WAREHOUSE_REMARK", requestHash));
when(repository.plan(1L)).thenReturn(plan("LOADING", 3));
when(repository.booking(99L)).thenReturn(booking());
when(repository.items(1L)).thenReturn(List.of(item(8, 9, 20, 1)));
Plan result = service.updateWarehouseRemark(1L, 8L,
new WarehouseRemarkRequest("备注", 2, 0, "warehouse-replay"), 7L, "u");
assertThat(result.version()).isEqualTo(3);
verify(loadingPickService, never()).lockMutable(anyLong());
verify(repository, never()).updateWarehouseRemark(anyLong(), anyLong(), anyInt(), anyInt(),
any(), anyLong(), anyString());
}
@Test
void quantityAdjustmentPreservesWarehouseRemark() {
LoadingPlanEntity plan = plan("LOADING", 2);
LoadingPlanItemEntity line = item(8, 9, 20, 1);
line.setWarehouseRemark("装柜时注意外箱");
when(repository.plan(1L)).thenReturn(plan, plan("LOADING", 3));
when(repository.item(1L, 8L)).thenReturn(line);
when(repository.booking(99L)).thenReturn(booking());
when(repository.candidate(100L, 10L, 20L)).thenReturn(candidate(10));
when(repository.items(1L)).thenReturn(List.of(line));
when(repository.touch(1L, 2, 7L, "u")).thenReturn(1);
when(repository.update(line)).thenReturn(1);
service.adjust(1L, 8L, new AdjustRequest(2, 2, 0, "adjust-remark"), 7L, "u");
ArgumentCaptor<LoadingPlanItemEntity> updated = ArgumentCaptor.forClass(LoadingPlanItemEntity.class);
verify(repository).update(updated.capture());
assertThat(updated.getValue().getWarehouseRemark()).isEqualTo("装柜时注意外箱");
}
@Test
void preservesDownstreamCode() {
stubAssignment();
@@ -368,13 +617,135 @@ class SmartLoadingServiceTest {
assertThat(result.status()).isEqualTo("EDITING");
verify(repository).push(1, "key", 3, 7, "u");
ArgumentCaptor<LoadingPlanHistoryEntity> history = ArgumentCaptor.forClass(LoadingPlanHistoryEntity.class);
verify(repository).insert(history.capture());
assertThat(history.getValue().getActionType()).isEqualTo("PUSH_PRELOAD");
assertThat(history.getValue().getAfterSnapshot())
.contains(sha256("PUSH_PRELOAD|1|3"));
var ordered = inOrder(splitPreloadService, loadingModelBridge);
ordered.verify(splitPreloadService).ensurePendingForLoadingAllocation(9L, 10L, 7L, "u");
ordered.verify(loadingModelBridge).bindAllocatedBeforeLoading(1, 7, "u", "key");
verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString());
}
@Test
void rejectsOverweightPlanBeforePushEvenWithoutContainerNumber() {
void exactPushReplayIsReadOnly() {
LoadingPlanEntity pushed = plan("EDITING", 4);
pushed.setPushIdempotencyKey("key");
when(repository.plan(1)).thenReturn(pushed);
when(repository.booking(99)).thenReturn(booking());
when(repository.operation("key"))
.thenReturn(history(1, null, "PUSH_PRELOAD", sha256("PUSH_PRELOAD|1|3")));
Plan replay = service.pushPreload(1, "key", new PushRequest(3), 7, "u");
assertThat(replay.version()).isEqualTo(4);
verify(splitPreloadService, never()).ensurePendingForLoadingAllocation(anyLong(), anyLong(), anyLong(), anyString());
verify(loadingModelBridge, never()).bindAllocatedBeforeLoading(anyLong(), anyLong(), anyString(), anyString());
verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString());
verify(repository, never()).insert(any(LoadingPlanHistoryEntity.class));
verify(repository, never()).push(anyLong(), anyString(), anyInt(), anyLong(), anyString());
}
@Test
void samePushKeyWithDifferentRequestIsRejectedWithoutWrites() {
LoadingPlanEntity pushed = plan("EDITING", 4);
pushed.setPushIdempotencyKey("key");
when(repository.plan(1)).thenReturn(pushed);
when(repository.booking(99)).thenReturn(booking());
when(repository.operation("key"))
.thenReturn(history(1, null, "PUSH_PRELOAD", sha256("PUSH_PRELOAD|1|3")));
BizException error = catchThrowableOfType(
() -> service.pushPreload(1, "key", new PushRequest(4), 7, "u"), BizException.class);
assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT);
assertThat(error.getMessage()).contains("不同推送请求");
verify(repository, never()).items(anyLong());
verify(repository, never()).push(anyLong(), anyString(), anyInt(), anyLong(), anyString());
verify(repository, never()).insert(any(LoadingPlanHistoryEntity.class));
verifyNoPushSideEffects();
}
@Test
void newPushKeyAfterSuccessfulPushIsRejectedWithoutWrites() {
LoadingPlanEntity pushed = plan("EDITING", 4);
pushed.setPushIdempotencyKey("first-key");
when(repository.plan(1)).thenReturn(pushed);
when(repository.booking(99)).thenReturn(booking());
BizException error = catchThrowableOfType(
() -> service.pushPreload(1, "new-key", new PushRequest(3), 7, "u"), BizException.class);
assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT);
assertThat(error.getMessage()).contains("新的幂等键");
verify(repository, never()).push(anyLong(), anyString(), anyInt(), anyLong(), anyString());
verifyNoPushSideEffects();
}
@Test
void pushKeyUsedByAnotherPlanOperationIsRejectedBeforeWrites() {
LoadingPlanEntity editing = plan("EDITING", 3);
when(repository.plan(1)).thenReturn(editing);
when(repository.booking(99)).thenReturn(booking());
when(repository.operation("used-key"))
.thenReturn(history(1, 8L, "REMOVE_ITEM", sha256("DELETE|1|8|3|0")));
BizException error = catchThrowableOfType(
() -> service.pushPreload(1, "used-key", new PushRequest(3), 7, "u"), BizException.class);
assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT);
assertThat(error.getMessage()).contains("不同推送请求");
verify(repository, never()).items(anyLong());
verify(repository, never()).push(anyLong(), anyString(), anyInt(), anyLong(), anyString());
verify(repository, never()).insert(any(LoadingPlanHistoryEntity.class));
verifyNoPushSideEffects();
}
@Test
void bindingFailureStopsBeforePdaAndPropagatesForTransactionRollback() {
LoadingPlanEntity editing = plan("EDITING", 3);
LoadingPlanEntity pushed = plan("EDITING", 4);
pushed.setPushIdempotencyKey("key");
when(repository.plan(1)).thenReturn(editing, pushed);
when(repository.items(1)).thenReturn(List.of(item(8, 9, 20, 1)));
when(repository.booking(99)).thenReturn(booking());
when(repository.push(1, "key", 3, 7, "u")).thenReturn(1);
doThrow(new BizException(ResultCode.CONFLICT, "预装计划不存在"))
.when(loadingModelBridge).bindAllocatedBeforeLoading(1, 7, "u", "key");
BizException error = catchThrowableOfType(
() -> service.pushPreload(1, "key", new PushRequest(3), 7, "u"), BizException.class);
assertThat(error.getMessage()).contains("预装计划不存在");
verify(repository).push(1, "key", 3, 7, "u");
verify(repository).insert(any(LoadingPlanHistoryEntity.class));
verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString());
}
@Test
void concurrentPushKeyClaimReturnsConflictBeforeBridgeAndPda() {
LoadingPlanEntity editing = plan("EDITING", 3);
when(repository.plan(1)).thenReturn(editing);
when(repository.items(1)).thenReturn(List.of(item(8, 9, 20, 1)));
when(repository.booking(99)).thenReturn(booking());
when(repository.push(1, "race-key", 3, 7, "u")).thenReturn(1);
doThrow(new DuplicateKeyException("duplicate operation_id"))
.when(repository).insert(any(LoadingPlanHistoryEntity.class));
BizException error = catchThrowableOfType(
() -> service.pushPreload(1, "race-key", new PushRequest(3), 7, "u"), BizException.class);
assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT);
assertThat(error.getMessage()).contains("不同推送请求");
verify(loadingModelBridge, never()).bindAllocatedBeforeLoading(anyLong(), anyLong(), anyString(), anyString());
verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString());
}
@Test
void rejectsOverweightPlanBeforePush() {
LoadingPlanEntity editing = plan("EDITING", 1);
ContainerBookingEntity booking = booking();
booking.setContainerNo(null);
booking.setMaxWeightKg(new BigDecimal("1"));
LoadingPlanItemEntity overweight = item(8, 9, 20, 1);
overweight.setGrossWeightKg(new BigDecimal("2"));
@@ -389,25 +760,46 @@ class SmartLoadingServiceTest {
assertThat(error.getResultCode()).isEqualTo(ResultCode.VALIDATION_FAILED);
assertThat(error.getMessage()).contains("重量").contains("110%");
verify(repository, never()).push(anyLong(), anyString(), anyInt(), anyLong(), anyString());
verify(loadingPickService, never()).begin(anyLong(), any(), anyLong(), anyString(), anyList());
verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString());
}
@Test
void pushesWithoutContainerNumberWhenCapacityIsWithinLimit() {
void pushesWithoutContainerNumberAndDoesNotOpenPda() {
LoadingPlanEntity editing = plan("EDITING", 1);
LoadingPlanEntity pushed = plan("EDITING", 2);
ContainerBookingEntity booking = booking();
booking.setContainerNo(null);
when(repository.plan(1)).thenReturn(editing, pushed);
when(repository.plan(1)).thenReturn(editing, plan("EDITING", 2));
when(repository.booking(99)).thenReturn(booking);
when(repository.items(1)).thenReturn(List.of(item(8, 9, 20, 1)));
when(repository.push(1, "no-container-key", 1, 7, "u")).thenReturn(1);
Plan result = service.pushPreload(1, "no-container-key", new PushRequest(1), 7, "u");
service.pushPreload(1, "no-container-key", new PushRequest(1), 7, "u");
assertThat(result.status()).isEqualTo("EDITING");
verify(repository).push(1, "no-container-key", 1, 7, "u");
verify(loadingPickService, never()).begin(anyLong(), any(), anyLong(), anyString(), anyList());
verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString());
}
@Test
void exactReplayWhenContainerNumberIsStillMissingIsReadOnly() {
LoadingPlanEntity pushed = plan("EDITING", 2);
pushed.setPushIdempotencyKey("replay-key");
ContainerBookingEntity booking = booking();
booking.setContainerNo(" ");
when(repository.plan(1)).thenReturn(pushed);
when(repository.booking(99)).thenReturn(booking);
when(repository.operation("replay-key"))
.thenReturn(history(1, null, "PUSH_PRELOAD", sha256("PUSH_PRELOAD|1|2")));
service.pushPreload(1, "replay-key", new PushRequest(2), 7, "u");
verify(repository, never()).push(anyLong(), anyString(), anyInt(), anyLong(), anyString());
verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString());
}
private void verifyNoPushSideEffects() {
verify(splitPreloadService, never()).ensurePendingForLoadingAllocation(anyLong(), anyLong(), anyLong(), anyString());
verify(loadingModelBridge, never()).bindAllocatedBeforeLoading(anyLong(), anyLong(), anyString(), anyString());
verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString());
}
private void stubAssignment() {
@@ -449,14 +841,14 @@ class SmartLoadingServiceTest {
private static LoadingPlanItemEntity item(long id, long account, long detail, int boxes) {
LoadingPlanItemEntity value = new LoadingPlanItemEntity();
value.setId(id); value.setLoadingPlanId(1L); value.setAccountId(account);
value.setInboundDetailId(detail); value.setPackageCount(boxes);
value.setInboundId(10L); value.setInboundDetailId(detail); value.setPackageCount(boxes);
value.setVersion(0); value.setDeleted(false);
return value;
}
private static Candidate candidate(int boxes) {
return new Candidate(9L, 10L, 20L, 30L, "O", "I", 100L, "FBA", "FBA渠道", 200L,
"FBA1", "FBA仓", "客户", "货物", boxes, new BigDecimal("2"), new BigDecimal("0.5"));
"FBA1", "FBA仓", "客户", "货物", boxes, new BigDecimal("2"), new BigDecimal("1.5"), new BigDecimal("0.5"));
}
private static ContainerBookingEntity booking() {
@@ -5,15 +5,21 @@ import com.baiyue.firstleg.domain.TruckAppointmentStatus;
import com.baiyue.firstleg.dto.TruckAppointmentDtos.ActionRequest;
import com.baiyue.firstleg.dto.TruckAppointmentDtos.SaveRequest;
import com.baiyue.firstleg.entity.TruckAppointmentEntity;
import com.baiyue.firstleg.entity.TruckAppointmentAttachmentEntity;
import com.baiyue.firstleg.entity.TruckAppointmentHistoryEntity;
import com.baiyue.firstleg.entity.TruckAppointmentOperationEntity;
import com.baiyue.firstleg.entity.LoadingPlanEntity;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.repository.TruckAppointmentRepository;
import com.baiyue.firstleg.repository.SmartLoadingRepository;
import com.baiyue.firstleg.repository.ContainerRepository;
import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity;
import com.baiyue.firstleg.client.IamCustomerSnapshotClient;
import com.baiyue.firstleg.client.TruckAppointmentFileClient;
import com.baiyue.firstleg.client.WarehouseSupplierClient;
import com.baiyue.common.core.api.ApiResponse;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
@@ -26,6 +32,7 @@ import com.fasterxml.jackson.databind.json.JsonMapper;
import java.lang.reflect.Constructor;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -36,10 +43,46 @@ class TruckAppointmentServiceTest {
@Mock TruckAppointmentRepository repository;
@Mock OceanBookingRepository oceanBookings;
@Mock SmartLoadingRepository loadingPlans;
@Mock ContainerRepository containers;
@Mock WarehouseSupplierSnapshotService suppliers;
@Mock IamCustomerSnapshotService customers;
@Mock TruckAppointmentFileClient files;
@Mock FirstlegDocumentNumberGenerator documentNumbers;
@Spy ObjectMapper objectMapper = JsonMapper.builder().findAndAddModules().build();
@InjectMocks TruckAppointmentService service;
@BeforeEach void setUp() { MockitoAnnotations.openMocks(this); }
@BeforeEach void setUp() { MockitoAnnotations.openMocks(this);
service.internalToken = "test-internal-token";
lenient().when(documentNumbers.next("TA")).thenReturn("TA-260818-0001");
when(customers.active(anyLong(), any())).thenAnswer(invocation ->
new IamCustomerSnapshotClient.Customer(invocation.getArgument(0), "C-7",
"Canonical Customer", "ACTIVE", null, null));
when(suppliers.trucking(any(), any())).thenReturn(
new WarehouseSupplierClient.Snapshot(8L, "Canonical Carrier", "TRUCKING", "ACTIVE"));
lenient().when(repository.attachments(anyLong())).thenReturn(List.of());
}
@Test void detailResolvesStableNamesAndIdentifiersFromLinkedResources() {
TruckAppointmentEntity appointment = new TruckAppointmentEntity();
appointment.setId(51L); appointment.setOceanBookingId(61L); appointment.setContainerBookingId(71L);
when(repository.find(51L)).thenReturn(appointment);
OceanBookingEntity booking = new OceanBookingEntity(); booking.setId(61L); booking.setBookingNo("OB61");
booking.setWarehouseName("Shenzhen Transit"); booking.setShippingCompanyId(81L);
booking.setShippingCompanyName("Carrier Line"); booking.setBookingAgentId(91L); booking.setBookingAgentName("Agent Co");
when(oceanBookings.find(61L)).thenReturn(booking);
ContainerBookingEntity container = new ContainerBookingEntity(); container.setId(71L);
container.setCabinetNo("CAB-71"); container.setContainerNo("MSCU1234567");
when(containers.find(71L)).thenReturn(container);
TruckAppointmentEntity detail = service.detail(51L);
assertThat(detail.getOceanBookingNo()).isEqualTo("OB61");
assertThat(detail.getWarehouseName()).isEqualTo("Shenzhen Transit");
assertThat(detail.getShippingCompanyName()).isEqualTo("Carrier Line");
assertThat(detail.getBookingAgentName()).isEqualTo("Agent Co");
assertThat(detail.getCabinetNo()).isEqualTo("CAB-71");
assertThat(detail.getContainerNo()).isEqualTo("MSCU1234567");
}
@Test void createRequiresZeroVersionAndInitialisesDraft() {
SaveRequest invalid = request(1);
@@ -47,47 +90,148 @@ class TruckAppointmentServiceTest {
verifyNoInteractions(repository);
}
@Test void createRejectsBookingThatIsNotReleased() {
@Test void createWithoutBookingContainerOrPlanWritesIndependentDraft() {
SaveRequest request = mock(SaveRequest.class);
when(request.expectedVersion()).thenReturn(0);
when(request.oceanBookingId()).thenReturn(99L);
OceanBookingEntity booking = new OceanBookingEntity(); booking.setId(99L); booking.setStatus("CONFIRMED");
when(oceanBookings.find(99L)).thenReturn(booking);
assertThatThrownBy(() -> service.create(7L, "admin", request)).isInstanceOf(BizException.class).hasMessageContaining("放舱");
verify(repository, never()).insert(any());
when(request.customerId()).thenReturn(7L);
when(request.carrierId()).thenReturn(8L);
when(request.plannedLoadingAt()).thenReturn(LocalDateTime.of(2026, 8, 17, 10, 0));
when(request.oceanBookingId()).thenReturn(null);
when(request.containerBookingId()).thenReturn(null);
when(request.loadingPlanId()).thenReturn(null);
when(request.warehouseId()).thenReturn(null);
TruckAppointmentEntity created = service.create(7L, "admin", request);
ArgumentCaptor<TruckAppointmentEntity> entity = ArgumentCaptor.forClass(TruckAppointmentEntity.class);
verify(repository).insert(entity.capture());
assertThat(entity.getValue().getOceanBookingId()).isNull();
assertThat(entity.getValue().getContainerBookingId()).isNull();
assertThat(entity.getValue().getLoadingPlanId()).isNull();
assertThat(entity.getValue().getCustomerName()).isEqualTo("Canonical Customer");
assertThat(entity.getValue().getCarrierName()).isEqualTo("Canonical Carrier");
assertThat(entity.getValue().getAppointmentNo()).isEqualTo("TA-260818-0001");
assertThat(created.getAttachmentCount()).isZero();
verifyNoInteractions(oceanBookings, containers, loadingPlans);
}
@Test void createAcceptsReleasedBookingAndWritesDraft() {
@Test void createAllowsDraftWithoutBillOfLadingOrAttachment() {
SaveRequest request = validRequest();
service.create(7L, "admin", request);
ArgumentCaptor<TruckAppointmentEntity> entity = ArgumentCaptor.forClass(TruckAppointmentEntity.class);
verify(repository).insert(entity.capture());
assertThat(entity.getValue().getBillOfLadingNo()).isNull();
verifyNoInteractions(files);
}
@Test void createFreezesReleasedBookingBillAndRejectsClientMismatch() {
SaveRequest request = validRequest();
when(request.oceanBookingId()).thenReturn(99L);
OceanBookingEntity booking = new OceanBookingEntity();
booking.setId(99L); booking.setStatus("RELEASED"); booking.setCustomerId(7L);
booking.setBillOfLadingNo(" BL-BOOKING-1 ");
when(oceanBookings.find(99L)).thenReturn(booking);
service.create(7L, "admin", request);
ArgumentCaptor<TruckAppointmentEntity> entity = ArgumentCaptor.forClass(TruckAppointmentEntity.class);
verify(repository).insert(entity.capture());
assertThat(entity.getValue().getBillOfLadingNo()).isEqualTo("BL-BOOKING-1");
SaveRequest mismatch = validRequest();
when(mismatch.oceanBookingId()).thenReturn(99L);
when(mismatch.billOfLadingNo()).thenReturn("BL-OTHER");
assertThatThrownBy(() -> service.create(7L, "admin", mismatch))
.isInstanceOf(BizException.class).hasMessageContaining("billOfLadingNo").hasMessageContaining("不一致");
}
@Test void createAcceptsConfirmedBookingWithoutContainerOrPlan() {
SaveRequest request = mock(SaveRequest.class);
when(request.expectedVersion()).thenReturn(0);
when(request.oceanBookingId()).thenReturn(99L);
when(request.customerId()).thenReturn(7L);
when(request.carrierId()).thenReturn(8L);
when(request.plannedLoadingAt()).thenReturn(LocalDateTime.of(2026, 8, 17, 10, 0));
when(request.containerBookingId()).thenReturn(null);
when(request.loadingPlanId()).thenReturn(null);
OceanBookingEntity booking = new OceanBookingEntity(); booking.setId(99L); booking.setStatus("RELEASED"); booking.setCustomerId(7L);
when(request.warehouseId()).thenReturn(null);
OceanBookingEntity booking = new OceanBookingEntity(); booking.setId(99L); booking.setStatus("CONFIRMED"); booking.setCustomerId(7L); booking.setWarehouseId(18L);
when(oceanBookings.find(99L)).thenReturn(booking);
service.create(7L, "admin", request);
verify(repository).insert(any(TruckAppointmentEntity.class));
ArgumentCaptor<TruckAppointmentEntity> entity = ArgumentCaptor.forClass(TruckAppointmentEntity.class);
verify(repository).insert(entity.capture());
assertThat(entity.getValue().getCustomerId()).isEqualTo(7L);
assertThat(entity.getValue().getCustomerName()).isEqualTo("Canonical Customer");
assertThat(entity.getValue().getOceanBookingId()).isEqualTo(99L);
assertThat(entity.getValue().getContainerBookingId()).isNull();
assertThat(entity.getValue().getLoadingPlanId()).isNull();
assertThat(entity.getValue().getWarehouseId()).isEqualTo(18L);
verify(repository).history(any(TruckAppointmentHistoryEntity.class));
}
@Test void createRejectsBookingFromAnotherCustomer() {
SaveRequest request = validRequest();
when(request.oceanBookingId()).thenReturn(99L);
OceanBookingEntity booking = new OceanBookingEntity();
booking.setId(99L); booking.setCustomerId(17L);
when(oceanBookings.find(99L)).thenReturn(booking);
assertThatThrownBy(() -> service.create(7L, "admin", request))
.isInstanceOf(BizException.class).hasMessageContaining("customerId").hasMessageContaining("订舱");
verify(repository, never()).insert(any());
}
@Test void createWithBookingAndContainerRequiresTheirAllocation() {
SaveRequest request = validRequest();
when(request.oceanBookingId()).thenReturn(99L);
when(request.containerBookingId()).thenReturn(88L);
OceanBookingEntity booking = new OceanBookingEntity();
booking.setId(99L); booking.setCustomerId(7L); booking.setWarehouseId(18L);
ContainerBookingEntity container = new ContainerBookingEntity();
container.setId(88L); container.setCustomerId(7L); container.setWarehouseId(18L);
when(oceanBookings.find(99L)).thenReturn(booking);
when(containers.find(88L)).thenReturn(container);
when(oceanBookings.allocations(99L)).thenReturn(List.of());
assertThatThrownBy(() -> service.create(7L, "admin", request))
.isInstanceOf(BizException.class).hasMessageContaining("未分配至该订舱");
verify(repository, never()).insert(any());
}
@Test void createRejectsLoadingPlanWithoutContainerAssociation() {
SaveRequest request = validRequest();
when(request.loadingPlanId()).thenReturn(77L);
assertThatThrownBy(() -> service.create(7L, "admin", request))
.isInstanceOf(BizException.class).hasMessageContaining("loadingPlanId")
.hasMessageContaining("containerBookingId");
verify(repository, never()).insert(any());
}
@Test void createRejectsLoadingPlanFromAnotherContainer() {
SaveRequest request = mock(SaveRequest.class);
when(request.expectedVersion()).thenReturn(0);
when(request.oceanBookingId()).thenReturn(99L);
when(request.containerBookingId()).thenReturn(88L);
when(request.loadingPlanId()).thenReturn(77L);
OceanBookingEntity booking = new OceanBookingEntity(); booking.setId(99L); booking.setStatus("RELEASED");
when(request.customerId()).thenReturn(7L);
when(request.carrierId()).thenReturn(8L);
when(request.plannedLoadingAt()).thenReturn(LocalDateTime.of(2026, 8, 17, 10, 0));
when(request.warehouseId()).thenReturn(null);
OceanBookingEntity booking = new OceanBookingEntity(); booking.setId(99L); booking.setStatus("CONFIRMED"); booking.setCustomerId(7L);
when(oceanBookings.find(99L)).thenReturn(booking);
ContainerBookingEntity container = new ContainerBookingEntity(); container.setId(88L);
when(oceanBookings.containerBooking(88L)).thenReturn(container);
when(containers.find(88L)).thenReturn(container);
var allocation = new OceanBookingContainerAllocationEntity();
allocation.setContainerBookingId(88L);
when(oceanBookings.allocations(99L)).thenReturn(java.util.List.of(allocation));
LoadingPlanEntity plan = new LoadingPlanEntity(); plan.setId(77L); plan.setContainerBookingId(66L);
when(loadingPlans.plan(77L)).thenReturn(plan);
LoadingPlanEntity plan = new LoadingPlanEntity(); plan.setId(78L); plan.setContainerBookingId(88L);
when(loadingPlans.planByBooking(88L)).thenReturn(plan);
assertThatThrownBy(() -> service.create(7L, "admin", request))
.isInstanceOf(BizException.class).hasMessageContaining("loadingPlanId").hasMessageContaining("柜体");
.isInstanceOf(BizException.class).hasMessageContaining("loadingPlanId").hasMessageContaining("当前装柜计划");
verify(repository, never()).insert(any());
}
@@ -97,17 +241,21 @@ class TruckAppointmentServiceTest {
when(request.oceanBookingId()).thenReturn(99L);
when(request.containerBookingId()).thenReturn(88L);
when(request.loadingPlanId()).thenReturn(77L);
OceanBookingEntity booking = new OceanBookingEntity(); booking.setId(99L); booking.setStatus("RELEASED");
when(request.customerId()).thenReturn(7L);
when(request.carrierId()).thenReturn(8L);
when(request.plannedLoadingAt()).thenReturn(LocalDateTime.of(2026, 8, 17, 10, 0));
when(request.warehouseId()).thenReturn(null);
OceanBookingEntity booking = new OceanBookingEntity(); booking.setId(99L); booking.setStatus("CONFIRMED"); booking.setCustomerId(7L);
when(oceanBookings.find(99L)).thenReturn(booking);
ContainerBookingEntity container = new ContainerBookingEntity(); container.setId(88L);
when(oceanBookings.containerBooking(88L)).thenReturn(container);
when(containers.find(88L)).thenReturn(container);
var allocation = new OceanBookingContainerAllocationEntity();
allocation.setContainerBookingId(88L);
when(oceanBookings.allocations(99L)).thenReturn(java.util.List.of(allocation));
when(loadingPlans.plan(77L)).thenReturn(null);
when(loadingPlans.planByBooking(88L)).thenReturn(null);
assertThatThrownBy(() -> service.create(7L, "admin", request))
.isInstanceOf(BizException.class).hasMessageContaining("装柜计划不存在");
.isInstanceOf(BizException.class).hasMessageContaining("loadingPlanId").hasMessageContaining("当前装柜计划");
verify(repository, never()).insert(any());
}
@@ -145,6 +293,57 @@ class TruckAppointmentServiceTest {
verify(repository, never()).history(any());
}
@Test void dispatchRequiresBillOfLadingNumberAndActiveOwnedAttachment() {
TruckAppointmentEntity missingBill = entity(TruckAppointmentStatus.PENDING_DISPATCH, 1);
when(repository.claimOperation(anyLong(), eq(1L), eq("DISPATCH"), eq("dispatch-no-bill"), anyString())).thenReturn(true);
when(repository.find(1L)).thenReturn(missingBill);
assertThatThrownBy(() -> service.action(1L, "DISPATCH", new ActionRequest(1, null),
"dispatch-no-bill", null, 7L, "admin"))
.isInstanceOf(BizException.class).hasMessageContaining("提单号");
TruckAppointmentEntity missingAttachment = entity(TruckAppointmentStatus.PENDING_DISPATCH, 1);
missingAttachment.setBillOfLadingNo("BL-1");
missingAttachment.setCustomerId(7L);
when(repository.claimOperation(anyLong(), eq(1L), eq("DISPATCH"), eq("dispatch-no-file"), anyString())).thenReturn(true);
when(repository.find(1L)).thenReturn(missingAttachment);
when(repository.attachments(1L)).thenReturn(List.of());
assertThatThrownBy(() -> service.action(1L, "DISPATCH", new ActionRequest(1, null),
"dispatch-no-file", null, 7L, "admin"))
.isInstanceOf(BizException.class).hasMessageContaining("有效提单附件");
verify(repository, never()).dispatch(anyLong(), anyString(), anyString(), anyInt());
}
@Test void dispatchFreezesReleasedBookingBillAndTransitionsOnceWithActiveAttachment() {
TruckAppointmentEntity before = entity(TruckAppointmentStatus.PENDING_DISPATCH, 1);
before.setCustomerId(7L); before.setOceanBookingId(99L); before.setBillOfLadingNo(null);
TruckAppointmentEntity after = entity(TruckAppointmentStatus.DISPATCHED, 2);
after.setCustomerId(7L); after.setOceanBookingId(99L); after.setBillOfLadingNo("BL-BOOKING-1");
OceanBookingEntity booking = new OceanBookingEntity();
booking.setId(99L); booking.setStatus("RELEASED"); booking.setBillOfLadingNo("BL-BOOKING-1");
TruckAppointmentAttachmentEntity attachment = new TruckAppointmentAttachmentEntity();
attachment.setFileId(88L); attachment.setAttachmentType("BILL_OF_LADING");
when(repository.claimOperation(anyLong(), eq(1L), eq("DISPATCH"), eq("dispatch-ok"), anyString())).thenReturn(true);
when(repository.find(1L)).thenReturn(before, after);
when(oceanBookings.find(99L)).thenReturn(booking);
when(repository.attachments(1L)).thenReturn(List.of(attachment));
when(files.metadata("test-internal-token", 7L, "TRUCK_APPOINTMENT_ATTACHMENT", "1", 88L))
.thenReturn(ApiResponse.success(new TruckAppointmentFileClient.Metadata(88L, 7L,
"TRUCK_APPOINTMENT_ATTACHMENT", "1", "bill.pdf", "hash", "application/pdf", 10L)));
when(repository.dispatch(1L, "PENDING_DISPATCH", "BL-BOOKING-1", 1)).thenReturn(1);
when(repository.completeOperation(eq(1L), eq("DISPATCH"), eq("dispatch-ok"), anyString())).thenReturn(1);
TruckAppointmentEntity result = service.action(1L, "DISPATCH", new ActionRequest(1, null),
"dispatch-ok", null, 7L, "admin");
assertThat(result.getStatus()).isEqualTo(TruckAppointmentStatus.DISPATCHED);
assertThat(result.getBillOfLadingNo()).isEqualTo("BL-BOOKING-1");
verify(repository).dispatch(1L, "PENDING_DISPATCH", "BL-BOOKING-1", 1);
verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt());
verify(repository).history(any(TruckAppointmentHistoryEntity.class));
}
@Test void actionRejectsMissingKeyAndMismatchedClientHash() {
var request = new ActionRequest(0, "提交");
assertThatThrownBy(() -> service.action(1L, "SUBMIT", request, null, null, 7L, "admin"))
@@ -184,6 +383,56 @@ class TruckAppointmentServiceTest {
assertThat(history.getValue().getToStatus()).isEqualTo("RESCHEDULED");
}
@Test void pdaCompletionDoesNotRequireTruckAppointment() {
when(repository.activeForUpdate(99L)).thenReturn(List.of());
service.completeLoadingFromPda(99L, 20L, 7L, "admin");
verify(repository, never()).completeLoadingFromPda(anyLong(), anyString(), anyInt());
verify(repository, never()).history(any());
}
@Test void pdaCompletionLocksAndCompletesTheOnlyActiveTruckWithHistory() {
TruckAppointmentEntity before = entity(TruckAppointmentStatus.LOADING, 4);
TruckAppointmentEntity after = entity(TruckAppointmentStatus.COMPLETED, 5);
when(repository.activeForUpdate(99L)).thenReturn(List.of(before));
when(repository.completeLoadingFromPda(1L, "LOADING", 4)).thenReturn(1);
when(repository.find(1L)).thenReturn(after);
service.completeLoadingFromPda(99L, 20L, 7L, "admin");
verify(repository).completeLoadingFromPda(1L, "LOADING", 4);
ArgumentCaptor<TruckAppointmentHistoryEntity> history = ArgumentCaptor.forClass(TruckAppointmentHistoryEntity.class);
verify(repository).history(history.capture());
assertThat(history.getValue().getFromStatus()).isEqualTo("LOADING");
assertThat(history.getValue().getToStatus()).isEqualTo("COMPLETED");
assertThat(history.getValue().getAction()).isEqualTo("PDA_COMPLETE_LOADING");
assertThat(history.getValue().getOperatorId()).isEqualTo(7L);
assertThat(history.getValue().getReason()).contains("20");
}
@Test void pdaCompletionRejectsMultipleActiveTruckAppointmentsWithoutMutation() {
when(repository.activeForUpdate(99L)).thenReturn(List.of(
entity(TruckAppointmentStatus.LOADING, 1), entity(TruckAppointmentStatus.DEPARTED, 2)));
assertThatThrownBy(() -> service.completeLoadingFromPda(99L, 20L, 7L, "admin"))
.isInstanceOf(BizException.class).hasMessageContaining("多条有效拖车预约");
verify(repository, never()).completeLoadingFromPda(anyLong(), anyString(), anyInt());
verify(repository, never()).history(any());
}
@Test void pdaCompletionRejectsConcurrentTruckModificationWithoutHistory() {
TruckAppointmentEntity before = entity(TruckAppointmentStatus.RESCHEDULED, 4);
when(repository.activeForUpdate(99L)).thenReturn(List.of(before));
when(repository.completeLoadingFromPda(1L, "RESCHEDULED", 4)).thenReturn(0);
assertThatThrownBy(() -> service.completeLoadingFromPda(99L, 20L, 7L, "admin"))
.isInstanceOf(BizException.class).hasMessageContaining("并发修改");
verify(repository, never()).history(any());
}
private String requestHash(String action, long id, Object request) throws Exception {
String canonical = action + '|' + id + '|' + objectMapper.writeValueAsString(request);
return java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256")
@@ -201,4 +450,17 @@ class TruckAppointmentServiceTest {
return (SaveRequest) c.newInstance(args);
} catch (ReflectiveOperationException ex) { throw new AssertionError(ex); }
}
private static SaveRequest validRequest() {
SaveRequest request = mock(SaveRequest.class);
when(request.expectedVersion()).thenReturn(0);
when(request.customerId()).thenReturn(7L);
when(request.carrierId()).thenReturn(8L);
when(request.plannedLoadingAt()).thenReturn(LocalDateTime.of(2026, 8, 17, 10, 0));
when(request.oceanBookingId()).thenReturn(null);
when(request.containerBookingId()).thenReturn(null);
when(request.loadingPlanId()).thenReturn(null);
when(request.warehouseId()).thenReturn(null);
return request;
}
}
@@ -0,0 +1,72 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.WarehouseSupplierClient;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.springframework.test.util.ReflectionTestUtils;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import static org.assertj.core.api.Assertions.assertThat;
import com.baiyue.common.core.api.ApiResponse;
class WarehouseSupplierSnapshotServiceTest {
@Mock WarehouseSupplierClient client;
private WarehouseSupplierSnapshotService service;
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
service = new WarehouseSupplierSnapshotService();
ReflectionTestUtils.setField(service, "client", client);
ReflectionTestUtils.setField(service, "internalToken", "internal-token");
}
@Test
void rejectsFreeTextWithoutSupplierId() {
assertThatThrownBy(() -> service.trucking(null, "任意承运商"))
.isInstanceOf(BizException.class).hasMessageContaining("自由文本");
verifyNoInteractions(client);
}
@Test
void resolvesActiveBookingSupplierByCategoryWithoutScopeAndUsesServerName() {
when(client.get("internal-token", 7L, true, "BOOKING"))
.thenReturn(ApiResponse.success(new WarehouseSupplierClient.Snapshot(7L, "海运代理", "BOOKING", "ACTIVE")));
var snapshot = service.booking(7L, "海运代理");
assertThat(snapshot.name()).isEqualTo("海运代理");
verify(client).get("internal-token", 7L, true, "BOOKING");
}
@Test
void feeWithoutCategoryAcceptsAnyActiveWarehouseSupplier() {
when(client.get("internal-token", 8L, true, null))
.thenReturn(ApiResponse.success(new WarehouseSupplierClient.Snapshot(
8L, "任意启用供应商", "CERTIFICATE", "ACTIVE")));
assertThat(service.fee(8L, null).name()).isEqualTo("任意启用供应商");
verify(client).get("internal-token", 8L, true, null);
}
@Test
void rejectsClientNameThatDiffersFromWarehouseSnapshot() {
when(client.get(anyString(), eq(9L), eq(true), eq("CUSTOMS")))
.thenReturn(ApiResponse.success(new WarehouseSupplierClient.Snapshot(9L, "真实报关行", "CUSTOMS", "ACTIVE")));
assertThatThrownBy(() -> service.customs(9L, "伪造名称"))
.isInstanceOf(BizException.class).hasMessageContaining("不一致");
}
@Test
void rejectsSupplierSnapshotWithWrongCategoryOrInactiveStatus() {
when(client.get("internal-token", 7L, true, "BOOKING"))
.thenReturn(ApiResponse.success(new WarehouseSupplierClient.Snapshot(
7L, "错误代理", "TRUCKING", "ACTIVE")));
assertThatThrownBy(() -> service.booking(7L, null))
.isInstanceOf(BizException.class).hasMessageContaining("类别不匹配");
}
}
@@ -1,6 +1,5 @@
package com.baiyue.firstleg.split.controller;
import com.baiyue.firstleg.split.dto.SplitRequests.ConfirmPreloadRequest;
import com.baiyue.firstleg.split.dto.SplitRequests.SmartSplitRequest;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
@@ -13,14 +12,19 @@ class PlatformSplitPreloadControllerContractTest {
@Test
void shouldDeclareStableOperationsAndButtonPermissions() throws Exception {
assertAuthority("smart", "hasAuthority('firstleg:inbound:split')", SmartSplitRequest.class);
assertAuthority("confirming", "hasAuthority('firstleg:inbound:preload')", ConfirmPreloadRequest.class);
assertAuthority("confirm", "hasAuthority('firstleg:inbound:preload')", ConfirmPreloadRequest.class);
assertThat(PlatformSplitPreloadController.class
.getDeclaredMethod("detail", com.baiyue.common.security.AuthenticatedUser.class,
long.class, long.class).getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('firstleg:inbound:preload-view')");
}
@Test
void doesNotExposeInboundScopedPreloadConfirmation() {
assertThat(PlatformSplitPreloadController.class.getDeclaredMethods())
.noneMatch(method -> method.getName().equals("confirm")
|| method.getName().equals("confirming"));
}
private void assertAuthority(String name, String expected, Class<?> requestType) throws Exception {
Method method = PlatformSplitPreloadController.class.getDeclaredMethod(name,
com.baiyue.common.security.AuthenticatedUser.class, long.class, long.class,
@@ -1,8 +1,5 @@
package com.baiyue.firstleg.split.service;
import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService;
import com.baiyue.firstleg.split.dto.SplitRequests.ConfirmPreloadRequest;
import com.baiyue.firstleg.split.dto.SplitRequests.PreloadItem;
import com.baiyue.firstleg.split.dto.SplitRequests.SmartSplitRequest;
import com.baiyue.firstleg.split.dto.SplitResponses.SplitActionResponse;
@@ -28,12 +25,11 @@ import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.never;
@ExtendWith(MockitoExtension.class)
class SplitPreloadServiceTest {
@Mock SplitRepository repository;
@Mock LoadingModelBridgeService loadingModelBridge;
@Mock InboundStatusHistoryMapper histories;
@InjectMocks SplitPreloadService service;
private final ObjectMapper objectMapper = new ObjectMapper();
@@ -57,52 +53,31 @@ class SplitPreloadServiceTest {
}
@Test
void confirmsPlanAndMovesInboundToLoading() {
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString())).thenReturn(true);
when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "WAIT_SPLIT", 4));
PreloadPlanEntity plan = new PreloadPlanEntity();
plan.setTenantId(10L); plan.setInboundId(20L); plan.setStatus("CONFIRMING"); plan.setVersion(1);
plan.setItemsJson("[]");
when(repository.findPlan(10, 20)).thenReturn(plan);
org.mockito.Mockito.doNothing().when(loadingModelBridge).requireStarted(10, 20);
when(repository.changePlanStatus(10, 20, "CONFIRMING", "CONFIRMED", 7, "admin", 1)).thenReturn(1);
when(repository.beginLoading(10, 20, 7, 4)).thenReturn(1);
SplitActionResponse result = service.confirmPreload(10, 20, 7, "admin", "k3",
new ConfirmPreloadRequest(1));
assertThat(result.mainStatus()).isEqualTo("LOADING");
verify(loadingModelBridge).requireStarted(10, 20);
verify(repository).beginLoading(10, 20, 7, 4);
void loadingAllocationCreatesPendingPreloadLifecycle() {
when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "WAIT_SPLIT", 3));
when(repository.findPlan(10, 20)).thenReturn(null);
service.ensurePendingForLoadingAllocation(10, 20, 7, "admin");
org.mockito.ArgumentCaptor<PreloadPlanEntity> captor =
org.mockito.ArgumentCaptor.forClass(PreloadPlanEntity.class);
verify(repository).createPlan(captor.capture());
assertThat(captor.getValue().getStatus()).isEqualTo("PENDING");
assertThat(captor.getValue().getItemsJson()).isEqualTo("[]");
assertThat(captor.getValue().getRemark()).isEqualTo("智能装柜分配自动建立");
}
@Test
void startsPreloadConfirmationAfterLoadingBeginsWithLinkedContainerGate() {
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString())).thenReturn(true);
when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "LOADING", 5));
void loadingAllocationDoesNotOverwriteConfirmedPreload() {
when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "WAIT_SPLIT", 3));
PreloadPlanEntity plan = new PreloadPlanEntity();
plan.setTenantId(10L); plan.setInboundId(20L); plan.setStatus("PENDING"); plan.setVersion(3);
plan.setItemsJson("[]");
plan.setStatus("CONFIRMING");
when(repository.findPlan(10, 20)).thenReturn(plan);
org.mockito.Mockito.doNothing().when(loadingModelBridge).requireStarted(10, 20);
when(repository.changePlanStatus(10, 20, "PENDING", "CONFIRMING", 7, "admin", 3)).thenReturn(1);
SplitActionResponse result = service.startConfirming(10, 20, 7, "admin", "k4",
new ConfirmPreloadRequest(3));
assertThatThrownBy(() -> service.ensurePendingForLoadingAllocation(10, 20, 7, "admin"))
.hasMessageContaining("已进入确认流程");
assertThat(result.mainStatus()).isEqualTo("LOADING");
verify(loadingModelBridge).requireStarted(10, 20);
verify(repository).changePlanStatus(10, 20, "PENDING", "CONFIRMING", 7, "admin", 3);
verify(repository, never()).createPlan(any());
}
@Test
void rejectsLoadingPreloadConfirmationWhenLinkedContainersHaveNotStarted() {
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString())).thenReturn(true);
when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "LOADING", 5));
org.mockito.Mockito.doThrow(new IllegalStateException("not started"))
.when(loadingModelBridge).requireStarted(10, 20);
assertThatThrownBy(() -> service.startConfirming(10, 20, 7, "admin", "k5",
new ConfirmPreloadRequest(3)))
.isInstanceOf(IllegalStateException.class)
.hasMessage("not started");
}
}