diff --git a/auth/src/main/java/com/baiyue/auth/client/IamOnboardingClient.java b/auth/src/main/java/com/baiyue/auth/client/IamOnboardingClient.java index bb60517ca..aae981b02 100644 --- a/auth/src/main/java/com/baiyue/auth/client/IamOnboardingClient.java +++ b/auth/src/main/java/com/baiyue/auth/client/IamOnboardingClient.java @@ -5,6 +5,7 @@ import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.cloud.openfeign.FeignClient; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; @@ -16,6 +17,11 @@ public interface IamOnboardingClient { @RequestHeader("X-Internal-Service-Token") String internalToken, @RequestBody OnboardingRequest request); + @GetMapping("/merchant-registrations/{authUserId}") + ApiResponse registration( + @RequestHeader("X-Internal-Service-Token") String internalToken, + @PathVariable("authUserId") Long authUserId); + @DeleteMapping("/merchant-registrations/{authUserId}/compensation") ApiResponse compensate( @RequestHeader("X-Internal-Service-Token") String internalToken, diff --git a/auth/src/main/java/com/baiyue/auth/dto/MerchantRegisterRequest.java b/auth/src/main/java/com/baiyue/auth/dto/MerchantRegisterRequest.java index 8203a100e..d0ca9eb4c 100644 --- a/auth/src/main/java/com/baiyue/auth/dto/MerchantRegisterRequest.java +++ b/auth/src/main/java/com/baiyue/auth/dto/MerchantRegisterRequest.java @@ -12,7 +12,7 @@ public record MerchantRegisterRequest( @NotBlank @Size(min = 8, max = 72) String password, @NotBlank @Size(max = 64) String displayName, @NotBlank @Size(max = 128) String companyName, - @NotBlank @Size(max = 64) String legalPerson, + @Size(max = 64) String legalPerson, @Schema(description = "首个企业管理员的登录手机号;注册时同时初始化企业联系电话,创建后两者独立维护") @NotBlank @Pattern(regexp = "^1[3-9]\\d{9}$") String phone, @Email @Size(max = 128) String contactEmail, diff --git a/auth/src/main/java/com/baiyue/auth/service/MerchantRegistrationService.java b/auth/src/main/java/com/baiyue/auth/service/MerchantRegistrationService.java index 1ce404873..e17c5410e 100644 --- a/auth/src/main/java/com/baiyue/auth/service/MerchantRegistrationService.java +++ b/auth/src/main/java/com/baiyue/auth/service/MerchantRegistrationService.java @@ -35,11 +35,20 @@ public class MerchantRegistrationService { @BusinessAudit(event = "MERCHANT_REGISTER", resourceType = "ACCOUNT", resourceId = "#request.username", description = "商户注册", captureAfter = false) public MerchantRegisterResponse register(MerchantRegisterRequest request) { - if (accountRepository.existsByUsername(request.username())) { + AuthAccount usernameAccount = accountRepository.findByUsername(request.username()); + AuthAccount phoneAccount = accountRepository.findByPhone(request.phone()); + if (usernameAccount != null && phoneAccount != null + && usernameAccount.getId().equals(phoneAccount.getId()) + && "MERCHANT".equals(usernameAccount.getDefaultAppCode()) + && "ACTIVE".equals(usernameAccount.getAccountStatus()) + && passwordEncoder.matches(request.password(), usernameAccount.getPasswordHash())) { + return existingRegistration(usernameAccount); + } + if (usernameAccount != null) { throw new BizException(ResultCode.CONFLICT, "用户名已存在"); } - if (accountRepository.existsByPhone(request.phone())) { - throw new BizException(ResultCode.CONFLICT, "手机号已存在"); + if (phoneAccount != null) { + throw new BizException(ResultCode.CONFLICT, "该手机号已注册,请直接登录后继续完善企业资质"); } long accountId = IdWorker.getId(); String passwordHash = passwordEncoder.encode(request.password()); @@ -111,6 +120,24 @@ public class MerchantRegistrationService { "LOGIN_AND_COMPLETE_QUALIFICATIONS"); } + private MerchantRegisterResponse existingRegistration(AuthAccount account) { + try { + var response = onboardingClient.registration(internalToken, account.getId()); + if (response == null || !response.successful() || response.data() == null) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "注册状态暂时无法确认,请直接登录后继续完善企业资质"); + } + var onboarding = response.data(); + if (!java.util.Set.of("PROFILE_REQUIRED", "REJECTED").contains(onboarding.tenantStatus())) { + throw new BizException(ResultCode.CONFLICT, "该账号已注册,请直接登录"); + } + return new MerchantRegisterResponse(account.getId(), onboarding.tenantId(), onboarding.membershipId(), + onboarding.tenantStatus(), onboarding.dimensionInitializationStatus(), + "LOGIN_AND_COMPLETE_QUALIFICATIONS"); + } catch (FeignException exception) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "注册状态暂时无法确认,请直接登录后继续完善企业资质"); + } + } + private void registerRollbackCompensation(long accountId, AtomicBoolean compensationCompleted) { if (!TransactionSynchronizationManager.isSynchronizationActive()) { return; diff --git a/auth/src/test/java/com/baiyue/auth/service/MerchantRegistrationServiceTest.java b/auth/src/test/java/com/baiyue/auth/service/MerchantRegistrationServiceTest.java index b09d43d33..7eb495634 100644 --- a/auth/src/test/java/com/baiyue/auth/service/MerchantRegistrationServiceTest.java +++ b/auth/src/test/java/com/baiyue/auth/service/MerchantRegistrationServiceTest.java @@ -23,6 +23,7 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.mockito.Mockito.never; @SpringJUnitConfig(MerchantRegistrationService.class) @TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token") @@ -61,6 +62,49 @@ class MerchantRegistrationServiceTest { assertEquals("PENDING", result.dimensionInitializationStatus()); } + @Test + void exactIncompleteRegistrationRetryReturnsTheExistingRegistration() { + AuthAccount account = new AuthAccount(); + account.setId(9001L); + account.setUsername("registered_owner"); + account.setPhone("13800138000"); + account.setPasswordHash("encoded-password"); + account.setAccountStatus("ACTIVE"); + account.setDefaultAppCode("MERCHANT"); + when(accounts.findByUsername("registered_owner")).thenReturn(account); + when(accounts.findByPhone("13800138000")).thenReturn(account); + when(encoder.matches("Password123", "encoded-password")).thenReturn(true); + when(onboarding.registration("internal-token", 9001L)).thenReturn(ApiResponse.success( + new IamOnboardingClient.OnboardingResponse(10001L, 30001L, "PROFILE_REQUIRED", "READY"))); + + var result = service.register(new MerchantRegisterRequest( + "registered_owner", "Password123", "注册人", "测试企业", null, + "13800138000", "owner@example.com", "CN", "深圳市宝安区")); + + assertEquals(9001L, result.accountId()); + assertEquals(10001L, result.tenantId()); + assertEquals("READY", result.dimensionInitializationStatus()); + verify(onboarding, never()).register(eq("internal-token"), any()); + verify(accounts, never()).insert(any()); + } + + @Test + void phoneOwnedByAnotherUsernameDirectsTheUserBackToLogin() { + AuthAccount account = new AuthAccount(); + account.setId(9001L); + account.setUsername("registered_owner"); + account.setPhone("13800138000"); + when(accounts.findByPhone("13800138000")).thenReturn(account); + + var failure = assertThrows(com.baiyue.common.core.exception.BizException.class, + () -> service.register(new MerchantRegisterRequest( + "another_owner", "Password123", "注册人", "测试企业", null, + "13800138000", "owner@example.com", "CN", "深圳市宝安区"))); + + assertEquals("该手机号已注册,请直接登录后继续完善企业资质", failure.getMessage()); + verify(onboarding, never()).register(eq("internal-token"), any()); + } + @Test void failedAuthInsertCompensatesTheIamRegistration() { when(encoder.encode("Password123")).thenReturn("encoded-password"); diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index c3af9c533..c1f6591d6 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -411,6 +411,7 @@ CREATE TABLE IF NOT EXISTS iam_tenant_qualification ( qualification_type VARCHAR(32) NOT NULL COMMENT '客户资质类型', certificate_no VARCHAR(128) NULL COMMENT '证书编号', document_url VARCHAR(500) NULL COMMENT '资质文件存储地址', + original_file_name VARCHAR(255) NOT NULL COMMENT '上传时原始文件名', valid_from DATE NULL COMMENT '有效期开始日期', valid_to DATE NULL COMMENT '有效期结束日期', status VARCHAR(20) NOT NULL DEFAULT 'PENDING' COMMENT '资质审核状态:PENDING待审核、APPROVED已通过、REJECTED已驳回', diff --git a/file/src/main/java/com/baiyue/file/service/LocalQualificationStorage.java b/file/src/main/java/com/baiyue/file/service/LocalQualificationStorage.java index 6d872dc02..8885e0be6 100644 --- a/file/src/main/java/com/baiyue/file/service/LocalQualificationStorage.java +++ b/file/src/main/java/com/baiyue/file/service/LocalQualificationStorage.java @@ -25,7 +25,8 @@ import java.util.UUID; public class LocalQualificationStorage { private static final Logger LOGGER = LoggerFactory.getLogger(LocalQualificationStorage.class); private static final long MAX_SIZE = 5L * 1024 * 1024; - private static final Set TYPES = Set.of("image/jpeg", "image/png", "application/pdf"); + private static final Set TYPES = Set.of( + "image/jpeg", "image/png", "image/bmp", "image/x-ms-bmp", "application/pdf"); @Value("${baiyue.file.storage-root}") private String configuredRoot; private Path root; private Path qualificationRoot; @@ -53,11 +54,16 @@ public class LocalQualificationStorage { if (file == null || file.isEmpty()) throw new BizException("One qualification file is required / 请选择资质文件"); if (file.getSize() > MAX_SIZE) throw new BizException("Qualification file must not exceed 5 MB / 资质文件不能超过 5MB"); String contentType = file.getContentType() == null ? "" : file.getContentType().toLowerCase(Locale.ROOT); - if (!TYPES.contains(contentType)) throw new BizException("Only PDF, JPG and PNG files are supported / 仅支持 PDF、JPG、PNG 文件"); + if (!TYPES.contains(contentType)) throw new BizException("Only PDF, JPG, PNG and BMP files are supported / 仅支持 PDF、JPG、PNG、BMP 文件"); if (!matchesSignature(file, contentType)) { throw new BizException("The uploaded file content does not match its type / 文件内容与格式不匹配"); } - String extension = "image/png".equals(contentType) ? ".png" : ("application/pdf".equals(contentType) ? ".pdf" : ".jpg"); + String extension = switch (contentType) { + case "image/png" -> ".png"; + case "image/bmp", "image/x-ms-bmp" -> ".bmp"; + case "application/pdf" -> ".pdf"; + default -> ".jpg"; + }; String fileName = UUID.randomUUID() + extension; Path directory = tenantDirectory(tenantId); Path target = directory.resolve(fileName).normalize(); @@ -92,6 +98,9 @@ public class LocalQualificationStorage { return read >= 5 && header[0] == 0x25 && header[1] == 0x50 && header[2] == 0x44 && header[3] == 0x46 && header[4] == 0x2d; } + if ("image/bmp".equals(contentType) || "image/x-ms-bmp".equals(contentType)) { + return read >= 2 && header[0] == 0x42 && header[1] == 0x4d; + } return read >= 8 && (header[0] & 0xff) == 0x89 && header[1] == 0x50 && header[2] == 0x4e && header[3] == 0x47 && header[4] == 0x0d && header[5] == 0x0a && header[6] == 0x1a && header[7] == 0x0a; @@ -101,7 +110,7 @@ public class LocalQualificationStorage { } public Resource load(long tenantId, String fileName) { - if (fileName == null || !fileName.matches("[0-9a-fA-F-]{36}\\.(jpg|png|pdf)")) { + if (fileName == null || !fileName.matches("[0-9a-fA-F-]{36}\\.(jpg|png|bmp|pdf)")) { throw fileNotFound(); } Path directory = tenantDirectory(tenantId); @@ -124,7 +133,7 @@ public class LocalQualificationStorage { String prefix = "/api/merchant/v1/files/qualifications/"; if (!documentUrl.startsWith(prefix)) return; String fileName = documentUrl.substring(prefix.length()); - if (!fileName.matches("[0-9a-fA-F-]{36}\\.(jpg|png|pdf)")) return; + if (!fileName.matches("[0-9a-fA-F-]{36}\\.(jpg|png|bmp|pdf)")) return; Path directory = tenantDirectory(tenantId); Path target = directory.resolve(fileName).normalize(); if (!target.startsWith(directory)) return; diff --git a/file/src/main/resources/openapi/qualification-required-package-v1.yaml b/file/src/main/resources/openapi/qualification-required-package-v1.yaml index 1316db9eb..99e4d0112 100644 --- a/file/src/main/resources/openapi/qualification-required-package-v1.yaml +++ b/file/src/main/resources/openapi/qualification-required-package-v1.yaml @@ -29,7 +29,7 @@ paths: type: string format: binary maxLength: 5242880 - description: PDF/JPG/PNG,最大5MB,声明媒体类型必须与文件签名一致 + description: PDF/JPG/PNG/BMP,最大5MB,声明媒体类型必须与文件签名一致 responses: '200': description: 成功或业务失败,失败时按 code 判断并保留 requestId/traceId @@ -139,12 +139,12 @@ components: businessLicense: type: string format: binary - description: 营业执照,必填;PDF/JPG/PNG,最大5MB + description: 营业执照,必填;PDF/JPG/PNG/BMP,最大5MB legalPersonId: type: string format: binary nullable: true - description: 法人身份证,选填;PDF/JPG/PNG,最大5MB + description: 法人身份证,选填;PDF/JPG/PNG/BMP,最大5MB responses: Error: description: 认证或授权失败,响应包含请求关联元数据 diff --git a/file/src/test/java/com/baiyue/file/config/QualificationAndOrderFileOpenApiContractTest.java b/file/src/test/java/com/baiyue/file/config/QualificationAndOrderFileOpenApiContractTest.java index 948b3eef3..0717cd6ce 100644 --- a/file/src/test/java/com/baiyue/file/config/QualificationAndOrderFileOpenApiContractTest.java +++ b/file/src/test/java/com/baiyue/file/config/QualificationAndOrderFileOpenApiContractTest.java @@ -16,7 +16,7 @@ class QualificationAndOrderFileOpenApiContractTest { assertThat(yaml) .contains("/api/merchant/v1/files/qualifications:") .contains("enum: [LEGAL_PERSON_ID, FREIGHT_FORWARDER_CERTIFICATE, BANK_ACCOUNT_LICENSE]") - .contains("merchant:qualification:upload", "maxLength: 5242880") + .contains("merchant:qualification:upload", "maxLength: 5242880", "PDF/JPG/PNG/BMP") .contains("name: replace", "requestId:", "traceId:") .doesNotContain("enum: [BUSINESS_LICENSE, LEGAL_PERSON_ID, FREIGHT_FORWARDER_CERTIFICATE, BANK_ACCOUNT_LICENSE]"); } diff --git a/file/src/test/java/com/baiyue/file/service/LocalQualificationStorageTest.java b/file/src/test/java/com/baiyue/file/service/LocalQualificationStorageTest.java index 9fd947bf1..e08d03c5c 100644 --- a/file/src/test/java/com/baiyue/file/service/LocalQualificationStorageTest.java +++ b/file/src/test/java/com/baiyue/file/service/LocalQualificationStorageTest.java @@ -43,7 +43,7 @@ class LocalQualificationStorageTest { Path qualificationRoot = storageRoot.resolve("qualifications"); assertTrue(Files.isDirectory(qualificationRoot)); try (var files = Files.list(qualificationRoot)) { - assertEquals(0, files.count()); + assertFalse(files.anyMatch(path -> path.getFileName().toString().startsWith(".file-service-probe-"))); } } @@ -78,6 +78,47 @@ class LocalQualificationStorageTest { storage.load(10001L, stored.fileName()).getFile().toPath().toAbsolutePath()); } + @Test + void storesBmpQualificationWithEitherStandardMimeTypeAndKeepsTenantOwnership() throws Exception { + storage.initialize(); + byte[] bmp = {0x42, 0x4d, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00}; + + for (String contentType : new String[] {"image/bmp", "image/x-ms-bmp"}) { + var upload = new MockMultipartFile("file", "license.bmp", contentType, bmp); + var stored = storage.store(10001L, upload); + + assertTrue(stored.fileName().endsWith(".bmp")); + assertEquals(stored.path().toAbsolutePath(), + storage.load(10001L, stored.fileName()).getFile().toPath().toAbsolutePath()); + BizException crossTenant = assertThrows(BizException.class, + () -> storage.load(10002L, stored.fileName())); + assertEquals(ResultCode.NOT_FOUND, crossTenant.getResultCode()); + } + } + + @Test + void rejectsBmpMimeTypeWhenTheFileSignatureIsNotBmp() { + storage.initialize(); + var upload = new MockMultipartFile("file", "license.bmp", "image/bmp", "not-bmp".getBytes()); + + BizException exception = assertThrows(BizException.class, + () -> storage.store(10001L, upload)); + + assertTrue(exception.getMessage().contains("文件内容与格式不匹配")); + } + + @Test + void deletesBmpQualificationByItsValidatedDocumentUrl() { + storage.initialize(); + byte[] bmp = {0x42, 0x4d, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00}; + var stored = storage.store(10001L, + new MockMultipartFile("file", "license.bmp", "image/bmp", bmp)); + + storage.deleteByDocumentUrl(10001L, stored.documentUrl()); + + assertFalse(Files.exists(stored.path())); + } + @Test void rejectsQualificationFilesLargerThanTheClientFiveMegabyteLimit() { storage.initialize(); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/PrintPdfClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/PrintPdfClient.java index 1275279de..ed265fbca 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/client/PrintPdfClient.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/PrintPdfClient.java @@ -60,4 +60,5 @@ public interface PrintPdfClient { record DropshipPalletSkuRow(String inboundCode, String skuCode, String cargoName, int cartonCount, int skuQuantity) {} + } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/domain/PriceBookChannelCatalog.java b/firstleg/src/main/java/com/baiyue/firstleg/domain/PriceBookChannelCatalog.java index 0766bf4f9..2504fbd92 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/domain/PriceBookChannelCatalog.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/domain/PriceBookChannelCatalog.java @@ -1,12 +1,16 @@ package com.baiyue.firstleg.domain; import java.util.List; +import java.util.Locale; import java.util.Set; /** 首版固定渠道目录;上传名称只能映射到这些稳定编码。 */ public final class PriceBookChannelCatalog { private PriceBookChannelCatalog() {} + private static final Set SEA_RAIL_CHANNEL_CODES = Set.of( + "EXX_RAIL_NY", "NYEXX", "OA_CHICAGO_RAIL", "CPHT"); + public static final List CHANNELS = List.of( fba("MATSON_CLX_TRUCK", "美森正班海卡", "美森正班海卡", "ZBYHK", "ZBYHWK"), fba("MATSON_MAX_TRUCK", "美森加班海卡", "美森加班海卡", "BYHK", "BYHWK"), @@ -37,6 +41,11 @@ public final class PriceBookChannelCatalog { inboundCodePrefix, overseasInboundCodePrefix); } + public static boolean isSeaRail(String channelCode) { + return channelCode != null + && SEA_RAIL_CHANNEL_CODES.contains(channelCode.trim().toUpperCase(Locale.ROOT)); + } + public record ChannelDefinition( String channelCode, String displayName, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java index 23ff26071..48d6f47cb 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java @@ -23,6 +23,7 @@ public interface LoadingListQueryMapper { p.id AS plan_id, p.plan_no, p.container_booking_id, p.plan_status, p.remark, p.created_by, p.created_by_name, p.created_at, p.version, b.container_no, b.cabinet_no, b.warehouse_id, b.warehouse_name, + b.channel_code, b.outbound_confirmed_at, b.actual_empty_reported_at, b.vessel_name, b.voyage_no, b.container_type, (SELECT COUNT(*) FROM fl_preload_loading_plan_link preload_link WHERE preload_link.loading_plan_id = p.id) AS preload_link_count, @@ -51,6 +52,7 @@ public interface LoadingListQueryMapper { p.id, p.plan_no, p.container_booking_id, p.plan_status, p.remark, p.created_by, p.created_by_name, p.created_at, p.version, b.container_no, b.cabinet_no, b.warehouse_id, b.warehouse_name, + b.channel_code, b.outbound_confirmed_at, b.actual_empty_reported_at, b.vessel_name, b.voyage_no, b.container_type """; @@ -394,6 +396,9 @@ public interface LoadingListQueryMapper { private String cabinetNo; private Long warehouseId; private String warehouseName; + private String channelCode; + private LocalDateTime outboundConfirmedAt; + private LocalDateTime actualEmptyReportedAt; private String vesselName; private String voyageNo; private String containerType; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingPrintService.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingPrintService.java index 1b98b7535..ca7a1b1dd 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingPrintService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingPrintService.java @@ -15,7 +15,6 @@ import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; import java.math.BigDecimal; -import java.math.RoundingMode; import java.time.format.DateTimeFormatter; import java.util.LinkedHashMap; import java.util.List; @@ -47,7 +46,7 @@ public class OceanBookingPrintService { String fileName = booking.getBookingNo() + "-" + suffix + ".pdf"; PrintPdfClient.FreightA4PdfRequest request = new PrintPdfClient.FreightA4PdfRequest( template, A4, fileName, List.of(new PrintPdfClient.FreightDocument( - fields(template, detail), List.of(cargo(detail.containerRequirements()))))); + fields(template, detail), List.of(emptyCargo())))); try { ResponseEntity response = print.generateFreightA4Documents(internalToken, requestId, request); MediaType contentType = response.getHeaders().getContentType(); @@ -78,12 +77,12 @@ public class OceanBookingPrintService { fields.put("placeOfDelivery", text(booking.getPlaceOfDelivery())); fields.put("serviceTerm", text(booking.getServiceTerm())); fields.put("freightTerm", text(booking.getFreightTerm())); - fields.put("totalPackagesInWords", Integer.toString(totalContainers(detail.containerRequirements()))); + fields.put("totalPackagesInWords", ""); if (SHIPPING_INSTRUCTION.equals(template)) { fields.put("toCompany", text(booking.getBookingAgentName())); fields.put("jobNumber", text(booking.getBookingNo())); fields.put("etdDate", date(booking.getEstimatedSailingAt())); - fields.put("cargoQuantity", Integer.toString(totalContainers(detail.containerRequirements()))); + fields.put("cargoQuantity", ""); fields.put("carrier", text(booking.getShippingCompanyName())); fields.put("bookingAgent", text(booking.getBookingAgentName())); fields.put("remark", text(booking.getRemark())); @@ -99,18 +98,8 @@ public class OceanBookingPrintService { return fields; } - private static PrintPdfClient.CargoRow cargo(List requirements) { - BigDecimal gross = requirements.stream().map(value -> value.getMaxWeightKg() - .multiply(BigDecimal.valueOf(value.getQuantity()))).reduce(BigDecimal.ZERO, BigDecimal::add); - BigDecimal volume = requirements.stream().map(value -> value.getCapacityCbm() - .multiply(BigDecimal.valueOf(value.getQuantity()))).reduce(BigDecimal.ZERO, BigDecimal::add); - return new PrintPdfClient.CargoRow("N/M", Integer.toString(totalContainers(requirements)), - "CONTAINERS", requirementSummary(requirements), decimal(gross, 3) + " KGS", - decimal(volume, 4) + " CBM"); - } - - private static int totalContainers(List values) { - return values.stream().mapToInt(OceanBookingContainerRequirementEntity::getQuantity).sum(); + private static PrintPdfClient.CargoRow emptyCargo() { + return new PrintPdfClient.CargoRow("—", "—", "—", "—", "—", "—"); } private static String requirementSummary(List values) { @@ -128,9 +117,6 @@ public class OceanBookingPrintService { private static String date(java.time.LocalDateTime value) { return value == null ? "" : value.format(DateTimeFormatter.ISO_LOCAL_DATE); } - private static String decimal(BigDecimal value, int scale) { - return value.setScale(scale, RoundingMode.HALF_UP).toPlainString(); - } private static String first(String first, String fallback) { return !text(first).isEmpty() ? first.trim() : text(fallback); } private static String join(String first, String second) { return List.of(text(first), text(second)).stream().filter(value -> !value.isEmpty()).collect(Collectors.joining(" / ")); } private static String text(String value) { return value == null ? "" : value.trim(); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java index b8c26a5e2..6f84a362f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java @@ -261,7 +261,7 @@ public class InboundMeasurementService { ConfirmedWeightTotals confirmedTotals = confirmedWeightTotals(weights); if (confirmedTotals.palletCount() == 0) { throw new BizException(ResultCode.VALIDATION_FAILED, - "请至少上传并确认一张称重图片后再提交测量审核"); + "即使只有一箱,也必须上传并确认至少一张称重图片后再提交测量审核"); } requireConfirmedPackageTotal(inbound, items); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java index d1f32c420..182cdcfa3 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java @@ -26,6 +26,7 @@ import com.baiyue.firstleg.repository.LoadingListRepository; import com.baiyue.firstleg.repository.LoadingPickRepository; import com.baiyue.firstleg.repository.ContainerRepository; import com.baiyue.firstleg.domain.Iso6346ContainerNumber; +import com.baiyue.firstleg.domain.PriceBookChannelCatalog; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -138,7 +139,9 @@ public class LoadingListService { } public BusinessDocument containerLogisticsDocument(long bookingId,String type){ requireLogisticsType(type); requireBookingPlan(bookingId); return document(repository.document(bookingId,type)); } public BusinessDocument uploadContainerLogisticsDocument(long bookingId,String type,long operatorId,String operatorName,String key,int expectedVersion,String reason,MultipartFile file){ - requireLogisticsType(type); String businessType=fileType(type); return uploadDocument(requireBookingPlan(bookingId),type,businessType,bookingId,Long.toString(bookingId),operatorId,operatorName,key,expectedVersion,reason,file); + requireLogisticsType(type); String businessType=fileType(type); AggregateRow plan=requireBookingPlan(bookingId); + requireDeliveryOrderUploadAllowed(plan); + return uploadDocument(plan,type,businessType,bookingId,Long.toString(bookingId),operatorId,operatorName,key,expectedVersion,reason,file); } public DocumentDownload downloadContainerDocument(long bookingId,String type,long fileId){ requireLogisticsType(type); String businessType=fileType(type); requireBookingPlan(bookingId); ContainerDocumentEntity d=requireDocumentEntity(bookingId,type); if(!Long.valueOf(fileId).equals(d.getFileId())) throw notFound("货柜资料不存在"); @@ -190,6 +193,17 @@ public class LoadingListService { public record DocumentDownload(String fileName,MediaType contentType,byte[] content) {} private static void requireLogisticsType(String type){ if(!DELIVERY_ORDER.equals(type)) throw badRequest("documentType只能是DELIVERY_ORDER"); } private static String fileType(String type){ if (DELIVERY_ORDER.equals(type)) return DO_FILE_TYPE; throw badRequest("documentType只能是DELIVERY_ORDER"); } + private static void requireDeliveryOrderUploadAllowed(AggregateRow plan) { + if (!"COMPLETED".equals(plan.getPlanStatus()) || plan.getOutboundConfirmedAt() == null) { + throw conflict("货柜进入海外履约后才可上传DO资料"); + } + if (plan.getActualEmptyReportedAt() != null) { + throw conflict("货柜已还柜,不可再上传DO资料"); + } + if (PriceBookChannelCatalog.isSeaRail(plan.getChannelCode())) { + throw conflict("海铁渠道不适用DO资料"); + } + } @Transactional @BusinessAudit(event = "CONTAINER_CLEARANCE_DOCUMENT_ATTACH", resourceType = "CONTAINER", diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementReviewService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementReviewService.java index f34ec5bbe..399d0c584 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementReviewService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementReviewService.java @@ -92,14 +92,18 @@ public class MeasurementReviewService { "人工确认箱数合计与入库单总箱数不一致,不能审核通过"); } var weights = measurements.listWeights(tenantId, inboundId); - if (weights.isEmpty() || weights.stream().anyMatch(weight -> + if (weights.isEmpty()) { + throw new BizException(ResultCode.VALIDATION_FAILED, + "该票测量已提交但没有称重图片;请先退回测量,上传并确认至少一张称重图片后重新提交(即使只有一箱也必须称重)"); + } + if (weights.stream().anyMatch(weight -> !"CONFIRMED".equals(weight.getRecognitionStatus()) || weight.getGrossWeightKg() == null || weight.getGrossWeightKg().signum() <= 0 || weight.getNetWeightKg() == null || weight.getNetWeightKg().signum() <= 0)) { throw new BizException(ResultCode.VALIDATION_FAILED, - "所有称重结果必须已确认且包含大于零的毛重和货物净重,才能审核通过"); + "该票存在未确认或重量无效的称重图片;请先在审核弹框逐张修正并确认后再审核"); } BigDecimal grossWeight = weights.stream() .map(com.baiyue.firstleg.entity.InboundWeightResultEntity::getGrossWeightKg) diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index a0e04bd50..21375b117 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -1224,7 +1224,7 @@ paths: /api/platform/v1/inbounds/{inboundId}/measurements/approve: post: summary: 审核通过测量 - description: 需要 firstleg:inbound:measurement-review 权限,且 Order 权威订单必须已审核通过。订单待审核期间可保留并提交到仓测量数据,但不能审核测量。标准头程审核通过后进入 WAIT_SPLIT;一件代发进入 WAIT_PALLETIZE,完成逐箱清点和封托后再进入 WAIT_SPLIT。审核时按该次测量数据尽力生成 CALCULATED 初算费用;价格未覆盖或初算异常只返回非阻断提示,不影响审核结果和后续履约状态。 + description: 需要 firstleg:inbound:measurement-review 权限,且 Order 权威订单必须已审核通过。订单待审核期间可保留并提交到仓测量数据,但不能审核测量。审核通过前必须存在至少一张已确认的称重图片;历史异常数据若已提交但没有称重图片,必须先退回测量后补录。标准头程审核通过后进入 WAIT_SPLIT;一件代发进入 WAIT_PALLETIZE,完成逐箱清点和封托后再进入 WAIT_SPLIT。审核时按该次测量数据尽力生成 CALCULATED 初算费用;价格未覆盖或初算异常只返回非阻断提示,不影响审核结果和后续履约状态。 parameters: - {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} - {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} @@ -1245,7 +1245,7 @@ paths: /api/platform/v1/inbounds/{inboundId}/measurements/submit: post: summary: 提交测量完成 - description: 提交只校验并复用称重图片确认阶段已经 OCCUPIED 且绑定完整的权威整票库位块,然后进入客服待审核;不会首次分配、重建或更换已显示槽位。Order 仍待审核时允许提交并冻结到仓测量证据,但不得继续审核测量或进入计费、分柜和装柜。 + description: 即使只有一箱,也必须先上传并确认至少一张称重图片。提交只校验并复用称重图片确认阶段已经 OCCUPIED 且绑定完整的权威整票库位块,然后进入客服待审核;不会首次分配、重建或更换已显示槽位。Order 仍待审核时允许提交并冻结到仓测量证据,但不得继续审核测量或进入计费、分柜和装柜。 parameters: - {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} - {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} diff --git a/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml b/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml index f0c12cb69..35fbed98f 100644 --- a/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml +++ b/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml @@ -89,7 +89,7 @@ paths: /api/platform/v1/ocean-bookings/{oceanBookingId}/prints/shipping-instruction: get: summary: Render the current saved booking as an export shipping instruction PDF - description: Firstleg maps the authoritative saved aggregate (DRAFT or RELEASED, with missing fields displayed as —) to SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1 and synchronously proxies the generated PDF. + description: Firstleg maps the authoritative saved aggregate (DRAFT or RELEASED, with missing fields displayed as —) to SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1 and synchronously proxies the generated PDF. Container requirements are not treated as actual cargo quantity, weight, volume, marks, packaging, or goods descriptions. x-permission: platform:firstleg:ocean-booking:list parameters: [{$ref: '#/components/parameters/OceanBookingId'}] responses: @@ -97,7 +97,7 @@ paths: /api/platform/v1/ocean-bookings/{oceanBookingId}/prints/bill-of-lading-confirmation: get: summary: Render the current saved booking as a bill-of-lading confirmation PDF - description: Firstleg maps the authoritative saved aggregate (DRAFT or RELEASED, with missing fields displayed as —) to BILL_OF_LADING_CONFIRMATION_A4_V1 and synchronously proxies the generated PDF. + description: Firstleg maps the authoritative saved aggregate (DRAFT or RELEASED, with missing fields displayed as —) to BILL_OF_LADING_CONFIRMATION_A4_V1 and synchronously proxies the generated PDF. Container requirements remain a container summary and are not treated as actual cargo facts. x-permission: platform:firstleg:ocean-booking:list parameters: [{$ref: '#/components/parameters/OceanBookingId'}] responses: diff --git a/firstleg/src/main/resources/openapi/phase3-container-overview-v1.yaml b/firstleg/src/main/resources/openapi/phase3-container-overview-v1.yaml index 4c4a1797b..73a9812f9 100644 --- a/firstleg/src/main/resources/openapi/phase3-container-overview-v1.yaml +++ b/firstleg/src/main/resources/openapi/phase3-container-overview-v1.yaml @@ -39,10 +39,10 @@ paths: /api/platform/v1/containers/{id}/documents/{documentType}/upload: post: operationId: uploadContainerDocument - description: '权限:platform:firstleg:terminal-drayage:document:edit;仅国外工作台DO,无完成动作' + description: '权限:platform:firstleg:terminal-drayage:document:edit;仅已进入海外履约且未还柜的非海铁渠道可上传DO,无完成动作' parameters: [{$ref: '#/components/parameters/ContainerId'}, {$ref: '#/components/parameters/DocumentType'}, {name: Idempotency-Key, in: header, required: true, schema: {type: string}}] requestBody: {required: true, content: {multipart/form-data: {schema: {type: object, required: [file, expectedVersion], properties: {file: {type: string, format: binary}, expectedVersion: {type: integer, minimum: 0}, operationReason: {type: string, maxLength: 500}}}}}} - responses: {'200': {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/DocumentEnvelope'}}}}} + responses: {'200': {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/DocumentEnvelope'}}}}, '409': {description: '尚未进入海外履约、海铁渠道不适用DO或货柜已还柜'}} /api/platform/v1/containers/{id}/documents/{documentType}/{fileId}/download: get: operationId: downloadContainerDocument diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementEvidenceOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementEvidenceOpenApiContractTest.java index 6954b2e87..3b363d893 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementEvidenceOpenApiContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementEvidenceOpenApiContractTest.java @@ -27,7 +27,11 @@ class InboundMeasurementEvidenceOpenApiContractTest { Map submitOperation = stringMap(stringMap(paths.get( "/api/platform/v1/inbounds/{inboundId}/measurements/submit")).get("post")); assertThat(submitOperation.get("description").toString()) - .contains("只校验并复用", "不会首次分配"); + .contains("即使只有一箱", "上传并确认至少一张称重图片", "只校验并复用", "不会首次分配"); + Map approveOperation = stringMap(stringMap(paths.get( + "/api/platform/v1/inbounds/{inboundId}/measurements/approve")).get("post")); + assertThat(approveOperation.get("description").toString()) + .contains("已确认的称重图片", "历史异常数据", "先退回测量"); Map schemas = stringMap(stringMap(document.get("components")).get("schemas")); Map detailProperties = properties(schemas, "InboundMeasurementDetail"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingPrintServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingPrintServiceTest.java index 25068db02..a27196d45 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingPrintServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingPrintServiceTest.java @@ -31,7 +31,7 @@ class OceanBookingPrintServiceTest { @BeforeEach void setUp() { MockitoAnnotations.openMocks(this); ReflectionTestUtils.setField(service, "internalToken", "token"); } - @Test void shippingInstructionUsesOnlyAuthoritativeReleasedAggregate() { + @Test void shippingInstructionDoesNotTreatContainerCapacityAsActualCargo() { when(bookings.detail(10L)).thenReturn(detail("RELEASED")); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_PDF); headers.set("X-Print-Page-Count", "1"); @@ -49,12 +49,17 @@ class OceanBookingPrintServiceTest { assertThat(document.fields()).containsEntry("jobNumber", "OB-1") .containsEntry("shipper", "Shipper\nAddress") .containsEntry("oceanVesselVoyage", "Vessel / V001") + .containsEntry("cargoQuantity", "—") + .containsEntry("totalPackagesInWords", "—") .containsKeys("toCompany", "consignee", "notifyParty", "cargoQuantity", "portOfLoading", "portOfDischarge", "placeOfDelivery", "totalPackagesInWords"); assertThat(document.cargoRows()).singleElement().satisfies(row -> { - assertThat(row.quantity()).isEqualTo("2"); - assertThat(row.descriptionOfGoods()).isEqualTo("40HQ DRY x 2"); - assertThat(row.grossWeight()).isEqualTo("56000.000 KGS"); + assertThat(row.marksAndNos()).isEqualTo("—"); + assertThat(row.quantity()).isEqualTo("—"); + assertThat(row.packaging()).isEqualTo("—"); + assertThat(row.descriptionOfGoods()).isEqualTo("—"); + assertThat(row.grossWeight()).isEqualTo("—"); + assertThat(row.measurement()).isEqualTo("—"); }); } @@ -85,7 +90,11 @@ class OceanBookingPrintServiceTest { .containsEntry("serviceTerm", "—") .containsEntry("freightTerm", "—"); } - assertThat(request.getValue().documents().get(0).fields()).containsEntry("bookingNumber", "—"); + assertThat(request.getValue().documents().get(0).fields()) + .containsEntry("bookingNumber", "—") + .containsEntry("containerSpec", "40HQ DRY x 2") + .containsEntry("containerSummary", "40HQ DRY x 2") + .containsEntry("totalPackagesInWords", "—"); } private static Detail detail(String status) { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java index b4aeb5d9f..dbb5b29cc 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java @@ -1188,7 +1188,8 @@ class InboundMeasurementServiceTest { assertThatThrownBy(() -> service.submit(1L, 10L, 9L, new InboundMeasurementRequests.SubmitRequest(30L, 0))) .isInstanceOf(BizException.class) - .hasMessageContaining("至少上传并确认一张称重图片"); + .hasMessageContaining("即使只有一箱") + .hasMessageContaining("上传并确认至少一张称重图片"); verify(repository, never()).updateMeasurement(any()); verify(repository, never()).updateInbound(any()); @@ -1212,7 +1213,8 @@ class InboundMeasurementServiceTest { assertThatThrownBy(() -> service.submit(1L, 10L, 9L, new InboundMeasurementRequests.SubmitRequest(30L, 0))) .isInstanceOf(BizException.class) - .hasMessageContaining("至少上传并确认一张称重图片"); + .hasMessageContaining("即使只有一箱") + .hasMessageContaining("上传并确认至少一张称重图片"); verify(repository, never()).updateMeasurement(any()); verify(repository, never()).updateInbound(any()); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java index fe004951c..1302c4097 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java @@ -681,6 +681,48 @@ class LoadingListServiceTest { eq("COMPLETED"), eq("COMPLETED"), anyString(), anyString(), eq(9L), eq("operator"), isNull()); } + @Test + void seaRailContainerCannotUploadDeliveryOrder() { + AggregateRow plan = row("COMPLETED", 4); + plan.setChannelCode("OA_CHICAGO_RAIL"); + var file = new MockMultipartFile("file", "do.pdf", "application/pdf", "do".getBytes()); + when(repository.aggregateByBooking(30L)).thenReturn(plan); + + assertThatThrownBy(() -> service.uploadContainerLogisticsDocument(30L, + LoadingListService.DELIVERY_ORDER, 9L, "operator", "do-rail", 0, null, file)) + .isInstanceOf(BizException.class) + .hasMessageContaining("海铁渠道不适用DO资料"); + verifyNoInteractions(documentFiles); + } + + @Test + void domesticStageContainerCannotUploadDeliveryOrder() { + AggregateRow plan = row("COMPLETED", 4); + plan.setOutboundConfirmedAt(null); + var file = new MockMultipartFile("file", "do.pdf", "application/pdf", "do".getBytes()); + when(repository.aggregateByBooking(30L)).thenReturn(plan); + + assertThatThrownBy(() -> service.uploadContainerLogisticsDocument(30L, + LoadingListService.DELIVERY_ORDER, 9L, "operator", "do-domestic", 0, null, file)) + .isInstanceOf(BizException.class) + .hasMessageContaining("进入海外履约后"); + verifyNoInteractions(documentFiles); + } + + @Test + void returnedContainerCannotUploadDeliveryOrder() { + AggregateRow plan = row("COMPLETED", 4); + plan.setActualEmptyReportedAt(java.time.LocalDateTime.now()); + var file = new MockMultipartFile("file", "do.pdf", "application/pdf", "do".getBytes()); + when(repository.aggregateByBooking(30L)).thenReturn(plan); + + assertThatThrownBy(() -> service.uploadContainerLogisticsDocument(30L, + LoadingListService.DELIVERY_ORDER, 9L, "operator", "do-returned", 0, null, file)) + .isInstanceOf(BizException.class) + .hasMessageContaining("已还柜"); + verifyNoInteractions(documentFiles); + } + @Test void staleDeliveryOrderVersionReturnsConflictBeforeUploadingAnotherFile() { AggregateRow plan = row("COMPLETED", 4); @@ -918,6 +960,8 @@ class LoadingListServiceTest { row.setContainerBookingId(30L); row.setPlanStatus(status); row.setWarehouseId(100L); + row.setChannelCode("OA_LA_TRUCK"); + row.setOutboundConfirmedAt(java.time.LocalDateTime.now()); row.setVersion(version); return row; } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java index 47d037ebd..291dc1e8f 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java @@ -135,6 +135,41 @@ class MeasurementReviewServiceTest { verify(fixture.repository, never()).transitionReview(anyLong(), anyLong(), anyLong(), anyInt(), anyBoolean(), anyLong()); } + @Test + @DisplayName("历史异常测量已提交但没有称重图片时提示退回补录") + void shouldExplainHowToRecoverSubmittedMeasurementWithoutWeightImage() { + Fixture fixture = fixture(false); + when(measurements.listWeights(10001L, 10L)).thenReturn(List.of()); + + assertThatThrownBy(() -> fixture.service.approve(10001L, 9L, "审核员", 10L, + "approve-without-weight", new MeasurementReviewRequest(20L, 3, null))) + .isInstanceOf(BizException.class) + .hasMessageContaining("已提交但没有称重图片") + .hasMessageContaining("先退回测量") + .hasMessageContaining("即使只有一箱也必须称重"); + + verify(fixture.repository, never()).transitionReview( + anyLong(), anyLong(), anyLong(), anyInt(), anyBoolean(), anyLong()); + } + + @Test + @DisplayName("已上传但未确认的称重图片提示在审核弹框修正") + void shouldExplainHowToConfirmInvalidWeightImageDuringReview() { + Fixture fixture = fixture(false); + InboundWeightResultEntity pending = confirmedWeight("357.500", "349.500"); + pending.setRecognitionStatus("PENDING_CONFIRM"); + when(measurements.listWeights(10001L, 10L)).thenReturn(List.of(pending)); + + assertThatThrownBy(() -> fixture.service.approve(10001L, 9L, "审核员", 10L, + "approve-pending-weight", new MeasurementReviewRequest(20L, 3, null))) + .isInstanceOf(BizException.class) + .hasMessageContaining("未确认或重量无效") + .hasMessageContaining("在审核弹框逐张修正并确认"); + + verify(fixture.repository, never()).transitionReview( + anyLong(), anyLong(), anyLong(), anyInt(), anyBoolean(), anyLong()); + } + @Test @DisplayName("多SKU测量行的箱数、重量、体积和体积重守恒后才能进入WAIT_SPLIT") void shouldApproveMultipleCargoLinesOnlyWhenAllFrozenTotalsMatch() { diff --git a/iam/src/main/java/com/baiyue/iam/controller/InternalTenantController.java b/iam/src/main/java/com/baiyue/iam/controller/InternalTenantController.java index c59fc9cd9..044f022c2 100644 --- a/iam/src/main/java/com/baiyue/iam/controller/InternalTenantController.java +++ b/iam/src/main/java/com/baiyue/iam/controller/InternalTenantController.java @@ -59,6 +59,14 @@ public class InternalTenantController { return ApiResponse.success(onboardingService.register(request)); } + @GetMapping("/merchant-registrations/{authUserId}") + public ApiResponse registration( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @PathVariable long authUserId) { + verify(token); + return ApiResponse.success(onboardingService.registration(authUserId)); + } + @DeleteMapping("/merchant-registrations/{authUserId}/compensation") public ApiResponse compensateRegistration( @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, @@ -127,7 +135,7 @@ public class InternalTenantController { @RequestParam(defaultValue = "500") @Min(1) @Max(500) int limit, @RequestParam(required = false) String keyword, @RequestParam(required = false) - @Pattern(regexp = "PROFILE_REQUIRED|PENDING|ACTIVE|REJECTED|DISABLED|ARCHIVED") String status) { + @Pattern(regexp = "PROFILE_REQUIRED|PENDING|ACTIVE|REJECTED|DISABLED") String status) { verify(token); return ApiResponse.success(tenantService.exportMerchants(cursor, limit, keyword, status)); } diff --git a/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java b/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java index 004b32af5..7378bee76 100644 --- a/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java +++ b/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java @@ -63,7 +63,7 @@ public class PlatformTenantController { @RequestParam(defaultValue = "20") @Min(1) @Max(200) long size, @RequestParam(required = false) String keyword, @RequestParam(required = false) - @Pattern(regexp = "PROFILE_REQUIRED|PENDING|ACTIVE|REJECTED|DISABLED|ARCHIVED") String status, + @Pattern(regexp = "PROFILE_REQUIRED|PENDING|ACTIVE|REJECTED|DISABLED") String status, @AuthenticationPrincipal AuthenticatedUser user) { TenantGuard.tenant(user, "PLATFORM"); return ApiResponse.success(service.pageMerchants(page, size, keyword, status)); diff --git a/iam/src/main/java/com/baiyue/iam/controller/ProfileResourceController.java b/iam/src/main/java/com/baiyue/iam/controller/ProfileResourceController.java index 7d819fa3e..9929e5734 100644 --- a/iam/src/main/java/com/baiyue/iam/controller/ProfileResourceController.java +++ b/iam/src/main/java/com/baiyue/iam/controller/ProfileResourceController.java @@ -3,9 +3,10 @@ package com.baiyue.iam.controller; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.iam.dto.ProfileDtos.AddressRequest; +import com.baiyue.iam.dto.ProfileDtos.QualificationDeletionResponse; +import com.baiyue.iam.dto.ProfileDtos.QualificationResponse; import com.baiyue.iam.dto.PlatformInternalUserDtos.*; import com.baiyue.iam.entity.IamTenantAddress; -import com.baiyue.iam.entity.IamTenantQualification; import com.baiyue.iam.service.ProfileResourceService; import com.baiyue.iam.service.PlatformInternalUserService; import com.baiyue.iam.service.TenantGuard; @@ -108,21 +109,48 @@ public class ProfileResourceController { } @GetMapping("/api/merchant/v1/iam/tenant/qualifications") + @PreAuthorize("hasAuthority('merchant:profile:view')") @Operation(summary = "List enterprise qualifications / 查询企业资质", description = "List qualification files owned by the current tenant. / 查询当前租户的资质文件。") @ApiResponses({@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Qualification list returned / 查询成功")}) - public ApiResponse> qualifications(@AuthenticationPrincipal AuthenticatedUser user) { + public ApiResponse> qualifications(@AuthenticationPrincipal AuthenticatedUser user) { return ApiResponse.success(service.qualifications(TenantGuard.tenant(user, "MERCHANT"))); } + @GetMapping("/api/merchant/v1/iam/tenant/qualifications/{qualificationId}") + @PreAuthorize("hasAuthority('merchant:profile:view')") + @Operation(summary = "查询企业资质详情", description = "客户范围来自 JWT,跨客户资质按不存在处理。") + public ApiResponse qualification( + @PathVariable long qualificationId, @AuthenticationPrincipal AuthenticatedUser user) { + return ApiResponse.success(service.qualification(TenantGuard.tenant(user, "MERCHANT"), qualificationId)); + } + + @DeleteMapping("/api/merchant/v1/iam/tenant/qualifications/{qualificationId}") + @PreAuthorize("hasAuthority('merchant:qualification:upload')") + @Operation(summary = "删除送审前企业资质", description = "仅待完善或已驳回客户可删除;提交审核后服务端拒绝。重复删除幂等成功。") + public ApiResponse deleteQualification( + @PathVariable long qualificationId, @AuthenticationPrincipal AuthenticatedUser user) { + return ApiResponse.success(service.deleteQualification(TenantGuard.tenant(user, "MERCHANT"), qualificationId)); + } + @GetMapping("/api/platform/v1/iam/tenants/{tenantId}/qualifications") @PreAuthorize("hasAuthority('platform:customer:list')") @Operation(summary = "查询企业客户资质", description = "只读查询企业客户资质元数据,不授予审核能力。") - public ApiResponse> platformQualifications( + public ApiResponse> platformQualifications( @PathVariable long tenantId, @AuthenticationPrincipal AuthenticatedUser user) { TenantGuard.tenant(user, "PLATFORM"); return ApiResponse.success(service.platformQualifications(tenantId)); } + @GetMapping("/api/platform/v1/iam/tenants/{tenantId}/qualifications/{qualificationId}") + @PreAuthorize("hasAuthority('platform:customer:list')") + @Operation(summary = "查询企业客户资质详情", description = "需要客户列表权限,且同时校验客户与资质归属。") + public ApiResponse platformQualification( + @PathVariable long tenantId, @PathVariable long qualificationId, + @AuthenticationPrincipal AuthenticatedUser user) { + TenantGuard.tenant(user, "PLATFORM"); + return ApiResponse.success(service.platformQualification(tenantId, qualificationId)); + } + @GetMapping("/api/platform/v1/iam/tenants/{tenantId}/addresses") @PreAuthorize("hasAuthority('platform:customer:list')") @Operation(summary = "查询企业客户地址", description = "只读查询企业客户地址,不开放平台端写入。") diff --git a/iam/src/main/java/com/baiyue/iam/dto/OnboardingDtos.java b/iam/src/main/java/com/baiyue/iam/dto/OnboardingDtos.java index 0383c9105..3b3d5fdb8 100644 --- a/iam/src/main/java/com/baiyue/iam/dto/OnboardingDtos.java +++ b/iam/src/main/java/com/baiyue/iam/dto/OnboardingDtos.java @@ -3,6 +3,7 @@ package com.baiyue.iam.dto; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; import java.time.LocalDate; @@ -13,7 +14,7 @@ public final class OnboardingDtos { @NotNull Long authUserId, @NotBlank String displayName, @NotBlank String companyName, - @NotBlank String legalPerson, + @Size(max = 64) String legalPerson, @NotBlank @Pattern(regexp = "^1[3-9]\\d{9}$") String administratorPhone, String contactEmail, String countryCode, @@ -28,6 +29,7 @@ public final class OnboardingDtos { @NotBlank @Pattern(regexp = "BUSINESS_LICENSE|LEGAL_PERSON_ID|FREIGHT_FORWARDER_CERTIFICATE|BANK_ACCOUNT_LICENSE") String qualificationType, String certificateNo, @NotBlank String documentUrl, + @NotBlank @Size(max = 255) String originalFileName, LocalDate validTo, boolean replace ) { } @@ -40,6 +42,7 @@ public final class OnboardingDtos { @NotBlank @Pattern(regexp = "BUSINESS_LICENSE|LEGAL_PERSON_ID|FREIGHT_FORWARDER_CERTIFICATE|BANK_ACCOUNT_LICENSE") String qualificationType, String certificateNo, @NotBlank String documentUrl, + @NotBlank @Size(max = 255) String originalFileName, LocalDate validTo, boolean replace ) { } @@ -55,9 +58,11 @@ public final class OnboardingDtos { @NotNull Long tenantId, @NotBlank @Pattern(regexp = "[0-9A-Za-z]{18}") String unifiedSocialCreditCode, @NotBlank String businessLicenseDocumentUrl, + @NotBlank @Size(max = 255) String businessLicenseOriginalFileName, LocalDate businessLicenseValidTo, boolean replaceBusinessLicense, String legalPersonIdDocumentUrl, + @Size(max = 255) String legalPersonIdOriginalFileName, LocalDate legalPersonIdValidTo, Boolean replaceLegalPersonId ) { } diff --git a/iam/src/main/java/com/baiyue/iam/dto/ProfileDtos.java b/iam/src/main/java/com/baiyue/iam/dto/ProfileDtos.java index ac9a88edf..626fadd0e 100644 --- a/iam/src/main/java/com/baiyue/iam/dto/ProfileDtos.java +++ b/iam/src/main/java/com/baiyue/iam/dto/ProfileDtos.java @@ -5,6 +5,9 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; +import java.time.LocalDate; +import java.time.LocalDateTime; + /** 用户档案、地址和资质的数据契约。 */ public final class ProfileDtos { private ProfileDtos() { } @@ -24,4 +27,12 @@ public final class ProfileDtos { String city, String district, @NotBlank String detail, String postalCode, Boolean isDefault) { } + public record QualificationResponse(Long id, Long tenantId, String qualificationType, + String certificateNo, String documentUrl, String originalFileName, + LocalDate validFrom, LocalDate validTo, String status, + String reviewComment, LocalDateTime createdAt, + LocalDateTime updatedAt) { } + + public record QualificationDeletionResponse(Long qualificationId, String deletedDocumentUrl) { } + } diff --git a/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java b/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java index aca36a421..ae59df536 100644 --- a/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java +++ b/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java @@ -25,7 +25,7 @@ public final class TenantDtos { public record RegisterRequest( @Schema(description = "Enterprise display name / 企业名称", example = "深圳虫虫出海科技有限公司") @NotBlank String tenantName, @Schema(description = "Legal entity name / 企业法定名称", example = "深圳虫虫出海科技有限公司") @NotBlank String legalName, - @Schema(description = "Legal representative / 法定代表人", example = "张三") @NotBlank @Size(max = 64) String legalRepresentative, + @Schema(description = "Legal representative (optional) / 法定代表人(选填)", example = "张三") @Size(max = 64) String legalRepresentative, @Schema(description = "Primary contact / 联系人", example = "张三") @NotBlank String contactName, @Schema(description = "Company contact phone; also initializes the first administrator login phone at creation, then maintained independently / 公司联系电话;创建时同时初始化首个管理员登录手机号,之后独立维护", example = "13800138000") @NotBlank @Pattern(regexp = "^1[3-9]\\d{9}$") String contactPhone, @@ -46,7 +46,7 @@ public final class TenantDtos { public record UpdateProfileRequest( @Schema(description = "Enterprise display name / 企业名称", example = "深圳虫虫出海科技有限公司") @NotBlank String tenantName, @Schema(description = "Legal entity name / 企业法定名称", example = "深圳虫虫出海科技有限公司") @NotBlank String legalName, - @Schema(description = "Legal representative / 法定代表人", example = "张三") @NotBlank @Size(max = 64) String legalRepresentative, + @Schema(description = "Legal representative (optional) / 法定代表人(选填)", example = "张三") @Size(max = 64) String legalRepresentative, @Schema(description = "Primary contact / 联系人", example = "李四") @NotBlank String contactName, @Schema(description = "Company contact phone, never used to resolve login identity / 公司联系电话,不作为登录标识", example = "0755-12345678") @NotBlank @Pattern(regexp = "[0-9-]{4,32}") String contactPhone, @@ -58,7 +58,7 @@ public final class TenantDtos { public record UpdateTenantRequest( @NotBlank String tenantName, @NotBlank String legalName, - @NotBlank @Size(max = 64) String legalRepresentative, + @Size(max = 64) String legalRepresentative, @NotBlank String contactName, @Schema(description = "Company contact phone, independent from every member login phone / 公司联系电话,与所有成员登录手机号独立") @NotBlank @Pattern(regexp = "[0-9-]{4,32}") String contactPhone, diff --git a/iam/src/main/java/com/baiyue/iam/entity/IamTenantQualification.java b/iam/src/main/java/com/baiyue/iam/entity/IamTenantQualification.java index eb5585fdb..af1a2bd5f 100644 --- a/iam/src/main/java/com/baiyue/iam/entity/IamTenantQualification.java +++ b/iam/src/main/java/com/baiyue/iam/entity/IamTenantQualification.java @@ -2,10 +2,10 @@ package com.baiyue.iam.entity; import lombok.Getter; import lombok.Setter; -import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import java.time.LocalDate; +import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableLogic; import com.baomidou.mybatisplus.annotation.TableName; import java.time.LocalDate; import java.time.LocalDateTime; /** * 租户资质实体,对应表 {@code iam_tenant_qualification};按租户保存营业执照等证照、有效期、文件位置和审核状态。 */ @Getter @Setter -@TableName("iam_tenant_qualification") public class IamTenantQualification { @TableId private Long id; private Long tenantId; private String qualificationType; private String certificateNo; private String documentUrl; private LocalDate validFrom; private LocalDate validTo; private String status; private String reviewComment; } +@TableName("iam_tenant_qualification") public class IamTenantQualification { @TableId private Long id; private Long tenantId; private String qualificationType; private String certificateNo; private String documentUrl; private String originalFileName; private LocalDate validFrom; private LocalDate validTo; private String status; private String reviewComment; private LocalDateTime createdAt; private LocalDateTime updatedAt; @TableLogic private Boolean deleted; } diff --git a/iam/src/main/java/com/baiyue/iam/repository/ProfileResourceRepository.java b/iam/src/main/java/com/baiyue/iam/repository/ProfileResourceRepository.java index 5fc245647..cf80bc93d 100644 --- a/iam/src/main/java/com/baiyue/iam/repository/ProfileResourceRepository.java +++ b/iam/src/main/java/com/baiyue/iam/repository/ProfileResourceRepository.java @@ -31,6 +31,18 @@ public class ProfileResourceRepository { public void saveQualification(IamTenantQualification qualification) { qualificationMapper.updateById(qualification); } public List findAddresses(long tenantId) { return addressMapper.selectList(new LambdaQueryWrapper().eq(IamTenantAddress::getTenantId, tenantId)); } public List findQualifications(long tenantId) { return qualificationMapper.selectList(new LambdaQueryWrapper().eq(IamTenantQualification::getTenantId, tenantId)); } + public IamTenantQualification findQualification(long tenantId, long qualificationId) { + return qualificationMapper.selectOne(new LambdaQueryWrapper() + .eq(IamTenantQualification::getTenantId, tenantId) + .eq(IamTenantQualification::getId, qualificationId)); + } + public IamTenantQualification findQualificationForUpdate(long tenantId, long qualificationId) { + return qualificationMapper.selectOne(new LambdaQueryWrapper() + .eq(IamTenantQualification::getTenantId, tenantId) + .eq(IamTenantQualification::getId, qualificationId) + .last("FOR UPDATE")); + } + public void deleteQualification(IamTenantQualification qualification) { qualificationMapper.deleteById(qualification); } public List findQualificationsForUpdate(long tenantId) { return qualificationMapper.selectList(new LambdaQueryWrapper() .eq(IamTenantQualification::getTenantId, tenantId) diff --git a/iam/src/main/java/com/baiyue/iam/repository/TenantRepository.java b/iam/src/main/java/com/baiyue/iam/repository/TenantRepository.java index c2fd87bae..ca8c1b9c4 100644 --- a/iam/src/main/java/com/baiyue/iam/repository/TenantRepository.java +++ b/iam/src/main/java/com/baiyue/iam/repository/TenantRepository.java @@ -83,6 +83,7 @@ public class TenantRepository { public PageResult pageMerchants(long page, long size, String keyword, String status) { LambdaQueryWrapper wrapper = new LambdaQueryWrapper() .eq(IamTenant::getTenantType, "MERCHANT") + .ne(IamTenant::getStatus, "ARCHIVED") .eq(StringUtils.hasText(status), IamTenant::getStatus, status) .orderByDesc(IamTenant::getCreatedAt); if (StringUtils.hasText(keyword)) { @@ -114,6 +115,7 @@ public class TenantRepository { public List exportMerchants(Long cursor, int limit, String keyword, String status) { LambdaQueryWrapper wrapper = new LambdaQueryWrapper() .eq(IamTenant::getTenantType, "MERCHANT") + .ne(IamTenant::getStatus, "ARCHIVED") .gt(cursor != null, IamTenant::getId, cursor) .eq(StringUtils.hasText(status), IamTenant::getStatus, status) .orderByAsc(IamTenant::getId) diff --git a/iam/src/main/java/com/baiyue/iam/service/MerchantOnboardingService.java b/iam/src/main/java/com/baiyue/iam/service/MerchantOnboardingService.java index 7073f3fd9..5dd4d22fa 100644 --- a/iam/src/main/java/com/baiyue/iam/service/MerchantOnboardingService.java +++ b/iam/src/main/java/com/baiyue/iam/service/MerchantOnboardingService.java @@ -74,7 +74,7 @@ public class MerchantOnboardingService { tenant.setStatus("PROFILE_REQUIRED"); tenant.setDimensionInitializationStatus("PENDING"); tenant.setLegalName(request.companyName()); - tenant.setLegalRepresentative(request.legalPerson()); + tenant.setLegalRepresentative(normalizeOptional(request.legalPerson())); tenant.setContactName(request.displayName()); tenant.setContactPhone(request.administratorPhone()); tenant.setContactEmail(request.contactEmail()); @@ -133,6 +133,32 @@ public class MerchantOnboardingService { tenant.getDimensionInitializationStatus()); } + public MerchantRegistrationResponse registration(long authUserId) { + IamUser user = userMapper.selectOne(new LambdaQueryWrapper() + .eq(IamUser::getAuthUserId, authUserId) + .last("LIMIT 1")); + if (user == null) { + throw new BizException(ResultCode.NOT_FOUND, "Registration not found"); + } + IamMembership membership = membershipMapper.selectOne(new LambdaQueryWrapper() + .eq(IamMembership::getUserId, user.getId()) + .eq(IamMembership::getMemberType, "OWNER") + .last("LIMIT 1")); + if (membership == null) { + throw new BizException(ResultCode.NOT_FOUND, "Registration not found"); + } + IamTenant tenant = tenantMapper.selectById(membership.getTenantId()); + if (tenant == null || !"MERCHANT".equals(tenant.getTenantType())) { + throw new BizException(ResultCode.NOT_FOUND, "Registration not found"); + } + return new MerchantRegistrationResponse(tenant.getId(), membership.getId(), tenant.getStatus(), + tenant.getDimensionInitializationStatus()); + } + + private String normalizeOptional(String value) { + return StringUtils.hasText(value) ? value.trim() : null; + } + /** * Removes a just-created IAM registration when Auth could not persist the paired login account. * Repeated calls are safe, while progressed onboarding data is deliberately protected. diff --git a/iam/src/main/java/com/baiyue/iam/service/ProfileResourceService.java b/iam/src/main/java/com/baiyue/iam/service/ProfileResourceService.java index af90e9bf5..4b1cc284a 100644 --- a/iam/src/main/java/com/baiyue/iam/service/ProfileResourceService.java +++ b/iam/src/main/java/com/baiyue/iam/service/ProfileResourceService.java @@ -10,6 +10,8 @@ import com.baiyue.iam.dto.OnboardingDtos.PlatformQualificationRegistrationRespon import com.baiyue.iam.dto.OnboardingDtos.RequiredQualificationPackageRequest; import com.baiyue.iam.dto.OnboardingDtos.RequiredQualificationPackageResponse; import com.baiyue.iam.dto.ProfileDtos.AddressRequest; +import com.baiyue.iam.dto.ProfileDtos.QualificationDeletionResponse; +import com.baiyue.iam.dto.ProfileDtos.QualificationResponse; import com.baiyue.iam.entity.IamTenant; import com.baiyue.iam.entity.IamTenantAddress; import com.baiyue.iam.entity.IamTenantQualification; @@ -43,16 +45,50 @@ public class ProfileResourceService { repository.insertAddress(a); return a; } - public List qualifications(long tenant) { return repository.findQualifications(tenant); } + public List qualifications(long tenant) { + return repository.findQualifications(tenant).stream().map(this::toQualificationResponse).toList(); + } + + public QualificationResponse qualification(long tenant, long qualificationId) { + IamTenantQualification qualification = repository.findQualification(tenant, qualificationId); + if (qualification == null) throw new BizException(ResultCode.NOT_FOUND, "Qualification not found / 企业资质不存在"); + return toQualificationResponse(qualification); + } public List platformAddresses(long tenantId) { requireMerchant(tenantId); return repository.findAddresses(tenantId); } - public List platformQualifications(long tenantId) { + public List platformQualifications(long tenantId) { requireMerchant(tenantId); - return repository.findQualifications(tenantId); + return qualifications(tenantId); + } + + public QualificationResponse platformQualification(long tenantId, long qualificationId) { + requireMerchant(tenantId); + return qualification(tenantId, qualificationId); + } + + @Transactional + @BusinessAudit(event = "TENANT_QUALIFICATION_DELETE", resourceType = "TENANT_QUALIFICATION", + resourceId = "#qualificationId", description = "删除送审前企业资质", captureAfter = false) + public QualificationDeletionResponse deleteQualification(long tenantId, long qualificationId) { + IamTenant tenant = tenantRepository.findTenantForUpdate(tenantId); + if (tenant == null || !"MERCHANT".equals(tenant.getTenantType())) { + throw new BizException(ResultCode.NOT_FOUND, "Enterprise not found / 企业客户不存在"); + } + if (!"PROFILE_REQUIRED".equals(tenant.getStatus()) && !"REJECTED".equals(tenant.getStatus())) { + throw new BizException(ResultCode.CONFLICT, + "Qualifications cannot be deleted after review submission / 资质提交审核后不可删除"); + } + IamTenantQualification qualification = repository.findQualificationForUpdate(tenantId, qualificationId); + if (qualification == null) { + return new QualificationDeletionResponse(qualificationId, null); + } + String documentUrl = qualification.getDocumentUrl(); + repository.deleteQualification(qualification); + return new QualificationDeletionResponse(qualificationId, documentUrl); } @Transactional @@ -86,7 +122,7 @@ public class ProfileResourceService { repository.saveQualification(existing); } IamTenantQualification qualification = createQualification(tenant.getId(), - request.qualificationType(), certificateNo, request.documentUrl()); + request.qualificationType(), certificateNo, request.documentUrl(), request.originalFileName()); qualification.setValidTo(request.validTo()); repository.saveQualification(qualification); return new QualificationSubmissionResponse(qualification.getId(), null, tenant.getStatus(), replacedDocumentUrl); @@ -122,7 +158,7 @@ public class ProfileResourceService { repository.saveQualification(existing); } IamTenantQualification qualification = createQualification(tenant.getId(), request.qualificationType(), - certificateNo, request.documentUrl()); + certificateNo, request.documentUrl(), request.originalFileName()); qualification.setValidTo(request.validTo()); repository.saveQualification(qualification); return new PlatformQualificationRegistrationResponse( @@ -162,6 +198,7 @@ public class ProfileResourceService { requireReplaceAllowed(businessLicenses, request.replaceBusinessLicense(), "营业执照"); boolean hasLegalPersonId = StringUtils.hasText(request.legalPersonIdDocumentUrl()); if (hasLegalPersonId) { + normalizeOriginalFileName(request.legalPersonIdOriginalFileName()); requireReplaceAllowed(legalPersonIds, Boolean.TRUE.equals(request.replaceLegalPersonId()), "法人身份证"); } else if (Boolean.TRUE.equals(request.replaceLegalPersonId())) { throw new BizException(ResultCode.BAD_REQUEST, "未上传法人身份证时不能选择替换"); @@ -170,13 +207,13 @@ public class ProfileResourceService { String replacedBusinessLicenseUrl = supersede(businessLicenses, replacementComment); String replacedLegalPersonIdUrl = hasLegalPersonId ? supersede(legalPersonIds, replacementComment) : null; IamTenantQualification businessLicense = createQualification(tenant.getId(), "BUSINESS_LICENSE", - creditCode, request.businessLicenseDocumentUrl()); + creditCode, request.businessLicenseDocumentUrl(), request.businessLicenseOriginalFileName()); businessLicense.setValidTo(request.businessLicenseValidTo()); repository.saveQualification(businessLicense); IamTenantQualification legalPersonId = null; if (hasLegalPersonId) { legalPersonId = createQualification(tenant.getId(), "LEGAL_PERSON_ID", - null, request.legalPersonIdDocumentUrl().trim()); + null, request.legalPersonIdDocumentUrl().trim(), request.legalPersonIdOriginalFileName()); legalPersonId.setValidTo(request.legalPersonIdValidTo()); repository.saveQualification(legalPersonId); } @@ -243,13 +280,32 @@ public class ProfileResourceService { return certificateNo; } - private IamTenantQualification createQualification(long tenant, String type, String certificateNo, String url) { + private IamTenantQualification createQualification(long tenant, String type, String certificateNo, String url, + String originalFileName) { IamTenantQualification x = new IamTenantQualification(); x.setId(IdWorker.getId()); x.setTenantId(tenant); x.setQualificationType(type); - x.setCertificateNo(certificateNo); x.setDocumentUrl(url); x.setStatus("PENDING"); + x.setCertificateNo(certificateNo); x.setDocumentUrl(url); + x.setOriginalFileName(normalizeOriginalFileName(originalFileName)); x.setStatus("PENDING"); repository.insertQualification(x); return x; } + private String normalizeOriginalFileName(String originalFileName) { + if (!StringUtils.hasText(originalFileName)) { + throw new BizException(ResultCode.BAD_REQUEST, "Original file name is required / 必须提供上传时原始文件名"); + } + String normalized = originalFileName.trim(); + if (normalized.length() > 255) { + throw new BizException(ResultCode.BAD_REQUEST, "Original file name is too long / 原始文件名不能超过255个字符"); + } + return normalized; + } + + private QualificationResponse toQualificationResponse(IamTenantQualification item) { + return new QualificationResponse(item.getId(), item.getTenantId(), item.getQualificationType(), + item.getCertificateNo(), item.getDocumentUrl(), item.getOriginalFileName(), item.getValidFrom(), + item.getValidTo(), item.getStatus(), item.getReviewComment(), item.getCreatedAt(), item.getUpdatedAt()); + } + private boolean isCurrentQualification(IamTenantQualification qualification) { return !"REJECTED".equals(qualification.getStatus()) && !"SUPERSEDED".equals(qualification.getStatus()); diff --git a/iam/src/main/java/com/baiyue/iam/service/TenantService.java b/iam/src/main/java/com/baiyue/iam/service/TenantService.java index b46d9f380..5659ec3de 100644 --- a/iam/src/main/java/com/baiyue/iam/service/TenantService.java +++ b/iam/src/main/java/com/baiyue/iam/service/TenantService.java @@ -174,7 +174,7 @@ public class TenantService { tenant.setStatus("PROFILE_REQUIRED"); tenant.setDimensionInitializationStatus("PENDING"); tenant.setLegalName(request.legalName()); - tenant.setLegalRepresentative(request.legalRepresentative()); + tenant.setLegalRepresentative(normalizeOptional(request.legalRepresentative())); tenant.setUnifiedSocialCreditCode(null); tenant.setContactName(request.contactName()); tenant.setContactPhone(contactPhone); @@ -391,7 +391,7 @@ public class TenantService { String contactPhone = normalizeContactPhone(request.contactPhone()); tenant.setTenantName(request.tenantName()); tenant.setLegalName(request.legalName()); - tenant.setLegalRepresentative(request.legalRepresentative()); + tenant.setLegalRepresentative(normalizeOptional(request.legalRepresentative())); tenant.setContactName(request.contactName()); tenant.setContactPhone(contactPhone); tenant.setContactEmail(request.contactEmail()); @@ -463,7 +463,7 @@ public class TenantService { ensureNotArchived(tenant); tenant.setTenantName(request.tenantName()); tenant.setLegalName(request.legalName()); - tenant.setLegalRepresentative(request.legalRepresentative()); + tenant.setLegalRepresentative(normalizeOptional(request.legalRepresentative())); tenant.setContactName(request.contactName()); tenant.setContactPhone(normalizeContactPhone(request.contactPhone())); tenant.setContactEmail(request.contactEmail()); @@ -485,6 +485,10 @@ public class TenantService { return contactPhone == null ? "" : contactPhone.trim(); } + private String normalizeOptional(String value) { + return value == null || value.isBlank() ? null : value.trim(); + } + private void ensureAdministratorPhoneAvailable(String phone) { if (!administratorPhoneAvailable(phone)) { throw new BizException(ResultCode.CONFLICT, "管理员登录手机号已被其他账号使用"); diff --git a/iam/src/main/resources/db/iam.sql b/iam/src/main/resources/db/iam.sql index 1916a76b4..0d7e51849 100644 --- a/iam/src/main/resources/db/iam.sql +++ b/iam/src/main/resources/db/iam.sql @@ -112,6 +112,7 @@ CREATE TABLE IF NOT EXISTS iam_tenant_qualification ( qualification_type VARCHAR(32) NOT NULL COMMENT '客户资质类型', certificate_no VARCHAR(128) NULL COMMENT '证书编号', document_url VARCHAR(500) NULL COMMENT '资质文件存储地址', + original_file_name VARCHAR(255) NOT NULL COMMENT '上传时原始文件名', valid_from DATE NULL COMMENT '有效期开始日期', valid_to DATE NULL COMMENT '有效期结束日期', status VARCHAR(20) NOT NULL DEFAULT 'PENDING' COMMENT '资质审核状态:PENDING待审核、APPROVED已通过、REJECTED已驳回', diff --git a/iam/src/main/resources/openapi/iam-v1.yaml b/iam/src/main/resources/openapi/iam-v1.yaml index 8abc9a16e..f5c939631 100644 --- a/iam/src/main/resources/openapi/iam-v1.yaml +++ b/iam/src/main/resources/openapi/iam-v1.yaml @@ -95,7 +95,7 @@ paths: - {name: page, in: query, required: false, schema: {type: integer, minimum: 1, default: 1}} - {name: size, in: query, required: false, schema: {type: integer, minimum: 1, maximum: 200, default: 20}} - {name: keyword, in: query, required: false, schema: {type: string}, description: 客户名称关键字,不支持客户编号、信用代码或联系人搜索} - - {name: status, in: query, required: false, schema: {type: string, enum: [PROFILE_REQUIRED, PENDING, ACTIVE, REJECTED, DISABLED, ARCHIVED]}} + - {name: status, in: query, required: false, schema: {type: string, enum: [PROFILE_REQUIRED, PENDING, ACTIVE, REJECTED, DISABLED]}} responses: '200': description: 企业客户分页查询成功 @@ -248,6 +248,22 @@ paths: schema: {$ref: '#/components/schemas/TenantQualificationListApiResponse'} '403': {$ref: '#/components/responses/Forbidden'} '404': {$ref: '#/components/responses/NotFound'} + /api/platform/v1/iam/tenants/{tenantId}/qualifications/{qualificationId}: + parameters: + - {$ref: '#/components/parameters/TenantId'} + - {name: qualificationId, in: path, required: true, schema: {type: integer, format: int64}} + get: + tags: [Platform Tenant / 平台租户] + summary: 查询企业客户资质详情 + description: 需要 `platform:customer:list` 权限,并同时校验客户与资质归属。 + responses: + '200': + description: 企业客户资质详情查询成功 + content: + application/json: + schema: {$ref: '#/components/schemas/TenantQualificationApiResponse'} + '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} /api/platform/v1/iam/tenants/{tenantId}/status: parameters: - {$ref: '#/components/parameters/TenantId'} @@ -677,8 +693,39 @@ paths: tags: [IAM Resources / IAM 资源] summary: List tenant qualifications / 查询企业资质 responses: - '200': {$ref: '#/components/responses/Success'} + '200': + description: 当前客户资质列表 + content: + application/json: + schema: {$ref: '#/components/schemas/TenantQualificationListApiResponse'} '403': {$ref: '#/components/responses/Forbidden'} + /api/merchant/v1/iam/tenant/qualifications/{qualificationId}: + parameters: + - {name: qualificationId, in: path, required: true, schema: {type: integer, format: int64}} + get: + tags: [IAM Resources / IAM 资源] + summary: 查询当前客户资质详情 + description: 需要 `merchant:profile:view` 权限;客户范围来自 JWT,跨客户资质按不存在处理。 + responses: + '200': + description: 当前客户资质详情 + content: + application/json: + schema: {$ref: '#/components/schemas/TenantQualificationApiResponse'} + '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} + delete: + tags: [IAM Resources / IAM 资源] + summary: 删除送审前企业资质 + description: 需要 `merchant:qualification:upload` 权限;仅客户状态为 `PROFILE_REQUIRED` 或 `REJECTED` 时允许,`PENDING`、`ACTIVE` 等提交审核后的状态由服务端拒绝。客户范围来自 JWT;重复删除或跨客户 ID 返回相同幂等结果,不泄露归属。 + responses: + '200': + description: 删除成功或目标已不存在 + content: + application/json: + schema: {$ref: '#/components/schemas/QualificationDeletionApiResponse'} + '403': {$ref: '#/components/responses/Forbidden'} + '409': {$ref: '#/components/responses/Conflict'} /api/merchant/v1/iam/tenant/qualifications/submit-review: post: tags: [IAM Resources / IAM 资源] @@ -810,6 +857,69 @@ paths: '400': {$ref: '#/components/responses/BadRequest'} '403': {$ref: '#/components/responses/Forbidden'} '409': {$ref: '#/components/responses/Conflict'} + /internal/iam/merchant-registrations/{authUserId}: + get: + tags: [IAM Resources / IAM 资源] + summary: 查询已创建的企业注册状态 + description: 仅供 Auth Service 对相同账号、手机号和密码的注册重试进行幂等确认,不创建或修改企业资料。 + security: [] + parameters: + - {$ref: '#/components/parameters/InternalServiceToken'} + - {name: authUserId, in: path, required: true, schema: {type: integer, format: int64}} + responses: + '200': + description: 注册状态查询成功 + content: + application/json: + schema: + allOf: + - {$ref: '#/components/schemas/ApiResponse'} + - type: object + properties: + data: + type: object + required: [tenantId, membershipId, tenantStatus, dimensionInitializationStatus] + properties: + tenantId: {type: integer, format: int64} + membershipId: {type: integer, format: int64} + tenantStatus: {type: string, enum: [PROFILE_REQUIRED, PENDING, ACTIVE, REJECTED, DISABLED, ARCHIVED]} + dimensionInitializationStatus: {type: string, enum: [PENDING, READY, FAILED]} + '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} + /internal/iam/qualifications: + post: + tags: [IAM Resources / IAM 资源] + summary: 登记客户单个选填资质 + description: 仅供 File Service 调用;原始文件名必须由上传请求传入,不得从存储 URL 推断。 + security: [] + parameters: + - {$ref: '#/components/parameters/InternalServiceToken'} + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/QualificationSubmissionRequest'} + responses: + '200': {$ref: '#/components/responses/Success'} + '403': {$ref: '#/components/responses/Forbidden'} + '409': {$ref: '#/components/responses/Conflict'} + /internal/iam/platform-qualifications: + post: + tags: [IAM Resources / IAM 资源] + summary: 平台代客户登记单个选填资质 + description: 仅供 File Service 调用;原始文件名必须由上传请求传入,不得从存储 URL 推断。 + security: [] + parameters: + - {$ref: '#/components/parameters/InternalServiceToken'} + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/QualificationSubmissionRequest'} + responses: + '200': {$ref: '#/components/responses/Success'} + '403': {$ref: '#/components/responses/Forbidden'} + '409': {$ref: '#/components/responses/Conflict'} /internal/iam/required-qualification-packages: post: tags: [IAM Resources / IAM 资源] @@ -1204,7 +1314,7 @@ components: status: {type: string, enum: [PROFILE_REQUIRED, PENDING, ACTIVE, REJECTED, DISABLED, ARCHIVED]} dimensionInitializationStatus: {type: string, enum: [PENDING, READY, FAILED], description: 客户默认尺寸初始化状态;未 READY 时依赖该配置的业务失败关闭} legalName: {type: string} - legalRepresentative: {type: string, maxLength: 64} + legalRepresentative: {type: string, maxLength: 64, nullable: true} unifiedSocialCreditCode: {type: string} contactName: {type: string} contactPhone: {type: string} @@ -1258,6 +1368,23 @@ components: data: type: array items: {$ref: '#/components/schemas/TenantQualification'} + TenantQualificationApiResponse: + allOf: + - {$ref: '#/components/schemas/ApiResponse'} + - type: object + properties: + data: {$ref: '#/components/schemas/TenantQualification'} + QualificationDeletionApiResponse: + allOf: + - {$ref: '#/components/schemas/ApiResponse'} + - type: object + properties: + data: + type: object + required: [qualificationId] + properties: + qualificationId: {type: string, pattern: '^\d+$'} + deletedDocumentUrl: {type: string, nullable: true, description: 首次删除时返回,供 File Service 回收文件;重复删除为空} TenantAddress: type: object properties: @@ -1273,26 +1400,43 @@ components: isDefault: {type: boolean} TenantQualification: type: object + required: [id, tenantId, qualificationType, documentUrl, originalFileName, status, createdAt, updatedAt] properties: id: {type: string, pattern: '^\d+$'} tenantId: {type: string, pattern: '^\d+$'} qualificationType: {type: string, enum: [BUSINESS_LICENSE, LEGAL_PERSON_ID, FREIGHT_FORWARDER_CERTIFICATE, BANK_ACCOUNT_LICENSE]} certificateNo: {type: string} documentUrl: {type: string} + originalFileName: {type: string, description: 上传时原始文件名;前端不得从 URL 或内部 ID 推断} validFrom: {type: string, format: date} validTo: {type: string, format: date, nullable: true} status: {type: string, enum: [PENDING, APPROVED, REJECTED, SUPERSEDED]} reviewComment: {type: string} + createdAt: {type: string, format: date-time} + updatedAt: {type: string, format: date-time} + QualificationSubmissionRequest: + type: object + required: [tenantId, qualificationType, documentUrl, originalFileName, replace] + properties: + tenantId: {type: string, pattern: '^\d+$'} + qualificationType: {type: string, enum: [LEGAL_PERSON_ID, FREIGHT_FORWARDER_CERTIFICATE, BANK_ACCOUNT_LICENSE]} + certificateNo: {type: string, nullable: true} + documentUrl: {type: string, minLength: 1} + originalFileName: {type: string, minLength: 1, maxLength: 255} + validTo: {type: string, format: date, nullable: true} + replace: {type: boolean} RequiredQualificationPackageRequest: type: object - required: [tenantId, unifiedSocialCreditCode, businessLicenseDocumentUrl, replaceBusinessLicense] + required: [tenantId, unifiedSocialCreditCode, businessLicenseDocumentUrl, businessLicenseOriginalFileName, replaceBusinessLicense] properties: tenantId: {type: string, pattern: '^\d+$'} unifiedSocialCreditCode: {type: string, pattern: '^[0-9A-Za-z]{18}$'} businessLicenseDocumentUrl: {type: string, minLength: 1} + businessLicenseOriginalFileName: {type: string, minLength: 1, maxLength: 255} businessLicenseValidTo: {type: string, format: date, nullable: true} replaceBusinessLicense: {type: boolean} legalPersonIdDocumentUrl: {type: string, minLength: 1, nullable: true, description: 法人身份证选填} + legalPersonIdOriginalFileName: {type: string, maxLength: 255, nullable: true, description: 上传法人身份证时必传} legalPersonIdValidTo: {type: string, format: date, nullable: true} replaceLegalPersonId: {type: boolean, nullable: true, default: false} RequiredQualificationPackageResponse: @@ -1312,11 +1456,11 @@ components: data: {$ref: '#/components/schemas/RequiredQualificationPackageResponse'} RegisterRequest: type: object - required: [tenantName, legalName, legalRepresentative, contactName, contactPhone, servicePackage] + required: [tenantName, legalName, contactName, contactPhone, servicePackage] properties: tenantName: {type: string, example: 深圳虫虫出海科技有限公司} legalName: {type: string, example: 深圳虫虫出海科技有限公司} - legalRepresentative: {type: string, maxLength: 64, example: 张三} + legalRepresentative: {type: string, maxLength: 64, nullable: true, example: 张三, description: 选填} contactName: {type: string, example: 李四} contactPhone: {type: string, pattern: '^1[3-9]\d{9}$', example: '13800138000', description: 创建时同时初始化公司联系电话与首个管理员登录手机号;创建后二者独立维护} contactEmail: {type: string, format: email, example: contact@example.com} @@ -1332,21 +1476,21 @@ components: internalRemark: {type: string, maxLength: 1000} UpdateProfileRequest: type: object - required: [tenantName, legalName, legalRepresentative, contactName, contactPhone] + required: [tenantName, legalName, contactName, contactPhone] properties: tenantName: {type: string} legalName: {type: string} - legalRepresentative: {type: string, maxLength: 64} + legalRepresentative: {type: string, maxLength: 64, nullable: true, description: 选填} contactName: {type: string} contactPhone: {type: string, pattern: '^[0-9-]{4,32}$', description: 公司联系电话,不作为登录标识且不要求跨企业唯一} contactEmail: {type: string, format: email} UpdateTenantRequest: type: object - required: [tenantName, legalName, legalRepresentative, contactName, contactPhone] + required: [tenantName, legalName, contactName, contactPhone] properties: tenantName: {type: string} legalName: {type: string} - legalRepresentative: {type: string, maxLength: 64} + legalRepresentative: {type: string, maxLength: 64, nullable: true, description: 选填} contactName: {type: string} contactPhone: {type: string, pattern: '^[0-9-]{4,32}$', description: 公司联系电话,与管理员及成员登录手机号独立且不要求跨企业唯一} contactEmail: {type: string, format: email} diff --git a/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java b/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java index 12d675ba8..9223ad914 100644 --- a/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java @@ -76,6 +76,12 @@ class IamOpenApiContractTest { .contains("id: {type: string, pattern: '^\\d+$'") .contains("/api/platform/v1/iam/tenants/{tenantId}/addresses:") .contains("/api/platform/v1/iam/tenants/{tenantId}/qualifications:") + .contains("/api/platform/v1/iam/tenants/{tenantId}/qualifications/{qualificationId}:") + .contains("/api/merchant/v1/iam/tenant/qualifications/{qualificationId}:") + .contains("originalFileName: {type: string, description: 上传时原始文件名;前端不得从 URL 或内部 ID 推断}") + .contains("updatedAt: {type: string, format: date-time}") + .contains("仅客户状态为 `PROFILE_REQUIRED` 或 `REJECTED` 时允许") + .contains("merchant:qualification:upload") .contains("platform:customer:list") .doesNotContain("platform:customer:view") .contains("TenantAddressListApiResponse:") diff --git a/iam/src/test/java/com/baiyue/iam/controller/PlatformCustomerResourceContractTest.java b/iam/src/test/java/com/baiyue/iam/controller/PlatformCustomerResourceContractTest.java index fdcb270cb..2c05b5d40 100644 --- a/iam/src/test/java/com/baiyue/iam/controller/PlatformCustomerResourceContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/controller/PlatformCustomerResourceContractTest.java @@ -4,6 +4,7 @@ import com.baiyue.common.security.AuthenticatedUser; import org.junit.jupiter.api.Test; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.DeleteMapping; import java.lang.reflect.Method; @@ -17,6 +18,22 @@ class PlatformCustomerResourceContractTest { "/api/platform/v1/iam/tenants/{tenantId}/addresses"); assertListPermissionContract("platformQualifications", "/api/platform/v1/iam/tenants/{tenantId}/qualifications"); + Method platformDetail = ProfileResourceController.class.getMethod( + "platformQualification", long.class, long.class, AuthenticatedUser.class); + assertEquals("/api/platform/v1/iam/tenants/{tenantId}/qualifications/{qualificationId}", + platformDetail.getAnnotation(GetMapping.class).value()[0]); + assertEquals("hasAuthority('platform:customer:list')", + platformDetail.getAnnotation(PreAuthorize.class).value()); + Method merchantDetail = ProfileResourceController.class.getMethod( + "qualification", long.class, AuthenticatedUser.class); + assertEquals("hasAuthority('merchant:profile:view')", + merchantDetail.getAnnotation(PreAuthorize.class).value()); + Method merchantDelete = ProfileResourceController.class.getMethod( + "deleteQualification", long.class, AuthenticatedUser.class); + assertEquals("/api/merchant/v1/iam/tenant/qualifications/{qualificationId}", + merchantDelete.getAnnotation(DeleteMapping.class).value()[0]); + assertEquals("hasAuthority('merchant:qualification:upload')", + merchantDelete.getAnnotation(PreAuthorize.class).value()); } private void assertListPermissionContract(String methodName, String path) throws Exception { diff --git a/iam/src/test/java/com/baiyue/iam/controller/PlatformTenantControllerContractTest.java b/iam/src/test/java/com/baiyue/iam/controller/PlatformTenantControllerContractTest.java index a6fc8ce28..b2ed7fb78 100644 --- a/iam/src/test/java/com/baiyue/iam/controller/PlatformTenantControllerContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/controller/PlatformTenantControllerContractTest.java @@ -31,7 +31,7 @@ class PlatformTenantControllerContractTest { assertNotNull(annotation(method, 0, Min.class)); assertNotNull(annotation(method, 1, Min.class)); assertEquals(200, annotation(method, 1, Max.class).value()); - assertEquals("PROFILE_REQUIRED|PENDING|ACTIVE|REJECTED|DISABLED|ARCHIVED", + assertEquals("PROFILE_REQUIRED|PENDING|ACTIVE|REJECTED|DISABLED", annotation(method, 3, Pattern.class).regexp()); Set statuses = Arrays.stream(method.getAnnotation(ApiResponses.class).value()) diff --git a/iam/src/test/java/com/baiyue/iam/repository/ArchivedCustomerListContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/ArchivedCustomerListContractTest.java new file mode 100644 index 000000000..b33acb2ac --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/repository/ArchivedCustomerListContractTest.java @@ -0,0 +1,19 @@ +package com.baiyue.iam.repository; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class ArchivedCustomerListContractTest { + @Test + void customerListAndExportExcludeArchivedTenants() throws Exception { + String source = Files.readString(Path.of( + "src/main/java/com/baiyue/iam/repository/TenantRepository.java")); + + assertThat(source.split("\\.ne\\(IamTenant::getStatus, \\\"ARCHIVED\\\"\\)", -1)) + .hasSizeGreaterThanOrEqualTo(3); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/repository/QualificationSchemaContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/QualificationSchemaContractTest.java new file mode 100644 index 000000000..fff5b2223 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/repository/QualificationSchemaContractTest.java @@ -0,0 +1,26 @@ +package com.baiyue.iam.repository; + +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class QualificationSchemaContractTest { + @Test + void baselineAndUnifiedSqlPersistTheUploadOriginalFileName() throws Exception { + String baseline = new ClassPathResource("db/iam.sql") + .getContentAsString(StandardCharsets.UTF_8); + Path unifiedPath = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql"); + if (!Files.exists(unifiedPath)) { + unifiedPath = Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"); + } + String unified = Files.readString(unifiedPath, StandardCharsets.UTF_8); + + assertThat(baseline).contains("original_file_name VARCHAR(255) NOT NULL COMMENT '上传时原始文件名'"); + assertThat(unified).contains("original_file_name VARCHAR(255) NOT NULL COMMENT '上传时原始文件名'"); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java index 7e4501eb1..339dc050c 100644 --- a/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java @@ -25,6 +25,7 @@ import jakarta.annotation.Resource; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.never; @@ -103,6 +104,52 @@ class MerchantOnboardingServiceTest { assertNotNull(result.tenantId()); } + @Test + void clientRegistrationAcceptsAnOmittedLegalRepresentative() { + IamRole ownerRole = new IamRole(); + ownerRole.setId(4000100L); + ownerRole.setTenantId(0L); + ownerRole.setAppCode("MERCHANT"); + ownerRole.setRoleCode("MERCHANT_OWNER"); + ownerRole.setStatus("ACTIVE"); + when(roles.selectOne(any())).thenReturn(ownerRole); + + service.register(new MerchantRegistrationRequest( + 9001L, "注册人", "测试企业", null, "13800138000", + "owner@example.com", "CN", "深圳市宝安区")); + + ArgumentCaptor tenant = ArgumentCaptor.forClass(IamTenant.class); + verify(tenants).insert(tenant.capture()); + assertNull(tenant.getValue().getLegalRepresentative()); + } + + @Test + void registrationLookupReturnsTheExistingQualificationDraftState() { + IamUser user = new IamUser(); + user.setId(20001L); + user.setAuthUserId(9001L); + IamMembership membership = new IamMembership(); + membership.setId(30001L); + membership.setTenantId(10001L); + membership.setUserId(20001L); + membership.setMemberType("OWNER"); + IamTenant tenant = new IamTenant(); + tenant.setId(10001L); + tenant.setTenantType("MERCHANT"); + tenant.setStatus("PROFILE_REQUIRED"); + tenant.setDimensionInitializationStatus("READY"); + when(users.selectOne(any())).thenReturn(user); + when(memberships.selectOne(any())).thenReturn(membership); + when(tenants.selectById(10001L)).thenReturn(tenant); + + var result = service.registration(9001L); + + assertEquals(10001L, result.tenantId()); + assertEquals(30001L, result.membershipId()); + assertEquals("PROFILE_REQUIRED", result.tenantStatus()); + assertEquals("READY", result.dimensionInitializationStatus()); + } + @Test void compensationRemovesOnlyAnUnstartedRegistration() { diff --git a/iam/src/test/java/com/baiyue/iam/service/MerchantQualificationUploadTest.java b/iam/src/test/java/com/baiyue/iam/service/MerchantQualificationUploadTest.java index 80173ee17..ef40a7b22 100644 --- a/iam/src/test/java/com/baiyue/iam/service/MerchantQualificationUploadTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/MerchantQualificationUploadTest.java @@ -15,6 +15,7 @@ import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import jakarta.annotation.Resource; import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -43,7 +44,8 @@ class MerchantQualificationUploadTest { var response = service.submitQualification(new QualificationSubmissionRequest( 10001L, "FREIGHT_FORWARDER_CERTIFICATE", null, - "/api/merchant/v1/files/qualifications/freight-forwarder.png", LocalDate.of(2030, 1, 1), false)); + "/api/merchant/v1/files/qualifications/freight-forwarder.png", "freight-forwarder.png", + LocalDate.of(2030, 1, 1), false)); assertEquals("PROFILE_REQUIRED", response.tenantStatus()); assertNull(response.reviewId()); @@ -68,6 +70,7 @@ class MerchantQualificationUploadTest { var response = service.submitQualification(new QualificationSubmissionRequest( 10001L, "LEGAL_PERSON_ID", null, "/api/merchant/v1/files/qualifications/new-legal-person.png", + "new-legal-person.png", LocalDate.of(2035, 12, 31), true)); assertEquals("/api/merchant/v1/files/qualifications/old-legal-person.png", @@ -92,7 +95,7 @@ class MerchantQualificationUploadTest { when(tenantRepository.findTenantForUpdate(10001L)).thenReturn(tenant); assertThrows(BizException.class, () -> service.submitQualification(new QualificationSubmissionRequest( 10001L, "BUSINESS_LICENSE", "91310101abcde12345", - "/api/merchant/v1/files/qualifications/license.png", null, false))); + "/api/merchant/v1/files/qualifications/license.png", "license.png", null, false))); verify(profileRepository, never()).insertQualification(any()); } @@ -108,8 +111,8 @@ class MerchantQualificationUploadTest { var response = service.submitRequiredQualificationPackage(new RequiredQualificationPackageRequest( 10001L, "91310101abcde12345", - "/api/merchant/v1/files/qualifications/license.png", null, false, - "/api/merchant/v1/files/qualifications/legal-person.png", null, false)); + "/api/merchant/v1/files/qualifications/license.png", "license.png", null, false, + "/api/merchant/v1/files/qualifications/legal-person.png", "legal-person.png", null, false)); assertEquals("PROFILE_REQUIRED", response.tenantStatus()); assertEquals("91310101ABCDE12345", tenant.getUnifiedSocialCreditCode()); @@ -128,8 +131,8 @@ class MerchantQualificationUploadTest { var response = service.submitRequiredQualificationPackage(new RequiredQualificationPackageRequest( 10001L, "91310101abcde12345", - "/api/merchant/v1/files/qualifications/license.png", null, false, - null, null, null)); + "/api/merchant/v1/files/qualifications/license.png", "license.png", null, false, + null, null, null, null)); assertNull(response.legalPersonIdQualificationId()); verify(profileRepository, times(1)).insertQualification(any()); @@ -147,12 +150,66 @@ class MerchantQualificationUploadTest { assertThrows(BizException.class, () -> service.submitRequiredQualificationPackage( new RequiredQualificationPackageRequest(10001L, "91310101ABCDE12345", - "/api/merchant/v1/files/qualifications/license.png", null, true, - "/api/merchant/v1/files/qualifications/legal-person.png", null, true))); + "/api/merchant/v1/files/qualifications/license.png", "license.png", null, true, + "/api/merchant/v1/files/qualifications/legal-person.png", "legal-person.png", null, true))); verify(profileRepository, never()).insertQualification(any()); } + @Test + void qualificationListReturnsPersistedOriginalFileNameAndUpdatedTime() { + IamTenantQualification item = qualification("LEGAL_PERSON_ID", "/files/qualification/1"); + item.setOriginalFileName("法人身份证.png"); + item.setUpdatedAt(LocalDateTime.of(2026, 9, 11, 10, 30)); + when(profileRepository.findQualifications(10001L)).thenReturn(List.of(item)); + + var response = service.qualifications(10001L); + + assertEquals("法人身份证.png", response.get(0).originalFileName()); + assertEquals(LocalDateTime.of(2026, 9, 11, 10, 30), response.get(0).updatedAt()); + } + + @Test + void deletesDraftQualificationWithTenantLock() { + IamTenant tenant = new IamTenant(); + tenant.setId(10001L); tenant.setTenantType("MERCHANT"); tenant.setStatus("PROFILE_REQUIRED"); + IamTenantQualification item = qualification("LEGAL_PERSON_ID", "/files/qualification/1"); + when(tenantRepository.findTenantForUpdate(10001L)).thenReturn(tenant); + when(profileRepository.findQualificationForUpdate(10001L, 1L)).thenReturn(item); + + var response = service.deleteQualification(10001L, 1L); + + assertEquals(1L, response.qualificationId()); + assertEquals("/files/qualification/1", response.deletedDocumentUrl()); + verify(profileRepository).deleteQualification(item); + } + + @Test + void repeatedOrCrossTenantDeleteIsIdempotentAndDoesNotLeakOwnership() { + IamTenant tenant = new IamTenant(); + tenant.setId(10001L); tenant.setTenantType("MERCHANT"); tenant.setStatus("REJECTED"); + when(tenantRepository.findTenantForUpdate(10001L)).thenReturn(tenant); + when(profileRepository.findQualificationForUpdate(10001L, 99L)).thenReturn(null); + + var response = service.deleteQualification(10001L, 99L); + + assertEquals(99L, response.qualificationId()); + assertNull(response.deletedDocumentUrl()); + verify(profileRepository, never()).deleteQualification(any()); + } + + @Test + void rejectsDeleteAfterReviewSubmission() { + IamTenant tenant = new IamTenant(); + tenant.setId(10001L); tenant.setTenantType("MERCHANT"); tenant.setStatus("PENDING"); + when(tenantRepository.findTenantForUpdate(10001L)).thenReturn(tenant); + + assertThrows(BizException.class, () -> service.deleteQualification(10001L, 1L)); + + verify(profileRepository, never()).findQualificationForUpdate(10001L, 1L); + verify(profileRepository, never()).deleteQualification(any()); + } + private static IamTenantQualification qualification(String type, String documentUrl) { IamTenantQualification qualification = new IamTenantQualification(); qualification.setId(1L); diff --git a/iam/src/test/java/com/baiyue/iam/service/PlatformQualificationRegistrationTest.java b/iam/src/test/java/com/baiyue/iam/service/PlatformQualificationRegistrationTest.java index b6131881b..38c2d7ec3 100644 --- a/iam/src/test/java/com/baiyue/iam/service/PlatformQualificationRegistrationTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/PlatformQualificationRegistrationTest.java @@ -35,7 +35,7 @@ class PlatformQualificationRegistrationTest { var response = service.registerPlatformQualification(new PlatformQualificationRegistrationRequest( 10001L, "BANK_ACCOUNT_LICENSE", null, "/api/merchant/v1/files/qualifications/file.pdf", - LocalDate.of(2030, 12, 31), false)); + "file.pdf", LocalDate.of(2030, 12, 31), false)); ArgumentCaptor captor = ArgumentCaptor.forClass(IamTenantQualification.class); verify(repository).insertQualification(captor.capture()); @@ -60,7 +60,7 @@ class PlatformQualificationRegistrationTest { var response = service.registerPlatformQualification(new PlatformQualificationRegistrationRequest( 10001L, "BANK_ACCOUNT_LICENSE", null, "/api/merchant/v1/files/qualifications/new-file.pdf", - LocalDate.of(2031, 12, 31), true)); + "new-file.pdf", LocalDate.of(2031, 12, 31), true)); ArgumentCaptor captor = ArgumentCaptor.forClass(IamTenantQualification.class); verify(repository, times(2)).saveQualification(captor.capture()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java index 88f84bda9..4fe040594 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java @@ -29,7 +29,8 @@ public record LastlegOrderSummary( String transferWarehouseName, String fbaWarehouseCode, String containerNo, - String containerStatus, + @Schema(description = "由柜级船节点门禁读取的最新标准货柜节点") + String containerNode, Boolean abnormal, Boolean detained, Boolean heldChargeable, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java index b86b2a53f..574def390 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java @@ -36,7 +36,6 @@ public class LastlegOrder { private String transferWarehouseId; private String transferWarehouseCode; private String transferWarehouseName; - private String containerStatus; private Boolean abnormal; private Boolean detained; private Boolean holdChargeable; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java index bde438fbd..0e0714e01 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java @@ -31,6 +31,8 @@ import com.baiyue.lastleg.mapper.LastlegOrderOperationLogMapper; import com.baiyue.lastleg.mapper.LastlegOrderManualNodeMapper; import com.baiyue.lastleg.mapper.ReceivingWarehouseOperationMapper; import com.baiyue.lastleg.model.LastlegOrderType; +import com.baiyue.lastleg.shipping.entity.ContainerShippingGate; +import com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper; import jakarta.annotation.Resource; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; @@ -74,12 +76,16 @@ public class LastlegOrderService { @Resource private InboundOrderDetainedOutboxService detainedOutbox; @Resource private ReceivingWarehouseOperationMapper receivingWarehouseOperations; @Resource private WarehouseResourceGateway warehouseResourceGateway; + @Resource private ContainerShippingGateMapper containerShippingGates; public PageResult page(String customerId, LastlegOrderRequests.Query query) { Page page = orderMapper.selectPage( new Page<>(query.page(), query.size()), filter(customerId, query).orderByDesc(LastlegOrder::getCreatedAt)); Map> mergeRelations = palletMergeRelations(page.getRecords()); - return new PageResult<>(page.getRecords().stream().map(order -> summary(order, mergeRelations.get(order.getId()))).toList(), + Map containerNodes = containerNodes(page.getRecords()); + return new PageResult<>(page.getRecords().stream().map(order -> summary(order, mergeRelations.get(order.getId()), + StringUtils.hasText(order.getSourceContainerBookingId()) + ? containerNodes.get(order.getSourceContainerBookingId()) : null)).toList(), page.getCurrent(), page.getSize(), page.getTotal(), page.getPages()); } @@ -261,7 +267,8 @@ public class LastlegOrderService { .map(value -> new LastlegOrderCommandDtos.ManualNodeView( value.getNodeCode(), value.getNodeAt(), value.getSource())) .sorted(java.util.Comparator.comparingInt(value -> MANUAL_NODE_CODES.indexOf(value.nodeCode()))).toList(); - return new LastlegOrderDetailResponse(summary(order, palletMergeRelations(List.of(order)).get(order.getId())), manualNodes, cargo, history.stream() + return new LastlegOrderDetailResponse(summary(order, palletMergeRelations(List.of(order)).get(order.getId()), + containerNode(order)), manualNodes, cargo, history.stream() .map(value -> new LastlegOrderDetailResponse.History(value.getId(), value.getFromStatus(), value.getToStatus(), value.getRemark(), value.getCreatedAt())).toList(), fees, operations.stream() .map(value -> new LastlegOrderDetailResponse.Operation(value.getId(), value.getOperationNode(), @@ -480,20 +487,22 @@ public class LastlegOrderService { public void writeExportCsv(String customerId, LastlegOrderRequests.Query query, OutputStream output) throws IOException { - output.write("\uFEFFID,尾程单号,订单类型,入仓码,头程单号,货主,海外仓,转仓,转仓编码,FBA仓库,集装箱号,货柜状态,是否异常,是否暂扣,托数,箱数,体积(m3),数量,体积重量,预计到港时间,实际到港时间,预计到仓,入库单号,换标单号,换标状态,合托标记,主流程状态,子单状态,备注(仅自己),备注(客户),备注(仓库)\r\n".getBytes(StandardCharsets.UTF_8)); + output.write("\uFEFFID,尾程单号,订单类型,入仓码,头程单号,货主,海外仓,转仓,转仓编码,FBA仓库,集装箱号,货柜节点,是否异常,是否暂扣,托数,箱数,体积(m3),数量,体积重量,预计到港时间,实际到港时间,预计到仓,入库单号,换标单号,换标状态,合托标记,主流程状态,子单状态,备注(仅自己),备注(客户),备注(仓库)\r\n".getBytes(StandardCharsets.UTF_8)); final int batchSize = 1000; long page = 1; while (true) { List orders = orderMapper.selectPage(new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(page, batchSize), filter(customerId, query).orderByDesc(LastlegOrder::getCreatedAt).orderByDesc(LastlegOrder::getId)).getRecords(); if (orders.isEmpty()) break; + Map containerNodes = containerNodes(orders); for (LastlegOrder order : orders) { String line = String.join(",", List.of( csv(order.getId()), csv(order.getOrderNo()), csv(LastlegOrderType.labelOf(order.getOrderType())), csv(displayInboundCode(order)), csv(order.getSourceOrderNo()), csv(order.getCustomerName()), csv(order.getOverseasWarehouseName()), csv(order.getTransferWarehouseName()), csv(order.getTransferWarehouseCode()), csv(order.getFbaWarehouseCode()), - csv(order.getContainerNo()), csv(order.getContainerStatus()), csv(order.getAbnormal()), csv(order.getDetained()), + csv(order.getContainerNo()), csv(StringUtils.hasText(order.getSourceContainerBookingId()) + ? containerNodes.get(order.getSourceContainerBookingId()) : null), csv(order.getAbnormal()), csv(order.getDetained()), csv(order.getPalletCount()), csv(order.getPackageCount()), csv(order.getVolumeCbm()), csv(order.getProductQuantity()), csv(order.getVolumeWeightKg()), csv(order.getEstimatedArrivalAt()), csv(order.getActualArrivalAt()), csv(order.getEstimatedWarehouseAt()), csv(order.getInboundOrderNo()), csv(order.getRelabelOrderNo()), csv(order.getRelabelStatus()), @@ -558,13 +567,14 @@ public class LastlegOrderService { return result; } - private LastlegOrderSummary summary(LastlegOrder o, List relations) { + private LastlegOrderSummary summary(LastlegOrder o, List relations, + String containerNode) { List related = relations == null ? List.of() : relations; return new LastlegOrderSummary(o.getId(), o.getOrderNo(), o.getOrderType(), LastlegOrderType.labelOf(o.getOrderType()), o.getDestinationType(), o.getSourceInboundId(), displayInboundCode(o), o.getLxCode(), o.getSourceOrderNo(), o.getCustomerId(), o.getCustomerName(), o.getOverseasWarehouseId(), o.getOverseasWarehouseCode(), o.getOverseasWarehouseName(), o.getTransferWarehouseId(), o.getTransferWarehouseCode(), - o.getTransferWarehouseName(), o.getFbaWarehouseCode(), o.getContainerNo(), o.getContainerStatus(), + o.getTransferWarehouseName(), o.getFbaWarehouseCode(), o.getContainerNo(), containerNode, o.getAbnormal(), o.getDetained(), o.getHoldChargeable(), o.getHoldReason(), Boolean.TRUE.equals(o.getManualNodeOverride()), Boolean.TRUE.equals(o.getManualNodeOverride()) ? "MANUAL" : "CONTAINER", o.getVersion() == null ? 0 : o.getVersion(), o.getPalletCount(), o.getPackageCount(), o.getVolumeCbm(), @@ -576,6 +586,25 @@ public class LastlegOrderService { o.getCustomerRemark(), o.getWarehouseRemark(), o.getCreatedAt(), o.getUpdatedAt()); } + private String containerNode(LastlegOrder order) { + if (!StringUtils.hasText(order.getSourceContainerBookingId())) return null; + ContainerShippingGate gate = containerShippingGates.selectById(order.getSourceContainerBookingId()); + return gate == null ? null : gate.getStandardNode(); + } + + private Map containerNodes(List orders) { + List bookingIds = orders.stream().map(LastlegOrder::getSourceContainerBookingId) + .filter(StringUtils::hasText).distinct().toList(); + if (bookingIds.isEmpty()) return Map.of(); + Map result = new LinkedHashMap<>(); + containerShippingGates.selectBatchIds(bookingIds).forEach(gate -> { + if (StringUtils.hasText(gate.getStandardNode())) { + result.put(gate.getContainerBookingId(), gate.getStandardNode()); + } + }); + return result; + } + private static String displayInboundCode(LastlegOrder order) { String orderType = order.getOrderType(); return "RELABEL_APPLICATION".equals(orderType) || "LOCAL_LOOSEBOARD".equals(orderType) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java index 1d8a01fa1..80b5175d1 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java @@ -253,7 +253,7 @@ public class LoadingCompletedConsumer { order.setReceivingWarehouseVersion(0); order.setFbaWarehouseCode("FBA".equals(destinationType) ? source.destinationWarehouseCode() : null); order.setContainerNo(containerNo); - order.setContainerStatus("待转柜"); order.setAbnormal(false); order.setDetained(false); + order.setAbnormal(false); order.setDetained(false); order.setPackageCount(source.packageCount()); order.setPalletCount("ONE_CLICK_DROPSHIP".equals(orderType) ? dropshipPallets.size() : Objects.requireNonNullElse(source.palletCount(), 0)); diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 21ec376d0..3718bfa44 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -67,7 +67,6 @@ CREATE TABLE IF NOT EXISTS ll_order ( transfer_warehouse_name VARCHAR(255) COMMENT '转入目的仓名称', fba_warehouse_code VARCHAR(128) COMMENT 'FBA 仓库编码', container_no VARCHAR(64) COMMENT '集装箱号', - container_status VARCHAR(32) DEFAULT '待转柜' COMMENT '货柜状态', abnormal TINYINT NOT NULL DEFAULT 0 COMMENT '是否异常:0否1是', detained TINYINT NOT NULL DEFAULT 0 COMMENT '是否暂扣:0否1是', hold_chargeable TINYINT NOT NULL DEFAULT 0 COMMENT '当前暂扣是否计费:0否1是', @@ -444,7 +443,6 @@ ALTER TABLE ll_order MODIFY COLUMN receiving_warehouse_version INT NOT NULL DEFAULT 0 COMMENT '柜级接柜仓分配乐观锁版本', MODIFY COLUMN fba_warehouse_code VARCHAR(128) NULL COMMENT 'FBA 仓库编码', MODIFY COLUMN container_no VARCHAR(64) NULL COMMENT '集装箱号', - MODIFY COLUMN container_status VARCHAR(32) NULL DEFAULT '待转柜' COMMENT '货柜状态', MODIFY COLUMN abnormal TINYINT NOT NULL DEFAULT 0 COMMENT '是否异常:0否1是', MODIFY COLUMN detained TINYINT NOT NULL DEFAULT 0 COMMENT '是否暂扣:0否1是', MODIFY COLUMN pallet_count INT NOT NULL DEFAULT 0 COMMENT '托数', diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java index f720cfcd9..d7e620312 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java @@ -18,6 +18,8 @@ import com.baiyue.lastleg.mapper.LastlegOrderOperationLogMapper; import com.baiyue.lastleg.mapper.LastlegOrderManualNodeMapper; import com.baiyue.lastleg.mapper.ReceivingWarehouseOperationMapper; import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; +import com.baiyue.lastleg.shipping.entity.ContainerShippingGate; +import com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -64,6 +66,7 @@ class LastlegOrderServiceTest { @Mock InboundOrderDetainedOutboxService detainedOutbox; @Mock ReceivingWarehouseOperationMapper receivingWarehouseOperations; @Mock WarehouseResourceGateway warehouseResourceGateway; + @Mock ContainerShippingGateMapper containerShippingGates; LastlegOrderService service; @BeforeEach @@ -85,6 +88,7 @@ class LastlegOrderServiceTest { ReflectionTestUtils.setField(service, "detainedOutbox", detainedOutbox); ReflectionTestUtils.setField(service, "receivingWarehouseOperations", receivingWarehouseOperations); ReflectionTestUtils.setField(service, "warehouseResourceGateway", warehouseResourceGateway); + ReflectionTestUtils.setField(service, "containerShippingGates", containerShippingGates); } @Test @@ -158,6 +162,26 @@ class LastlegOrderServiceTest { .noneMatch(component -> "eventId".equals(component.getName()))); } + @Test + void detailReadsContainerNodeFromTheContainerShippingGate() { + LastlegOrder order = order(11L, "100", "PENDING_ASSIGNMENT", null); + order.setSourceContainerBookingId("BOOKING-1"); + ContainerShippingGate gate = new ContainerShippingGate(); + gate.setContainerBookingId("BOOKING-1"); + gate.setStandardNode("PICKUP_READY"); + when(orders.selectOne(any())).thenReturn(order); + when(details.selectList(any())).thenReturn(List.of()); + when(histories.selectList(any())).thenReturn(List.of()); + when(feeSources.listForBusiness("100", "LASTLEG_ORDER", 11L)).thenReturn(List.of()); + when(operations.selectList(any())).thenReturn(List.of()); + when(manualNodes.selectList(any())).thenReturn(List.of()); + when(containerShippingGates.selectById("BOOKING-1")).thenReturn(gate); + + var response = service.detail("100", 11L); + + assertEquals("PICKUP_READY", response.order().containerNode()); + } + @Test void transferUpdatesOrderAndEveryCargoSnapshot() { LastlegOrder order = order(11L, "100", "PENDING_ASSIGNMENT", null); diff --git a/print/src/main/java/com/baiyue/print/controller/InternalPdfController.java b/print/src/main/java/com/baiyue/print/controller/InternalPdfController.java index d185bd58f..48261eaa9 100644 --- a/print/src/main/java/com/baiyue/print/controller/InternalPdfController.java +++ b/print/src/main/java/com/baiyue/print/controller/InternalPdfController.java @@ -117,4 +117,5 @@ public class InternalPdfController { .header("X-Content-Type-Options", "nosniff").header(PAGE_COUNT_HEADER, Integer.toString(pdf.pageCount())) .contentType(MediaType.APPLICATION_PDF).contentLength(content.length).body(content); } + } diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 4344c1e76..f85a82438 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -295,6 +295,7 @@ function usage() { --wait-split-package-count 单票指定总箱数,1-1000;省略时使用既有轮换 --wait-split-sequence 单票全局序号,正整数,默认1 --wait-split-random-value 单票随机选择值,正整数,默认使用全局序号 + --prepare-dropship-manual-count 一件代发停在PDA清点前,不领取、不清点、不归托 --verify-pallet-location-guide 验证整票库位块、图片槽绑定与删除后 RESERVED_EMPTY --route-mode 线路选择:fixed 或 random,默认fixed --manual-split-fixture-report 人工分柜准备报告路径,仅只读核验模块使用 @@ -364,6 +365,7 @@ export function parseArguments(argv) { waitSplitPackageCount: Number(values.get('wait-split-package-count') || 0), waitSplitSequence: Number(values.get('wait-split-sequence') || 1), waitSplitRandomValue: Number(values.get('wait-split-random-value') || values.get('wait-split-sequence') || 1), + prepareDropshipManualCount: values.get('prepare-dropship-manual-count') === 'true', verifyPalletLocationGuide: values.has('verify-pallet-location-guide'), routeMode: values.get('route-mode') || 'fixed', quiet: values.get('quiet') === 'true', @@ -423,6 +425,10 @@ export function validateExecutionOptions(options) { && (!options.originWarehouseName || !options.channelName || !options.channelCode)) { throw new Error('randomized订单档案必须同时固定起运仓、渠道名称和渠道编码') } + if (options.prepareDropshipManualCount + && (!options.modules.includes('orders') || !options.modules.includes('inbound-measurement'))) { + throw new Error('prepare-dropship-manual-count要求同时执行orders和inbound-measurement模块') + } if (options.waitSplitCount > 1) { const fixtureModules = new Set(['merchant-qualification-fixture', 'orders', 'inbound-measurement', 'manual-order-splitting-fixture', 'coload-groups', 'firstleg-container-fee-allocation']) diff --git a/scripts/admin-test-env-modules/inbound-measurement.mjs b/scripts/admin-test-env-modules/inbound-measurement.mjs index d088c1f97..74ac017e7 100644 --- a/scripts/admin-test-env-modules/inbound-measurement.mjs +++ b/scripts/admin-test-env-modules/inbound-measurement.mjs @@ -128,17 +128,24 @@ export async function completeDropshipPalletization(context, { inboundId, inboun `/api/platform/v1/first-leg/pda-dropship/tasks/${taskId}/cartons/resolve?warehouseId=${warehouseId}&boxCode=${encodeURIComponent(boxCode)}`) const resolved = context.apiSuccess(MODULE, `解析第${index + 1}箱的冻结箱唛`, resolveResponse, `boxCode=${boxCode}`, OPTIONS) - context.assert(MODULE, `第${index + 1}箱允许清点且每箱SKU数完整`, resolveResponse, - resolved?.canCount === true && Number(resolved?.expectedSkuQuantity) > 0, - `canCount=${resolved?.canCount ?? 'missing'}, expectedSkuQuantity=${resolved?.expectedSkuQuantity ?? 'missing'}`, OPTIONS) - if (resolved?.canCount !== true || Number(resolved?.expectedSkuQuantity) < 1) continue + const resolvedSkuLines = Array.isArray(resolved?.skuLines) ? resolved.skuLines : [] + context.assert(MODULE, `第${index + 1}箱允许清点且每箱SKU明细完整`, resolveResponse, + resolved?.canCount === true && resolvedSkuLines.length > 0 + && resolvedSkuLines.every((line) => asId(line?.inboundDetailId) && line?.skuCode + && Number(line?.expectedQuantity) > 0), + `canCount=${resolved?.canCount ?? 'missing'}, skuLines=${resolvedSkuLines.length}`, OPTIONS) + if (resolved?.canCount !== true || resolvedSkuLines.length < 1) continue const countPayload = { warehouseId, boxCode, - actualSkuQuantity: Number(resolved.expectedSkuQuantity), - discrepancyAction: 'MATCH', - discrepancyRemark: null, + skuLines: resolvedSkuLines.map((line) => ({ + inboundDetailId: asId(line.inboundDetailId), + skuCode: line.skuCode, + actualQuantity: Number(line.expectedQuantity), + discrepancyAction: 'MATCH', + discrepancyRemark: null, + })), palletNo: index === 0 ? null : generatedPallets[Math.min(index, generatedPallets.length - 1)].palletNo, operationId: `${context.marker}-dropship-count-${index + 1}`.slice(0, 128), } @@ -148,10 +155,11 @@ export async function completeDropshipPalletization(context, { inboundId, inboun `boxCode=${boxCode}`, OPTIONS) context.assert(MODULE, `第${index + 1}箱清点结果与冻结箱唛一致`, countResponse, counted?.carton?.boxCode === resolved?.boxCode - && Number(counted?.carton?.expectedSkuQuantity) === Number(resolved?.expectedSkuQuantity) - && Number(counted?.carton?.actualSkuQuantity) === Number(resolved?.expectedSkuQuantity) - && counted?.carton?.discrepancyStatus === 'MATCH', - `cartonId=${asId(counted?.carton?.cartonId) || 'missing'}, discrepancy=${counted?.carton?.discrepancyStatus ?? 'missing'}`, OPTIONS) + && Array.isArray(counted?.carton?.skuLines) + && counted.carton.skuLines.length === resolvedSkuLines.length + && counted.carton.skuLines.every((line) => Number(line?.actualQuantity) === Number(line?.expectedQuantity) + && line?.discrepancyStatus === 'MATCH'), + `cartonId=${asId(counted?.carton?.cartonId) || 'missing'}, skuLines=${counted?.carton?.skuLines?.length ?? 'missing'}`, OPTIONS) if (counted?.carton?.boxCode === resolved?.boxCode) { countedCartons += 1 sealedCartonMarks.push(resolved.boxCode) @@ -1035,21 +1043,40 @@ export async function run(context) { let sealedCartonMarks = [] let sealedSourcePalletNos = [] if (dropship && finalMeasurement?.processStatus === 'WAIT_PALLETIZE') { - const sealed = await completeDropshipPalletization(context, { - inboundId, inboundCode, warehouseId: asId(summary?.warehouseId), measurement: finalMeasurement, - }) - sealedCartonMarks = sealed?.taskStatus === 'COMPLETED' ? sealed.sealedCartonMarks : [] - sealedSourcePalletNos = sealed?.taskStatus === 'COMPLETED' - ? (sealed.sealedSourcePalletNos || []).filter(Boolean) : [] - completed = await waitForCompletedMeasurement(context, measurementPath, ['WAIT_SPLIT'], 10) - finalResponse = completed.response - finalMeasurement = context.apiSuccess(MODULE, '合托后回查一件代发入仓单', finalResponse, - `pollAttempts=${completed.attempts}`, OPTIONS) - context.assert(MODULE, '一件代发合托完成后进入智能分柜门禁', finalResponse, - finalMeasurement?.processStatus === 'WAIT_SPLIT' - && finalMeasurement?.measureReviewStatus === 'APPROVED' - && finalMeasurement?.measurementStatus === 'APPROVED', - `processStatus=${finalMeasurement?.processStatus ?? 'missing'}`, OPTIONS) + if (context.options?.prepareDropshipManualCount) { + const warehouseId = asId(summary?.warehouseId) + const pendingResponse = await context.request('GET', + `/api/platform/v1/first-leg/pda-dropship/tasks/pending?warehouseId=${warehouseId}&keyword=${encodeURIComponent(inboundCode)}`) + const pending = context.apiSuccess(MODULE, '准备PDA人工清点任务', pendingResponse, + `inboundId=${inboundId}`, OPTIONS) + const task = (pending || []).find((item) => asId(item?.inboundId) === inboundId) + context.assert(MODULE, '任务停在未领取、未清点、未归托状态', pendingResponse, + Boolean(asId(task?.taskId)) && task?.taskStatus === 'PENDING' + && Number(task?.countedCartons) === 0 && Number(task?.boundCartons) === 0 + && Number(task?.totalCartons) === Number(finalMeasurement?.actualPackageCount), + `taskId=${asId(task?.taskId) || 'missing'}, status=${task?.taskStatus ?? 'missing'}, counted=${task?.countedCartons ?? 'missing'}`, OPTIONS) + context.createdResources.dropshipManualCount = { + taskId: asId(task?.taskId), inboundId, inboundCode, warehouseId, + warehouseName: task?.warehouseName || null, totalCartons: Number(task?.totalCartons || 0), + taskStatus: task?.taskStatus || null, + } + } else { + const sealed = await completeDropshipPalletization(context, { + inboundId, inboundCode, warehouseId: asId(summary?.warehouseId), measurement: finalMeasurement, + }) + sealedCartonMarks = sealed?.taskStatus === 'COMPLETED' ? sealed.sealedCartonMarks : [] + sealedSourcePalletNos = sealed?.taskStatus === 'COMPLETED' + ? (sealed.sealedSourcePalletNos || []).filter(Boolean) : [] + completed = await waitForCompletedMeasurement(context, measurementPath, ['WAIT_SPLIT'], 10) + finalResponse = completed.response + finalMeasurement = context.apiSuccess(MODULE, '合托后回查一件代发入仓单', finalResponse, + `pollAttempts=${completed.attempts}`, OPTIONS) + context.assert(MODULE, '一件代发合托完成后进入智能分柜门禁', finalResponse, + finalMeasurement?.processStatus === 'WAIT_SPLIT' + && finalMeasurement?.measureReviewStatus === 'APPROVED' + && finalMeasurement?.measurementStatus === 'APPROVED', + `processStatus=${finalMeasurement?.processStatus ?? 'missing'}`, OPTIONS) + } } const approvedScanResponse = await multipartRequest(context, scanPath, { diff --git a/scripts/admin-test-env-modules/orders.mjs b/scripts/admin-test-env-modules/orders.mjs index 56d45e27b..64bf7e032 100644 --- a/scripts/admin-test-env-modules/orders.mjs +++ b/scripts/admin-test-env-modules/orders.mjs @@ -402,6 +402,11 @@ function buildPayload(context, profile, master, productImageFileId, customerAddr ? context.context.waitSplitVariant.packageCounts : [] const selectedBoxes = BOXES.slice(0, skuCount) const orderProfile = context.context?.orderProfile || {} + const dropship = String(context.context?.orderFulfillmentType || 'STANDARD').trim().toUpperCase() + === 'ONE_CLICK_DROPSHIP' + const mixedPackageCount = Math.max(1, + Number(context.context?.waitSplitVariant?.totalPackageCount || packageCountPerSku)) + const mixedPackingGroupNo = `MIX-${context.marker}`.slice(0, 32) const isPrivate = master.destinationType === 'PRIVATE' const pickupMethod = orderProfile.pickupMethod || 'SELF_EXPRESS' const receiver = isPrivate ? { @@ -411,7 +416,7 @@ function buildPayload(context, profile, master, productImageFileId, customerAddr addressLine: `${context.marker} 88 Randomized Test Ave`, postalCode: '11354', } : null return { - fulfillmentType: String(context.context?.orderFulfillmentType || 'STANDARD').trim().toUpperCase(), + fulfillmentType: dropship ? 'ONE_CLICK_DROPSHIP' : 'STANDARD', customerId: profile.id, customerName: companyName, transportMode: 'SEA', tradeTerm: 'FOB', @@ -441,14 +446,17 @@ function buildPayload(context, profile, master, productImageFileId, customerAddr cargoes: selectedBoxes.map((box, index) => { const volumeCbm = box.lengthCm * box.widthCm * box.heightCm / 1_000_000 const unitsPerPackage = 10 + index - const packageCount = Number(packageCounts[index] || packageCountPerSku) + const packageCount = dropship ? mixedPackageCount : Number(packageCounts[index] || packageCountPerSku) + const carton = dropship ? selectedBoxes[0] : box + const cartonVolumeCbm = carton.lengthCm * carton.widthCm * carton.heightCm / 1_000_000 return { skuNo: `SKU-${box.boxType}-${context.marker}`.slice(0, 64), cargoName: `${box.boxType} 测试产品`, cargoNameEn: `${box.boxType} test product`, hsCode: '8518210000', customsHsCode: '8518210000', packageType: 'CARTON', - lengthCm: box.lengthCm, widthCm: box.widthCm, heightCm: box.heightCm, + packingGroupNo: dropship ? mixedPackingGroupNo : null, + lengthCm: carton.lengthCm, widthCm: carton.widthCm, heightCm: carton.heightCm, unitsPerPackage, packageCount, grossWeightKg: (10 + index) * packageCount, - volumeCbm: volumeCbm * packageCount, totalQuantity: unitsPerPackage * packageCount, + volumeCbm: cartonVolumeCbm * packageCount, totalQuantity: unitsPerPackage * packageCount, netWeightKg: (9 + index) * packageCount, unitDeclaredValue: 8.5, declaredValue: unitsPerPackage * packageCount * 8.5, currency: 'USD', material: '塑料 / Plastic', usage: '接口验收 / API acceptance', diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1 index 42596bc14..8159aa84a 100644 --- a/scripts/agents-index-contract.ps1 +++ b/scripts/agents-index-contract.ps1 @@ -518,13 +518,13 @@ foreach ($pdaApplication in $pdaApplications) { } } -# 海外 PDA 已开放拆柜收货、库内、换标和现场查询真实作业。 +# 海外 PDA 已开放拆柜收货、移库、托盘、换标和现场查询真实作业;库存盘点仅保留底层实现。 $overseasWorkbenchPath = Join-Path $workspaceRoot 'apps/pda-overseas-uniapp/src/data/overseas-workbench.js' $overseasWorkbench = Read-RequiredText $overseasWorkbenchPath Assert-Matches $overseasWorkbench "(?s)name:\s*'拆柜收货'.{0,300}?available:\s*true" 'Overseas PDA 拆柜收货开关' Assert-Matches $overseasWorkbench "(?s)name:\s*'托盘管理'.{0,300}?available:\s*true.{0,200}?route:\s*'/pages/pallet/pallet'" 'Overseas PDA 托盘管理开关' +Assert-NotContains $overseasWorkbench "key: 'stocktake'" 'Overseas PDA 隐藏库存盘点入口' foreach ($enabledOperation in @( - @{ Name = '库存盘点'; Route = '/pages/stocktake/stocktake' }, @{ Name = '商品移库'; Route = '/pages/move/move' }, @{ Name = '换标收货'; Route = '/pages/relabel/relabel\?op=relabelReceive' }, @{ Name = '换标执行'; Route = '/pages/relabel/relabel\?op=relabelExecute' },