This commit is contained in:
2026-09-11 16:17:16 +08:00
parent 2d928d667b
commit 51ce609ab1
57 changed files with 942 additions and 139 deletions
@@ -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: 认证或授权失败,响应包含请求关联元数据
@@ -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;
@@ -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
@@ -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");
@@ -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) {
@@ -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, "管理员登录手机号已被其他账号使用");
+1
View File
@@ -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已驳回',
+154 -10
View File
@@ -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 {
@@ -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)
@@ -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);
}
}
+6
View File
@@ -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, {
+12 -4
View File
@@ -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',
+2 -2
View File
@@ -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' },