优化
This commit is contained in:
@@ -19,6 +19,16 @@ public interface IamQualificationClient {
|
||||
@RequestHeader("X-Internal-Service-Token") String internalToken,
|
||||
@RequestBody PlatformQualificationRequest request);
|
||||
|
||||
@PostMapping("/required-qualification-packages")
|
||||
ApiResponse<RequiredPackageResponse> submitRequiredPackage(
|
||||
@RequestHeader("X-Internal-Service-Token") String internalToken,
|
||||
@RequestBody RequiredPackageRequest request);
|
||||
|
||||
@PostMapping("/platform-required-qualification-packages")
|
||||
ApiResponse<RequiredPackageResponse> submitPlatformRequiredPackage(
|
||||
@RequestHeader("X-Internal-Service-Token") String internalToken,
|
||||
@RequestBody RequiredPackageRequest request);
|
||||
|
||||
record QualificationRequest(Long tenantId, String qualificationType,
|
||||
String certificateNo, String documentUrl,
|
||||
java.time.LocalDate validTo, boolean replace) { }
|
||||
@@ -29,4 +39,16 @@ public interface IamQualificationClient {
|
||||
record PlatformQualificationRequest(Long tenantId, String qualificationType,
|
||||
String certificateNo, String documentUrl,
|
||||
java.time.LocalDate validTo, boolean replace) { }
|
||||
record RequiredPackageRequest(Long tenantId, String unifiedSocialCreditCode,
|
||||
String businessLicenseDocumentUrl,
|
||||
java.time.LocalDate businessLicenseValidTo,
|
||||
boolean replaceBusinessLicense,
|
||||
String legalPersonIdDocumentUrl,
|
||||
java.time.LocalDate legalPersonIdValidTo,
|
||||
boolean replaceLegalPersonId) { }
|
||||
record RequiredPackageResponse(Long businessLicenseQualificationId,
|
||||
Long legalPersonIdQualificationId,
|
||||
String tenantStatus,
|
||||
String replacedBusinessLicenseDocumentUrl,
|
||||
String replacedLegalPersonIdDocumentUrl) { }
|
||||
}
|
||||
|
||||
+21
-1
@@ -6,6 +6,7 @@ import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.file.client.IamQualificationClient;
|
||||
import com.baiyue.file.service.LocalQualificationStorage;
|
||||
import com.baiyue.file.service.QualificationPackageService;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -32,6 +33,7 @@ public class PlatformQualificationFileController {
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(PlatformQualificationFileController.class);
|
||||
@Resource private LocalQualificationStorage storage;
|
||||
@Resource private IamQualificationClient iamClient;
|
||||
@Resource private QualificationPackageService packageService;
|
||||
@Value("${baiyue.security.internal-service-token}") private String internalToken;
|
||||
|
||||
@PostMapping(value = "/{tenantId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@@ -45,7 +47,7 @@ public class PlatformQualificationFileController {
|
||||
@RequestParam(required = false) LocalDate validTo,
|
||||
@RequestParam(defaultValue = "false") boolean replace) {
|
||||
requirePlatform(user);
|
||||
qualificationType = QualificationTypes.requireSupported(qualificationType);
|
||||
qualificationType = QualificationTypes.requireOptional(qualificationType);
|
||||
var stored = storage.store(tenantId, file);
|
||||
try {
|
||||
var response = iamClient.submitPlatform(internalToken,
|
||||
@@ -79,6 +81,24 @@ public class PlatformQualificationFileController {
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping(value = "/{tenantId}/required-package", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@PreAuthorize("hasAnyAuthority('platform:customer:create','platform:customer:edit')")
|
||||
public ApiResponse<QualificationPackageService.PackageUploadResult> uploadRequiredPackage(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable long tenantId,
|
||||
@RequestPart("businessLicense") MultipartFile businessLicense,
|
||||
@RequestPart("legalPersonId") MultipartFile legalPersonId,
|
||||
@RequestParam String unifiedSocialCreditCode,
|
||||
@RequestParam(required = false) LocalDate businessLicenseValidTo,
|
||||
@RequestParam(required = false) LocalDate legalPersonIdValidTo,
|
||||
@RequestParam(defaultValue = "false") boolean replaceBusinessLicense,
|
||||
@RequestParam(defaultValue = "false") boolean replaceLegalPersonId) {
|
||||
requirePlatform(user);
|
||||
return ApiResponse.success(packageService.upload(tenantId, businessLicense, legalPersonId,
|
||||
unifiedSocialCreditCode, businessLicenseValidTo, legalPersonIdValidTo,
|
||||
replaceBusinessLicense, replaceLegalPersonId, true));
|
||||
}
|
||||
|
||||
@GetMapping("/{tenantId}/{fileName}")
|
||||
@PreAuthorize("hasAuthority('platform:customer:view')")
|
||||
public ResponseEntity<org.springframework.core.io.Resource> download(
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.file.client.IamQualificationClient;
|
||||
import com.baiyue.file.service.LocalQualificationStorage;
|
||||
import com.baiyue.file.service.QualificationPackageService;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -26,6 +27,7 @@ public class QualificationFileController {
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(QualificationFileController.class);
|
||||
@Resource private LocalQualificationStorage storage;
|
||||
@Resource private IamQualificationClient iamClient;
|
||||
@Resource private QualificationPackageService packageService;
|
||||
@Value("${baiyue.security.internal-service-token}") private String internalToken;
|
||||
|
||||
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@@ -38,7 +40,7 @@ public class QualificationFileController {
|
||||
@RequestParam(required = false) LocalDate validTo,
|
||||
@RequestParam(defaultValue = "false") boolean replace) {
|
||||
long tenantId = merchantTenant(user);
|
||||
qualificationType = QualificationTypes.requireSupported(qualificationType);
|
||||
qualificationType = QualificationTypes.requireOptional(qualificationType);
|
||||
var stored = storage.store(tenantId, file);
|
||||
try {
|
||||
var response = iamClient.submit(internalToken,
|
||||
@@ -72,6 +74,22 @@ public class QualificationFileController {
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping(value = "/required-package", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@PreAuthorize("hasAuthority('merchant:qualification:upload')")
|
||||
public ApiResponse<QualificationPackageService.PackageUploadResult> uploadRequiredPackage(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestPart("businessLicense") MultipartFile businessLicense,
|
||||
@RequestPart("legalPersonId") MultipartFile legalPersonId,
|
||||
@RequestParam String unifiedSocialCreditCode,
|
||||
@RequestParam(required = false) LocalDate businessLicenseValidTo,
|
||||
@RequestParam(required = false) LocalDate legalPersonIdValidTo,
|
||||
@RequestParam(defaultValue = "false") boolean replaceBusinessLicense,
|
||||
@RequestParam(defaultValue = "false") boolean replaceLegalPersonId) {
|
||||
return ApiResponse.success(packageService.upload(merchantTenant(user), businessLicense, legalPersonId,
|
||||
unifiedSocialCreditCode, businessLicenseValidTo, legalPersonIdValidTo,
|
||||
replaceBusinessLicense, replaceLegalPersonId, false));
|
||||
}
|
||||
|
||||
@GetMapping("/{fileName}")
|
||||
public ResponseEntity<org.springframework.core.io.Resource> download(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String fileName) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.baiyue.file.controller;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
@@ -19,4 +20,13 @@ final class QualificationTypes {
|
||||
}
|
||||
return qualificationType;
|
||||
}
|
||||
|
||||
static String requireOptional(String qualificationType) {
|
||||
String type = requireSupported(qualificationType);
|
||||
if ("BUSINESS_LICENSE".equals(type) || "LEGAL_PERSON_ID".equals(type)) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST,
|
||||
"Required qualifications must be uploaded as one package / 营业执照、统一社会信用代码和法人身份证必须整包上传");
|
||||
}
|
||||
return type;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package com.baiyue.file.service;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.file.client.IamQualificationClient;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
@Service
|
||||
public class QualificationPackageService {
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(QualificationPackageService.class);
|
||||
@Resource private LocalQualificationStorage storage;
|
||||
@Resource private IamQualificationClient iamClient;
|
||||
@Value("${baiyue.security.internal-service-token}") private String internalToken;
|
||||
|
||||
public PackageUploadResult upload(long tenantId, MultipartFile businessLicense,
|
||||
MultipartFile legalPersonId, String creditCode,
|
||||
LocalDate businessLicenseValidTo, LocalDate legalPersonIdValidTo,
|
||||
boolean replaceBusinessLicense, boolean replaceLegalPersonId,
|
||||
boolean platform) {
|
||||
String normalizedCreditCode = creditCode == null ? "" : creditCode.trim().toUpperCase();
|
||||
if (!normalizedCreditCode.matches("[0-9A-Z]{18}")) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "请输入有效的18位统一社会信用代码");
|
||||
}
|
||||
LocalQualificationStorage.StoredFile storedBusinessLicense = storage.store(tenantId, businessLicense);
|
||||
LocalQualificationStorage.StoredFile storedLegalPersonId;
|
||||
try {
|
||||
storedLegalPersonId = storage.store(tenantId, legalPersonId);
|
||||
} catch (RuntimeException exception) {
|
||||
storage.delete(storedBusinessLicense);
|
||||
throw exception;
|
||||
}
|
||||
|
||||
var request = new IamQualificationClient.RequiredPackageRequest(
|
||||
tenantId, normalizedCreditCode, storedBusinessLicense.documentUrl(), businessLicenseValidTo,
|
||||
replaceBusinessLicense, storedLegalPersonId.documentUrl(), legalPersonIdValidTo, replaceLegalPersonId);
|
||||
try {
|
||||
var response = platform
|
||||
? iamClient.submitPlatformRequiredPackage(internalToken, request)
|
||||
: iamClient.submitRequiredPackage(internalToken, request);
|
||||
if (response == null) throw new IllegalStateException("IAM required qualification package response is empty");
|
||||
if (!response.successful()) {
|
||||
ResultCode code = ResultCode.of(response.code());
|
||||
String message = response.message() == null || response.message().isBlank()
|
||||
? code.message() : response.message();
|
||||
storage.delete(storedBusinessLicense);
|
||||
storage.delete(storedLegalPersonId);
|
||||
throw new BizException(code, message);
|
||||
}
|
||||
if (response.data() == null) {
|
||||
throw new IllegalStateException("IAM required qualification package response data is empty");
|
||||
}
|
||||
var data = response.data();
|
||||
deleteReplacedFile(tenantId, data.replacedBusinessLicenseDocumentUrl(), storedBusinessLicense.documentUrl());
|
||||
deleteReplacedFile(tenantId, data.replacedLegalPersonIdDocumentUrl(), storedLegalPersonId.documentUrl());
|
||||
return new PackageUploadResult(data.businessLicenseQualificationId(), data.legalPersonIdQualificationId(),
|
||||
data.tenantStatus(), storedBusinessLicense.documentUrl(), storedLegalPersonId.documentUrl());
|
||||
} catch (BizException exception) {
|
||||
throw exception;
|
||||
} catch (RuntimeException exception) {
|
||||
LOGGER.error("IAM 必填资质包登记结果未知,保留已写入文件等待重试或对账,tenantId={}, businessLicenseUrl={}, legalPersonIdUrl={}",
|
||||
tenantId, storedBusinessLicense.documentUrl(), storedLegalPersonId.documentUrl(), exception);
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM qualification service is unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
private void deleteReplacedFile(long tenantId, String replacedUrl, String currentUrl) {
|
||||
if (!currentUrl.equals(replacedUrl)) storage.deleteByDocumentUrl(tenantId, replacedUrl);
|
||||
}
|
||||
|
||||
public record PackageUploadResult(Long businessLicenseQualificationId,
|
||||
Long legalPersonIdQualificationId,
|
||||
String tenantStatus,
|
||||
String businessLicenseDocumentUrl,
|
||||
String legalPersonIdDocumentUrl) { }
|
||||
}
|
||||
@@ -61,6 +61,13 @@ class QualificationFileContractTest {
|
||||
assertTrue(hasMultipartPost);
|
||||
}
|
||||
|
||||
@Test
|
||||
void merchantAndPlatformExposeDedicatedRequiredPackageRoutes() {
|
||||
assertTrue(hasMultipartRoute(QualificationFileController.class, "/required-package"));
|
||||
assertTrue(hasMultipartRoute(PlatformQualificationFileController.class,
|
||||
"/{tenantId}/required-package"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void platformQualificationPreviewUsesTheCustomerViewPermission() throws Exception {
|
||||
var method = PlatformQualificationFileController.class.getDeclaredMethod(
|
||||
@@ -82,7 +89,7 @@ class QualificationFileContractTest {
|
||||
when(iamClient.submitPlatform(eq("internal-token"), any())).thenReturn(ApiResponse.success(
|
||||
new IamQualificationClient.PlatformQualificationResponse(31L, null, "PROFILE_REQUIRED", oldUrl)));
|
||||
|
||||
var response = controller.upload(platformUser(), 10001L, file, "LEGAL_PERSON_ID", null,
|
||||
var response = controller.upload(platformUser(), 10001L, file, "BANK_ACCOUNT_LICENSE", null,
|
||||
LocalDate.of(2030, 12, 31), true);
|
||||
|
||||
var ordered = inOrder(storage, iamClient);
|
||||
@@ -108,7 +115,7 @@ class QualificationFileContractTest {
|
||||
when(iamClient.submitPlatform(eq("internal-token"), any())).thenReturn(ApiResponse.success(
|
||||
new IamQualificationClient.PlatformQualificationResponse(31L, null, "PROFILE_REQUIRED", currentUrl)));
|
||||
|
||||
controller.upload(platformUser(), 10001L, file, "LEGAL_PERSON_ID", null, null, true);
|
||||
controller.upload(platformUser(), 10001L, file, "BANK_ACCOUNT_LICENSE", null, null, true);
|
||||
|
||||
verify(storage, never()).deleteByDocumentUrl(10001L, currentUrl);
|
||||
}
|
||||
@@ -124,7 +131,7 @@ class QualificationFileContractTest {
|
||||
when(iamClient.submitPlatform(eq("internal-token"), any())).thenThrow(new RuntimeException("timeout"));
|
||||
|
||||
var exception = assertThrows(BizException.class,
|
||||
() -> controller.upload(platformUser(), 10001L, file, "LEGAL_PERSON_ID", null, null, true));
|
||||
() -> controller.upload(platformUser(), 10001L, file, "BANK_ACCOUNT_LICENSE", null, null, true));
|
||||
|
||||
assertEquals(ResultCode.SERVICE_UNAVAILABLE, exception.getResultCode());
|
||||
verify(storage, never()).delete(stored);
|
||||
@@ -142,7 +149,7 @@ class QualificationFileContractTest {
|
||||
ApiResponse.failure(ResultCode.CONFLICT, "duplicate", null, null));
|
||||
|
||||
var exception = assertThrows(BizException.class,
|
||||
() -> controller.upload(platformUser(), 10001L, file, "LEGAL_PERSON_ID", null, null, false));
|
||||
() -> controller.upload(platformUser(), 10001L, file, "BANK_ACCOUNT_LICENSE", null, null, false));
|
||||
|
||||
assertEquals(ResultCode.CONFLICT, exception.getResultCode());
|
||||
verify(storage).delete(stored);
|
||||
@@ -159,7 +166,7 @@ class QualificationFileContractTest {
|
||||
when(iamClient.submit(eq("internal-token"), any())).thenThrow(new RuntimeException("timeout"));
|
||||
|
||||
var exception = assertThrows(BizException.class,
|
||||
() -> controller.upload(merchantUser(), file, "BUSINESS_LICENSE", null, null, false));
|
||||
() -> controller.upload(merchantUser(), file, "FREIGHT_FORWARDER_CERTIFICATE", null, null, false));
|
||||
|
||||
assertEquals(ResultCode.SERVICE_UNAVAILABLE, exception.getResultCode());
|
||||
verify(storage, never()).delete(stored);
|
||||
@@ -177,12 +184,12 @@ class QualificationFileContractTest {
|
||||
when(iamClient.submit(eq("internal-token"), any())).thenReturn(ApiResponse.success(
|
||||
new IamQualificationClient.QualificationResponse(31L, null, "PROFILE_REQUIRED", oldUrl)));
|
||||
|
||||
controller.upload(merchantUser(), file, "LEGAL_PERSON_ID", null, LocalDate.of(2030, 1, 1), true);
|
||||
controller.upload(merchantUser(), file, "FREIGHT_FORWARDER_CERTIFICATE", null, LocalDate.of(2030, 1, 1), true);
|
||||
|
||||
var requestCaptor = org.mockito.ArgumentCaptor.forClass(IamQualificationClient.QualificationRequest.class);
|
||||
verify(iamClient).submit(eq("internal-token"), requestCaptor.capture());
|
||||
verify(storage).deleteByDocumentUrl(10001L, oldUrl);
|
||||
assertEquals("LEGAL_PERSON_ID", requestCaptor.getValue().qualificationType());
|
||||
assertEquals("FREIGHT_FORWARDER_CERTIFICATE", requestCaptor.getValue().qualificationType());
|
||||
assertTrue(requestCaptor.getValue().replace());
|
||||
}
|
||||
|
||||
@@ -203,9 +210,8 @@ class QualificationFileContractTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void everyRequiredQualificationTypeIsAcceptedAndForwarded() {
|
||||
void everyOptionalQualificationTypeIsAcceptedAndForwarded() {
|
||||
for (String qualificationType : Set.of(
|
||||
"BUSINESS_LICENSE", "LEGAL_PERSON_ID",
|
||||
"FREIGHT_FORWARDER_CERTIFICATE", "BANK_ACCOUNT_LICENSE")) {
|
||||
var storage = mock(LocalQualificationStorage.class);
|
||||
var iamClient = mock(IamQualificationClient.class);
|
||||
@@ -224,6 +230,22 @@ class QualificationFileContractTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void legacySingleUploadRejectsBothRequiredTypesBeforeWritingTheFile() {
|
||||
for (String qualificationType : Set.of("BUSINESS_LICENSE", "LEGAL_PERSON_ID")) {
|
||||
var storage = mock(LocalQualificationStorage.class);
|
||||
var iamClient = mock(IamQualificationClient.class);
|
||||
var controller = merchantController(storage, iamClient);
|
||||
var file = new MockMultipartFile("file", "qualification.pdf", "application/pdf", "%PDF-1.4".getBytes());
|
||||
|
||||
assertThrows(BizException.class,
|
||||
() -> controller.upload(merchantUser(), file, qualificationType, null, null, false));
|
||||
|
||||
verify(storage, never()).store(any(Long.class), any());
|
||||
verify(iamClient, never()).submit(any(), any());
|
||||
}
|
||||
}
|
||||
|
||||
private static PlatformQualificationFileController platformController(
|
||||
LocalQualificationStorage storage, IamQualificationClient iamClient) {
|
||||
var controller = new PlatformQualificationFileController();
|
||||
@@ -233,6 +255,14 @@ class QualificationFileContractTest {
|
||||
return controller;
|
||||
}
|
||||
|
||||
private static boolean hasMultipartRoute(Class<?> controllerType, String route) {
|
||||
return Arrays.stream(controllerType.getDeclaredMethods())
|
||||
.map(method -> method.getAnnotation(PostMapping.class))
|
||||
.filter(java.util.Objects::nonNull)
|
||||
.anyMatch(mapping -> Arrays.asList(mapping.value()).contains(route)
|
||||
&& Arrays.asList(mapping.consumes()).contains(MediaType.MULTIPART_FORM_DATA_VALUE));
|
||||
}
|
||||
|
||||
private static QualificationFileController merchantController(
|
||||
LocalQualificationStorage storage, IamQualificationClient iamClient) {
|
||||
var controller = new QualificationFileController();
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
package com.baiyue.file.service;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.file.client.IamQualificationClient;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.inOrder;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class QualificationPackageServiceTest {
|
||||
private static final long TENANT_ID = 10001L;
|
||||
private static final String BUSINESS_URL =
|
||||
"/api/merchant/v1/files/qualifications/11111111-1111-1111-1111-111111111111.pdf";
|
||||
private static final String LEGAL_PERSON_URL =
|
||||
"/api/merchant/v1/files/qualifications/22222222-2222-2222-2222-222222222222.png";
|
||||
|
||||
@Test
|
||||
void storesBothFilesBeforeRegisteringOneIamPackage() {
|
||||
var fixture = fixture();
|
||||
when(fixture.storage.store(TENANT_ID, fixture.businessLicense)).thenReturn(fixture.businessStored);
|
||||
when(fixture.storage.store(TENANT_ID, fixture.legalPersonId)).thenReturn(fixture.legalPersonStored);
|
||||
when(fixture.iamClient.submitRequiredPackage(eq("internal-token"), any())).thenReturn(ApiResponse.success(
|
||||
new IamQualificationClient.RequiredPackageResponse(31L, 32L, "PROFILE_REQUIRED", null, null)));
|
||||
|
||||
var result = fixture.service.upload(TENANT_ID, fixture.businessLicense, fixture.legalPersonId,
|
||||
"91310101abcde12345", null, null, false, false, false);
|
||||
|
||||
var ordered = inOrder(fixture.storage, fixture.iamClient);
|
||||
ordered.verify(fixture.storage).store(TENANT_ID, fixture.businessLicense);
|
||||
ordered.verify(fixture.storage).store(TENANT_ID, fixture.legalPersonId);
|
||||
var captor = ArgumentCaptor.forClass(IamQualificationClient.RequiredPackageRequest.class);
|
||||
ordered.verify(fixture.iamClient).submitRequiredPackage(eq("internal-token"), captor.capture());
|
||||
assertEquals("91310101ABCDE12345", captor.getValue().unifiedSocialCreditCode());
|
||||
assertEquals(BUSINESS_URL, result.businessLicenseDocumentUrl());
|
||||
assertEquals(LEGAL_PERSON_URL, result.legalPersonIdDocumentUrl());
|
||||
}
|
||||
|
||||
@Test
|
||||
void secondStorageFailureDeletesTheFirstFileAndNeverCallsIam() {
|
||||
var fixture = fixture();
|
||||
when(fixture.storage.store(TENANT_ID, fixture.businessLicense)).thenReturn(fixture.businessStored);
|
||||
when(fixture.storage.store(TENANT_ID, fixture.legalPersonId)).thenThrow(new BizException("disk full"));
|
||||
|
||||
assertThrows(BizException.class, () -> fixture.service.upload(TENANT_ID,
|
||||
fixture.businessLicense, fixture.legalPersonId, "91310101ABCDE12345",
|
||||
null, null, false, false, false));
|
||||
|
||||
verify(fixture.storage).delete(fixture.businessStored);
|
||||
verify(fixture.iamClient, never()).submitRequiredPackage(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void explicitIamRejectionDeletesBothUnreferencedFiles() {
|
||||
var fixture = storedFixture();
|
||||
when(fixture.iamClient.submitRequiredPackage(eq("internal-token"), any())).thenReturn(
|
||||
ApiResponse.failure(ResultCode.CONFLICT, "duplicate", null, null));
|
||||
|
||||
var exception = assertThrows(BizException.class, () -> fixture.service.upload(TENANT_ID,
|
||||
fixture.businessLicense, fixture.legalPersonId, "91310101ABCDE12345",
|
||||
null, null, false, false, false));
|
||||
|
||||
assertEquals(ResultCode.CONFLICT, exception.getResultCode());
|
||||
verify(fixture.storage).delete(fixture.businessStored);
|
||||
verify(fixture.storage).delete(fixture.legalPersonStored);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownIamOutcomePreservesBothFilesForReconciliation() {
|
||||
var fixture = storedFixture();
|
||||
when(fixture.iamClient.submitRequiredPackage(eq("internal-token"), any()))
|
||||
.thenThrow(new RuntimeException("timeout"));
|
||||
|
||||
var exception = assertThrows(BizException.class, () -> fixture.service.upload(TENANT_ID,
|
||||
fixture.businessLicense, fixture.legalPersonId, "91310101ABCDE12345",
|
||||
null, null, false, false, false));
|
||||
|
||||
assertEquals(ResultCode.SERVICE_UNAVAILABLE, exception.getResultCode());
|
||||
verify(fixture.storage, never()).delete(fixture.businessStored);
|
||||
verify(fixture.storage, never()).delete(fixture.legalPersonStored);
|
||||
}
|
||||
|
||||
@Test
|
||||
void platformSuccessCleansReplacedFilesOnlyAfterIamCommit() {
|
||||
var fixture = storedFixture();
|
||||
var oldBusinessUrl = "/api/merchant/v1/files/qualifications/33333333-3333-3333-3333-333333333333.pdf";
|
||||
var oldLegalPersonUrl = "/api/merchant/v1/files/qualifications/44444444-4444-4444-4444-444444444444.png";
|
||||
when(fixture.iamClient.submitPlatformRequiredPackage(eq("internal-token"), any())).thenReturn(
|
||||
ApiResponse.success(new IamQualificationClient.RequiredPackageResponse(
|
||||
31L, 32L, "PROFILE_REQUIRED", oldBusinessUrl, oldLegalPersonUrl)));
|
||||
|
||||
fixture.service.upload(TENANT_ID, fixture.businessLicense, fixture.legalPersonId,
|
||||
"91310101ABCDE12345", null, null, true, true, true);
|
||||
|
||||
var ordered = inOrder(fixture.iamClient, fixture.storage);
|
||||
ordered.verify(fixture.iamClient).submitPlatformRequiredPackage(eq("internal-token"), any());
|
||||
ordered.verify(fixture.storage).deleteByDocumentUrl(TENANT_ID, oldBusinessUrl);
|
||||
ordered.verify(fixture.storage).deleteByDocumentUrl(TENANT_ID, oldLegalPersonUrl);
|
||||
}
|
||||
|
||||
private static Fixture storedFixture() {
|
||||
var fixture = fixture();
|
||||
when(fixture.storage.store(TENANT_ID, fixture.businessLicense)).thenReturn(fixture.businessStored);
|
||||
when(fixture.storage.store(TENANT_ID, fixture.legalPersonId)).thenReturn(fixture.legalPersonStored);
|
||||
return fixture;
|
||||
}
|
||||
|
||||
private static Fixture fixture() {
|
||||
var storage = mock(LocalQualificationStorage.class);
|
||||
var iamClient = mock(IamQualificationClient.class);
|
||||
var service = new QualificationPackageService();
|
||||
ReflectionTestUtils.setField(service, "storage", storage);
|
||||
ReflectionTestUtils.setField(service, "iamClient", iamClient);
|
||||
ReflectionTestUtils.setField(service, "internalToken", "internal-token");
|
||||
var businessLicense = new MockMultipartFile(
|
||||
"businessLicense", "license.pdf", "application/pdf", "%PDF-1.4".getBytes());
|
||||
var legalPersonId = new MockMultipartFile(
|
||||
"legalPersonId", "legal-person.png", "image/png", new byte[] {1, 2, 3});
|
||||
var businessStored = new LocalQualificationStorage.StoredFile(
|
||||
Path.of("business.pdf"), "business.pdf", BUSINESS_URL);
|
||||
var legalPersonStored = new LocalQualificationStorage.StoredFile(
|
||||
Path.of("legal-person.png"), "legal-person.png", LEGAL_PERSON_URL);
|
||||
return new Fixture(service, storage, iamClient, businessLicense, legalPersonId,
|
||||
businessStored, legalPersonStored);
|
||||
}
|
||||
|
||||
private record Fixture(QualificationPackageService service,
|
||||
LocalQualificationStorage storage,
|
||||
IamQualificationClient iamClient,
|
||||
MockMultipartFile businessLicense,
|
||||
MockMultipartFile legalPersonId,
|
||||
LocalQualificationStorage.StoredFile businessStored,
|
||||
LocalQualificationStorage.StoredFile legalPersonStored) { }
|
||||
}
|
||||
+13
-2
@@ -2,6 +2,8 @@ package com.baiyue.firstleg.controller;
|
||||
|
||||
import com.baiyue.common.audit.BusinessAudit;
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.firstleg.dto.FbaPricingDtos.CarrierRateVersionRequest;
|
||||
import com.baiyue.firstleg.dto.FbaPricingDtos.CustomerRuleVersionRequest;
|
||||
@@ -36,10 +38,12 @@ public class PlatformFbaMasterDataController {
|
||||
|
||||
@GetMapping("/customer-rules")
|
||||
@PreAuthorize("hasAuthority('firstleg:fba-pricing:rule:view')")
|
||||
@Operation(summary = "查询客户计费规则版本", description = "默认查询全部企业;tenantId 可用于筛选目标企业")
|
||||
public ApiResponse<List<CustomerChargeRuleEntity>> rules(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId,
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) Long tenantId,
|
||||
@RequestParam(required = false) String ruleCode) {
|
||||
return ApiResponse.success(service.listRules(FirstlegScopeGuard.platform(user, tenantId), ruleCode));
|
||||
return ApiResponse.success(service.listRules(optionalPlatformTenant(user, tenantId), ruleCode));
|
||||
}
|
||||
|
||||
@GetMapping("/customer-rules/{id}")
|
||||
@@ -105,4 +109,11 @@ public class PlatformFbaMasterDataController {
|
||||
service.retireRate(FirstlegScopeGuard.platform(user, tenantId), id, user.userId());
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
private static Long optionalPlatformTenant(AuthenticatedUser user, Long tenantId) {
|
||||
if (user == null || !"PLATFORM".equals(user.appCode())) {
|
||||
throw new BizException(ResultCode.FORBIDDEN, "仅允许平台端访问");
|
||||
}
|
||||
return tenantId == null ? null : FirstlegScopeGuard.platform(user, tenantId);
|
||||
}
|
||||
}
|
||||
|
||||
+16
-6
@@ -10,10 +10,13 @@ import com.baiyue.firstleg.dto.InboundOrderQuery;
|
||||
import com.baiyue.firstleg.dto.InboundOrderSummary;
|
||||
import com.baiyue.firstleg.service.InboundOrderQueryService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
@@ -22,6 +25,7 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/** 平台端入库单列表和详情只读接口。 */
|
||||
@Tag(name = "平台入库单", description = "二期入库单列表、页签、筛选和只读聚合详情")
|
||||
@Validated
|
||||
@RestController
|
||||
@RequestMapping("/api/platform/inbounds")
|
||||
public class PlatformInboundOrderController {
|
||||
@@ -31,14 +35,16 @@ public class PlatformInboundOrderController {
|
||||
this.service = service;
|
||||
}
|
||||
|
||||
@Operation(summary = "分页查询入库单", description = "按目标租户和二期状态页签分页查询入库单快照。")
|
||||
@Operation(summary = "分页查询入库单",
|
||||
description = "默认分页查询全部企业入库单;可按目标租户和二期状态页签筛选入库单快照。")
|
||||
@GetMapping
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:inbound:list')")
|
||||
public ApiResponse<PageResult<InboundOrderSummary>> page(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam long tenantId,
|
||||
@Parameter(description = "可选目标企业租户ID;不传时查询全部企业")
|
||||
@RequestParam(required = false) @Min(1) Long tenantId,
|
||||
@Valid InboundOrderQuery query) {
|
||||
requirePlatformTenant(user, tenantId);
|
||||
requirePlatform(user);
|
||||
return ApiResponse.success(service.page(tenantId, query));
|
||||
}
|
||||
|
||||
@@ -57,11 +63,15 @@ public class PlatformInboundOrderController {
|
||||
}
|
||||
|
||||
private static void requirePlatformTenant(AuthenticatedUser user, long tenantId) {
|
||||
if (user == null || !"PLATFORM".equals(user.appCode())) {
|
||||
throw new BizException(ResultCode.FORBIDDEN, "仅允许平台端访问");
|
||||
}
|
||||
requirePlatform(user);
|
||||
if (tenantId <= 0) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "必须指定有效目标租户");
|
||||
}
|
||||
}
|
||||
|
||||
private static void requirePlatform(AuthenticatedUser user) {
|
||||
if (user == null || !"PLATFORM".equals(user.appCode())) {
|
||||
throw new BizException(ResultCode.FORBIDDEN, "仅允许平台端访问");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import java.time.LocalDateTime;
|
||||
|
||||
/** 入库列表行,所有名称和运输信息均为业务快照。 */
|
||||
public record InboundOrderSummary(
|
||||
Long id, Long orderId, String orderNo, String inboundCode,
|
||||
Long id, Long tenantId, Long orderId, String orderNo, String inboundCode,
|
||||
String processStatus, String orderStatusSnapshot, String measureReviewStatus,
|
||||
String feeStatus, String feeReviewStatus, String notificationStatus,
|
||||
Integer version,
|
||||
@@ -33,7 +33,7 @@ public record InboundOrderSummary(
|
||||
public static InboundOrderSummary from(
|
||||
InboundOrderEntity order, InboundTransportSnapshotEntity transport, String locationCodes) {
|
||||
return new InboundOrderSummary(
|
||||
order.getId(), order.getOrderId(), order.getOrderNo(), order.getInboundCode(),
|
||||
order.getId(), order.getTenantId(), order.getOrderId(), order.getOrderNo(), order.getInboundCode(),
|
||||
order.getProcessStatus(), order.getOrderStatusSnapshot(), order.getMeasureReviewStatus(),
|
||||
order.getFeeStatus(), order.getFeeReviewStatus(), order.getNotificationStatus(),
|
||||
order.getVersion(),
|
||||
|
||||
@@ -21,7 +21,7 @@ import org.springframework.stereotype.Repository;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/** FBA 计费仓储,所有业务查询都强制带租户条件。 */
|
||||
/** FBA 计费仓储;平台客户规则列表可跨企业读取,其他业务操作强制带租户条件。 */
|
||||
@Repository
|
||||
public class FbaPricingRepository {
|
||||
private final CustomerChargeRuleMapper rules;
|
||||
@@ -61,9 +61,9 @@ public class FbaPricingRepository {
|
||||
.eq(CarrierFbaRateEntity::getId, id));
|
||||
}
|
||||
|
||||
public List<CustomerChargeRuleEntity> listRules(long tenantId, String ruleCode) {
|
||||
public List<CustomerChargeRuleEntity> listRules(Long tenantId, String ruleCode) {
|
||||
return rules.selectList(new LambdaQueryWrapper<CustomerChargeRuleEntity>()
|
||||
.eq(CustomerChargeRuleEntity::getTenantId, tenantId)
|
||||
.eq(tenantId != null, CustomerChargeRuleEntity::getTenantId, tenantId)
|
||||
.eq(ruleCode != null && !ruleCode.isBlank(), CustomerChargeRuleEntity::getRuleCode, ruleCode)
|
||||
.orderByAsc(CustomerChargeRuleEntity::getRuleCode)
|
||||
.orderByDesc(CustomerChargeRuleEntity::getRuleVersion));
|
||||
|
||||
@@ -26,7 +26,7 @@ import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/** 入库单只读仓储,所有查询均以租户作为首要隔离条件。 */
|
||||
/** 入库单只读仓储;平台分页可跨企业查询,详情继续强制租户隔离。 */
|
||||
@Repository
|
||||
public class InboundOrderRepository {
|
||||
private final InboundOrderMapper orders;
|
||||
@@ -51,9 +51,9 @@ public class InboundOrderRepository {
|
||||
this.histories = histories;
|
||||
}
|
||||
|
||||
public PageResult<InboundOrderSummary> page(long tenantId, InboundOrderQuery query) {
|
||||
public PageResult<InboundOrderSummary> page(Long tenantId, InboundOrderQuery query) {
|
||||
LambdaQueryWrapper<InboundOrderEntity> wrapper = new LambdaQueryWrapper<InboundOrderEntity>()
|
||||
.eq(InboundOrderEntity::getTenantId, tenantId)
|
||||
.eq(tenantId != null, InboundOrderEntity::getTenantId, tenantId)
|
||||
.likeRight(StringUtils.hasText(query.inboundCode()), InboundOrderEntity::getInboundCode,
|
||||
query.inboundCode())
|
||||
.likeRight(StringUtils.hasText(query.orderNo()), InboundOrderEntity::getOrderNo, query.orderNo())
|
||||
@@ -85,9 +85,10 @@ public class InboundOrderRepository {
|
||||
.orderByDesc(InboundOrderEntity::getCreatedAt)
|
||||
.orderByDesc(InboundOrderEntity::getId);
|
||||
Page<InboundOrderEntity> result = orders.selectPage(new Page<>(query.page(), query.size()), wrapper);
|
||||
List<Long> ids = result.getRecords().stream().map(InboundOrderEntity::getId).toList();
|
||||
Map<Long, InboundTransportSnapshotEntity> transportByInbound = transportMap(tenantId, ids);
|
||||
Map<Long, String> locationCodesByInbound = locationCodesMap(tenantId, ids);
|
||||
Map<Long, Long> tenantByInbound = result.getRecords().stream()
|
||||
.collect(Collectors.toMap(InboundOrderEntity::getId, InboundOrderEntity::getTenantId));
|
||||
Map<Long, InboundTransportSnapshotEntity> transportByInbound = transportMap(tenantId, tenantByInbound);
|
||||
Map<Long, String> locationCodesByInbound = locationCodesMap(tenantId, tenantByInbound);
|
||||
List<InboundOrderSummary> records = result.getRecords().stream()
|
||||
.map(order -> InboundOrderSummary.from(order, transportByInbound.get(order.getId()),
|
||||
locationCodesByInbound.get(order.getId())))
|
||||
@@ -156,27 +157,31 @@ public class InboundOrderRepository {
|
||||
palletSummaries, fileSummaries, noteSummaries, historySummaries);
|
||||
}
|
||||
|
||||
private Map<Long, InboundTransportSnapshotEntity> transportMap(long tenantId, List<Long> inboundIds) {
|
||||
if (inboundIds.isEmpty()) {
|
||||
private Map<Long, InboundTransportSnapshotEntity> transportMap(
|
||||
Long tenantId, Map<Long, Long> tenantByInbound) {
|
||||
if (tenantByInbound.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
return transports.selectList(new LambdaQueryWrapper<InboundTransportSnapshotEntity>()
|
||||
.eq(InboundTransportSnapshotEntity::getTenantId, tenantId)
|
||||
.in(InboundTransportSnapshotEntity::getInboundId, inboundIds))
|
||||
.stream().collect(Collectors.toMap(InboundTransportSnapshotEntity::getInboundId,
|
||||
.eq(tenantId != null, InboundTransportSnapshotEntity::getTenantId, tenantId)
|
||||
.in(InboundTransportSnapshotEntity::getInboundId, tenantByInbound.keySet()))
|
||||
.stream()
|
||||
.filter(item -> owns(tenantByInbound, item.getInboundId(), item.getTenantId()))
|
||||
.collect(Collectors.toMap(InboundTransportSnapshotEntity::getInboundId,
|
||||
Function.identity(), (left, right) -> left));
|
||||
}
|
||||
|
||||
private Map<Long, String> locationCodesMap(long tenantId, List<Long> inboundIds) {
|
||||
if (inboundIds.isEmpty()) {
|
||||
private Map<Long, String> locationCodesMap(Long tenantId, Map<Long, Long> tenantByInbound) {
|
||||
if (tenantByInbound.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
return pallets.selectList(new LambdaQueryWrapper<InboundPalletAllocationEntity>()
|
||||
.eq(InboundPalletAllocationEntity::getTenantId, tenantId)
|
||||
.in(InboundPalletAllocationEntity::getInboundId, inboundIds)
|
||||
.eq(tenantId != null, InboundPalletAllocationEntity::getTenantId, tenantId)
|
||||
.in(InboundPalletAllocationEntity::getInboundId, tenantByInbound.keySet())
|
||||
.orderByAsc(InboundPalletAllocationEntity::getInboundId)
|
||||
.orderByAsc(InboundPalletAllocationEntity::getAllocationNo))
|
||||
.stream()
|
||||
.filter(item -> owns(tenantByInbound, item.getInboundId(), item.getTenantId()))
|
||||
.filter(item -> StringUtils.hasText(item.getLocationCode()))
|
||||
.collect(Collectors.groupingBy(InboundPalletAllocationEntity::getInboundId,
|
||||
java.util.LinkedHashMap::new,
|
||||
@@ -184,6 +189,10 @@ public class InboundOrderRepository {
|
||||
Collectors.joining("、"))));
|
||||
}
|
||||
|
||||
private static boolean owns(Map<Long, Long> tenantByInbound, Long inboundId, Long tenantId) {
|
||||
return inboundId != null && tenantId != null && tenantId.equals(tenantByInbound.get(inboundId));
|
||||
}
|
||||
|
||||
private static <T> LambdaQueryWrapper<T> owned(
|
||||
com.baomidou.mybatisplus.core.toolkit.support.SFunction<T, ?> tenantColumn,
|
||||
com.baomidou.mybatisplus.core.toolkit.support.SFunction<T, ?> inboundColumn,
|
||||
|
||||
@@ -98,7 +98,7 @@ public class FbaMasterDataService {
|
||||
return value;
|
||||
}
|
||||
|
||||
public List<CustomerChargeRuleEntity> listRules(long tenantId, String code) {
|
||||
public List<CustomerChargeRuleEntity> listRules(Long tenantId, String code) {
|
||||
return repository.listRules(tenantId, code);
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ public class InboundOrderQueryService {
|
||||
this.repository = repository;
|
||||
}
|
||||
|
||||
public PageResult<InboundOrderSummary> page(long tenantId, InboundOrderQuery query) {
|
||||
public PageResult<InboundOrderSummary> page(Long tenantId, InboundOrderQuery query) {
|
||||
validateQuery(query);
|
||||
return repository.page(tenantId, query);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,9 @@ paths:
|
||||
get:
|
||||
summary: 查询客户计费规则版本
|
||||
description: 需要 firstleg:fba-pricing:rule:view 权限
|
||||
parameters: [{$ref: '#/components/parameters/TenantId'}]
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/OptionalTenantId'}
|
||||
- {name: ruleCode, in: query, schema: {type: string, maxLength: 50}}
|
||||
responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}}
|
||||
/api/platform/v1/fba-pricing/customer-rules/versions:
|
||||
post:
|
||||
@@ -87,9 +89,10 @@ components:
|
||||
securitySchemes:
|
||||
bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT}
|
||||
parameters:
|
||||
TenantId: {name: tenantId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}}
|
||||
Id: {name: id, in: path, required: true, schema: {type: integer, format: int64}}
|
||||
InboundId: {name: inboundId, in: path, required: true, schema: {type: integer, format: int64}}
|
||||
OptionalTenantId: {name: tenantId, in: query, required: false, description: 不传时查询全部企业, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
TenantId: {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
Id: {name: id, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
InboundId: {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}}
|
||||
schemas:
|
||||
AdjustFeeRequest:
|
||||
@@ -107,7 +110,7 @@ components:
|
||||
required: [ruleCode, customerId, customerName, businessType, chargeMode, taxMode, conversionFactor, roundingScale, roundingMode, currency, effectiveFrom]
|
||||
properties:
|
||||
ruleCode: {type: string, maxLength: 50}
|
||||
customerId: {type: integer, format: int64}
|
||||
customerId: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
customerName: {type: string, maxLength: 128}
|
||||
businessType: {type: string, maxLength: 32}
|
||||
chargeMode: {type: string, enum: [KG, CBM]}
|
||||
@@ -123,11 +126,11 @@ components:
|
||||
required: [rateCode, originWarehouseId, originWarehouseName, businessType, channelCode, carrierId, carrierName, destinationCode, chargeUnit, unitPrice, currency, additionalFee, effectiveFrom]
|
||||
properties:
|
||||
rateCode: {type: string, maxLength: 50}
|
||||
originWarehouseId: {type: integer, format: int64}
|
||||
originWarehouseId: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
originWarehouseName: {type: string}
|
||||
businessType: {type: string}
|
||||
channelCode: {type: string}
|
||||
carrierId: {type: integer, format: int64}
|
||||
carrierId: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
carrierName: {type: string}
|
||||
destinationCode: {type: string}
|
||||
chargeUnit: {type: string, enum: [KG, CBM]}
|
||||
|
||||
@@ -127,12 +127,16 @@ paths:
|
||||
/api/platform/inbounds:
|
||||
get:
|
||||
summary: 分页查询入库单
|
||||
description: 需要 platform:firstleg:inbound:list 权限;仅使用 V2 四个页签
|
||||
description: 需要 platform:firstleg:inbound:list 权限;默认查询全部企业,可选 tenantId 筛选;仅使用 V2 四个页签
|
||||
parameters:
|
||||
- {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
- {name: tenantId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
- {name: tab, in: query, schema: {type: string, enum: [WAIT_MEASURE, WAIT_REVIEW, WAIT_FEE_SEND, INBOUNDED]}}
|
||||
responses:
|
||||
'200': {description: 成功或业务失败,按响应体 code 判断}
|
||||
'200':
|
||||
description: 成功或业务失败,按响应体 code 判断;每条记录携带后续租户隔离操作所需的 tenantId
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/InboundOrderPageEnvelope'}
|
||||
'403': {description: 无查询权限}
|
||||
/api/platform/inbounds/{inboundId}:
|
||||
get:
|
||||
@@ -251,6 +255,34 @@ components:
|
||||
in: query
|
||||
schema: {type: integer, format: int64, minimum: 1, maximum: 200, default: 20}
|
||||
schemas:
|
||||
InboundOrderPageEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
code: {type: string, example: '200'}
|
||||
message: {type: string}
|
||||
data: {$ref: '#/components/schemas/InboundOrderPage'}
|
||||
InboundOrderPage:
|
||||
type: object
|
||||
properties:
|
||||
records:
|
||||
type: array
|
||||
items: {$ref: '#/components/schemas/InboundOrderSummary'}
|
||||
page: {type: integer, minimum: 1}
|
||||
size: {type: integer, minimum: 1}
|
||||
total: {type: integer, format: int64, minimum: 0}
|
||||
pages: {type: integer, format: int64, minimum: 0}
|
||||
InboundOrderSummary:
|
||||
type: object
|
||||
required: [id, tenantId]
|
||||
properties:
|
||||
id: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
tenantId: {type: string, pattern: '^[1-9][0-9]*$', description: 记录所属企业租户 ID,用于详情和处理接口的租户隔离}
|
||||
orderId: {type: string, nullable: true}
|
||||
orderNo: {type: string, nullable: true}
|
||||
inboundCode: {type: string, nullable: true}
|
||||
processStatus: {type: string}
|
||||
customerId: {type: string, nullable: true}
|
||||
companyId: {type: string, nullable: true}
|
||||
InboundMeasurementCallbackRequest:
|
||||
type: object
|
||||
required: [eventNo, externalDetailNo, warehouseCode, inboundCode, inboundDetailId, deviceCode, measuredAt, boxSpecification, packageCount, lengthCm, widthCm, heightCm]
|
||||
|
||||
@@ -3,13 +3,30 @@ package com.baiyue.firstleg.controller;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.lang.reflect.Method;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@DisplayName("FBA计费OpenAPI契约")
|
||||
class FbaPricingContractTest {
|
||||
@Test
|
||||
@DisplayName("只有客户规则列表允许省略企业参数")
|
||||
void onlyCustomerRuleListAllowsOptionalTenant() {
|
||||
Method rules = method("rules");
|
||||
assertThat(rules.getParameterTypes()[1]).isEqualTo(Long.class);
|
||||
assertThat(rules.getParameters()[1].getAnnotation(RequestParam.class).required()).isFalse();
|
||||
|
||||
for (String scopedMethod : new String[] {"rule", "createRule", "retireRule", "rates",
|
||||
"rate", "createRate", "retireRate"}) {
|
||||
Method method = method(scopedMethod);
|
||||
assertThat(method.getParameterTypes()[1]).isEqualTo(long.class);
|
||||
assertThat(method.getParameters()[1].getAnnotation(RequestParam.class).required()).isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("契约声明自动发布、已发布费用调整和幂等头")
|
||||
void documentsPricingLifecycle() throws Exception {
|
||||
@@ -28,5 +45,26 @@ class FbaPricingContractTest {
|
||||
.doesNotContain("应收")
|
||||
.doesNotContain("应付")
|
||||
.doesNotContain("待客户确认");
|
||||
String customerRules = openapi.substring(
|
||||
openapi.indexOf("/api/platform/v1/fba-pricing/customer-rules:"),
|
||||
openapi.indexOf("/api/platform/v1/fba-pricing/customer-rules/versions:"));
|
||||
assertThat(customerRules)
|
||||
.contains("OptionalTenantId")
|
||||
.contains("name: ruleCode")
|
||||
.doesNotContain("#/components/parameters/Page")
|
||||
.doesNotContain("#/components/parameters/Size")
|
||||
.doesNotContain("name: customerName")
|
||||
.doesNotContain("name: chargeMode")
|
||||
.doesNotContain("name: status");
|
||||
assertThat(openapi)
|
||||
.contains("OptionalTenantId: {name: tenantId, in: query, required: false")
|
||||
.contains("schema: {type: string, pattern: '^[1-9][0-9]*$'}")
|
||||
.doesNotContain("format: int64");
|
||||
}
|
||||
|
||||
private static Method method(String name) {
|
||||
return java.util.Arrays.stream(PlatformFbaMasterDataController.class.getDeclaredMethods())
|
||||
.filter(candidate -> candidate.getName().equals(name))
|
||||
.findFirst().orElseThrow();
|
||||
}
|
||||
}
|
||||
|
||||
+21
-1
@@ -5,6 +5,7 @@ import com.baiyue.firstleg.dto.InboundOrderQuery;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
@@ -15,21 +16,40 @@ class InboundOrderControllerContractTest {
|
||||
@Test
|
||||
void shouldDeclareReadOnlyPermissions() throws Exception {
|
||||
assertAuthority("page", "hasAuthority('platform:firstleg:inbound:list')",
|
||||
AuthenticatedUser.class, long.class, InboundOrderQuery.class);
|
||||
AuthenticatedUser.class, Long.class, InboundOrderQuery.class);
|
||||
assertAuthority("detail", "hasAuthority('platform:firstleg:inbound:view')",
|
||||
AuthenticatedUser.class, long.class, long.class);
|
||||
|
||||
Method page = PlatformInboundOrderController.class.getDeclaredMethod(
|
||||
"page", AuthenticatedUser.class, Long.class, InboundOrderQuery.class);
|
||||
assertThat(page.getParameters()[1].getAnnotation(RequestParam.class).required()).isFalse();
|
||||
Method detail = PlatformInboundOrderController.class.getDeclaredMethod(
|
||||
"detail", AuthenticatedUser.class, long.class, long.class);
|
||||
assertThat(detail.getParameters()[1].getAnnotation(RequestParam.class).required()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldDocumentOnlyV2TabsAndStringLongIds() throws Exception {
|
||||
String openapi = new ClassPathResource("openapi/firstleg-v1.yaml")
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
String inboundList = openapi.substring(
|
||||
openapi.indexOf("/api/platform/inbounds:"),
|
||||
openapi.indexOf("/api/platform/inbounds/{inboundId}:"));
|
||||
String taskList = openapi.substring(
|
||||
openapi.indexOf("/api/platform/v1/first-leg/tasks:"),
|
||||
openapi.indexOf("/api/platform/v1/first-leg/tasks/{taskId}:"));
|
||||
assertThat(openapi)
|
||||
.contains("/api/platform/inbounds:")
|
||||
.contains("/api/platform/inbounds/{inboundId}:")
|
||||
.contains("required: false")
|
||||
.contains("InboundOrderSummary:")
|
||||
.contains("required: [id, tenantId]")
|
||||
.contains("tenantId: {type: string, pattern: '^[1-9][0-9]*$', description:")
|
||||
.contains("[WAIT_MEASURE, WAIT_REVIEW, WAIT_FEE_SEND, INBOUNDED]")
|
||||
.contains("platform:firstleg:inbound:list")
|
||||
.doesNotContain("WAIT_CUSTOMER_CONFIRM");
|
||||
assertThat(inboundList).contains("#/components/schemas/InboundOrderPageEnvelope");
|
||||
assertThat(taskList).doesNotContain("InboundOrderPageEnvelope");
|
||||
}
|
||||
|
||||
private static void assertAuthority(String methodName, String value, Class<?>... types)
|
||||
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
package com.baiyue.firstleg.repository;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import com.baiyue.firstleg.entity.CustomerChargeRuleEntity;
|
||||
import com.baiyue.firstleg.mapper.CarrierFbaRateMapper;
|
||||
import com.baiyue.firstleg.mapper.CustomerChargeRuleMapper;
|
||||
import com.baiyue.firstleg.mapper.InboundFeeItemMapper;
|
||||
import com.baiyue.firstleg.mapper.InboundFeeMapper;
|
||||
import com.baiyue.firstleg.mapper.InboundOutboxMapper;
|
||||
import com.baiyue.firstleg.mapper.InboundPricingIdempotencyMapper;
|
||||
import com.baiyue.firstleg.mapper.InboundPricingMapper;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.apache.ibatis.session.Configuration;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class FbaPricingRepositoryRuleListTest {
|
||||
@BeforeAll
|
||||
static void initializeEntityMetadata() {
|
||||
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new Configuration(), "test"),
|
||||
CustomerChargeRuleEntity.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void platformRuleListOmitsTenantConditionWhenTenantIsAbsent() {
|
||||
assertThat(queryValues(null, null)).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void platformRuleListKeepsTenantAndRuleCodeFiltersWhenProvided() {
|
||||
assertThat(queryValues(10001L, "CR-1")).containsExactlyInAnyOrder(10001L, "CR-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void internalVersionCalculationKeepsTenantAndRuleCodeFilters() {
|
||||
CustomerChargeRuleMapper rules = mock(CustomerChargeRuleMapper.class);
|
||||
CustomerChargeRuleEntity current = new CustomerChargeRuleEntity();
|
||||
current.setRuleVersion(3);
|
||||
when(rules.selectList(org.mockito.ArgumentMatchers.any())).thenReturn(List.of(current));
|
||||
FbaPricingRepository repository = repository(rules);
|
||||
|
||||
int result = repository.nextRuleVersion(10001L, "CR-1");
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<LambdaQueryWrapper<CustomerChargeRuleEntity>> wrapper =
|
||||
ArgumentCaptor.forClass(LambdaQueryWrapper.class);
|
||||
verify(rules).selectList(wrapper.capture());
|
||||
wrapper.getValue().getSqlSegment();
|
||||
assertThat(wrapper.getValue().getParamNameValuePairs().values())
|
||||
.containsExactlyInAnyOrder(10001L, "CR-1");
|
||||
assertThat(result).isEqualTo(4);
|
||||
}
|
||||
|
||||
private static Collection<Object> queryValues(Long tenantId, String ruleCode) {
|
||||
CustomerChargeRuleMapper rules = mock(CustomerChargeRuleMapper.class);
|
||||
when(rules.selectList(org.mockito.ArgumentMatchers.any())).thenReturn(List.of());
|
||||
FbaPricingRepository repository = repository(rules);
|
||||
|
||||
repository.listRules(tenantId, ruleCode);
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<LambdaQueryWrapper<CustomerChargeRuleEntity>> wrapper =
|
||||
ArgumentCaptor.forClass(LambdaQueryWrapper.class);
|
||||
verify(rules).selectList(wrapper.capture());
|
||||
wrapper.getValue().getSqlSegment();
|
||||
return wrapper.getValue().getParamNameValuePairs().values();
|
||||
}
|
||||
|
||||
private static FbaPricingRepository repository(CustomerChargeRuleMapper rules) {
|
||||
return new FbaPricingRepository(rules, mock(CarrierFbaRateMapper.class),
|
||||
mock(InboundPricingMapper.class), mock(InboundFeeMapper.class), mock(InboundFeeItemMapper.class),
|
||||
mock(InboundPricingIdempotencyMapper.class), mock(InboundOutboxMapper.class));
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baiyue.firstleg.dto.InboundOrderQuery;
|
||||
import com.baiyue.firstleg.entity.InboundOrderEntity;
|
||||
import com.baiyue.firstleg.entity.InboundPalletAllocationEntity;
|
||||
import com.baiyue.firstleg.entity.InboundTransportSnapshotEntity;
|
||||
import com.baiyue.firstleg.mapper.InboundFileMapper;
|
||||
import com.baiyue.firstleg.mapper.InboundInternalNoteMapper;
|
||||
import com.baiyue.firstleg.mapper.InboundOrderMapper;
|
||||
@@ -73,8 +74,10 @@ class InboundOrderRepositoryTest {
|
||||
Page<InboundOrderEntity> page = new Page<>(1, 20);
|
||||
InboundOrderEntity first = new InboundOrderEntity();
|
||||
first.setId(1L);
|
||||
first.setTenantId(10001L);
|
||||
InboundOrderEntity second = new InboundOrderEntity();
|
||||
second.setId(2L);
|
||||
second.setTenantId(10001L);
|
||||
page.setRecords(java.util.List.of(first, second));
|
||||
when(orders.selectPage(any(Page.class), any(Wrapper.class))).thenReturn(page);
|
||||
when(pallets.selectList(any(Wrapper.class))).thenReturn(java.util.List.of(
|
||||
@@ -104,12 +107,85 @@ class InboundOrderRepositoryTest {
|
||||
.contains("ORDER BY inbound_id ASC,allocation_no ASC");
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings({"rawtypes", "unchecked"})
|
||||
void shouldQueryAndAggregateCrossTenantPageByInboundOwnership() {
|
||||
TableInfoHelper.initTableInfo(
|
||||
new MapperBuilderAssistant(new MybatisConfiguration(), "test"), InboundOrderEntity.class);
|
||||
TableInfoHelper.initTableInfo(
|
||||
new MapperBuilderAssistant(new MybatisConfiguration(), "test"),
|
||||
InboundTransportSnapshotEntity.class);
|
||||
TableInfoHelper.initTableInfo(
|
||||
new MapperBuilderAssistant(new MybatisConfiguration(), "test"),
|
||||
InboundPalletAllocationEntity.class);
|
||||
InboundOrderMapper orders = mock(InboundOrderMapper.class);
|
||||
InboundTransportSnapshotMapper transports = mock(InboundTransportSnapshotMapper.class);
|
||||
InboundPalletAllocationMapper pallets = mock(InboundPalletAllocationMapper.class);
|
||||
Page<InboundOrderEntity> page = new Page<>(1, 20);
|
||||
page.setRecords(java.util.List.of(order(1L, 10001L), order(2L, 10002L)));
|
||||
when(orders.selectPage(any(Page.class), any(Wrapper.class))).thenReturn(page);
|
||||
when(transports.selectList(any(Wrapper.class))).thenReturn(java.util.List.of(
|
||||
transport(1L, 10001L, "T1"),
|
||||
transport(2L, 10002L, "T2"),
|
||||
transport(1L, 10002L, "WRONG")));
|
||||
when(pallets.selectList(any(Wrapper.class))).thenReturn(java.util.List.of(
|
||||
allocation(1L, 10001L, 1, "A1"),
|
||||
allocation(2L, 10002L, 1, "B1"),
|
||||
allocation(1L, 10002L, 2, "WRONG")));
|
||||
InboundOrderRepository repository = new InboundOrderRepository(
|
||||
orders, transports, pallets, mock(InboundFileMapper.class),
|
||||
mock(InboundInternalNoteMapper.class), mock(InboundStatusHistoryMapper.class));
|
||||
|
||||
var result = repository.page(null, new InboundOrderQuery(
|
||||
null, null, null, null, null, null, null, null,
|
||||
null, null, null, null, null, null, null, null, null, 1, 20));
|
||||
|
||||
assertThat(result.records()).extracting(record -> record.tenantId())
|
||||
.containsExactly(10001L, 10002L);
|
||||
assertThat(result.records()).extracting(record -> record.loadingStatus())
|
||||
.containsExactly("T1", "T2");
|
||||
assertThat(result.records()).extracting(record -> record.locationCodes())
|
||||
.containsExactly("A1", "B1");
|
||||
ArgumentCaptor<Wrapper<InboundOrderEntity>> orderCaptor = ArgumentCaptor.forClass(Wrapper.class);
|
||||
verify(orders).selectPage(any(Page.class), orderCaptor.capture());
|
||||
assertThat(orderCaptor.getValue().getCustomSqlSegment()).doesNotContain("tenant_id");
|
||||
|
||||
ArgumentCaptor<Wrapper<InboundTransportSnapshotEntity>> transportCaptor =
|
||||
ArgumentCaptor.forClass(Wrapper.class);
|
||||
verify(transports).selectList(transportCaptor.capture());
|
||||
assertThat(transportCaptor.getValue().getCustomSqlSegment())
|
||||
.doesNotContain("tenant_id")
|
||||
.contains("inbound_id IN");
|
||||
}
|
||||
|
||||
private static InboundPalletAllocationEntity allocation(
|
||||
long inboundId, int allocationNo, String locationCode) {
|
||||
return allocation(inboundId, 10001L, allocationNo, locationCode);
|
||||
}
|
||||
|
||||
private static InboundPalletAllocationEntity allocation(
|
||||
long inboundId, long tenantId, int allocationNo, String locationCode) {
|
||||
InboundPalletAllocationEntity entity = new InboundPalletAllocationEntity();
|
||||
entity.setInboundId(inboundId);
|
||||
entity.setTenantId(tenantId);
|
||||
entity.setAllocationNo(allocationNo);
|
||||
entity.setLocationCode(locationCode);
|
||||
return entity;
|
||||
}
|
||||
|
||||
private static InboundOrderEntity order(long id, long tenantId) {
|
||||
InboundOrderEntity entity = new InboundOrderEntity();
|
||||
entity.setId(id);
|
||||
entity.setTenantId(tenantId);
|
||||
return entity;
|
||||
}
|
||||
|
||||
private static InboundTransportSnapshotEntity transport(
|
||||
long inboundId, long tenantId, String loadingStatus) {
|
||||
InboundTransportSnapshotEntity entity = new InboundTransportSnapshotEntity();
|
||||
entity.setInboundId(inboundId);
|
||||
entity.setTenantId(tenantId);
|
||||
entity.setLoadingStatus(loadingStatus);
|
||||
return entity;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,12 +16,27 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@DisplayName("FBA计费基础资料版本")
|
||||
class FbaMasterDataServiceTest {
|
||||
private static final LocalDateTime AUGUST = LocalDateTime.of(2026, 8, 1, 0, 0);
|
||||
|
||||
@Test
|
||||
@DisplayName("平台列表不传企业时委托仓储查询全部企业规则")
|
||||
void listsRulesAcrossTenantsWithoutTenantFilter() {
|
||||
FbaPricingRepository repository = mock(FbaPricingRepository.class);
|
||||
List<CustomerChargeRuleEntity> expected = List.of(activeRule(AUGUST, null));
|
||||
when(repository.listRules(null, "CR-1")).thenReturn(expected);
|
||||
|
||||
List<CustomerChargeRuleEntity> result =
|
||||
new FbaMasterDataService(repository).listRules(null, "CR-1");
|
||||
|
||||
assertThat(result).isSameAs(expected);
|
||||
verify(repository).listRules(null, "CR-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("半开有效期首尾相接不视为重叠")
|
||||
void acceptsAdjacentHalfOpenPeriods() {
|
||||
|
||||
@@ -21,6 +21,17 @@ import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class InboundOrderQueryServiceTest {
|
||||
@Test
|
||||
void shouldQueryAllPlatformTenantsWhenTenantFilterIsMissing() {
|
||||
InboundOrderRepository repository = mock(InboundOrderRepository.class);
|
||||
InboundOrderQuery query = query(null, null, null, null);
|
||||
PageResult<InboundOrderSummary> expected = new PageResult<>(List.of(), 1, 20, 0, 0);
|
||||
when(repository.page(null, query)).thenReturn(expected);
|
||||
|
||||
assertThat(new InboundOrderQueryService(repository).page(null, query)).isSameAs(expected);
|
||||
verify(repository).page(null, query);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldKeepV2TabMappingWithoutCustomerConfirmation() {
|
||||
assertThat(InboundListTab.WAIT_MEASURE.processStatuses())
|
||||
|
||||
@@ -10,6 +10,8 @@ import com.baiyue.iam.dto.OnboardingDtos.QualificationSubmissionRequest;
|
||||
import com.baiyue.iam.dto.OnboardingDtos.QualificationSubmissionResponse;
|
||||
import com.baiyue.iam.dto.OnboardingDtos.PlatformQualificationRegistrationRequest;
|
||||
import com.baiyue.iam.dto.OnboardingDtos.PlatformQualificationRegistrationResponse;
|
||||
import com.baiyue.iam.dto.OnboardingDtos.RequiredQualificationPackageRequest;
|
||||
import com.baiyue.iam.dto.OnboardingDtos.RequiredQualificationPackageResponse;
|
||||
import com.baiyue.iam.service.MerchantOnboardingService;
|
||||
import com.baiyue.iam.service.ProfileResourceService;
|
||||
import com.baiyue.iam.service.TenantService;
|
||||
@@ -73,6 +75,22 @@ public class InternalTenantController {
|
||||
return ApiResponse.success(profileResourceService.registerPlatformQualification(request));
|
||||
}
|
||||
|
||||
@PostMapping("/required-qualification-packages")
|
||||
public ApiResponse<RequiredQualificationPackageResponse> submitRequiredQualificationPackage(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@Valid @RequestBody RequiredQualificationPackageRequest request) {
|
||||
verify(token);
|
||||
return ApiResponse.success(profileResourceService.submitRequiredQualificationPackage(request));
|
||||
}
|
||||
|
||||
@PostMapping("/platform-required-qualification-packages")
|
||||
public ApiResponse<RequiredQualificationPackageResponse> registerPlatformRequiredQualificationPackage(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@Valid @RequestBody RequiredQualificationPackageRequest request) {
|
||||
verify(token);
|
||||
return ApiResponse.success(profileResourceService.registerPlatformRequiredQualificationPackage(request));
|
||||
}
|
||||
|
||||
@GetMapping("/tenants/{tenantId}/order-eligibility")
|
||||
public ApiResponse<OrderEligibilityResponse> orderEligibility(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
|
||||
@@ -50,5 +50,24 @@ public final class OnboardingDtos {
|
||||
String replacedDocumentUrl
|
||||
) { }
|
||||
|
||||
public record RequiredQualificationPackageRequest(
|
||||
@NotNull Long tenantId,
|
||||
@NotBlank @Pattern(regexp = "[0-9A-Za-z]{18}") String unifiedSocialCreditCode,
|
||||
@NotBlank String businessLicenseDocumentUrl,
|
||||
LocalDate businessLicenseValidTo,
|
||||
boolean replaceBusinessLicense,
|
||||
@NotBlank String legalPersonIdDocumentUrl,
|
||||
LocalDate legalPersonIdValidTo,
|
||||
boolean replaceLegalPersonId
|
||||
) { }
|
||||
|
||||
public record RequiredQualificationPackageResponse(
|
||||
Long businessLicenseQualificationId,
|
||||
Long legalPersonIdQualificationId,
|
||||
String tenantStatus,
|
||||
String replacedBusinessLicenseDocumentUrl,
|
||||
String replacedLegalPersonIdDocumentUrl
|
||||
) { }
|
||||
|
||||
public record OrderEligibilityResponse(boolean eligible, String tenantStatus, String reason) { }
|
||||
}
|
||||
|
||||
@@ -26,7 +26,6 @@ public final class TenantDtos {
|
||||
@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 = "Optional while creating a draft; required before review / 创建草稿时选填,送审前必填", example = "91440300MA5EXAMPLE", nullable = true) @Pattern(regexp = "[0-9A-Za-z]{18}") String unifiedSocialCreditCode,
|
||||
@Schema(description = "Primary contact / 联系人", example = "张三") @NotBlank String contactName,
|
||||
@Schema(description = "Contact phone / 联系电话", example = "13800138000") @NotBlank String contactPhone,
|
||||
@Schema(description = "Contact email / 联系邮箱", example = "ops@example.com") @Email String contactEmail,
|
||||
|
||||
@@ -7,6 +7,8 @@ import com.baiyue.iam.dto.OnboardingDtos.QualificationSubmissionRequest;
|
||||
import com.baiyue.iam.dto.OnboardingDtos.QualificationSubmissionResponse;
|
||||
import com.baiyue.iam.dto.OnboardingDtos.PlatformQualificationRegistrationRequest;
|
||||
import com.baiyue.iam.dto.OnboardingDtos.PlatformQualificationRegistrationResponse;
|
||||
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.entity.IamTenant;
|
||||
import com.baiyue.iam.entity.IamTenantAddress;
|
||||
@@ -65,6 +67,7 @@ public class ProfileResourceService {
|
||||
throw new BizException(ResultCode.CONFLICT,
|
||||
"Qualifications can only be uploaded before review / 仅待完善或已驳回客户可上传资质");
|
||||
}
|
||||
requireOptionalQualificationType(request.qualificationType());
|
||||
String certificateNo = resolveCertificateNo(tenant, request.qualificationType(), request.certificateNo());
|
||||
List<IamTenantQualification> activeQualifications = repository.findQualificationsForUpdate(tenant.getId()).stream()
|
||||
.filter(item -> request.qualificationType().equals(item.getQualificationType())
|
||||
@@ -86,11 +89,6 @@ public class ProfileResourceService {
|
||||
request.qualificationType(), certificateNo, request.documentUrl());
|
||||
qualification.setValidTo(request.validTo());
|
||||
repository.saveQualification(qualification);
|
||||
if ("BUSINESS_LICENSE".equals(request.qualificationType())
|
||||
&& !certificateNo.equals(tenant.getUnifiedSocialCreditCode())) {
|
||||
tenant.setUnifiedSocialCreditCode(certificateNo);
|
||||
tenantRepository.saveTenant(tenant);
|
||||
}
|
||||
return new QualificationSubmissionResponse(qualification.getId(), null, tenant.getStatus(), replacedDocumentUrl);
|
||||
}
|
||||
|
||||
@@ -105,6 +103,7 @@ public class ProfileResourceService {
|
||||
if (!"PROFILE_REQUIRED".equals(tenant.getStatus()) && !"REJECTED".equals(tenant.getStatus())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "Qualifications can only be uploaded before review / 仅待完善或已驳回客户可上传资质");
|
||||
}
|
||||
requireOptionalQualificationType(request.qualificationType());
|
||||
String certificateNo = resolveCertificateNo(tenant, request.qualificationType(), request.certificateNo());
|
||||
List<IamTenantQualification> activeQualifications = repository.findQualificationsForUpdate(tenant.getId()).stream()
|
||||
.filter(item -> request.qualificationType().equals(item.getQualificationType())
|
||||
@@ -126,15 +125,93 @@ public class ProfileResourceService {
|
||||
certificateNo, request.documentUrl());
|
||||
qualification.setValidTo(request.validTo());
|
||||
repository.saveQualification(qualification);
|
||||
if ("BUSINESS_LICENSE".equals(request.qualificationType())
|
||||
&& !certificateNo.equals(tenant.getUnifiedSocialCreditCode())) {
|
||||
tenant.setUnifiedSocialCreditCode(certificateNo);
|
||||
tenantRepository.saveTenant(tenant);
|
||||
}
|
||||
return new PlatformQualificationRegistrationResponse(
|
||||
qualification.getId(), null, tenant.getStatus(), replacedDocumentUrl);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@BusinessAudit(event = "TENANT_REQUIRED_QUALIFICATION_PACKAGE_UPLOAD", resourceType = "TENANT_QUALIFICATION",
|
||||
resourceId = "#request.tenantId", description = "上传企业必填资质包", captureAfter = false)
|
||||
public RequiredQualificationPackageResponse submitRequiredQualificationPackage(
|
||||
RequiredQualificationPackageRequest request) {
|
||||
return registerRequiredQualificationPackage(request, "商户在送审前替换文件");
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@BusinessAudit(event = "PLATFORM_REQUIRED_QUALIFICATION_PACKAGE_UPLOAD", resourceType = "TENANT_QUALIFICATION",
|
||||
resourceId = "#request.tenantId", description = "平台代客户上传必填资质包", captureAfter = false)
|
||||
public RequiredQualificationPackageResponse registerPlatformRequiredQualificationPackage(
|
||||
RequiredQualificationPackageRequest request) {
|
||||
return registerRequiredQualificationPackage(request, "平台在送审前替换文件");
|
||||
}
|
||||
|
||||
private RequiredQualificationPackageResponse registerRequiredQualificationPackage(
|
||||
RequiredQualificationPackageRequest request, String replacementComment) {
|
||||
IamTenant tenant = tenantRepository.findTenantForUpdate(request.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 can only be uploaded before review / 仅待完善或已驳回客户可上传资质");
|
||||
}
|
||||
String creditCode = resolveCertificateNo(tenant, "BUSINESS_LICENSE", request.unifiedSocialCreditCode());
|
||||
List<IamTenantQualification> locked = repository.findQualificationsForUpdate(tenant.getId());
|
||||
List<IamTenantQualification> businessLicenses = currentQualifications(locked, "BUSINESS_LICENSE");
|
||||
List<IamTenantQualification> legalPersonIds = currentQualifications(locked, "LEGAL_PERSON_ID");
|
||||
requireReplaceAllowed(businessLicenses, request.replaceBusinessLicense(), "营业执照");
|
||||
requireReplaceAllowed(legalPersonIds, request.replaceLegalPersonId(), "法人身份证");
|
||||
|
||||
String replacedBusinessLicenseUrl = supersede(businessLicenses, replacementComment);
|
||||
String replacedLegalPersonIdUrl = supersede(legalPersonIds, replacementComment);
|
||||
IamTenantQualification businessLicense = createQualification(tenant.getId(), "BUSINESS_LICENSE",
|
||||
creditCode, request.businessLicenseDocumentUrl());
|
||||
businessLicense.setValidTo(request.businessLicenseValidTo());
|
||||
repository.saveQualification(businessLicense);
|
||||
IamTenantQualification legalPersonId = createQualification(tenant.getId(), "LEGAL_PERSON_ID",
|
||||
null, request.legalPersonIdDocumentUrl());
|
||||
legalPersonId.setValidTo(request.legalPersonIdValidTo());
|
||||
repository.saveQualification(legalPersonId);
|
||||
if (!creditCode.equals(tenant.getUnifiedSocialCreditCode())) {
|
||||
tenant.setUnifiedSocialCreditCode(creditCode);
|
||||
tenantRepository.saveTenant(tenant);
|
||||
}
|
||||
return new RequiredQualificationPackageResponse(
|
||||
businessLicense.getId(), legalPersonId.getId(), tenant.getStatus(),
|
||||
replacedBusinessLicenseUrl, replacedLegalPersonIdUrl);
|
||||
}
|
||||
|
||||
private List<IamTenantQualification> currentQualifications(
|
||||
List<IamTenantQualification> qualifications, String type) {
|
||||
return qualifications.stream()
|
||||
.filter(item -> type.equals(item.getQualificationType()) && isCurrentQualification(item))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private void requireReplaceAllowed(List<IamTenantQualification> current, boolean replace, String label) {
|
||||
if (!current.isEmpty() && !replace) {
|
||||
throw new BizException(ResultCode.CONFLICT, label + "已上传,请明确选择替换");
|
||||
}
|
||||
}
|
||||
|
||||
private String supersede(List<IamTenantQualification> current, String comment) {
|
||||
String replacedUrl = null;
|
||||
for (IamTenantQualification existing : current) {
|
||||
if (replacedUrl == null) replacedUrl = existing.getDocumentUrl();
|
||||
existing.setStatus("SUPERSEDED");
|
||||
existing.setReviewComment(comment);
|
||||
repository.saveQualification(existing);
|
||||
}
|
||||
return replacedUrl;
|
||||
}
|
||||
|
||||
private void requireOptionalQualificationType(String type) {
|
||||
if ("BUSINESS_LICENSE".equals(type) || "LEGAL_PERSON_ID".equals(type)) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST,
|
||||
"Required qualifications must be uploaded as one package / 营业执照、统一社会信用代码和法人身份证必须整包上传");
|
||||
}
|
||||
}
|
||||
|
||||
private String resolveCertificateNo(IamTenant tenant, String qualificationType, String submittedCertificateNo) {
|
||||
if (!"BUSINESS_LICENSE".equals(qualificationType)) {
|
||||
return submittedCertificateNo;
|
||||
|
||||
@@ -24,7 +24,6 @@ import com.baiyue.iam.dto.CustomerExportDtos.CustomerExportRow;
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
@@ -54,11 +53,6 @@ public class TenantService {
|
||||
)
|
||||
@Transactional
|
||||
public TenantResponse register(RegisterRequest request, long operatorId) {
|
||||
String creditCode = normalizeCreditCode(request.unifiedSocialCreditCode());
|
||||
if (creditCode != null && repository.existsByCreditCode(creditCode)) {
|
||||
throw new BizException(ResultCode.CONFLICT, "Enterprise already exists / 企业已存在");
|
||||
}
|
||||
|
||||
IamTenant tenant = new IamTenant();
|
||||
tenant.setId(IdWorker.getId());
|
||||
tenant.setTenantCode("TEN" + tenant.getId());
|
||||
@@ -67,7 +61,7 @@ public class TenantService {
|
||||
tenant.setStatus("PROFILE_REQUIRED");
|
||||
tenant.setLegalName(request.legalName());
|
||||
tenant.setLegalRepresentative(request.legalRepresentative());
|
||||
tenant.setUnifiedSocialCreditCode(creditCode);
|
||||
tenant.setUnifiedSocialCreditCode(null);
|
||||
tenant.setContactName(request.contactName());
|
||||
tenant.setContactPhone(request.contactPhone());
|
||||
tenant.setContactEmail(request.contactEmail());
|
||||
@@ -334,10 +328,6 @@ public class TenantService {
|
||||
return tenant;
|
||||
}
|
||||
|
||||
private String normalizeCreditCode(String creditCode) {
|
||||
return StringUtils.hasText(creditCode) ? creditCode.trim().toUpperCase() : null;
|
||||
}
|
||||
|
||||
private TenantResponse response(IamTenant tenant) {
|
||||
return new TenantResponse(
|
||||
tenant.getId(), tenant.getTenantCode(), tenant.getTenantName(), tenant.getStatus(),
|
||||
|
||||
@@ -608,6 +608,46 @@ paths:
|
||||
'400': {$ref: '#/components/responses/BadRequest'}
|
||||
'403': {$ref: '#/components/responses/Forbidden'}
|
||||
'409': {$ref: '#/components/responses/Conflict'}
|
||||
/internal/iam/required-qualification-packages:
|
||||
post:
|
||||
tags: [IAM Resources / IAM 资源]
|
||||
summary: 商户必填资质包登记
|
||||
description: 仅供 File Service 调用;在一个 IAM 事务中登记营业执照、统一社会信用代码和法人身份证。
|
||||
security: []
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/InternalServiceToken'}
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/RequiredQualificationPackageRequest'}
|
||||
responses:
|
||||
'200':
|
||||
description: 统一响应;仅 data 非空且 code 为 200 表示资质包已完整登记
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/RequiredQualificationPackageApiResponse'}
|
||||
'403': {$ref: '#/components/responses/Forbidden'}
|
||||
/internal/iam/platform-required-qualification-packages:
|
||||
post:
|
||||
tags: [IAM Resources / IAM 资源]
|
||||
summary: 平台代客户必填资质包登记
|
||||
description: 仅供 File Service 调用;与商户入口使用相同的三项事务保存规则。
|
||||
security: []
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/InternalServiceToken'}
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/RequiredQualificationPackageRequest'}
|
||||
responses:
|
||||
'200':
|
||||
description: 统一响应;仅 data 非空且 code 为 200 表示资质包已完整登记
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/RequiredQualificationPackageApiResponse'}
|
||||
'403': {$ref: '#/components/responses/Forbidden'}
|
||||
/internal/exports/v1/CUSTOMER_LIST/batches:
|
||||
post:
|
||||
tags: [Internal Export Provider]
|
||||
@@ -650,6 +690,11 @@ components:
|
||||
scheme: bearer
|
||||
bearerFormat: JWT
|
||||
parameters:
|
||||
InternalServiceToken:
|
||||
name: X-Internal-Service-Token
|
||||
in: header
|
||||
required: true
|
||||
schema: {type: string}
|
||||
Id:
|
||||
name: id
|
||||
in: path
|
||||
@@ -838,6 +883,33 @@ components:
|
||||
validTo: {type: string, format: date, nullable: true}
|
||||
status: {type: string, enum: [PENDING, APPROVED, REJECTED, SUPERSEDED]}
|
||||
reviewComment: {type: string}
|
||||
RequiredQualificationPackageRequest:
|
||||
type: object
|
||||
required: [tenantId, unifiedSocialCreditCode, businessLicenseDocumentUrl, replaceBusinessLicense, legalPersonIdDocumentUrl, replaceLegalPersonId]
|
||||
properties:
|
||||
tenantId: {type: string, pattern: '^\d+$'}
|
||||
unifiedSocialCreditCode: {type: string, pattern: '^[0-9A-Za-z]{18}$'}
|
||||
businessLicenseDocumentUrl: {type: string, minLength: 1}
|
||||
businessLicenseValidTo: {type: string, format: date, nullable: true}
|
||||
replaceBusinessLicense: {type: boolean}
|
||||
legalPersonIdDocumentUrl: {type: string, minLength: 1}
|
||||
legalPersonIdValidTo: {type: string, format: date, nullable: true}
|
||||
replaceLegalPersonId: {type: boolean}
|
||||
RequiredQualificationPackageResponse:
|
||||
type: object
|
||||
required: [businessLicenseQualificationId, legalPersonIdQualificationId, tenantStatus]
|
||||
properties:
|
||||
businessLicenseQualificationId: {type: string, pattern: '^\d+$'}
|
||||
legalPersonIdQualificationId: {type: string, pattern: '^\d+$'}
|
||||
tenantStatus: {type: string, enum: [PROFILE_REQUIRED, REJECTED]}
|
||||
replacedBusinessLicenseDocumentUrl: {type: string, nullable: true}
|
||||
replacedLegalPersonIdDocumentUrl: {type: string, nullable: true}
|
||||
RequiredQualificationPackageApiResponse:
|
||||
allOf:
|
||||
- {$ref: '#/components/schemas/ApiResponse'}
|
||||
- type: object
|
||||
properties:
|
||||
data: {$ref: '#/components/schemas/RequiredQualificationPackageResponse'}
|
||||
RegisterRequest:
|
||||
type: object
|
||||
required: [tenantName, legalName, legalRepresentative, contactName, contactPhone, servicePackage]
|
||||
@@ -845,7 +917,6 @@ components:
|
||||
tenantName: {type: string, example: 深圳虫虫出海科技有限公司}
|
||||
legalName: {type: string, example: 深圳虫虫出海科技有限公司}
|
||||
legalRepresentative: {type: string, maxLength: 64, example: 张三}
|
||||
unifiedSocialCreditCode: {type: string, nullable: true, pattern: '^[0-9A-Za-z]{18}$', example: 91440300MA5ABCDE12, description: 创建草稿时选填,送审前必填}
|
||||
contactName: {type: string, example: 李四}
|
||||
contactPhone: {type: string, example: '13800138000'}
|
||||
contactEmail: {type: string, format: email, example: contact@example.com}
|
||||
|
||||
@@ -38,4 +38,28 @@ class QualificationUploadContractTest {
|
||||
assertTrue(yaml.contains(" /api/merchant/v1/iam/tenant/qualifications/submit-review:"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void internalControllerExposesBothRequiredPackageRoutes() {
|
||||
var routes = java.util.Arrays.stream(InternalTenantController.class.getDeclaredMethods())
|
||||
.map(method -> method.getAnnotation(PostMapping.class))
|
||||
.filter(java.util.Objects::nonNull)
|
||||
.flatMap(mapping -> java.util.Arrays.stream(mapping.value()))
|
||||
.toList();
|
||||
assertTrue(routes.contains("/required-qualification-packages"));
|
||||
assertTrue(routes.contains("/platform-required-qualification-packages"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void staticOpenApiDocumentsTheRequiredPackageContract() throws IOException {
|
||||
try (var input = getClass().getClassLoader().getResourceAsStream("openapi/iam-v1.yaml")) {
|
||||
assertNotNull(input);
|
||||
String yaml = new String(input.readAllBytes(), StandardCharsets.UTF_8);
|
||||
assertTrue(yaml.contains("/internal/iam/required-qualification-packages:"));
|
||||
assertTrue(yaml.contains("/internal/iam/platform-required-qualification-packages:"));
|
||||
assertTrue(yaml.contains("RequiredQualificationPackageRequest:"));
|
||||
assertTrue(yaml.contains("replaceBusinessLicense"));
|
||||
assertTrue(yaml.contains("replaceLegalPersonId"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.baiyue.iam.service;
|
||||
|
||||
import com.baiyue.iam.dto.OnboardingDtos.QualificationSubmissionRequest;
|
||||
import com.baiyue.iam.dto.OnboardingDtos.RequiredQualificationPackageRequest;
|
||||
import com.baiyue.iam.entity.IamTenant;
|
||||
import com.baiyue.iam.repository.ProfileResourceRepository;
|
||||
import com.baiyue.iam.repository.TenantRepository;
|
||||
@@ -18,12 +19,13 @@ import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.mockito.Mockito.times;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
|
||||
class MerchantQualificationUploadTest {
|
||||
|
||||
@Test
|
||||
void uploadsOneDraftQualificationWithoutCreatingAReview() {
|
||||
void uploadsOneOptionalDraftQualificationWithoutCreatingAReview() {
|
||||
TenantRepository tenantRepository = mock(TenantRepository.class);
|
||||
ProfileResourceRepository profileRepository = mock(ProfileResourceRepository.class);
|
||||
ProfileResourceService service = new ProfileResourceService();
|
||||
@@ -37,8 +39,8 @@ class MerchantQualificationUploadTest {
|
||||
when(profileRepository.findQualificationsForUpdate(10001L)).thenReturn(List.of());
|
||||
|
||||
var response = service.submitQualification(new QualificationSubmissionRequest(
|
||||
10001L, "LEGAL_PERSON_ID", null,
|
||||
"/api/merchant/v1/files/qualifications/legal-person.png", LocalDate.of(2030, 1, 1), false));
|
||||
10001L, "FREIGHT_FORWARDER_CERTIFICATE", null,
|
||||
"/api/merchant/v1/files/qualifications/freight-forwarder.png", LocalDate.of(2030, 1, 1), false));
|
||||
|
||||
assertEquals("PROFILE_REQUIRED", response.tenantStatus());
|
||||
assertNull(response.reviewId());
|
||||
@@ -47,7 +49,26 @@ class MerchantQualificationUploadTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void businessLicenseRequiresAndPersistsAUniqueUnifiedSocialCreditCode() {
|
||||
void requiredQualificationsRejectTheLegacySingleFilePath() {
|
||||
TenantRepository tenantRepository = mock(TenantRepository.class);
|
||||
ProfileResourceRepository profileRepository = mock(ProfileResourceRepository.class);
|
||||
ProfileResourceService service = new ProfileResourceService();
|
||||
ReflectionTestUtils.setField(service, "tenantRepository", tenantRepository);
|
||||
ReflectionTestUtils.setField(service, "repository", profileRepository);
|
||||
IamTenant tenant = new IamTenant();
|
||||
tenant.setId(10001L);
|
||||
tenant.setTenantType("MERCHANT");
|
||||
tenant.setStatus("PROFILE_REQUIRED");
|
||||
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)));
|
||||
|
||||
verify(profileRepository, never()).insertQualification(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void requiredPackagePersistsBothFilesAndCreditCodeTogether() {
|
||||
TenantRepository tenantRepository = mock(TenantRepository.class);
|
||||
ProfileResourceRepository profileRepository = mock(ProfileResourceRepository.class);
|
||||
ProfileResourceService service = new ProfileResourceService();
|
||||
@@ -60,21 +81,19 @@ class MerchantQualificationUploadTest {
|
||||
when(tenantRepository.findTenantForUpdate(10001L)).thenReturn(tenant);
|
||||
when(profileRepository.findQualificationsForUpdate(10001L)).thenReturn(List.of());
|
||||
|
||||
assertThrows(BizException.class, () -> service.submitQualification(new QualificationSubmissionRequest(
|
||||
10001L, "BUSINESS_LICENSE", null,
|
||||
"/api/merchant/v1/files/qualifications/license.png", null, false)));
|
||||
|
||||
var response = service.submitQualification(new QualificationSubmissionRequest(
|
||||
10001L, "BUSINESS_LICENSE", "91310101abcde12345",
|
||||
"/api/merchant/v1/files/qualifications/license.png", null, false));
|
||||
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));
|
||||
|
||||
assertEquals("PROFILE_REQUIRED", response.tenantStatus());
|
||||
assertEquals("91310101ABCDE12345", tenant.getUnifiedSocialCreditCode());
|
||||
verify(profileRepository, times(2)).insertQualification(any());
|
||||
verify(tenantRepository).saveTenant(tenant);
|
||||
}
|
||||
|
||||
@Test
|
||||
void businessLicenseCannotOverwriteAnEstablishedCreditCode() {
|
||||
void requiredPackageCannotOverwriteAnEstablishedCreditCode() {
|
||||
TenantRepository tenantRepository = mock(TenantRepository.class);
|
||||
ProfileResourceRepository profileRepository = mock(ProfileResourceRepository.class);
|
||||
ProfileResourceService service = new ProfileResourceService();
|
||||
@@ -87,11 +106,11 @@ class MerchantQualificationUploadTest {
|
||||
tenant.setUnifiedSocialCreditCode("91330100EXAMPLE001");
|
||||
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, true)));
|
||||
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)));
|
||||
|
||||
verify(profileRepository, never()).insertQualification(any());
|
||||
verify(tenantRepository, never()).saveTenant(tenant);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ import static org.mockito.Mockito.when;
|
||||
|
||||
class PlatformQualificationRegistrationTest {
|
||||
@Test
|
||||
void platformUploadRegistersMetadataWithoutCreatingReviewTicket() {
|
||||
void platformUploadRegistersOptionalMetadataWithoutCreatingReviewTicket() {
|
||||
ProfileResourceRepository repository = mock(ProfileResourceRepository.class);
|
||||
TenantRepository tenantRepository = mock(TenantRepository.class);
|
||||
ProfileResourceService service = new ProfileResourceService();
|
||||
@@ -33,13 +33,13 @@ class PlatformQualificationRegistrationTest {
|
||||
when(repository.findQualificationsForUpdate(10001L)).thenReturn(List.of());
|
||||
|
||||
var response = service.registerPlatformQualification(new PlatformQualificationRegistrationRequest(
|
||||
10001L, "LEGAL_PERSON_ID", null, "/api/merchant/v1/files/qualifications/file.pdf",
|
||||
10001L, "BANK_ACCOUNT_LICENSE", null, "/api/merchant/v1/files/qualifications/file.pdf",
|
||||
LocalDate.of(2030, 12, 31), false));
|
||||
|
||||
ArgumentCaptor<IamTenantQualification> captor = ArgumentCaptor.forClass(IamTenantQualification.class);
|
||||
verify(repository).insertQualification(captor.capture());
|
||||
verify(repository).saveQualification(captor.getValue());
|
||||
assertEquals("LEGAL_PERSON_ID", captor.getValue().getQualificationType());
|
||||
assertEquals("BANK_ACCOUNT_LICENSE", captor.getValue().getQualificationType());
|
||||
assertEquals(LocalDate.of(2030, 12, 31), captor.getValue().getValidTo());
|
||||
assertEquals("PROFILE_REQUIRED", response.tenantStatus());
|
||||
assertNull(response.reviewId());
|
||||
@@ -56,14 +56,14 @@ class PlatformQualificationRegistrationTest {
|
||||
IamTenant tenant = new IamTenant();
|
||||
tenant.setId(10001L); tenant.setTenantType("MERCHANT"); tenant.setStatus("PROFILE_REQUIRED");
|
||||
IamTenantQualification existing = new IamTenantQualification();
|
||||
existing.setId(88L); existing.setTenantId(10001L); existing.setQualificationType("LEGAL_PERSON_ID");
|
||||
existing.setId(88L); existing.setTenantId(10001L); existing.setQualificationType("BANK_ACCOUNT_LICENSE");
|
||||
existing.setStatus("PENDING");
|
||||
existing.setDocumentUrl("/api/merchant/v1/files/qualifications/old-file.pdf");
|
||||
when(tenantRepository.findTenantForUpdate(10001L)).thenReturn(tenant);
|
||||
when(repository.findQualificationsForUpdate(10001L)).thenReturn(List.of(existing));
|
||||
|
||||
var response = service.registerPlatformQualification(new PlatformQualificationRegistrationRequest(
|
||||
10001L, "LEGAL_PERSON_ID", null, "/api/merchant/v1/files/qualifications/new-file.pdf",
|
||||
10001L, "BANK_ACCOUNT_LICENSE", null, "/api/merchant/v1/files/qualifications/new-file.pdf",
|
||||
LocalDate.of(2031, 12, 31), true));
|
||||
|
||||
ArgumentCaptor<IamTenantQualification> captor = ArgumentCaptor.forClass(IamTenantQualification.class);
|
||||
|
||||
@@ -120,7 +120,7 @@ class TenantServiceTest {
|
||||
ReflectionTestUtils.setField(service, "repository", repository);
|
||||
|
||||
var created = service.register(new RegisterRequest(
|
||||
"Example Merchant", "Example Merchant Ltd", "Bob", "91330100EXAMPLE001", "Alice", "13800138000",
|
||||
"Example Merchant", "Example Merchant Ltd", "Bob", "Alice", "13800138000",
|
||||
"alice@example.com", "基础版", "alice_wechat", "Sales Zhang", 30, "LIMITED_COMPANY",
|
||||
LocalDate.of(2020, 1, 2), new BigDecimal("5000000.00"), "CNY", "深圳市南山区", "草稿客户"),
|
||||
9001L);
|
||||
@@ -134,13 +134,13 @@ class TenantServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void platformRegistrationAllowsCreditCodeToBeCompletedWithQualificationsLater() {
|
||||
void platformRegistrationAlwaysCreatesTheDraftWithoutACreditCode() {
|
||||
TenantRepository repository = mock(TenantRepository.class);
|
||||
TenantService service = new TenantService();
|
||||
ReflectionTestUtils.setField(service, "repository", repository);
|
||||
|
||||
var created = service.register(new RegisterRequest(
|
||||
"Example Merchant", "Example Merchant Ltd", "Bob", null, "Alice", "13800138000",
|
||||
"Example Merchant", "Example Merchant Ltd", "Bob", "Alice", "13800138000",
|
||||
"alice@example.com", "基础版", null, null, 30, null,
|
||||
null, null, "CNY", null, null), 9001L);
|
||||
|
||||
|
||||
+5
-3
@@ -13,6 +13,7 @@ import com.baiyue.warehouse.entity.ExpressCompanyEntity;
|
||||
import com.baiyue.warehouse.service.ExpressCompanyQueryService;
|
||||
import com.baiyue.warehouse.service.ExpressCompanyImportService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
@@ -42,7 +43,7 @@ import java.nio.charset.StandardCharsets;
|
||||
|
||||
/** 平台快递公司管理接口。 */
|
||||
@Validated
|
||||
@Tag(name = "平台快递公司", description = "按目标租户分页查询快递公司主数据")
|
||||
@Tag(name = "平台快递公司", description = "跨企业分页查询和按目标租户维护快递公司主数据")
|
||||
@RestController
|
||||
@RequestMapping("/api/platform/v1/express-companies")
|
||||
public class PlatformExpressCompanyController {
|
||||
@@ -56,7 +57,7 @@ public class PlatformExpressCompanyController {
|
||||
|
||||
@GetMapping
|
||||
@PreAuthorize("hasAuthority('platform:express:list')")
|
||||
@Operation(summary = "分页查询快递公司", description = "按目标租户、关键字和状态分页查询快递公司")
|
||||
@Operation(summary = "分页查询快递公司", description = "默认查询全部企业;可按目标租户、关键字和状态筛选")
|
||||
@ApiResponses({
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "查询条件无效"),
|
||||
@@ -65,7 +66,8 @@ public class PlatformExpressCompanyController {
|
||||
})
|
||||
public ApiResponse<PageResult<ExpressCompanyEntity>> page(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam @Min(1) long tenantId,
|
||||
@Parameter(description = "可选目标企业租户ID;不传时查询全部企业")
|
||||
@RequestParam(required = false) @Min(1) Long tenantId,
|
||||
@Valid @ParameterObject ExpressCompanyPageQuery query) {
|
||||
requirePlatform(user);
|
||||
return ApiResponse.success(service.page(tenantId, query));
|
||||
|
||||
+6
-3
@@ -13,6 +13,7 @@ import com.baiyue.warehouse.entity.PartnerEntity;
|
||||
import com.baiyue.warehouse.service.PlatformPartnerQueryService;
|
||||
import com.baiyue.warehouse.service.PartnerImportService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
@@ -42,7 +43,7 @@ import java.nio.charset.StandardCharsets;
|
||||
|
||||
/** 平台往来单位管理接口。 */
|
||||
@Validated
|
||||
@Tag(name = "平台往来单位", description = "按目标租户分页查询往来单位主数据")
|
||||
@Tag(name = "平台往来单位", description = "跨企业分页查询和按目标租户维护往来单位主数据")
|
||||
@RestController
|
||||
@RequestMapping("/api/platform/v1/partners")
|
||||
public class PlatformPartnerController {
|
||||
@@ -83,7 +84,8 @@ public class PlatformPartnerController {
|
||||
|
||||
@GetMapping
|
||||
@PreAuthorize("hasAuthority('platform:counterparty:list')")
|
||||
@Operation(summary = "分页查询往来单位", description = "按目标租户、关键字、类型和状态分页查询往来单位")
|
||||
@Operation(summary = "分页查询往来单位",
|
||||
description = "默认分页查询全部企业往来单位;可按目标租户、关键字、类型和状态筛选")
|
||||
@ApiResponses({
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "查询条件无效"),
|
||||
@@ -92,7 +94,8 @@ public class PlatformPartnerController {
|
||||
})
|
||||
public ApiResponse<PageResult<PartnerEntity>> page(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam @Min(1) long tenantId,
|
||||
@Parameter(description = "可选目标企业租户ID;不传时查询全部企业")
|
||||
@RequestParam(required = false) @Min(1) Long tenantId,
|
||||
@Valid @ParameterObject PartnerPageQuery query) {
|
||||
requirePlatform(user);
|
||||
return ApiResponse.success(service.page(tenantId, query));
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.baiyue.warehouse.mapper;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baiyue.warehouse.entity.PartnerEntity;
|
||||
import com.baiyue.warehouse.entity.PartnerTypeEntity;
|
||||
import org.apache.ibatis.annotations.Delete;
|
||||
@@ -25,6 +26,56 @@ public interface PartnerMapper extends BaseMapper<PartnerEntity> {
|
||||
@Select("SELECT partner_id FROM wh_partner_type WHERE tenant_id = #{tenantId} AND partner_type = #{type}")
|
||||
List<Long> selectPartnerIdsByType(@Param("tenantId") long tenantId, @Param("type") String type);
|
||||
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT p.*
|
||||
FROM wh_partner p
|
||||
WHERE p.deleted = 0
|
||||
<if test="tenantId != null">
|
||||
AND p.tenant_id = #{tenantId}
|
||||
</if>
|
||||
<choose>
|
||||
<when test="type == 'BOTH'">
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM wh_partner_type customer_type
|
||||
WHERE customer_type.partner_id = p.id
|
||||
AND customer_type.partner_type = 'CUSTOMER'
|
||||
AND customer_type.tenant_id = p.tenant_id
|
||||
)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM wh_partner_type supplier_type
|
||||
WHERE supplier_type.partner_id = p.id
|
||||
AND supplier_type.partner_type = 'SUPPLIER'
|
||||
AND supplier_type.tenant_id = p.tenant_id
|
||||
)
|
||||
</when>
|
||||
<otherwise>
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM wh_partner_type pt
|
||||
WHERE pt.partner_id = p.id
|
||||
AND pt.partner_type = #{type}
|
||||
AND pt.tenant_id = p.tenant_id
|
||||
)
|
||||
</otherwise>
|
||||
</choose>
|
||||
<if test="status != null and status != ''">
|
||||
AND p.status = #{status}
|
||||
</if>
|
||||
<if test="keyword != null and keyword != ''">
|
||||
AND (p.partner_code LIKE CONCAT('%', #{keyword}, '%')
|
||||
OR p.partner_name LIKE CONCAT('%', #{keyword}, '%')
|
||||
OR p.contact_name LIKE CONCAT('%', #{keyword}, '%')
|
||||
OR p.contact_phone LIKE CONCAT('%', #{keyword}, '%'))
|
||||
</if>
|
||||
ORDER BY p.partner_name ASC, p.id ASC
|
||||
</script>
|
||||
""")
|
||||
Page<PartnerEntity> selectPartnerPageByType(Page<PartnerEntity> page,
|
||||
@Param("tenantId") Long tenantId,
|
||||
@Param("type") String type,
|
||||
@Param("status") String status,
|
||||
@Param("keyword") String keyword);
|
||||
|
||||
@Delete("DELETE FROM wh_partner_type WHERE partner_id = #{partnerId}")
|
||||
void deleteTypes(@Param("partnerId") long partnerId);
|
||||
|
||||
|
||||
+3
-3
@@ -9,7 +9,7 @@ import com.baiyue.warehouse.mapper.ExpressCompanyMapper;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/** 快递公司主数据仓储。 */
|
||||
/** 快递公司主数据仓储;平台分页租户条件可选,资源读写仍强制租户边界。 */
|
||||
@Repository
|
||||
public class ExpressCompanyRepository {
|
||||
private final ExpressCompanyMapper mapper;
|
||||
@@ -18,9 +18,9 @@ public class ExpressCompanyRepository {
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
public PageResult<ExpressCompanyEntity> page(long tenantId, ExpressCompanyPageQuery query) {
|
||||
public PageResult<ExpressCompanyEntity> page(Long tenantId, ExpressCompanyPageQuery query) {
|
||||
LambdaQueryWrapper<ExpressCompanyEntity> wrapper = new LambdaQueryWrapper<ExpressCompanyEntity>()
|
||||
.eq(ExpressCompanyEntity::getTenantId, tenantId)
|
||||
.eq(tenantId != null, ExpressCompanyEntity::getTenantId, tenantId)
|
||||
.eq(StringUtils.hasText(query.status()), ExpressCompanyEntity::getStatus, query.status())
|
||||
.and(StringUtils.hasText(query.keyword()), condition -> condition
|
||||
.like(ExpressCompanyEntity::getCompanyCode, query.keyword())
|
||||
|
||||
@@ -41,14 +41,17 @@ public class PartnerRepository {
|
||||
return entity;
|
||||
}
|
||||
|
||||
public PageResult<PartnerEntity> page(long tenantId, PartnerPageQuery request) {
|
||||
List<Long> typedIds = typedIds(tenantId, request.type());
|
||||
if (StringUtils.hasText(request.type()) && typedIds.isEmpty()) {
|
||||
return new PageResult<>(List.of(), request.page(), request.size(), 0, 0);
|
||||
public PageResult<PartnerEntity> page(Long tenantId, PartnerPageQuery request) {
|
||||
if (StringUtils.hasText(request.type())) {
|
||||
Page<PartnerEntity> page = mapper.selectPartnerPageByType(
|
||||
new Page<>(request.page(), request.size()), tenantId, request.type(),
|
||||
request.status(), request.keyword());
|
||||
PageResult<PartnerEntity> result = PageResult.from(page);
|
||||
hydrateTypes(result.records());
|
||||
return result;
|
||||
}
|
||||
LambdaQueryWrapper<PartnerEntity> wrapper = new LambdaQueryWrapper<PartnerEntity>()
|
||||
.eq(PartnerEntity::getTenantId, tenantId)
|
||||
.in(StringUtils.hasText(request.type()), PartnerEntity::getId, typedIds)
|
||||
.eq(tenantId != null, PartnerEntity::getTenantId, tenantId)
|
||||
.eq(StringUtils.hasText(request.status()), PartnerEntity::getStatus, request.status())
|
||||
.and(StringUtils.hasText(request.keyword()), query -> query
|
||||
.like(PartnerEntity::getPartnerCode, request.keyword())
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ public class ExpressCompanyQueryService {
|
||||
this.repository = repository;
|
||||
}
|
||||
|
||||
public PageResult<ExpressCompanyEntity> page(long tenantId, ExpressCompanyPageQuery query) {
|
||||
public PageResult<ExpressCompanyEntity> page(Long tenantId, ExpressCompanyPageQuery query) {
|
||||
return repository.page(tenantId, query);
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ public class PlatformPartnerQueryService {
|
||||
this.repository = repository;
|
||||
}
|
||||
|
||||
public PageResult<PartnerEntity> page(long tenantId, PartnerPageQuery query) {
|
||||
public PageResult<PartnerEntity> page(Long tenantId, PartnerPageQuery query) {
|
||||
return repository.page(tenantId, query);
|
||||
}
|
||||
|
||||
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package com.baiyue.warehouse.controller;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.Arrays;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class PlatformExpressCompanyAllTenantQueryContractTest {
|
||||
@Test
|
||||
void 分页租户参数可选但详情写入启停和导入仍然必填() {
|
||||
Method page = method("page");
|
||||
assertThat(page.getParameterTypes()[1]).isEqualTo(Long.class);
|
||||
assertThat(page.getParameters()[1].getAnnotation(RequestParam.class).required()).isFalse();
|
||||
|
||||
for (String scopedMethod : new String[] {"detail", "create", "update", "changeStatus", "importCompanies"}) {
|
||||
Method method = method(scopedMethod);
|
||||
assertThat(method.getParameterTypes()[1]).isEqualTo(long.class);
|
||||
assertThat(method.getParameters()[1].getAnnotation(RequestParam.class).required()).isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
private static Method method(String name) {
|
||||
return Arrays.stream(PlatformExpressCompanyController.class.getDeclaredMethods())
|
||||
.filter(candidate -> candidate.getName().equals(name))
|
||||
.findFirst()
|
||||
.orElseThrow();
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -75,7 +75,7 @@ class PlatformMasterDataControllerContractTest {
|
||||
|
||||
Method page = controller.getDeclaredMethod("page",
|
||||
com.baiyue.common.security.AuthenticatedUser.class,
|
||||
long.class,
|
||||
Long.class,
|
||||
controller == PlatformPartnerController.class
|
||||
? com.baiyue.warehouse.dto.MasterDataQueries.PartnerPageQuery.class
|
||||
: com.baiyue.warehouse.dto.MasterDataQueries.ExpressCompanyPageQuery.class);
|
||||
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
package com.baiyue.warehouse.controller;
|
||||
|
||||
import com.baiyue.warehouse.dto.MasterDataQueries.PartnerPageQuery;
|
||||
import com.baiyue.warehouse.mapper.PartnerMapper;
|
||||
import com.baiyue.warehouse.repository.PartnerRepository;
|
||||
import com.baiyue.warehouse.service.PlatformPartnerQueryService;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class PlatformPartnerAllTenantQueryContractTest {
|
||||
@Test
|
||||
void 平台分页租户可选但详情写入和导入仍要求租户() {
|
||||
Method page = method(PlatformPartnerController.class, "page");
|
||||
assertThat(page.getParameterTypes()[1]).isEqualTo(Long.class);
|
||||
assertThat(page.getParameters()[1].getAnnotation(RequestParam.class).required()).isFalse();
|
||||
|
||||
for (String scopedMethod : new String[] {"importPartners", "detail", "create", "update", "changeStatus"}) {
|
||||
Method method = method(PlatformPartnerController.class, scopedMethod);
|
||||
assertThat(method.getParameterTypes()[1]).isEqualTo(long.class);
|
||||
assertThat(method.getParameters()[1].getAnnotation(RequestParam.class).required()).isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void 分页链路使用可选租户而其他仓储方法保持原有强租户边界() throws Exception {
|
||||
assertThat(PlatformPartnerQueryService.class.getMethod("page", Long.class, PartnerPageQuery.class))
|
||||
.isNotNull();
|
||||
assertThat(PartnerRepository.class.getMethod("page", Long.class, PartnerPageQuery.class))
|
||||
.isNotNull();
|
||||
assertThat(PartnerRepository.class.getMethod("findOwned", long.class, long.class))
|
||||
.isNotNull();
|
||||
assertThat(PartnerRepository.class.getMethod("existsCode", long.class, String.class, Long.class))
|
||||
.isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void 类型筛选在数据库内关联分页且租户条件可选() throws Exception {
|
||||
Method method = PartnerMapper.class.getMethod("selectPartnerPageByType",
|
||||
Page.class, Long.class, String.class, String.class, String.class);
|
||||
String sql = String.join("\n", method.getAnnotation(Select.class).value());
|
||||
|
||||
assertThat(sql)
|
||||
.contains("AND EXISTS (")
|
||||
.contains("pt.partner_id = p.id")
|
||||
.contains("pt.partner_type = #{type}")
|
||||
.contains("pt.tenant_id = p.tenant_id")
|
||||
.contains("<if test=\"tenantId != null\">")
|
||||
.contains("AND p.tenant_id = #{tenantId}")
|
||||
.doesNotContain("partner_id IN");
|
||||
}
|
||||
|
||||
private static Method method(Class<?> type, String name) {
|
||||
return java.util.Arrays.stream(type.getDeclaredMethods())
|
||||
.filter(candidate -> candidate.getName().equals(name))
|
||||
.findFirst()
|
||||
.orElseThrow();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.baiyue.warehouse.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class PartnerMapperContractTest {
|
||||
@Test
|
||||
void bothTypeRequiresCustomerAndSupplierAssociations() throws Exception {
|
||||
Select select = PartnerMapper.class.getDeclaredMethod("selectPartnerPageByType",
|
||||
Page.class, Long.class, String.class, String.class, String.class)
|
||||
.getAnnotation(Select.class);
|
||||
String sql = String.join("\n", select.value());
|
||||
|
||||
assertThat(sql)
|
||||
.contains("<when test=\"type == 'BOTH'\">")
|
||||
.contains("customer_type.partner_type = 'CUSTOMER'")
|
||||
.contains("supplier_type.partner_type = 'SUPPLIER'")
|
||||
.contains("<otherwise>")
|
||||
.contains("pt.partner_type = #{type}");
|
||||
}
|
||||
}
|
||||
+19
-1
@@ -25,7 +25,7 @@ class ExpressCompanyRepositoryTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void 平台分页强制租户并支持筛选() {
|
||||
void 平台分页传入租户时按租户并支持筛选() {
|
||||
ExpressCompanyMapper mapper = mock(ExpressCompanyMapper.class);
|
||||
when(mapper.selectPage(any(Page.class), any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
@@ -40,4 +40,22 @@ class ExpressCompanyRepositoryTest {
|
||||
assertThat(wrapper.getValue().getParamNameValuePairs().values())
|
||||
.contains(10001L, "ACTIVE", "%顺丰%");
|
||||
}
|
||||
|
||||
@Test
|
||||
void 平台分页不传租户时查询全部企业并保留其他筛选() {
|
||||
ExpressCompanyMapper mapper = mock(ExpressCompanyMapper.class);
|
||||
when(mapper.selectPage(any(Page.class), any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
new ExpressCompanyRepository(mapper).page(null,
|
||||
new ExpressCompanyPageQuery(1, 20, "顺丰", "ACTIVE"));
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<LambdaQueryWrapper<ExpressCompanyEntity>> wrapper =
|
||||
ArgumentCaptor.forClass(LambdaQueryWrapper.class);
|
||||
verify(mapper).selectPage(any(Page.class), wrapper.capture());
|
||||
wrapper.getValue().getSqlSegment();
|
||||
assertThat(wrapper.getValue().getParamNameValuePairs().values())
|
||||
.contains("ACTIVE", "%顺丰%")
|
||||
.doesNotContain(10001L);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,17 +56,27 @@ class PartnerRepositoryTest {
|
||||
|
||||
@Test void 平台分页强制租户并支持筛选() {
|
||||
PartnerMapper mapper=mock(PartnerMapper.class);
|
||||
when(mapper.selectPartnerIdsByType(10001L, "CUSTOMER")).thenReturn(List.of(9L));
|
||||
when(mapper.selectPage(any(Page.class), any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
when(mapper.selectPartnerPageByType(any(Page.class), eq(10001L), eq("CUSTOMER"),
|
||||
eq("ACTIVE"), eq("示例"))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
new PartnerRepository(mapper).page(10001L,
|
||||
new PartnerPageQuery(1, 20, "示例", "CUSTOMER", "ACTIVE"));
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<LambdaQueryWrapper<PartnerEntity>> wrapper=ArgumentCaptor.forClass(LambdaQueryWrapper.class);
|
||||
verify(mapper).selectPage(any(Page.class), wrapper.capture());
|
||||
wrapper.getValue().getSqlSegment();
|
||||
assertThat(wrapper.getValue().getParamNameValuePairs().values())
|
||||
.contains(10001L, 9L, "ACTIVE", "%示例%");
|
||||
verify(mapper).selectPartnerPageByType(any(Page.class), eq(10001L), eq("CUSTOMER"),
|
||||
eq("ACTIVE"), eq("示例"));
|
||||
verify(mapper, never()).selectPartnerIdsByType(anyLong(), anyString());
|
||||
}
|
||||
|
||||
@Test void 平台分页不传租户时查询全部企业并支持承运商类型筛选() {
|
||||
PartnerMapper mapper=mock(PartnerMapper.class);
|
||||
when(mapper.selectPartnerPageByType(any(Page.class), isNull(), eq("CARRIER"),
|
||||
eq("ACTIVE"), isNull())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
new PartnerRepository(mapper).page(null,
|
||||
new PartnerPageQuery(1, 20, null, "CARRIER", "ACTIVE"));
|
||||
|
||||
verify(mapper).selectPartnerPageByType(any(Page.class), isNull(), eq("CARRIER"),
|
||||
eq("ACTIVE"), isNull());
|
||||
verify(mapper, never()).selectPartnerIdsByType(anyLong(), anyString());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user