Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into dev
This commit is contained in:
+26
@@ -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");
|
||||
}
|
||||
}
|
||||
+37
@@ -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"));
|
||||
}
|
||||
}
|
||||
+42
@@ -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");
|
||||
}
|
||||
}
|
||||
+42
-1
@@ -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:"),
|
||||
|
||||
+26
-8
@@ -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) {
|
||||
|
||||
+80
@@ -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;
|
||||
}
|
||||
}
|
||||
+63
@@ -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;
|
||||
}
|
||||
}
|
||||
+20
-36
@@ -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 {
|
||||
|
||||
+5
@@ -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]")
|
||||
|
||||
+21
-6
@@ -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
@@ -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) {
|
||||
|
||||
+36
-2
@@ -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(
|
||||
|
||||
+27
@@ -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:");
|
||||
}
|
||||
}
|
||||
|
||||
+4
-4
@@ -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(
|
||||
|
||||
+25
@@ -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);
|
||||
|
||||
+1
-1
@@ -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);
|
||||
|
||||
+37
@@ -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 模式仅校验渠道可用性");
|
||||
}
|
||||
}
|
||||
|
||||
+14
-1
@@ -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"),
|
||||
|
||||
+3
-2
@@ -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")
|
||||
|
||||
+1
-1
@@ -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));
|
||||
}
|
||||
|
||||
+24
@@ -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("容量和限重");
|
||||
}
|
||||
}
|
||||
+34
@@ -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();
|
||||
}
|
||||
}
|
||||
+124
@@ -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(
|
||||
|
||||
+4
@@ -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");
|
||||
|
||||
+5
@@ -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())
|
||||
|
||||
+2
-4
@@ -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("<", ">");
|
||||
}
|
||||
|
||||
+139
-3
@@ -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);
|
||||
|
||||
+18
-10
@@ -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();
|
||||
|
||||
+8
-8
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
+24
-10
@@ -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 {
|
||||
|
||||
+193
-100
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+8
@@ -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)
|
||||
|
||||
+1
-1
@@ -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) {
|
||||
|
||||
+29
@@ -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("<>");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("草稿完整更新和预装运输白名单都带版本与状态门禁")
|
||||
void guardsUpdatesInSql() {
|
||||
|
||||
+27
@@ -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");
|
||||
}
|
||||
}
|
||||
+20
@@ -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);
|
||||
|
||||
+3
@@ -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")
|
||||
|
||||
+14
-10
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
+20
@@ -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)");
|
||||
}
|
||||
}
|
||||
+5
-3
@@ -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);
|
||||
|
||||
+15
-3
@@ -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))
|
||||
|
||||
+29
@@ -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();
|
||||
|
||||
+22
@@ -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("同票托盘唯一约束仍阻止托盘主数据复用");
|
||||
}
|
||||
}
|
||||
|
||||
+32
-1
@@ -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
|
||||
|
||||
+13
-2
@@ -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);
|
||||
}
|
||||
|
||||
+22
@@ -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'");
|
||||
}
|
||||
}
|
||||
|
||||
+6
-3
@@ -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 =");
|
||||
}
|
||||
}
|
||||
|
||||
+8
-1
@@ -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
|
||||
|
||||
+16
-10
@@ -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");
|
||||
|
||||
+5
-1
@@ -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);
|
||||
|
||||
+21
-2
@@ -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")
|
||||
|
||||
+8
@@ -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");
|
||||
}
|
||||
|
||||
+50
-3
@@ -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")
|
||||
|
||||
+14
-2
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
+71
@@ -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);
|
||||
}
|
||||
}
|
||||
+20
@@ -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");
|
||||
}
|
||||
}
|
||||
-92
@@ -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;
|
||||
}
|
||||
}
|
||||
+72
-7
@@ -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;
|
||||
}
|
||||
|
||||
+132
-27
@@ -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, "整柜拖车费");
|
||||
}
|
||||
}
|
||||
|
||||
+89
-3
@@ -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"));
|
||||
|
||||
+20
-21
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
+61
@@ -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);
|
||||
}
|
||||
}
|
||||
+59
@@ -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("不一致");
|
||||
}
|
||||
}
|
||||
+17
-17
@@ -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("洛杉矶普船海卡");
|
||||
|
||||
-150
@@ -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) {
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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);
|
||||
|
||||
+278
-15
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
+13
-10
@@ -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);
|
||||
|
||||
+46
@@ -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
-1
@@ -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"), "备注");
|
||||
}
|
||||
|
||||
|
||||
+71
-7
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+37
-2
@@ -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) {
|
||||
|
||||
+62
-1
@@ -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
|
||||
|
||||
+8
-8
@@ -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() {
|
||||
|
||||
+281
-19
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+72
@@ -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("类别不匹配");
|
||||
}
|
||||
}
|
||||
+7
-3
@@ -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,
|
||||
|
||||
+19
-44
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user