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