From 9eb03d4bced2938418e54a19ecb804009f1a324d Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 3 Aug 2026 13:16:19 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/InternalAccountController.java | 5 + .../auth/dto/AccountManagementDtos.java | 20 +- .../service/AccountManagementService.java | 19 +- .../service/AccountManagementServiceTest.java | 33 ++ deploy/scripts/initialize-test-databases.sh | 8 + iam/README-IAM.md | 5 + .../baiyue/iam/client/AuthAccountClient.java | 20 +- .../PlatformUserManagementController.java | 83 +++++ .../iam/dto/PlatformUserManagementDtos.java | 48 +++ .../java/com/baiyue/iam/dto/TenantDtos.java | 8 +- .../iam/repository/MemberRepository.java | 4 + .../iam/repository/TenantRepository.java | 5 + .../PlatformUserManagementService.java | 343 ++++++++++++++++++ .../main/resources/db/phase1-admin-rbac.sql | 8 +- .../db/phase1-customer-profile-fields.sql | 12 - ...-master-data-and-permission-management.sql | 3 +- iam/src/main/resources/openapi/iam-v1.yaml | 104 ++++++ .../PlatformInternalUserContractTest.java | 28 ++ .../PlatformUserManagementServiceTest.java | 158 ++++++++ .../baiyue/order/client/PartnerClient.java | 4 +- .../PlatformFirstLegOrderController.java | 16 + .../order/dto/TenantOrderCountResponse.java | 6 + .../order/repository/OrderRepository.java | 14 + .../service/OrderApplicationService.java | 8 + .../order/service/OrderMasterDataGuard.java | 2 + .../src/main/resources/openapi/order-v1.yaml | 12 + .../OrderControllerPermissionTest.java | 3 + .../service/OrderMasterDataGuardTest.java | 20 +- .../service/TenantOrderCountServiceTest.java | 29 ++ warehouse/pom.xml | 5 + .../PlatformExpressCompanyController.java | 42 ++- .../controller/PlatformPartnerController.java | 42 ++- .../warehouse/dto/MasterDataQueries.java | 43 ++- .../warehouse/entity/PartnerEntity.java | 10 + .../warehouse/entity/PartnerTypeEntity.java | 12 + .../warehouse/mapper/PartnerMapper.java | 40 +- .../repository/ExpressCompanyRepository.java | 7 + .../repository/PartnerRepository.java | 64 +++- .../service/ExpressCompanyImportService.java | 193 ++++++++++ .../service/ExpressCompanyQueryService.java | 12 +- .../service/PartnerImportService.java | 218 +++++++++++ .../service/PartnerQueryService.java | 3 + .../service/PlatformPartnerQueryService.java | 30 +- .../WarehouseSchemaReadinessService.java | 3 +- .../db/phase1-warehouse-schema-migration.sql | 3 + .../db/warehouse-express-company.sql | 1 + .../main/resources/db/warehouse-partner.sql | 17 +- ...tformMasterDataControllerContractTest.java | 4 + .../repository/PartnerRepositoryTest.java | 25 +- .../WarehouseSchemaContractTest.java | 6 +- .../ExpressCompanyImportServiceTest.java | 71 ++++ .../service/PartnerImportServiceTest.java | 75 ++++ .../service/PartnerQueryServiceTest.java | 5 + ...atformMasterDataManagementServiceTest.java | 18 +- 54 files changed, 1925 insertions(+), 52 deletions(-) create mode 100644 iam/src/main/java/com/baiyue/iam/controller/PlatformUserManagementController.java create mode 100644 iam/src/main/java/com/baiyue/iam/dto/PlatformUserManagementDtos.java create mode 100644 iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java delete mode 100644 iam/src/main/resources/db/phase1-customer-profile-fields.sql create mode 100644 iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java create mode 100644 order/src/main/java/com/baiyue/order/dto/TenantOrderCountResponse.java create mode 100644 order/src/test/java/com/baiyue/order/service/TenantOrderCountServiceTest.java create mode 100644 warehouse/src/main/java/com/baiyue/warehouse/entity/PartnerTypeEntity.java create mode 100644 warehouse/src/main/java/com/baiyue/warehouse/service/ExpressCompanyImportService.java create mode 100644 warehouse/src/main/java/com/baiyue/warehouse/service/PartnerImportService.java create mode 100644 warehouse/src/test/java/com/baiyue/warehouse/service/ExpressCompanyImportServiceTest.java create mode 100644 warehouse/src/test/java/com/baiyue/warehouse/service/PartnerImportServiceTest.java diff --git a/auth/src/main/java/com/baiyue/auth/controller/InternalAccountController.java b/auth/src/main/java/com/baiyue/auth/controller/InternalAccountController.java index 7b714c93e..cb51fad93 100644 --- a/auth/src/main/java/com/baiyue/auth/controller/InternalAccountController.java +++ b/auth/src/main/java/com/baiyue/auth/controller/InternalAccountController.java @@ -56,6 +56,11 @@ public class InternalAccountController { @PathVariable Long id, @Valid @RequestBody ResetPasswordRequest request) { verify(token); service.resetPassword(id, request); return ApiResponse.success(null); } + @PutMapping("/{id}/scope") + public ApiResponse scope(@RequestHeader(value="X-Internal-Service-Token", required=false) String token, + @PathVariable Long id, @Valid @RequestBody UpdateAccountScopeRequest request) { + verify(token); service.updateScope(id, request); return ApiResponse.success(null); + } @DeleteMapping("/{id}/compensation") public ApiResponse compensate(@RequestHeader(value="X-Internal-Service-Token", required=false) String token, @PathVariable Long id) { diff --git a/auth/src/main/java/com/baiyue/auth/dto/AccountManagementDtos.java b/auth/src/main/java/com/baiyue/auth/dto/AccountManagementDtos.java index 407082eb4..18cd61743 100644 --- a/auth/src/main/java/com/baiyue/auth/dto/AccountManagementDtos.java +++ b/auth/src/main/java/com/baiyue/auth/dto/AccountManagementDtos.java @@ -5,6 +5,8 @@ import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; import jakarta.validation.constraints.Pattern; +import java.time.LocalDateTime; + /** 账号安全及内部账号管理契约。 */ public final class AccountManagementDtos { private AccountManagementDtos() { } @@ -19,10 +21,24 @@ public final class AccountManagementDtos { @NotBlank String displayName, @NotNull Long tenantId, @NotNull Long membershipId, @Pattern(regexp = "MERCHANT|PLATFORM", message = "应用端只能是 MERCHANT 或 PLATFORM") - String appCode) { } + String appCode, + @Pattern(regexp = "ACTIVE|DISABLED", message = "账号状态只能是 ACTIVE 或 DISABLED") + String accountStatus) { + public CreateAccountRequest(Long accountId, String username, String password, String displayName, + Long tenantId, Long membershipId, String appCode) { + this(accountId, username, password, displayName, tenantId, membershipId, appCode, "ACTIVE"); + } + } public record UpdateAccountProfileRequest(@NotBlank @Size(max = 64) String displayName) { } public record UpdateAccountStatusRequest(@NotBlank String status) { } public record ResetPasswordRequest(@NotBlank @Size(min = 8, max = 64) @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") String newPassword) { } - public record AccountSummary(Long accountId, String username, String displayName, String status) { } + public record UpdateAccountScopeRequest(@NotNull Long tenantId, @NotNull Long membershipId, + @NotBlank @Pattern(regexp = "MERCHANT|PLATFORM") String appCode) { } + public record AccountSummary(Long accountId, String username, String displayName, String status, + LocalDateTime lastLoginAt, Long tenantId, Long membershipId, String appCode) { + public AccountSummary(Long accountId, String username, String displayName, String status) { + this(accountId, username, displayName, status, null, null, null, null); + } + } } diff --git a/auth/src/main/java/com/baiyue/auth/service/AccountManagementService.java b/auth/src/main/java/com/baiyue/auth/service/AccountManagementService.java index 82ea35053..5c4252efc 100644 --- a/auth/src/main/java/com/baiyue/auth/service/AccountManagementService.java +++ b/auth/src/main/java/com/baiyue/auth/service/AccountManagementService.java @@ -54,7 +54,7 @@ public class AccountManagementService { account.setUsername(request.username()); account.setPasswordHash(passwordEncoder.encode(request.password())); account.setDisplayName(request.displayName()); - account.setAccountStatus("ACTIVE"); + account.setAccountStatus(request.accountStatus() == null ? "ACTIVE" : request.accountStatus()); account.setFailedLoginCount(0); account.setTokenVersion(1); account.setDefaultTenantId(request.tenantId()); @@ -93,6 +93,20 @@ public class AccountManagementService { setPasswordAndRevoke(required(accountId), request.newPassword()); } + @Transactional + @BusinessAudit(event = "ACCOUNT_SCOPE_CHANGE", resourceType = "ACCOUNT", resourceId = "#accountId", + description = "修改账号所属租户", captureAfter = false) + public void updateScope(Long accountId, UpdateAccountScopeRequest request) { + String appCode = request.appCode().trim().toUpperCase(Locale.ROOT); + validateScope(appCode, request.tenantId()); + AuthAccount account = required(accountId); + account.setDefaultTenantId(request.tenantId()); + account.setOperatorTenantId(request.tenantId()); + account.setDefaultMembershipId(request.membershipId()); + account.setDefaultAppCode(appCode); + bumpVersion(account); + } + @Transactional @BusinessAudit(event = "ACCOUNT_COMPENSATE_DELETE", resourceType = "ACCOUNT", resourceId = "#accountId", description = "补偿删除认证账号", captureAfter = false) @@ -127,7 +141,8 @@ public class AccountManagementService { } private AccountSummary summary(AuthAccount a) { - return new AccountSummary(a.getId(), a.getUsername(), a.getDisplayName(), a.getAccountStatus()); + return new AccountSummary(a.getId(), a.getUsername(), a.getDisplayName(), a.getAccountStatus(), + a.getLastLoginAt(), a.getDefaultTenantId(), a.getDefaultMembershipId(), a.getDefaultAppCode()); } private void validateScope(String appCode, Long tenantId) { diff --git a/auth/src/test/java/com/baiyue/auth/service/AccountManagementServiceTest.java b/auth/src/test/java/com/baiyue/auth/service/AccountManagementServiceTest.java index 07dda406a..161404945 100644 --- a/auth/src/test/java/com/baiyue/auth/service/AccountManagementServiceTest.java +++ b/auth/src/test/java/com/baiyue/auth/service/AccountManagementServiceTest.java @@ -4,6 +4,7 @@ import com.baiyue.auth.dto.AccountManagementDtos.ChangePasswordRequest; import com.baiyue.auth.dto.AccountManagementDtos.CreateAccountRequest; import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountProfileRequest; import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountStatusRequest; +import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountScopeRequest; import com.baiyue.auth.entity.AuthAccount; import com.baiyue.auth.repository.AuthAccountRepository; import com.baiyue.common.core.exception.BizException; @@ -15,6 +16,7 @@ import org.springframework.test.util.ReflectionTestUtils; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; +import java.time.LocalDateTime; @DisplayName("账号管理服务") class AccountManagementServiceTest { @@ -69,6 +71,37 @@ class AccountManagementServiceTest { assertNotEquals("Password1", captor.getValue().getPasswordHash()); } + @Test void createDisabledAccountAndSummaryExposeLastLoginAndScope() { + when(repository.findById(20L)).thenReturn(null); + when(repository.existsByUsername("merchant.user")).thenReturn(false); + + var created = service.create(new CreateAccountRequest(20L, "merchant.user", "Password1", + "企业用户", 10001L, 30L, "MERCHANT", "DISABLED")); + var captor = org.mockito.ArgumentCaptor.forClass(AuthAccount.class); + verify(repository).insert(captor.capture()); + AuthAccount stored = captor.getValue(); + stored.setLastLoginAt(LocalDateTime.of(2026, 6, 1, 10, 30)); + when(repository.findById(20L)).thenReturn(stored); + + var summary = service.summary(20L); + assertEquals("DISABLED", created.status()); + assertEquals(10001L, summary.tenantId()); + assertEquals(30L, summary.membershipId()); + assertEquals(LocalDateTime.of(2026, 6, 1, 10, 30), summary.lastLoginAt()); + } + + @Test void updateScopeChangesTenantAndRevokesExistingSessions() { + account.setDefaultTenantId(10001L); account.setOperatorTenantId(10001L); + account.setDefaultMembershipId(30L); account.setDefaultAppCode("MERCHANT"); + + service.updateScope(10L, new UpdateAccountScopeRequest(10002L, 30L, "MERCHANT")); + + assertEquals(10002L, account.getDefaultTenantId()); + assertEquals(10002L, account.getOperatorTenantId()); + verify(repository).save(account); + verify(sessions).revokeAll(10L); + } + @Test void createAccountRetriesIdempotentlyForSameAccountAndOwner() { account.setUsername("platform.user"); account.setDisplayName("平台用户"); account.setDefaultTenantId(0L); account.setOperatorTenantId(0L); diff --git a/deploy/scripts/initialize-test-databases.sh b/deploy/scripts/initialize-test-databases.sh index b3840db78..9556f8234 100644 --- a/deploy/scripts/initialize-test-databases.sh +++ b/deploy/scripts/initialize-test-databases.sh @@ -17,7 +17,15 @@ if [ "$(docker inspect -f '{{.State.Running}}' "$MYSQL_CONTAINER")" != "true" ]; fi for sql_file in \ + auth/src/main/resources/db/auth.sql \ iam/src/main/resources/db/iam.sql \ + auth/src/main/resources/db/phase1-admin-accounts.sql \ + iam/src/main/resources/db/phase1-admin-rbac.sql \ + iam/src/main/resources/db/phase1-onboarding.sql \ + iam/src/main/resources/db/phase1-customer-profile-fields.sql \ + iam/src/main/resources/db/phase1-member-permissions.sql \ + iam/src/main/resources/db/rbac-role-management-v2.sql \ + iam/src/main/resources/db/phase1-platform-master-data-and-permission-management.sql \ order/src/main/resources/db/order.sql \ order/src/main/resources/db/phase1-order-extension.sql \ order/src/main/resources/db/phase1-masterdata-extension.sql \ diff --git a/iam/README-IAM.md b/iam/README-IAM.md index 58d0e9de6..f9cc436ef 100644 --- a/iam/README-IAM.md +++ b/iam/README-IAM.md @@ -20,6 +20,11 @@ Merge the packages under `src/main/java` into `D:/baiyue/iam/src/main/java`; exe | GET | `/api/platform/v1/iam/reviews` | PLATFORM | List pending reviews / 待审核列表 | | POST | `/api/platform/v1/iam/reviews/{id}/approve` | PLATFORM | Approve registration / 审核通过 | | POST | `/api/platform/v1/iam/reviews/{id}/reject` | PLATFORM | Reject registration / 审核驳回 | +| GET/POST | `/api/platform/v1/iam/users` | PLATFORM | Unified platform and merchant user administration / 统一用户管理 | +| GET | `/api/platform/v1/iam/users/options` | PLATFORM | Tenant and role options / 用户租户与角色选项 | +| PUT | `/api/platform/v1/iam/users/{membershipId}` | PLATFORM | Edit profile, tenant and roles / 编辑用户资料、租户和角色 | +| PUT | `/api/platform/v1/iam/users/{membershipId}/status` | PLATFORM | Enable or disable user / 启停用户 | +| PUT | `/api/platform/v1/iam/users/{membershipId}/password` | PLATFORM | Reset password / 重置密码 | | GET/PUT | `/api/merchant/v1/iam/tenant/profile` | MERCHANT | View/update own profile / 企业资料 | | GET/POST | `/api/merchant/v1/iam/members` | MERCHANT | List/add members / 成员 | | GET/POST | `/api/{app}/v1/iam/roles` | PLATFORM/MERCHANT | Role management / 角色 | diff --git a/iam/src/main/java/com/baiyue/iam/client/AuthAccountClient.java b/iam/src/main/java/com/baiyue/iam/client/AuthAccountClient.java index ef80f6ca5..2f5e6808d 100644 --- a/iam/src/main/java/com/baiyue/iam/client/AuthAccountClient.java +++ b/iam/src/main/java/com/baiyue/iam/client/AuthAccountClient.java @@ -4,6 +4,8 @@ import com.baiyue.common.core.api.ApiResponse; import org.springframework.cloud.openfeign.FeignClient; import org.springframework.web.bind.annotation.*; +import java.time.LocalDateTime; + /** auth-service 内部账号管理客户端。 */ @FeignClient(name = "auth-service", contextId = "authAccountClient", url = "${baiyue.clients.auth.url:}", path = "/internal/auth/accounts") @@ -18,18 +20,30 @@ public interface AuthAccountClient { @PathVariable Long id, @RequestBody ProfileRequest request); @PutMapping("/{id}/password") ApiResponse reset(@RequestHeader("X-Internal-Service-Token") String token, @PathVariable Long id, @RequestBody PasswordRequest request); + @PutMapping("/{id}/scope") ApiResponse scope(@RequestHeader("X-Internal-Service-Token") String token, + @PathVariable Long id, @RequestBody ScopeRequest request); @DeleteMapping("/{id}/compensation") ApiResponse compensate(@RequestHeader("X-Internal-Service-Token") String token, @PathVariable Long id); record CreateAccountRequest(Long accountId, String username, String password, String displayName, - Long tenantId, Long membershipId, String appCode) { + Long tenantId, Long membershipId, String appCode, String accountStatus) { public CreateAccountRequest(Long accountId, String username, String password, String displayName, Long tenantId, Long membershipId) { - this(accountId, username, password, displayName, tenantId, membershipId, "MERCHANT"); + this(accountId, username, password, displayName, tenantId, membershipId, "MERCHANT", "ACTIVE"); + } + public CreateAccountRequest(Long accountId, String username, String password, String displayName, + Long tenantId, Long membershipId, String appCode) { + this(accountId, username, password, displayName, tenantId, membershipId, appCode, "ACTIVE"); + } + } + record AccountSummary(Long accountId, String username, String displayName, String status, + LocalDateTime lastLoginAt, Long tenantId, Long membershipId, String appCode) { + public AccountSummary(Long accountId, String username, String displayName, String status) { + this(accountId, username, displayName, status, null, null, null, null); } } - record AccountSummary(Long accountId, String username, String displayName, String status) { } record StatusRequest(String status) { } record ProfileRequest(String displayName) { } record PasswordRequest(String newPassword) { } + record ScopeRequest(Long tenantId, Long membershipId, String appCode) { } } diff --git a/iam/src/main/java/com/baiyue/iam/controller/PlatformUserManagementController.java b/iam/src/main/java/com/baiyue/iam/controller/PlatformUserManagementController.java new file mode 100644 index 000000000..54bb5fbe3 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/controller/PlatformUserManagementController.java @@ -0,0 +1,83 @@ +package com.baiyue.iam.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.iam.dto.PlatformUserManagementDtos.*; +import com.baiyue.iam.service.PlatformUserManagementService; +import com.baiyue.iam.service.TenantGuard; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** 原型“用户管理”的平台统一入口,覆盖平台与企业租户账号。 */ +@RestController +@RequestMapping("/api/platform/v1/iam/users") +@Tag(name = "Platform user administration / 平台用户管理") +public class PlatformUserManagementController { + private final PlatformUserManagementService service; + + public PlatformUserManagementController(PlatformUserManagementService service) { + this.service = service; + } + + @GetMapping + @PreAuthorize("hasAuthority('platform:user:list')") + @Operation(summary = "统一查询平台及企业租户用户") + public ApiResponse> list(@AuthenticationPrincipal AuthenticatedUser user) { + TenantGuard.tenant(user, "PLATFORM"); + return ApiResponse.success(service.list()); + } + + @GetMapping("/options") + @PreAuthorize("hasAuthority('platform:user:list')") + @Operation(summary = "查询用户管理可选租户和角色") + public ApiResponse options(@AuthenticationPrincipal AuthenticatedUser user) { + TenantGuard.tenant(user, "PLATFORM"); + return ApiResponse.success(service.options()); + } + + @PostMapping + @PreAuthorize("hasAuthority('platform:user:create') and hasAuthority('platform:user:grant-role')") + @Operation(summary = "新增平台或企业租户用户") + public ApiResponse create(@Valid @RequestBody CreateUserRequest request, + @AuthenticationPrincipal AuthenticatedUser user) { + TenantGuard.tenant(user, "PLATFORM"); + return ApiResponse.success(service.create(request)); + } + + @PutMapping("/{membershipId:\\d+}") + @PreAuthorize("hasAuthority('platform:user:edit')") + @Operation(summary = "编辑用户资料、租户、角色、状态和密码") + public ApiResponse update(@PathVariable Long membershipId, + @Valid @RequestBody UpdateUserRequest request, + @AuthenticationPrincipal AuthenticatedUser user) { + TenantGuard.tenant(user, "PLATFORM"); + return ApiResponse.success(service.update(membershipId, request, user.permissions())); + } + + @PutMapping("/{membershipId:\\d+}/status") + @PreAuthorize("hasAuthority('platform:user:status')") + @Operation(summary = "启用或停用用户") + public ApiResponse status(@PathVariable Long membershipId, + @Valid @RequestBody ChangeStatusRequest request, + @AuthenticationPrincipal AuthenticatedUser user) { + TenantGuard.tenant(user, "PLATFORM"); + return ApiResponse.success(service.changeStatus(membershipId, request, user.membershipId())); + } + + @PutMapping("/{membershipId:\\d+}/password") + @PreAuthorize("hasAuthority('platform:user:reset-password')") + @Operation(summary = "重置用户密码") + public ApiResponse password(@PathVariable Long membershipId, + @Valid @RequestBody ResetPasswordRequest request, + @AuthenticationPrincipal AuthenticatedUser user) { + TenantGuard.tenant(user, "PLATFORM"); + service.resetPassword(membershipId, request); + return ApiResponse.success(null); + } +} diff --git a/iam/src/main/java/com/baiyue/iam/dto/PlatformUserManagementDtos.java b/iam/src/main/java/com/baiyue/iam/dto/PlatformUserManagementDtos.java new file mode 100644 index 000000000..a974a3a39 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/dto/PlatformUserManagementDtos.java @@ -0,0 +1,48 @@ +package com.baiyue.iam.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +import java.time.LocalDateTime; +import java.util.List; + +/** 平台统一用户管理契约,覆盖平台账号与企业租户账号。 */ +public final class PlatformUserManagementDtos { + private PlatformUserManagementDtos() { } + + public record CreateUserRequest( + @NotBlank @Pattern(regexp = "^[A-Za-z0-9_.@-]{4,64}$") String username, + @NotBlank @Size(max = 64) String displayName, + @NotBlank @Pattern(regexp = "^1\\d{10}$", message = "手机号格式不正确") String phone, + @Email @Size(max = 128) String email, + @NotNull Long tenantId, + @NotNull List roleIds, + @NotBlank @Size(min = 8, max = 64) + @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") String password, + @NotBlank @Pattern(regexp = "ACTIVE|DISABLED") String status) { } + + public record UpdateUserRequest( + @NotBlank @Size(max = 64) String displayName, + @NotBlank @Pattern(regexp = "^1\\d{10}$", message = "手机号格式不正确") String phone, + @Email @Size(max = 128) String email, + @NotNull Long tenantId, + @NotNull List roleIds) { } + + public record ChangeStatusRequest(@NotBlank @Pattern(regexp = "ACTIVE|DISABLED") String status) { } + public record ResetPasswordRequest(@NotBlank @Size(min = 8, max = 64) + @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") + String newPassword) { } + + public record UserResponse(Long membershipId, Long userId, Long authUserId, String username, + String displayName, String phone, String email, Long tenantId, + String tenantName, String memberType, String memberNo, String status, + List roleIds, List roleNames, LocalDateTime lastLoginAt, + boolean protectedAccount) { } + + public record TenantOption(Long id, String name, String status) { } + public record RoleOption(Long id, Long tenantId, String name, String status) { } + public record UserOptions(List tenants, List roles) { } +} diff --git a/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java b/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java index b5e6044f5..ed3535b9b 100644 --- a/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java +++ b/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java @@ -51,7 +51,13 @@ public final class TenantDtos { @Size(max = 64) String salesPerson, @Size(max = 255) String businessAddress, @Size(max = 500) String remark - ) { } + ) { + public UpdateTenantRequest(String tenantName, String legalName, String contactName, + String contactPhone, String contactEmail) { + this(tenantName, legalName, contactName, contactPhone, contactEmail, + null, null, null, null, null); + } + } @Schema(name = "TenantStatusChangeRequest", description = "Platform controlled merchant tenant status") public record ChangeTenantStatusRequest( diff --git a/iam/src/main/java/com/baiyue/iam/repository/MemberRepository.java b/iam/src/main/java/com/baiyue/iam/repository/MemberRepository.java index 79b82b81b..8a9eec32f 100644 --- a/iam/src/main/java/com/baiyue/iam/repository/MemberRepository.java +++ b/iam/src/main/java/com/baiyue/iam/repository/MemberRepository.java @@ -30,6 +30,10 @@ public class MemberRepository { return membershipMapper.selectList(new LambdaQueryWrapper() .eq(IamMembership::getTenantId, tenantId).orderByDesc(IamMembership::getJoinedAt)); } + public List findAll() { + return membershipMapper.selectList(new LambdaQueryWrapper() + .orderByDesc(IamMembership::getJoinedAt)); + } public IamMembership findById(Long id) { return membershipMapper.selectById(id); } public IamMembership findByTenantAndId(long tenantId, Long id) { return membershipMapper.selectOne(new LambdaQueryWrapper() diff --git a/iam/src/main/java/com/baiyue/iam/repository/TenantRepository.java b/iam/src/main/java/com/baiyue/iam/repository/TenantRepository.java index a58629118..a8b65250d 100644 --- a/iam/src/main/java/com/baiyue/iam/repository/TenantRepository.java +++ b/iam/src/main/java/com/baiyue/iam/repository/TenantRepository.java @@ -33,6 +33,11 @@ public class TenantRepository { } public IamTenant findTenant(Long tenantId) { return tenantMapper.selectById(tenantId); } + public List listMerchants() { + return tenantMapper.selectList(new LambdaQueryWrapper() + .eq(IamTenant::getTenantType, "MERCHANT") + .orderByAsc(IamTenant::getTenantName)); + } public IamTenantReview findReview(Long reviewId) { return reviewMapper.selectById(reviewId); } public IamTenantReview findPendingReviewByTenant(Long tenantId) { return reviewMapper.selectOne(new LambdaQueryWrapper() diff --git a/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java b/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java new file mode 100644 index 000000000..7507f3c20 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java @@ -0,0 +1,343 @@ +package com.baiyue.iam.service; + +import com.baiyue.common.audit.BusinessAudit; +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.iam.client.AuthAccountClient; +import com.baiyue.iam.dto.PlatformUserManagementDtos.*; +import com.baiyue.iam.entity.IamMembership; +import com.baiyue.iam.entity.IamRole; +import com.baiyue.iam.entity.IamTenant; +import com.baiyue.iam.entity.IamUser; +import com.baiyue.iam.mapper.IamRoleMapper; +import com.baiyue.iam.repository.MemberRepository; +import com.baiyue.iam.repository.TenantRepository; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import feign.FeignException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** 平台管理员跨租户维护登录用户;凭据仍只由 auth-service 保存。 */ +@Service +public class PlatformUserManagementService { + private static final long PLATFORM_TENANT_ID = 0L; + private static final Logger log = LoggerFactory.getLogger(PlatformUserManagementService.class); + + private final MemberRepository members; + private final TenantRepository tenants; + private final IamRoleMapper roleMapper; + private final AuthAccountClient authClient; + private final String internalToken; + + public PlatformUserManagementService(MemberRepository members, TenantRepository tenants, + IamRoleMapper roleMapper, AuthAccountClient authClient, + @Value("${baiyue.security.internal-service-token}") String internalToken) { + this.members = members; + this.tenants = tenants; + this.roleMapper = roleMapper; + this.authClient = authClient; + this.internalToken = internalToken; + } + + @Transactional(readOnly = true) + public List list() { + Map tenantMap = tenants.listMerchants().stream() + .collect(Collectors.toMap(IamTenant::getId, Function.identity())); + Map roleMap = allRoles().stream() + .collect(Collectors.toMap(IamRole::getId, Function.identity())); + return members.findAll().stream().map(membership -> response(membership, tenantMap, roleMap)).toList(); + } + + @Transactional(readOnly = true) + public UserOptions options() { + List tenantOptions = new java.util.ArrayList<>(); + tenantOptions.add(new TenantOption(PLATFORM_TENANT_ID, "平台", "ACTIVE")); + tenants.listMerchants().stream() + .map(value -> new TenantOption(value.getId(), value.getTenantName(), value.getStatus())) + .forEach(tenantOptions::add); + List roleOptions = allRoles().stream() + .map(value -> new RoleOption(value.getId(), value.getTenantId(), value.getRoleName(), value.getStatus())) + .toList(); + return new UserOptions(List.copyOf(tenantOptions), roleOptions); + } + + @Transactional + @BusinessAudit(event = "PLATFORM_USER_CREATE", resourceType = "MEMBERSHIP", + resourceId = "#request.username", description = "平台新增用户", captureAfter = false) + public UserResponse create(CreateUserRequest request) { + long tenantId = request.tenantId(); + validateTenant(tenantId); + List roleIds = validateRoles(tenantId, request.roleIds()); + long authUserId = IdWorker.getId(); + long userId = IdWorker.getId(); + long membershipId = IdWorker.getId(); + String appCode = appCode(tenantId); + call(() -> authClient.create(internalToken, new AuthAccountClient.CreateAccountRequest( + authUserId, request.username().trim(), request.password(), request.displayName().trim(), + tenantId, membershipId, appCode, request.status()))); + try { + IamUser user = new IamUser(); + user.setId(userId); + user.setAuthUserId(authUserId); + user.setUserType(userType(tenantId)); + user.setDisplayName(request.displayName().trim()); + user.setPhone(request.phone().trim()); + user.setEmail(trimToNull(request.email())); + user.setStatus(request.status()); + members.insertUser(user); + + IamMembership membership = new IamMembership(); + membership.setId(membershipId); + membership.setTenantId(tenantId); + membership.setUserId(userId); + membership.setMemberType("EMPLOYEE"); + membership.setMemberNo(memberNo(tenantId, membershipId)); + membership.setStatus(request.status()); + membership.setJoinedAt(LocalDateTime.now()); + membership.setLeftAt("DISABLED".equals(request.status()) ? LocalDateTime.now() : null); + members.insert(membership); + members.replaceRoles(tenantId, membershipId, roleIds); + return response(membership); + } catch (RuntimeException localFailure) { + compensateCreate(authUserId, localFailure); + throw localFailure; + } + } + + @Transactional + @BusinessAudit(event = "PLATFORM_USER_UPDATE", resourceType = "MEMBERSHIP", + resourceId = "#membershipId", description = "平台编辑用户", captureAfter = false) + public UserResponse update(Long membershipId, UpdateUserRequest request, Set operatorPermissions) { + IamMembership membership = required(membershipId); + IamUser user = requiredUser(membership.getUserId()); + long oldTenantId = membership.getTenantId(); + long newTenantId = request.tenantId(); + validateTenant(newTenantId); + List roleIds = validateRoles(newTenantId, request.roleIds()); + List oldRoleIds = members.findRoleIds(oldTenantId, membershipId); + if (isProtectedPlatformAdmin(membership, oldRoleIds) + && (newTenantId != PLATFORM_TENANT_ID || !containsPlatformAdminRole(roleIds))) { + throw new BizException(ResultCode.FORBIDDEN, "系统管理员不能更换租户或移除超级管理员角色"); + } + if ((oldTenantId != newTenantId || !new java.util.HashSet<>(oldRoleIds).equals(new java.util.HashSet<>(roleIds))) + && (operatorPermissions == null || !operatorPermissions.contains("platform:user:grant-role"))) { + throw new BizException(ResultCode.FORBIDDEN, "变更用户所属租户或角色需要分配角色权限"); + } + AuthAccountClient.AccountSummary oldAccount = account(user.getAuthUserId()); + boolean profileChanged = !Objects.equals(user.getDisplayName(), request.displayName().trim()); + boolean scopeChanged = oldTenantId != newTenantId; + + if (profileChanged) call(() -> authClient.profile(internalToken, user.getAuthUserId(), + new AuthAccountClient.ProfileRequest(request.displayName().trim()))); + if (scopeChanged) call(() -> authClient.scope(internalToken, user.getAuthUserId(), + new AuthAccountClient.ScopeRequest(newTenantId, membershipId, appCode(newTenantId)))); + try { + user.setDisplayName(request.displayName().trim()); + user.setPhone(request.phone().trim()); + user.setEmail(trimToNull(request.email())); + user.setUserType(userType(newTenantId)); + membership.setTenantId(newTenantId); + if (scopeChanged && members.existsMemberNo(newTenantId, membership.getMemberNo(), membershipId)) { + membership.setMemberNo(memberNo(newTenantId, membershipId)); + } + members.saveUser(user); + members.save(membership); + if (scopeChanged) members.replaceRoles(oldTenantId, membershipId, List.of()); + members.replaceRoles(newTenantId, membershipId, roleIds); + } catch (RuntimeException localFailure) { + compensateUpdate(user.getAuthUserId(), membershipId, oldTenantId, oldAccount, + profileChanged, scopeChanged, localFailure); + throw localFailure; + } + return response(membership); + } + + @Transactional + @BusinessAudit(event = "PLATFORM_USER_STATUS_CHANGE", resourceType = "MEMBERSHIP", + resourceId = "#membershipId", description = "平台启停用户", captureAfter = false) + public UserResponse changeStatus(Long membershipId, ChangeStatusRequest request, Long operatorMembershipId) { + IamMembership membership = required(membershipId); + if (membershipId.equals(operatorMembershipId) && "DISABLED".equals(request.status())) { + throw new BizException(ResultCode.BAD_REQUEST, "不能停用当前登录用户"); + } + IamUser user = requiredUser(membership.getUserId()); + if ("DISABLED".equals(request.status()) + && isProtectedPlatformAdmin(membership, + members.findRoleIds(membership.getTenantId(), membershipId))) { + throw new BizException(ResultCode.FORBIDDEN, "系统管理员不能被禁用"); + } + AuthAccountClient.AccountSummary oldAccount = account(user.getAuthUserId()); + call(() -> authClient.status(internalToken, user.getAuthUserId(), + new AuthAccountClient.StatusRequest(request.status()))); + try { + membership.setStatus(request.status()); + membership.setLeftAt("DISABLED".equals(request.status()) ? LocalDateTime.now() : null); + user.setStatus(request.status()); + members.saveUser(user); + members.save(membership); + } catch (RuntimeException localFailure) { + call(() -> authClient.status(internalToken, user.getAuthUserId(), + new AuthAccountClient.StatusRequest(oldAccount.status()))); + throw localFailure; + } + return response(membership); + } + + @BusinessAudit(event = "PLATFORM_USER_PASSWORD_RESET", resourceType = "MEMBERSHIP", + resourceId = "#membershipId", description = "平台重置用户密码", captureAfter = false) + public void resetPassword(Long membershipId, ResetPasswordRequest request) { + IamUser user = requiredUser(required(membershipId).getUserId()); + call(() -> authClient.reset(internalToken, user.getAuthUserId(), + new AuthAccountClient.PasswordRequest(request.newPassword()))); + } + + private UserResponse response(IamMembership membership) { + Map tenantMap = tenants.listMerchants().stream() + .collect(Collectors.toMap(IamTenant::getId, Function.identity())); + Map roleMap = allRoles().stream() + .collect(Collectors.toMap(IamRole::getId, Function.identity())); + return response(membership, tenantMap, roleMap); + } + + private UserResponse response(IamMembership membership, Map tenantMap, + Map roleMap) { + IamUser user = requiredUser(membership.getUserId()); + AuthAccountClient.AccountSummary account = account(user.getAuthUserId()); + List roleIds = members.findRoleIds(membership.getTenantId(), membership.getId()); + List roleNames = roleIds.stream().map(roleMap::get).filter(Objects::nonNull) + .map(IamRole::getRoleName).toList(); + String tenantName = membership.getTenantId() == PLATFORM_TENANT_ID ? "平台" + : tenantMap.containsKey(membership.getTenantId()) + ? tenantMap.get(membership.getTenantId()).getTenantName() : "未知租户"; + return new UserResponse(membership.getId(), user.getId(), user.getAuthUserId(), account.username(), + user.getDisplayName(), user.getPhone(), user.getEmail(), membership.getTenantId(), tenantName, + membership.getMemberType(), membership.getMemberNo(), membership.getStatus(), roleIds, + roleNames, account.lastLoginAt(), isProtectedPlatformAdmin(membership, roleIds)); + } + + private List allRoles() { + return roleMapper.selectList(new LambdaQueryWrapper() + .in(IamRole::getAppCode, "PLATFORM", "MERCHANT") + .orderByAsc(IamRole::getTenantId).orderByAsc(IamRole::getRoleName)); + } + + private List validateRoles(long tenantId, List requested) { + List roleIds = List.copyOf(new LinkedHashSet<>(requested == null ? List.of() : requested)); + if (roleIds.isEmpty()) throw new BizException(ResultCode.BAD_REQUEST, "请选择角色"); + for (Long roleId : roleIds) { + IamRole role = roleMapper.selectById(roleId); + if (role == null || !Objects.equals(role.getTenantId(), tenantId) + || !appCode(tenantId).equals(role.getAppCode()) || !"ACTIVE".equals(role.getStatus())) { + throw new BizException(ResultCode.FORBIDDEN, "角色必须属于所选租户且处于启用状态"); + } + } + return roleIds; + } + + private boolean isProtectedPlatformAdmin(IamMembership membership, List roleIds) { + return membership.getTenantId() == PLATFORM_TENANT_ID && containsPlatformAdminRole(roleIds); + } + + private boolean containsPlatformAdminRole(List roleIds) { + return roleIds.stream().map(roleMapper::selectById).filter(Objects::nonNull) + .anyMatch(role -> "PLATFORM_ADMIN".equals(role.getRoleCode())); + } + + private void validateTenant(long tenantId) { + if (tenantId == PLATFORM_TENANT_ID) return; + IamTenant tenant = tenants.findTenant(tenantId); + if (tenant == null || !"MERCHANT".equals(tenant.getTenantType()) || !"ACTIVE".equals(tenant.getStatus())) { + throw new BizException(ResultCode.BAD_REQUEST, "只能选择正常状态的企业租户"); + } + } + + private IamMembership required(Long membershipId) { + IamMembership membership = members.findById(membershipId); + if (membership == null) throw new BizException(ResultCode.NOT_FOUND, "用户不存在"); + return membership; + } + + private IamUser requiredUser(Long userId) { + IamUser user = members.findUser(userId); + if (user == null) throw new BizException(ResultCode.NOT_FOUND, "用户档案不存在"); + return user; + } + + private AuthAccountClient.AccountSummary account(Long authUserId) { + ApiResponse result = call(() -> authClient.summary(internalToken, authUserId)); + if (result.data() == null) throw unavailable(); + return result.data(); + } + + private String appCode(long tenantId) { return tenantId == PLATFORM_TENANT_ID ? "PLATFORM" : "MERCHANT"; } + private String userType(long tenantId) { return tenantId == PLATFORM_TENANT_ID ? "INTERNAL" : "ENTERPRISE"; } + private String memberNo(long tenantId, long membershipId) { + return (tenantId == PLATFORM_TENANT_ID ? "PLATFORM-" : "MEMBER-") + membershipId; + } + private String trimToNull(String value) { + return value == null || value.isBlank() ? null : value.trim(); + } + + private void compensateCreate(long authUserId, RuntimeException localFailure) { + try { + call(() -> authClient.compensate(internalToken, authUserId)); + } catch (RuntimeException compensationFailure) { + log.error("统一用户创建失败且认证账号补偿失败,authUserId={}", authUserId, compensationFailure); + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, + "用户创建未完成,认证账号补偿失败,请按 authUserId=" + authUserId + " 修复一致性"); + } + } + + private void compensateUpdate(long authUserId, long membershipId, long oldTenantId, + AuthAccountClient.AccountSummary oldAccount, boolean profileChanged, + boolean scopeChanged, RuntimeException localFailure) { + try { + if (scopeChanged) call(() -> authClient.scope(internalToken, authUserId, + new AuthAccountClient.ScopeRequest(oldTenantId, membershipId, appCode(oldTenantId)))); + if (profileChanged) call(() -> authClient.profile(internalToken, authUserId, + new AuthAccountClient.ProfileRequest(oldAccount.displayName()))); + } catch (RuntimeException compensationFailure) { + log.error("统一用户编辑失败且认证账号补偿失败,authUserId={}", authUserId, compensationFailure); + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, + "用户编辑未完成,认证账号补偿失败,请按 authUserId=" + authUserId + " 修复一致性"); + } + } + + private ApiResponse call(RemoteCall action) { + try { + ApiResponse result = action.call(); + if (result == null) throw unavailable(); + if (!result.successful()) { + ResultCode code = ResultCode.of(result.code()); + throw new BizException(code, result.message() == null || result.message().isBlank() + ? code.message() : result.message()); + } + return result; + } catch (FeignException exception) { + throw unavailable(); + } catch (BizException exception) { + throw exception; + } catch (RuntimeException exception) { + throw unavailable(); + } + } + + private BizException unavailable() { + return new BizException(ResultCode.SERVICE_UNAVAILABLE, "认证账号服务暂时不可用"); + } + @FunctionalInterface private interface RemoteCall { ApiResponse call(); } +} diff --git a/iam/src/main/resources/db/phase1-admin-rbac.sql b/iam/src/main/resources/db/phase1-admin-rbac.sql index 8f832adb1..1f6a8507b 100644 --- a/iam/src/main/resources/db/phase1-admin-rbac.sql +++ b/iam/src/main/resources/db/phase1-admin-rbac.sql @@ -93,6 +93,10 @@ VALUES (5100021, 0, 'PLATFORM', 'BUTTON', 5100020, 'platform:customer:approve', '审核通过', NULL, NULL, 10, 'ACTIVE', 0), (5100022, 0, 'PLATFORM', 'BUTTON', 5100020, 'platform:customer:reject', '审核驳回', NULL, NULL, 20, 'ACTIVE', 0), (5101011, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:create', '新增用户', NULL, NULL, 10, 'ACTIVE', 0), + (5101012, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:edit', '编辑用户', NULL, NULL, 20, 'ACTIVE', 0), + (5101013, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:status', '启停用户', NULL, NULL, 30, 'ACTIVE', 0), + (5101014, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:reset-password', '重置密码', NULL, NULL, 40, 'ACTIVE', 0), + (5101015, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:grant-role', '分配用户角色', NULL, NULL, 50, 'ACTIVE', 0), (5101021, 0, 'PLATFORM', 'BUTTON', 5101020, 'platform:role:create', '新增角色', NULL, NULL, 10, 'ACTIVE', 0), (5101022, 0, 'PLATFORM', 'BUTTON', 5101020, 'platform:role:grant', '角色授权', NULL, NULL, 20, 'ACTIVE', 0), (5101031, 0, 'PLATFORM', 'BUTTON', 5101030, 'platform:permission:create', '新增菜单按钮', NULL, NULL, 10, 'ACTIVE', 0), @@ -113,9 +117,11 @@ VALUES (5103021, 0, 'PLATFORM', 'BUTTON', 5103020, 'platform:express:create', '新增快递公司', NULL, NULL, 10, 'ACTIVE', 0), (5103022, 0, 'PLATFORM', 'BUTTON', 5103020, 'platform:express:edit', '编辑快递公司', NULL, NULL, 20, 'ACTIVE', 0), (5103023, 0, 'PLATFORM', 'BUTTON', 5103020, 'platform:express:status', '启用停用快递公司', NULL, NULL, 30, 'ACTIVE', 0), + (5103024, 0, 'PLATFORM', 'BUTTON', 5103020, 'platform:express:import', '批量导入快递公司', NULL, NULL, 40, 'ACTIVE', 0), (5103031, 0, 'PLATFORM', 'BUTTON', 5103030, 'platform:counterparty:create', '新增往来单位', NULL, NULL, 10, 'ACTIVE', 0), (5103032, 0, 'PLATFORM', 'BUTTON', 5103030, 'platform:counterparty:edit', '编辑往来单位', NULL, NULL, 20, 'ACTIVE', 0), - (5103033, 0, 'PLATFORM', 'BUTTON', 5103030, 'platform:counterparty:status', '启用停用往来单位', NULL, NULL, 30, 'ACTIVE', 0) + (5103033, 0, 'PLATFORM', 'BUTTON', 5103030, 'platform:counterparty:status', '启用停用往来单位', NULL, NULL, 30, 'ACTIVE', 0), + (5103034, 0, 'PLATFORM', 'BUTTON', 5103030, 'platform:counterparty:import', '批量导入往来单位', NULL, NULL, 40, 'ACTIVE', 0) ON DUPLICATE KEY UPDATE permission_type = VALUES(permission_type), parent_id = VALUES(parent_id), permission_name = VALUES(permission_name), route_path = VALUES(route_path), diff --git a/iam/src/main/resources/db/phase1-customer-profile-fields.sql b/iam/src/main/resources/db/phase1-customer-profile-fields.sql deleted file mode 100644 index 6d6ca5971..000000000 --- a/iam/src/main/resources/db/phase1-customer-profile-fields.sql +++ /dev/null @@ -1,12 +0,0 @@ --- 平台客户档案原型字段补齐,可重复执行。 -USE baiyue_iam; - -SET @add_customer_columns = IF( - EXISTS(SELECT 1 FROM information_schema.columns - WHERE table_schema = DATABASE() AND table_name = 'iam_tenant' AND column_name = 'service_package'), - 'SELECT 1', - 'ALTER TABLE iam_tenant ADD COLUMN service_package VARCHAR(64) NULL COMMENT ''客户服务套餐'' AFTER contact_email, ADD COLUMN credit_days INT NOT NULL DEFAULT 0 COMMENT ''账期天数'' AFTER service_package, ADD COLUMN sales_person VARCHAR(64) NULL COMMENT ''负责销售人员'' AFTER credit_days, ADD COLUMN business_address VARCHAR(255) NULL COMMENT ''企业经营地址'' AFTER sales_person, ADD COLUMN remark VARCHAR(500) NULL COMMENT ''平台客户备注'' AFTER business_address' -); -PREPARE add_customer_columns_stmt FROM @add_customer_columns; -EXECUTE add_customer_columns_stmt; -DEALLOCATE PREPARE add_customer_columns_stmt; diff --git a/iam/src/main/resources/db/phase1-platform-master-data-and-permission-management.sql b/iam/src/main/resources/db/phase1-platform-master-data-and-permission-management.sql index 2a76408f6..dc61344b3 100644 --- a/iam/src/main/resources/db/phase1-platform-master-data-and-permission-management.sql +++ b/iam/src/main/resources/db/phase1-platform-master-data-and-permission-management.sql @@ -31,6 +31,7 @@ VALUES (5103021, 0, 'PLATFORM', 'BUTTON', 5103020, 'platform:express:create', '新增快递公司', 10, 'ACTIVE', 1, 0), (5103022, 0, 'PLATFORM', 'BUTTON', 5103020, 'platform:express:edit', '编辑快递公司', 20, 'ACTIVE', 1, 0), (5103023, 0, 'PLATFORM', 'BUTTON', 5103020, 'platform:express:status', '启停快递公司', 30, 'ACTIVE', 1, 0), + (5103024, 0, 'PLATFORM', 'BUTTON', 5103020, 'platform:express:import', '批量导入快递公司', 40, 'ACTIVE', 1, 0), (5103031, 0, 'PLATFORM', 'BUTTON', 5103030, 'platform:counterparty:create', '新增往来单位', 10, 'ACTIVE', 1, 0), (5103032, 0, 'PLATFORM', 'BUTTON', 5103030, 'platform:counterparty:edit', '编辑往来单位', 20, 'ACTIVE', 1, 0), (5103033, 0, 'PLATFORM', 'BUTTON', 5103030, 'platform:counterparty:status', '启停往来单位', 30, 'ACTIVE', 1, 0) @@ -46,7 +47,7 @@ WHERE tenant_id = 0 AND app_code = 'PLATFORM' AND permission_code IN ( 'platform:customer:edit', 'platform:customer:status', 'platform:user:grant-role', 'platform:permission:edit', 'platform:permission:status', 'platform:permission:delete', - 'platform:express:list', 'platform:express:create', 'platform:express:edit', 'platform:express:status', + 'platform:express:list', 'platform:express:create', 'platform:express:edit', 'platform:express:status', 'platform:express:import', 'platform:counterparty:list', 'platform:counterparty:create', 'platform:counterparty:edit', 'platform:counterparty:status' ) ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); diff --git a/iam/src/main/resources/openapi/iam-v1.yaml b/iam/src/main/resources/openapi/iam-v1.yaml index 421e959bb..cb3d9d434 100644 --- a/iam/src/main/resources/openapi/iam-v1.yaml +++ b/iam/src/main/resources/openapi/iam-v1.yaml @@ -145,6 +145,73 @@ paths: responses: '200': {$ref: '#/components/responses/Success'} '403': {$ref: '#/components/responses/Forbidden'} + /api/platform/v1/iam/users: + get: + tags: [IAM Resources / IAM 资源] + summary: 统一查询平台及企业租户用户 + responses: + '200': {$ref: '#/components/responses/Success'} + '403': {$ref: '#/components/responses/Forbidden'} + post: + tags: [IAM Resources / IAM 资源] + summary: 新增平台或企业租户用户 + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/CreatePlatformManagedUserRequest'} + responses: + '200': {$ref: '#/components/responses/Success'} + '403': {$ref: '#/components/responses/Forbidden'} + /api/platform/v1/iam/users/options: + get: + tags: [IAM Resources / IAM 资源] + summary: 查询用户管理可选租户和角色 + responses: + '200': {$ref: '#/components/responses/Success'} + '403': {$ref: '#/components/responses/Forbidden'} + /api/platform/v1/iam/users/{membershipId}: + parameters: + - {$ref: '#/components/parameters/MembershipId'} + put: + tags: [IAM Resources / IAM 资源] + summary: 编辑用户资料、所属租户及角色 + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/UpdatePlatformManagedUserRequest'} + responses: + '200': {$ref: '#/components/responses/Success'} + '403': {$ref: '#/components/responses/Forbidden'} + /api/platform/v1/iam/users/{membershipId}/status: + parameters: + - {$ref: '#/components/parameters/MembershipId'} + put: + tags: [IAM Resources / IAM 资源] + summary: 启用或停用平台及企业租户用户 + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/ChangeInternalUserStatusRequest'} + responses: + '200': {$ref: '#/components/responses/Success'} + '403': {$ref: '#/components/responses/Forbidden'} + /api/platform/v1/iam/users/{membershipId}/password: + parameters: + - {$ref: '#/components/parameters/MembershipId'} + put: + tags: [IAM Resources / IAM 资源] + summary: 重置平台及企业租户用户密码 + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/ResetInternalUserPasswordRequest'} + responses: + '200': {$ref: '#/components/responses/Success'} + '403': {$ref: '#/components/responses/Forbidden'} /api/platform/v1/iam/internal-users: get: tags: [IAM Resources / IAM 资源] @@ -505,6 +572,12 @@ components: contactName: {type: string} contactPhone: {type: string} contactEmail: {type: string, format: email} + servicePackage: {type: string, maxLength: 64} + creditDays: {type: integer, minimum: 0} + salesPerson: {type: string, maxLength: 64} + businessAddress: {type: string, maxLength: 255} + remark: {type: string, maxLength: 500} + createdAt: {type: string, format: date-time} RegisterRequest: type: object required: [tenantName, legalName, unifiedSocialCreditCode, contactName, contactPhone] @@ -515,6 +588,11 @@ components: contactName: {type: string, example: 李四} contactPhone: {type: string, example: '13800138000'} contactEmail: {type: string, format: email, example: contact@example.com} + servicePackage: {type: string, maxLength: 64, example: 标准版} + creditDays: {type: integer, minimum: 0, example: 30} + salesPerson: {type: string, maxLength: 64, example: 李四} + businessAddress: {type: string, maxLength: 255} + remark: {type: string, maxLength: 500} UpdateProfileRequest: type: object required: [tenantName, legalName, contactName, contactPhone] @@ -533,6 +611,11 @@ components: contactName: {type: string} contactPhone: {type: string} contactEmail: {type: string, format: email} + servicePackage: {type: string, maxLength: 64} + creditDays: {type: integer, minimum: 0} + salesPerson: {type: string, maxLength: 64} + businessAddress: {type: string, maxLength: 255} + remark: {type: string, maxLength: 500} ChangeTenantStatusRequest: type: object required: [status] @@ -542,6 +625,27 @@ components: type: object properties: comment: {type: string, maxLength: 500, description: Review comment or rejection reason / 审核意见或驳回原因} + CreatePlatformManagedUserRequest: + type: object + required: [username, displayName, phone, tenantId, roleIds, password, status] + properties: + username: {type: string, minLength: 4, maxLength: 64} + displayName: {type: string, maxLength: 64} + phone: {type: string, pattern: '^1\\d{10}$'} + email: {type: string, format: email, maxLength: 128} + tenantId: {type: integer, format: int64} + roleIds: {type: array, minItems: 1, items: {type: integer, format: int64}} + password: {type: string, format: password, minLength: 8, maxLength: 64, writeOnly: true} + status: {type: string, enum: [ACTIVE, DISABLED]} + UpdatePlatformManagedUserRequest: + type: object + required: [displayName, phone, tenantId, roleIds] + properties: + displayName: {type: string, maxLength: 64} + phone: {type: string, pattern: '^1\\d{10}$'} + email: {type: string, format: email, maxLength: 128} + tenantId: {type: integer, format: int64} + roleIds: {type: array, minItems: 1, items: {type: integer, format: int64}} CreateInternalUserRequest: type: object required: [displayName] diff --git a/iam/src/test/java/com/baiyue/iam/controller/PlatformInternalUserContractTest.java b/iam/src/test/java/com/baiyue/iam/controller/PlatformInternalUserContractTest.java index eb5a40c10..2932f1c0c 100644 --- a/iam/src/test/java/com/baiyue/iam/controller/PlatformInternalUserContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/controller/PlatformInternalUserContractTest.java @@ -5,6 +5,7 @@ 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.PutMapping; +import org.springframework.web.bind.annotation.PostMapping; import java.lang.reflect.Method; @@ -35,6 +36,33 @@ class PlatformInternalUserContractTest { "/api/platform/v1/iam/internal-users/{membershipId:\\d+}/roles"); } + @Test + void unifiedPlatformUserEndpointsKeepIndependentPermissions() throws Exception { + Method list = PlatformUserManagementController.class.getMethod("list", + com.baiyue.common.security.AuthenticatedUser.class); + Method create = PlatformUserManagementController.class.getMethod("create", + com.baiyue.iam.dto.PlatformUserManagementDtos.CreateUserRequest.class, + com.baiyue.common.security.AuthenticatedUser.class); + Method update = PlatformUserManagementController.class.getMethod("update", Long.class, + com.baiyue.iam.dto.PlatformUserManagementDtos.UpdateUserRequest.class, + com.baiyue.common.security.AuthenticatedUser.class); + Method status = PlatformUserManagementController.class.getMethod("status", Long.class, + com.baiyue.iam.dto.PlatformUserManagementDtos.ChangeStatusRequest.class, + com.baiyue.common.security.AuthenticatedUser.class); + Method password = PlatformUserManagementController.class.getMethod("password", Long.class, + com.baiyue.iam.dto.PlatformUserManagementDtos.ResetPasswordRequest.class, + com.baiyue.common.security.AuthenticatedUser.class); + + assertEquals("hasAuthority('platform:user:list')", list.getAnnotation(PreAuthorize.class).value()); + assertEquals("hasAuthority('platform:user:create') and hasAuthority('platform:user:grant-role')", + create.getAnnotation(PreAuthorize.class).value()); + assertEquals("hasAuthority('platform:user:edit')", update.getAnnotation(PreAuthorize.class).value()); + assertEquals("hasAuthority('platform:user:status')", status.getAnnotation(PreAuthorize.class).value()); + assertEquals("hasAuthority('platform:user:reset-password')", password.getAnnotation(PreAuthorize.class).value()); + assertNotNull(create.getAnnotation(PostMapping.class)); + assertNotNull(update.getAnnotation(PutMapping.class)); + } + @Test void tenantProfileReadRequiresViewPermission() throws Exception { Method method = MerchantTenantController.class.getMethod("profile", diff --git a/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java new file mode 100644 index 000000000..25518be11 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java @@ -0,0 +1,158 @@ +package com.baiyue.iam.service; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.iam.client.AuthAccountClient; +import com.baiyue.iam.dto.PlatformUserManagementDtos.*; +import com.baiyue.iam.entity.IamMembership; +import com.baiyue.iam.entity.IamRole; +import com.baiyue.iam.entity.IamTenant; +import com.baiyue.iam.entity.IamUser; +import com.baiyue.iam.mapper.IamRoleMapper; +import com.baiyue.iam.repository.MemberRepository; +import com.baiyue.iam.repository.TenantRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class PlatformUserManagementServiceTest { + private final MemberRepository members = mock(MemberRepository.class); + private final TenantRepository tenants = mock(TenantRepository.class); + private final IamRoleMapper roles = mock(IamRoleMapper.class); + private final AuthAccountClient auth = mock(AuthAccountClient.class); + private final PlatformUserManagementService service = + new PlatformUserManagementService(members, tenants, roles, auth, "internal-token"); + private IamTenant merchant; + private IamRole merchantRole; + private IamMembership membership; + private IamUser user; + + @BeforeEach + void setUp() { + merchant = new IamTenant(); + merchant.setId(10001L); merchant.setTenantType("MERCHANT"); merchant.setTenantName("华远贸易"); + merchant.setStatus("ACTIVE"); + merchantRole = new IamRole(); + merchantRole.setId(40001L); merchantRole.setTenantId(10001L); merchantRole.setAppCode("MERCHANT"); + merchantRole.setRoleName("租户管理员"); merchantRole.setStatus("ACTIVE"); + membership = new IamMembership(); + membership.setId(30L); membership.setTenantId(10001L); membership.setUserId(20L); + membership.setMemberType("EMPLOYEE"); membership.setMemberNo("MEMBER-30"); membership.setStatus("ACTIVE"); + user = new IamUser(); + user.setId(20L); user.setAuthUserId(10L); user.setUserType("ENTERPRISE"); + user.setDisplayName("张伟"); user.setPhone("13800138000"); user.setStatus("ACTIVE"); + + when(tenants.findTenant(10001L)).thenReturn(merchant); + when(tenants.listMerchants()).thenReturn(List.of(merchant)); + when(roles.selectById(40001L)).thenReturn(merchantRole); + when(roles.selectList(any())).thenReturn(List.of(merchantRole)); + when(members.findById(30L)).thenReturn(membership); + when(members.findUser(20L)).thenReturn(user); + when(members.findRoleIds(10001L, 30L)).thenReturn(List.of(40001L)); + when(auth.summary("internal-token", 10L)).thenReturn(ApiResponse.success( + new AuthAccountClient.AccountSummary(10L, "zhangwei", "张伟", "ACTIVE", + LocalDateTime.of(2026, 6, 1, 10, 30), 10001L, 30L, "MERCHANT"))); + } + + @Test + void listReturnsTenantRoleAndLastLoginForMerchantUser() { + when(members.findAll()).thenReturn(List.of(membership)); + + UserResponse response = service.list().get(0); + + assertEquals("zhangwei", response.username()); + assertEquals("华远贸易", response.tenantName()); + assertEquals(List.of("租户管理员"), response.roleNames()); + assertEquals(LocalDateTime.of(2026, 6, 1, 10, 30), response.lastLoginAt()); + } + + @Test + void createMerchantUserCreatesDisabledMerchantAccountAndMembership() { + when(auth.create(anyString(), any())).thenReturn(ApiResponse.success( + new AuthAccountClient.AccountSummary(99L, "newuser", "新用户", "DISABLED"))); + when(auth.summary(anyString(), anyLong())).thenReturn(ApiResponse.success( + new AuthAccountClient.AccountSummary(99L, "newuser", "新用户", "DISABLED"))); + when(members.findUser(anyLong())).thenReturn(user); + CreateUserRequest request = new CreateUserRequest("newuser", "新用户", "13900139000", + "new@example.com", 10001L, List.of(40001L), "Password1", "DISABLED"); + + service.create(request); + + ArgumentCaptor account = + ArgumentCaptor.forClass(AuthAccountClient.CreateAccountRequest.class); + verify(auth).create(eq("internal-token"), account.capture()); + assertEquals("MERCHANT", account.getValue().appCode()); + assertEquals("DISABLED", account.getValue().accountStatus()); + verify(members).insertUser(argThat(value -> "ENTERPRISE".equals(value.getUserType()))); + verify(members).insert(argThat(value -> value.getTenantId() == 10001L + && "DISABLED".equals(value.getStatus()))); + } + + @Test + void tenantMoveUpdatesAuthScopeAndRolesWhenOperatorCanGrantRoles() { + IamTenant target = new IamTenant(); + target.setId(10002L); target.setTenantType("MERCHANT"); target.setTenantName("深圳速达"); target.setStatus("ACTIVE"); + IamRole targetRole = new IamRole(); + targetRole.setId(40002L); targetRole.setTenantId(10002L); targetRole.setAppCode("MERCHANT"); + targetRole.setRoleName("操作员"); targetRole.setStatus("ACTIVE"); + when(tenants.findTenant(10002L)).thenReturn(target); + when(tenants.listMerchants()).thenReturn(List.of(merchant, target)); + when(roles.selectById(40002L)).thenReturn(targetRole); + when(roles.selectList(any())).thenReturn(List.of(merchantRole, targetRole)); + when(auth.scope(anyString(), anyLong(), any())).thenReturn(ApiResponse.success(null)); + when(auth.profile(anyString(), anyLong(), any())).thenReturn(ApiResponse.success(null)); + UpdateUserRequest request = new UpdateUserRequest("张伟新", "13800138000", null, + 10002L, List.of(40002L)); + + UserResponse response = service.update(30L, request, Set.of("platform:user:grant-role")); + + verify(auth).scope(eq("internal-token"), eq(10L), + argThat(value -> value.tenantId() == 10002L && "MERCHANT".equals(value.appCode()))); + verify(members).replaceRoles(10001L, 30L, List.of()); + verify(members).replaceRoles(10002L, 30L, List.of(40002L)); + assertEquals(10002L, response.tenantId()); + } + + @Test + void tenantOrRoleChangeRequiresGrantRolePermission() { + IamRole otherRole = new IamRole(); + otherRole.setId(40003L); otherRole.setTenantId(10001L); otherRole.setAppCode("MERCHANT"); + otherRole.setRoleName("操作员"); otherRole.setStatus("ACTIVE"); + when(roles.selectById(40003L)).thenReturn(otherRole); + UpdateUserRequest request = new UpdateUserRequest("张伟", "13800138000", null, + 10001L, List.of(40003L)); + + assertThrows(BizException.class, () -> service.update(30L, request, Set.of())); + verify(auth, never()).scope(anyString(), anyLong(), any()); + } + + @Test + void currentPlatformOperatorCannotDisableSelf() { + assertThrows(BizException.class, + () -> service.changeStatus(30L, new ChangeStatusRequest("DISABLED"), 30L)); + verify(auth, never()).status(anyString(), anyLong(), any()); + } + + @Test + void builtInPlatformAdministratorCannotBeDisabled() { + membership.setTenantId(0L); + IamRole platformAdmin = new IamRole(); + platformAdmin.setId(40000L); platformAdmin.setTenantId(0L); platformAdmin.setAppCode("PLATFORM"); + platformAdmin.setRoleCode("PLATFORM_ADMIN"); platformAdmin.setRoleName("平台超级管理员"); + platformAdmin.setStatus("ACTIVE"); + when(roles.selectById(40000L)).thenReturn(platformAdmin); + when(members.findRoleIds(0L, 30L)).thenReturn(List.of(40000L)); + + assertThrows(BizException.class, + () -> service.changeStatus(30L, new ChangeStatusRequest("DISABLED"), 99L)); + verify(auth, never()).status(anyString(), anyLong(), any()); + } +} diff --git a/order/src/main/java/com/baiyue/order/client/PartnerClient.java b/order/src/main/java/com/baiyue/order/client/PartnerClient.java index 158e90f5b..85fbae56e 100644 --- a/order/src/main/java/com/baiyue/order/client/PartnerClient.java +++ b/order/src/main/java/com/baiyue/order/client/PartnerClient.java @@ -3,6 +3,7 @@ package com.baiyue.order.client; import com.baiyue.common.core.api.ApiResponse; import org.springframework.cloud.openfeign.FeignClient; import org.springframework.web.bind.annotation.*; +import java.util.List; @FeignClient(name="warehouse-service", contextId="warehousePartnerClient", url="${baiyue.clients.warehouse.url:}", path="/internal/warehouse-partners") @@ -10,5 +11,6 @@ public interface PartnerClient { @GetMapping("/{tenantId}/{id}/active") ApiResponse active(@RequestHeader("X-Internal-Service-Token") String token, @PathVariable("tenantId") long tenantId, @PathVariable("id") long id); - record Partner(Long id, Long tenantId, String partnerName, String status) { } + record Partner(Long id, Long tenantId, String partnerName, String status, + String partnerType, List types) { } } diff --git a/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java b/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java index cf1a4836e..0d2ac59c0 100644 --- a/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java +++ b/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java @@ -6,6 +6,7 @@ import com.baiyue.common.core.api.PageResult; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.order.dto.OrderDetailResponse; import com.baiyue.order.dto.OrderRequests; +import com.baiyue.order.dto.TenantOrderCountResponse; import com.baiyue.order.entity.OrderEntity; import com.baiyue.order.entity.OrderStatus; import com.baiyue.order.service.OrderApplicationService; @@ -27,6 +28,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.PutMapping; +import java.util.List; /** * 平台端头程订单管理接口,在明确的目标租户范围内提供查询、审核和履约状态流转能力。 @@ -110,6 +112,20 @@ public class PlatformFirstLegOrderController { return ApiResponse.success(service.page(OrderScopeGuard.platform(user, tenantId), query)); } + @GetMapping("/tenant-counts") + @PreAuthorize("hasAuthority('platform:order:list')") + @Operation(summary = "批量查询客户运单数", description = "按客户租户 ID 返回真实头程运单数量,最多 200 个客户。") + public ApiResponse> tenantCounts( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam List tenantIds) { + if (tenantIds.isEmpty() || tenantIds.size() > 200 || tenantIds.stream().anyMatch(id -> id == null || id <= 0)) { + throw new com.baiyue.common.core.exception.BizException( + com.baiyue.common.core.api.ResultCode.BAD_REQUEST, "1 to 200 valid tenant IDs are required / 需要 1 至 200 个有效租户 ID"); + } + OrderScopeGuard.platform(user, tenantIds.get(0)); + return ApiResponse.success(service.countByTenantIds(tenantIds)); + } + @Operation(summary = "Tenant order detail / 租户订单详情", description = "Returns the complete order aggregate within the explicit target tenant. / 返回明确目标租户内的完整订单聚合。") @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Query succeeded / 查询成功"), diff --git a/order/src/main/java/com/baiyue/order/dto/TenantOrderCountResponse.java b/order/src/main/java/com/baiyue/order/dto/TenantOrderCountResponse.java new file mode 100644 index 000000000..d83c6caa8 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/dto/TenantOrderCountResponse.java @@ -0,0 +1,6 @@ +package com.baiyue.order.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(name = "TenantOrderCountResponse", description = "平台客户的头程运单数量") +public record TenantOrderCountResponse(Long tenantId, Long orderCount) { } diff --git a/order/src/main/java/com/baiyue/order/repository/OrderRepository.java b/order/src/main/java/com/baiyue/order/repository/OrderRepository.java index 320c34588..278576bc0 100644 --- a/order/src/main/java/com/baiyue/order/repository/OrderRepository.java +++ b/order/src/main/java/com/baiyue/order/repository/OrderRepository.java @@ -5,11 +5,13 @@ import com.baiyue.order.dto.OrderRequests; import com.baiyue.order.entity.*; import com.baiyue.order.mapper.*; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import org.springframework.stereotype.Repository; import org.springframework.util.StringUtils; import java.util.List; +import java.util.Map; /** * 订单聚合仓储,组合订单主表、货物、参与方、备注、状态历史与 Outbox 的持久化操作, @@ -86,6 +88,18 @@ public class OrderRepository { return PageResult.from(orderMapper.selectPage(new Page<>(query.page(), query.size()), wrapper)); } + public Map countByTenantIds(List tenantIds) { + if (tenantIds.isEmpty()) return Map.of(); + var rows = orderMapper.selectMaps(new QueryWrapper() + .select("tenant_id", "COUNT(*) AS order_count") + .in("tenant_id", tenantIds) + .groupBy("tenant_id")); + return rows.stream().collect(java.util.stream.Collectors.toMap( + row -> ((Number) row.get("tenant_id")).longValue(), + row -> ((Number) row.get("order_count")).longValue() + )); + } + public List findCargo(long tenantId, long orderId) { return cargoMapper.selectList(new LambdaQueryWrapper() .eq(OrderCargoEntity::getTenantId, tenantId).eq(OrderCargoEntity::getOrderId, orderId) diff --git a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java index 61133c992..05b949560 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java +++ b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java @@ -4,6 +4,7 @@ import com.baiyue.common.core.api.PageResult; import com.baiyue.common.core.exception.BizException; import com.baiyue.order.dto.OrderDetailResponse; import com.baiyue.order.dto.OrderRequests; +import com.baiyue.order.dto.TenantOrderCountResponse; import com.baiyue.order.entity.*; import com.baiyue.order.repository.OrderRepository; import com.baomidou.mybatisplus.core.toolkit.IdWorker; @@ -108,6 +109,13 @@ public class OrderApplicationService { } public PageResult page(long tenantId, OrderRequests.QueryRequest query) { return repository.page(tenantId, query); } + + public List countByTenantIds(List tenantIds) { + var counts = repository.countByTenantIds(tenantIds); + return tenantIds.stream().distinct() + .map(tenantId -> new TenantOrderCountResponse(tenantId, counts.getOrDefault(tenantId, 0L))) + .toList(); + } public OrderDetailResponse detail(long tenantId, long id) { OrderEntity order = require(tenantId, id); return new OrderDetailResponse(order, repository.findCargo(tenantId, id), repository.findParties(tenantId, id), diff --git a/order/src/main/java/com/baiyue/order/service/OrderMasterDataGuard.java b/order/src/main/java/com/baiyue/order/service/OrderMasterDataGuard.java index 506b76c3c..03456711f 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderMasterDataGuard.java +++ b/order/src/main/java/com/baiyue/order/service/OrderMasterDataGuard.java @@ -72,6 +72,8 @@ public class OrderMasterDataGuard { throw new BizException(ResultCode.FORBIDDEN,"往来单位不属于当前企业"); if (!"ACTIVE".equals(value.status())) throw new BizException(ResultCode.CONFLICT,"往来单位已停用"); + if (value.types() == null || !value.types().contains("SHIPPER")) + throw new BizException(ResultCode.VALIDATION_FAILED,"所选往来单位不具备发货单位类型"); if (!StringUtils.hasText(value.partnerName())) throw unavailable("往来单位"); return value; } diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index f051cfe4a..e25726b43 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -40,6 +40,18 @@ paths: responses: '200': {description: 成功或业务失败,按响应体 code 判断} '403': {description: 需要 platform:order:create 权限} + /api/platform/v1/orders/first-leg/tenant-counts: + get: + summary: 批量查询客户运单数 + description: 按客户租户 ID 返回真实头程运单数量,最多 200 个租户。 + parameters: + - name: tenantIds + in: query + required: true + schema: {type: array, maxItems: 200, items: {type: integer, format: int64}} + responses: + '200': {description: 查询成功或业务失败,按响应体 code 判断} + '403': {description: 需要 platform:order:list 权限} /api/platform/v1/orders/first-leg/{id}/submit: post: summary: 平台代客提交指定订单 diff --git a/order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java b/order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java index 13fe8e123..e06c3e71d 100644 --- a/order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java +++ b/order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java @@ -7,6 +7,7 @@ import org.junit.jupiter.api.Test; import org.springframework.security.access.prepost.PreAuthorize; import java.lang.reflect.Method; +import java.util.List; import static org.assertj.core.api.Assertions.assertThat; @@ -26,6 +27,8 @@ class OrderControllerPermissionTest { AuthenticatedUser.class, long.class, OrderRequests.QueryRequest.class); assertAuthority(PlatformFirstLegOrderController.class, "detail", "hasAuthority('platform:order:list')", AuthenticatedUser.class, long.class, long.class); + assertAuthority(PlatformFirstLegOrderController.class, "tenantCounts", "hasAuthority('platform:order:list')", + AuthenticatedUser.class, List.class); } @Test diff --git a/order/src/test/java/com/baiyue/order/service/OrderMasterDataGuardTest.java b/order/src/test/java/com/baiyue/order/service/OrderMasterDataGuardTest.java index a73464f49..3e3436895 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderMasterDataGuardTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderMasterDataGuardTest.java @@ -24,7 +24,7 @@ class OrderMasterDataGuardTest { @Test void validMasterDataReturnsTrustedServerSnapshot() { WarehouseClient warehouses=mock(WarehouseClient.class); PartnerClient partners=mock(PartnerClient.class); - var partner=new PartnerClient.Partner(7L,10001L,"服务端客户名","ACTIVE"); + var partner=new PartnerClient.Partner(7L,10001L,"服务端客户名","ACTIVE","SHIPPER",List.of("SHIPPER")); var address=new WarehouseClient.Address("CN","广东省","深圳市","宝安区","518000","示例路1号",null); var capability=new WarehouseClient.Capability("RECEIVING",true,java.time.LocalTime.of(16,0)); var wh=new WarehouseClient.Detail(new WarehouseClient.Warehouse(8L,10001L,"深圳仓","ACTIVE"),List.of(address),List.of(capability)); @@ -57,7 +57,7 @@ class OrderMasterDataGuardTest { @Test void masterDataResponseWithNullElementReturnsControlledBusinessError() { WarehouseClient warehouses=mock(WarehouseClient.class); PartnerClient partners=mock(PartnerClient.class); - var partner=new PartnerClient.Partner(7L,10001L,"服务端客户名","ACTIVE"); + var partner=new PartnerClient.Partner(7L,10001L,"服务端客户名","ACTIVE","SHIPPER",List.of("SHIPPER")); var wh=new WarehouseClient.Detail(new WarehouseClient.Warehouse(8L,10001L,"深圳仓","ACTIVE"), Collections.singletonList(null), Collections.singletonList(null)); when(partners.active("token",10001,7)).thenReturn(ApiResponse.success(partner)); @@ -71,7 +71,7 @@ class OrderMasterDataGuardTest { @Test void masterDataResponseMissingSnapshotNameIsServiceUnavailable() { WarehouseClient warehouses=mock(WarehouseClient.class); PartnerClient partners=mock(PartnerClient.class); - when(partners.active("token",10001,7)).thenReturn(ApiResponse.success(new PartnerClient.Partner(7L,10001L,null,"ACTIVE"))); + when(partners.active("token",10001,7)).thenReturn(ApiResponse.success(new PartnerClient.Partner(7L,10001L,null,"ACTIVE","SHIPPER",List.of("SHIPPER")))); assertThatThrownBy(() -> new OrderMasterDataGuard(warehouses,partners,"token").validate(10001,request(7L,8L))) .isInstanceOf(BizException.class) @@ -133,13 +133,25 @@ class OrderMasterDataGuardTest { void inactiveMasterDataInSuccessfulResponseMapsToConflict() { WarehouseClient warehouses=mock(WarehouseClient.class); PartnerClient partners=mock(PartnerClient.class); when(partners.active("token",10001,7)) - .thenReturn(ApiResponse.success(new PartnerClient.Partner(7L,10001L,"客户","INACTIVE"))); + .thenReturn(ApiResponse.success(new PartnerClient.Partner(7L,10001L,"客户","INACTIVE","SHIPPER",List.of("SHIPPER")))); assertThatThrownBy(() -> new OrderMasterDataGuard(warehouses,partners,"token").validate(10001,request(7L,8L))) .isInstanceOf(BizException.class) .extracting(exception -> ((BizException) exception).getResultCode()) .isEqualTo(ResultCode.CONFLICT); } + @Test + void partnerWithoutShipperTypeIsRejected() { + WarehouseClient warehouses=mock(WarehouseClient.class); PartnerClient partners=mock(PartnerClient.class); + when(partners.active("token",10001,7)).thenReturn(ApiResponse.success( + new PartnerClient.Partner(7L,10001L,"收货单位","ACTIVE","CONSIGNEE",List.of("CONSIGNEE")))); + + assertThatThrownBy(() -> new OrderMasterDataGuard(warehouses,partners,"token").validate(10001,request(7L,8L))) + .isInstanceOf(BizException.class) + .extracting(exception -> ((BizException) exception).getResultCode()) + .isEqualTo(ResultCode.VALIDATION_FAILED); + verifyNoInteractions(warehouses); + } private FeignException feign(int status) { Request request = Request.create(Request.HttpMethod.GET, "http://warehouse-service/internal/test", Map.of(), null, StandardCharsets.UTF_8, null); diff --git a/order/src/test/java/com/baiyue/order/service/TenantOrderCountServiceTest.java b/order/src/test/java/com/baiyue/order/service/TenantOrderCountServiceTest.java new file mode 100644 index 000000000..e0d5849d8 --- /dev/null +++ b/order/src/test/java/com/baiyue/order/service/TenantOrderCountServiceTest.java @@ -0,0 +1,29 @@ +package com.baiyue.order.service; + +import com.baiyue.order.repository.OrderRepository; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class TenantOrderCountServiceTest { + @Test + void returnsZeroForTenantsWithoutOrdersAndDeduplicatesRequestedIds() { + OrderRepository repository = mock(OrderRepository.class); + when(repository.countByTenantIds(List.of(8L, 9L, 8L))).thenReturn(Map.of(8L, 12L)); + OrderApplicationService service = new OrderApplicationService(); + ReflectionTestUtils.setField(service, "repository", repository); + + assertThat(service.countByTenantIds(List.of(8L, 9L, 8L))) + .extracting("tenantId", "orderCount") + .containsExactly( + org.assertj.core.groups.Tuple.tuple(8L, 12L), + org.assertj.core.groups.Tuple.tuple(9L, 0L) + ); + } +} diff --git a/warehouse/pom.xml b/warehouse/pom.xml index 58592ae71..29a1ff1d9 100644 --- a/warehouse/pom.xml +++ b/warehouse/pom.xml @@ -12,6 +12,11 @@ warehouse-service + + org.apache.poi + poi-ooxml + 5.3.0 + com.baiyue.shared baiyue-common-openapi diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformExpressCompanyController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformExpressCompanyController.java index 86fa128e4..1d928079d 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformExpressCompanyController.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformExpressCompanyController.java @@ -6,10 +6,12 @@ import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.warehouse.dto.MasterDataQueries.ExpressCompanyPageQuery; +import com.baiyue.warehouse.dto.MasterDataQueries.ExpressCompanyImportResult; import com.baiyue.warehouse.dto.MasterDataQueries.ExpressCompanySaveRequest; import com.baiyue.warehouse.dto.MasterDataQueries.StatusRequest; import com.baiyue.warehouse.entity.ExpressCompanyEntity; import com.baiyue.warehouse.service.ExpressCompanyQueryService; +import com.baiyue.warehouse.service.ExpressCompanyImportService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; @@ -19,6 +21,10 @@ import org.springdoc.core.annotations.ParameterObject; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -28,6 +34,11 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; /** 平台快递公司管理接口。 */ @Validated @@ -36,9 +47,11 @@ import org.springframework.web.bind.annotation.RestController; @RequestMapping("/api/platform/v1/express-companies") public class PlatformExpressCompanyController { private final ExpressCompanyQueryService service; + private final ExpressCompanyImportService importService; - public PlatformExpressCompanyController(ExpressCompanyQueryService service) { + public PlatformExpressCompanyController(ExpressCompanyQueryService service, ExpressCompanyImportService importService) { this.service = service; + this.importService = importService; } @GetMapping @@ -57,6 +70,33 @@ public class PlatformExpressCompanyController { return ApiResponse.success(service.page(tenantId, query)); } + @GetMapping("/import-template") + @PreAuthorize("hasAuthority('platform:express:import')") + @Operation(summary = "下载快递公司导入模板", description = "下载当前版本的 XLSX 标准模板") + public ResponseEntity importTemplate(@AuthenticationPrincipal AuthenticatedUser user) { + requirePlatform(user); + HttpHeaders headers = new HttpHeaders(); + headers.setContentDisposition(ContentDisposition.attachment() + .filename("express-company-import-template-v1.xlsx", StandardCharsets.UTF_8).build()); + headers.setContentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")); + return ResponseEntity.ok().headers(headers).body(importService.template()); + } + + @PostMapping(value = "/import", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @PreAuthorize("hasAuthority('platform:express:import')") + @Operation(summary = "批量导入快递公司", description = "使用标准 XLSX 模板整批校验并新增快递公司") + public ApiResponse importCompanies( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam @Min(1) long tenantId, + @RequestPart("file") MultipartFile file) { + requirePlatform(user); + try { + return ApiResponse.success(importService.importFile(tenantId, file.getOriginalFilename(), file.getBytes())); + } catch (IOException exception) { + throw new BizException(ResultCode.BAD_REQUEST, "导入文件读取失败"); + } + } + @GetMapping("/{id}") @PreAuthorize("hasAuthority('platform:express:list')") @Operation(summary = "查询快递公司详情", description = "查询目标租户内的快递公司详情") diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformPartnerController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformPartnerController.java index 91fcf0a37..eb43a8d46 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformPartnerController.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformPartnerController.java @@ -7,9 +7,11 @@ import com.baiyue.common.core.exception.BizException; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.warehouse.dto.MasterDataQueries.PartnerPageQuery; import com.baiyue.warehouse.dto.MasterDataQueries.PartnerSaveRequest; +import com.baiyue.warehouse.dto.MasterDataQueries.PartnerImportResult; import com.baiyue.warehouse.dto.MasterDataQueries.StatusRequest; import com.baiyue.warehouse.entity.PartnerEntity; import com.baiyue.warehouse.service.PlatformPartnerQueryService; +import com.baiyue.warehouse.service.PartnerImportService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; @@ -28,6 +30,15 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; /** 平台往来单位管理接口。 */ @Validated @@ -36,9 +47,38 @@ import org.springframework.web.bind.annotation.RestController; @RequestMapping("/api/platform/v1/partners") public class PlatformPartnerController { private final PlatformPartnerQueryService service; + private final PartnerImportService importService; - public PlatformPartnerController(PlatformPartnerQueryService service) { + public PlatformPartnerController(PlatformPartnerQueryService service, PartnerImportService importService) { this.service = service; + this.importService = importService; + } + + @GetMapping("/import-template") + @PreAuthorize("hasAuthority('platform:counterparty:import')") + @Operation(summary = "下载往来单位导入模板", description = "下载当前版本的 XLSX 标准模板") + public ResponseEntity importTemplate(@AuthenticationPrincipal AuthenticatedUser user) { + requirePlatform(user); + HttpHeaders headers = new HttpHeaders(); + headers.setContentDisposition(ContentDisposition.attachment() + .filename("partner-import-template-v1.xlsx", StandardCharsets.UTF_8).build()); + headers.setContentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")); + return ResponseEntity.ok().headers(headers).body(importService.template()); + } + + @PostMapping(value = "/import", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @PreAuthorize("hasAuthority('platform:counterparty:import')") + @Operation(summary = "批量导入往来单位", description = "使用标准 XLSX 模板整批校验并新增往来单位") + public ApiResponse importPartners( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam @Min(1) long tenantId, + @RequestPart("file") MultipartFile file) { + requirePlatform(user); + try { + return ApiResponse.success(importService.importFile(tenantId, file.getOriginalFilename(), file.getBytes())); + } catch (IOException exception) { + throw new BizException(ResultCode.BAD_REQUEST, "导入文件读取失败"); + } } @GetMapping diff --git a/warehouse/src/main/java/com/baiyue/warehouse/dto/MasterDataQueries.java b/warehouse/src/main/java/com/baiyue/warehouse/dto/MasterDataQueries.java index 801ac7afb..ae8143dde 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/dto/MasterDataQueries.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/dto/MasterDataQueries.java @@ -8,6 +8,8 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; +import java.util.List; + /** 平台基础资料请求契约。 */ public final class MasterDataQueries { private MasterDataQueries() { @@ -63,8 +65,8 @@ public final class MasterDataQueries { @NotBlank @Size(max = 50) String partnerCode, @Schema(description = "往来单位名称", example = "深圳示例贸易有限公司") @NotBlank @Size(max = 128) String partnerName, - @Schema(description = "往来单位类型", allowableValues = {"CUSTOMER", "SUPPLIER", "BOTH"}) - @NotBlank @Pattern(regexp = "^(CUSTOMER|SUPPLIER|BOTH)$") String partnerType, + @Schema(description = "兼容旧客户端的单值类型", allowableValues = {"CUSTOMER", "SUPPLIER", "BOTH", "CARRIER", "CUSTOMS_BROKER", "SHIPPER", "CONSIGNEE"}) + @Pattern(regexp = "^(CUSTOMER|SUPPLIER|BOTH|CARRIER|CUSTOMS_BROKER|SHIPPER|CONSIGNEE)$") String partnerType, @Schema(description = "联系人", example = "张三") @NotBlank @Size(max = 64) String contactName, @Schema(description = "联系电话", example = "13800138000") @@ -84,7 +86,24 @@ public final class MasterDataQueries { @Schema(description = "邮政编码", example = "518000") @Size(max = 20) String postalCode, @Schema(description = "状态", allowableValues = {"ACTIVE", "INACTIVE"}) - @NotBlank @Pattern(regexp = "^(ACTIVE|INACTIVE)$") String status + @NotBlank @Pattern(regexp = "^(ACTIVE|INACTIVE)$") String status, + @Schema(description = "单位类型集合", allowableValues = {"CUSTOMER", "SUPPLIER", "CARRIER", "CUSTOMS_BROKER", "SHIPPER", "CONSIGNEE"}) + @Size(max = 6) List<@Pattern(regexp = "^(CUSTOMER|SUPPLIER|CARRIER|CUSTOMS_BROKER|SHIPPER|CONSIGNEE)$") String> types, + @Schema(description = "备注") @Size(max = 500) String remark + ) { + } + + @Schema(name = "PartnerImportError", description = "往来单位导入错误") + public record PartnerImportError(int row, String field, String message) { + } + + @Schema(name = "PartnerImportResult", description = "往来单位批量导入结果") + public record PartnerImportResult( + String fileName, + int totalRows, + int importedRows, + boolean success, + List errors ) { } @@ -107,6 +126,24 @@ public final class MasterDataQueries { ) { } + @Schema(name = "ExpressCompanyImportError", description = "快递公司导入错误") + public record ExpressCompanyImportError( + @Schema(description = "数据行号,表头后的第一行数据为 4") int row, + @Schema(description = "字段名称") String field, + @Schema(description = "错误原因") String message + ) { + } + + @Schema(name = "ExpressCompanyImportResult", description = "快递公司批量导入结果") + public record ExpressCompanyImportResult( + @Schema(description = "原始文件名") String fileName, + @Schema(description = "数据行总数") int totalRows, + @Schema(description = "成功写入行数") int importedRows, + @Schema(description = "是否成功") boolean success, + @Schema(description = "逐行错误") List errors + ) { + } + @Schema(name = "MasterDataStatusRequest", description = "基础资料状态变更请求") public record StatusRequest( @Schema(description = "目标状态", allowableValues = {"ACTIVE", "INACTIVE"}) diff --git a/warehouse/src/main/java/com/baiyue/warehouse/entity/PartnerEntity.java b/warehouse/src/main/java/com/baiyue/warehouse/entity/PartnerEntity.java index 356eb1c62..d8ba05569 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/entity/PartnerEntity.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/entity/PartnerEntity.java @@ -1,6 +1,7 @@ package com.baiyue.warehouse.entity; import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import com.baiyue.common.core.model.BaseEntity; @@ -8,6 +9,8 @@ import io.swagger.v3.oas.annotations.media.Schema; import lombok.Getter; import lombok.Setter; +import java.util.List; + /** 仓储域内的租户往来单位配置。 */ @Schema(name = "Partner", description = "租户维度往来单位主数据") @Getter @Setter @@ -17,12 +20,17 @@ public class PartnerEntity extends BaseEntity { @TableId(type = IdType.ASSIGN_ID) private Long id; @Schema(description = "所属租户 ID", example = "10001") private Long tenantId; + @Schema(description = "所属业务数据范围 ID,由服务端维护", example = "10001") + private Long dataScopeId; @Schema(description = "租户内唯一往来单位编码", example = "CUS-001") private String partnerCode; @Schema(description = "往来单位名称", example = "深圳示例贸易有限公司") private String partnerName; @Schema(description = "类型", example = "CUSTOMER", allowableValues = {"CUSTOMER", "SUPPLIER", "BOTH"}) private String partnerType; + @TableField(exist = false) + @Schema(description = "单位类型集合", allowableValues = {"CUSTOMER", "SUPPLIER", "CARRIER", "CUSTOMS_BROKER", "SHIPPER", "CONSIGNEE"}) + private List types; @Schema(description = "联系人") private String contactName; @Schema(description = "联系电话") @@ -43,4 +51,6 @@ public class PartnerEntity extends BaseEntity { private String postalCode; @Schema(description = "状态", example = "ACTIVE", allowableValues = {"ACTIVE", "INACTIVE"}) private String status; + @Schema(description = "备注") + private String remark; } diff --git a/warehouse/src/main/java/com/baiyue/warehouse/entity/PartnerTypeEntity.java b/warehouse/src/main/java/com/baiyue/warehouse/entity/PartnerTypeEntity.java new file mode 100644 index 000000000..75ee15a48 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/entity/PartnerTypeEntity.java @@ -0,0 +1,12 @@ +package com.baiyue.warehouse.entity; + +import lombok.Getter; +import lombok.Setter; + +/** 往来单位与稳定业务类型的关联。 */ +@Getter +@Setter +public class PartnerTypeEntity { + private Long partnerId; + private String partnerType; +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/mapper/PartnerMapper.java b/warehouse/src/main/java/com/baiyue/warehouse/mapper/PartnerMapper.java index 01b8d9e20..3d7795a22 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/mapper/PartnerMapper.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/mapper/PartnerMapper.java @@ -1,4 +1,42 @@ package com.baiyue.warehouse.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.warehouse.entity.PartnerEntity; -public interface PartnerMapper extends BaseMapper { } +import com.baiyue.warehouse.entity.PartnerTypeEntity; +import org.apache.ibatis.annotations.Delete; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.Collection; +import java.util.List; + +public interface PartnerMapper extends BaseMapper { + @Select(""" + + """) + List selectTypes(@Param("partnerIds") Collection partnerIds); + + @Select("SELECT partner_id FROM wh_partner_type WHERE tenant_id = #{tenantId} AND partner_type = #{type}") + List selectPartnerIdsByType(@Param("tenantId") long tenantId, @Param("type") String type); + + @Delete("DELETE FROM wh_partner_type WHERE partner_id = #{partnerId}") + void deleteTypes(@Param("partnerId") long partnerId); + + @Insert(""" + + """) + void insertTypes(@Param("partnerId") long partnerId, @Param("tenantId") long tenantId, + @Param("dataScopeId") long dataScopeId, @Param("types") Collection types); +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/repository/ExpressCompanyRepository.java b/warehouse/src/main/java/com/baiyue/warehouse/repository/ExpressCompanyRepository.java index 5dbd2f7b8..3c8ecc38c 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/repository/ExpressCompanyRepository.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/repository/ExpressCompanyRepository.java @@ -44,6 +44,13 @@ public class ExpressCompanyRepository { .ne(excludedId != null, ExpressCompanyEntity::getId, excludedId)) > 0; } + public boolean existsName(long tenantId, String name, Long excludedId) { + return mapper.selectCount(new LambdaQueryWrapper() + .eq(ExpressCompanyEntity::getTenantId, tenantId) + .eq(ExpressCompanyEntity::getCompanyName, name) + .ne(excludedId != null, ExpressCompanyEntity::getId, excludedId)) > 0; + } + public void insert(ExpressCompanyEntity entity) { mapper.insert(entity); } diff --git a/warehouse/src/main/java/com/baiyue/warehouse/repository/PartnerRepository.java b/warehouse/src/main/java/com/baiyue/warehouse/repository/PartnerRepository.java index 22a9dcf84..ee2fb292e 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/repository/PartnerRepository.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/repository/PartnerRepository.java @@ -9,29 +9,46 @@ import com.baiyue.warehouse.mapper.PartnerMapper; import org.springframework.stereotype.Repository; import org.springframework.util.StringUtils; import java.util.List; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.Map; @Repository public class PartnerRepository { private final PartnerMapper mapper; public PartnerRepository(PartnerMapper mapper) { this.mapper = mapper; } public List activeCandidates(long tenantId, String keyword, String type) { - return mapper.selectList(new LambdaQueryWrapper() + List typedIds = StringUtils.hasText(type) + ? typedIds(tenantId, type) + : java.util.stream.Stream.concat( + typedIds(tenantId, "SHIPPER").stream(), typedIds(tenantId, "CONSIGNEE").stream()) + .distinct().toList(); + if (typedIds.isEmpty()) return List.of(); + List records = mapper.selectList(new LambdaQueryWrapper() .eq(PartnerEntity::getTenantId, tenantId).eq(PartnerEntity::getStatus, "ACTIVE") - .eq(StringUtils.hasText(type), PartnerEntity::getPartnerType, type) + .in(PartnerEntity::getId, typedIds) .and(StringUtils.hasText(keyword), q -> q.like(PartnerEntity::getPartnerCode, keyword) .or().like(PartnerEntity::getPartnerName, keyword).or().like(PartnerEntity::getContactName, keyword)) .orderByAsc(PartnerEntity::getPartnerName)); + hydrateTypes(records); + return records; } public PartnerEntity findActive(long tenantId, long id) { - return mapper.selectOne(new LambdaQueryWrapper() + PartnerEntity entity = mapper.selectOne(new LambdaQueryWrapper() .eq(PartnerEntity::getTenantId, tenantId).eq(PartnerEntity::getId, id) .eq(PartnerEntity::getStatus, "ACTIVE")); + hydrateTypes(entity == null ? List.of() : List.of(entity)); + return entity; } public PageResult page(long tenantId, PartnerPageQuery request) { + List typedIds = typedIds(tenantId, request.type()); + if (StringUtils.hasText(request.type()) && typedIds.isEmpty()) { + return new PageResult<>(List.of(), request.page(), request.size(), 0, 0); + } LambdaQueryWrapper wrapper = new LambdaQueryWrapper() .eq(PartnerEntity::getTenantId, tenantId) - .eq(StringUtils.hasText(request.type()), PartnerEntity::getPartnerType, request.type()) + .in(StringUtils.hasText(request.type()), PartnerEntity::getId, typedIds) .eq(StringUtils.hasText(request.status()), PartnerEntity::getStatus, request.status()) .and(StringUtils.hasText(request.keyword()), query -> query .like(PartnerEntity::getPartnerCode, request.keyword()) @@ -40,13 +57,17 @@ public class PartnerRepository { .or().like(PartnerEntity::getContactPhone, request.keyword())) .orderByAsc(PartnerEntity::getPartnerName) .orderByAsc(PartnerEntity::getId); - return PageResult.from(mapper.selectPage(new Page<>(request.page(), request.size()), wrapper)); + PageResult result = PageResult.from(mapper.selectPage(new Page<>(request.page(), request.size()), wrapper)); + hydrateTypes(result.records()); + return result; } public PartnerEntity findOwned(long tenantId, long id) { - return mapper.selectOne(new LambdaQueryWrapper() + PartnerEntity entity = mapper.selectOne(new LambdaQueryWrapper() .eq(PartnerEntity::getTenantId, tenantId) .eq(PartnerEntity::getId, id)); + hydrateTypes(entity == null ? List.of() : List.of(entity)); + return entity; } public boolean existsCode(long tenantId, String code, Long excludedId) { @@ -58,9 +79,40 @@ public class PartnerRepository { public void insert(PartnerEntity entity) { mapper.insert(entity); + replaceTypes(entity); } public void save(PartnerEntity entity) { mapper.updateById(entity); + replaceTypes(entity); + } + + private List typedIds(long tenantId, String type) { + return StringUtils.hasText(type) ? mapper.selectPartnerIdsByType(tenantId, type) : List.of(); + } + + private void replaceTypes(PartnerEntity entity) { + mapper.deleteTypes(entity.getId()); + Collection types = entity.getTypes(); + if (types != null && !types.isEmpty()) { + mapper.insertTypes(entity.getId(), entity.getTenantId(), entity.getDataScopeId(), types); + } + } + + private void hydrateTypes(List records) { + if (records.isEmpty()) return; + Map> grouped = new LinkedHashMap<>(); + mapper.selectTypes(records.stream().map(PartnerEntity::getId).toList()) + .forEach(item -> grouped.computeIfAbsent(item.getPartnerId(), ignored -> new java.util.ArrayList<>()) + .add(item.getPartnerType())); + records.forEach(record -> { + List types = grouped.get(record.getId()); + record.setTypes(types == null || types.isEmpty() ? legacyTypes(record.getPartnerType()) : List.copyOf(types)); + }); + } + + private static List legacyTypes(String type) { + if ("BOTH".equals(type)) return List.of("CUSTOMER", "SUPPLIER"); + return StringUtils.hasText(type) ? List.of(type) : List.of(); } } diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/ExpressCompanyImportService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/ExpressCompanyImportService.java new file mode 100644 index 000000000..0f8dc6c8b --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/ExpressCompanyImportService.java @@ -0,0 +1,193 @@ +package com.baiyue.warehouse.service; + +import com.baiyue.common.audit.BusinessAudit; +import com.baiyue.warehouse.dto.MasterDataQueries.ExpressCompanyImportError; +import com.baiyue.warehouse.dto.MasterDataQueries.ExpressCompanyImportResult; +import com.baiyue.warehouse.entity.ExpressCompanyEntity; +import com.baiyue.warehouse.repository.ExpressCompanyRepository; +import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.DataFormatter; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.xssf.usermodel.XSSFSheet; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import org.springframework.transaction.interceptor.TransactionAspectSupport; + +/** 标准模板和快递公司批量导入。 */ +@Service +public class ExpressCompanyImportService { + private static final int MAX_FILE_BYTES = 5 * 1024 * 1024; + private static final int MAX_ROWS = 1000; + private static final String TEMPLATE_VERSION = "1"; + private static final String[] HEADERS = { + "快递公司编码*", "快递公司名称*", "客服电话", "官方网站", "轨迹查询地址模板", "状态*", "备注" + }; + + private final ExpressCompanyRepository repository; + + public ExpressCompanyImportService(ExpressCompanyRepository repository) { + this.repository = repository; + } + + public byte[] template() { + try (XSSFWorkbook workbook = new XSSFWorkbook(); ByteArrayOutputStream output = new ByteArrayOutputStream()) { + XSSFSheet sheet = workbook.createSheet("快递公司"); + Row version = sheet.createRow(0); + version.createCell(0).setCellValue("模板版本"); + version.createCell(1).setCellValue(TEMPLATE_VERSION); + Row instruction = sheet.createRow(1); + instruction.createCell(0).setCellValue("说明"); + instruction.createCell(1).setCellValue("仅新增;编码、名称、状态必填;状态填写 ACTIVE 或 INACTIVE"); + Row header = sheet.createRow(2); + for (int i = 0; i < HEADERS.length; i++) header.createCell(i).setCellValue(HEADERS[i]); + for (int i = 0; i < HEADERS.length; i++) sheet.autoSizeColumn(i); + workbook.write(output); + return output.toByteArray(); + } catch (IOException exception) { + throw new IllegalStateException("生成快递公司导入模板失败", exception); + } + } + + @Transactional + @BusinessAudit(event = "EXPRESS_COMPANY_IMPORT", resourceType = "EXPRESS_COMPANY_IMPORT", description = "批量导入快递公司", captureBefore = false) + public ExpressCompanyImportResult importFile(long tenantId, String fileName, byte[] content) { + String safeFileName = fileName == null || fileName.isBlank() ? "未命名文件.xlsx" : fileName; + if (content == null || content.length == 0) { + return failure(safeFileName, 0, new ExpressCompanyImportError(0, "文件", "文件不能为空")); + } + if (content.length > MAX_FILE_BYTES) { + return failure(safeFileName, 0, new ExpressCompanyImportError(0, "文件", "文件不能超过 5MB")); + } + if (!safeFileName.toLowerCase(Locale.ROOT).endsWith(".xlsx")) { + return failure(safeFileName, 0, new ExpressCompanyImportError(0, "文件", "只支持 .xlsx 文件")); + } + + List rows = new ArrayList<>(); + List errors = new ArrayList<>(); + try (XSSFWorkbook workbook = new XSSFWorkbook(new ByteArrayInputStream(content))) { + if (workbook.getNumberOfSheets() == 0) { + return failure(safeFileName, 0, new ExpressCompanyImportError(0, "模板", "缺少快递公司工作表")); + } + XSSFSheet sheet = workbook.getSheetAt(0); + DataFormatter formatter = new DataFormatter(); + if (!"模板版本".equals(value(sheet.getRow(0), 0, formatter)) + || !TEMPLATE_VERSION.equals(value(sheet.getRow(0), 1, formatter))) { + errors.add(new ExpressCompanyImportError(1, "模板版本", "模板版本不匹配,请下载最新模板")); + } + for (int i = 0; i < HEADERS.length; i++) { + if (!HEADERS[i].equals(value(sheet.getRow(2), i, formatter))) { + errors.add(new ExpressCompanyImportError(3, "表头", "第 " + (i + 1) + " 列应为“" + HEADERS[i] + "”")); + } + } + if (!errors.isEmpty()) return new ExpressCompanyImportResult(safeFileName, 0, 0, false, errors); + + for (int rowIndex = 3; rowIndex <= sheet.getLastRowNum(); rowIndex++) { + Row row = sheet.getRow(rowIndex); + if (isBlank(row, formatter)) continue; + if (rows.size() >= MAX_ROWS) { + errors.add(new ExpressCompanyImportError(rowIndex + 1, "文件", "单次最多导入 1000 行")); + break; + } + ImportRow importRow = new ImportRow(rowIndex + 1, + value(row, 0, formatter), value(row, 1, formatter), value(row, 2, formatter), + value(row, 3, formatter), value(row, 4, formatter), value(row, 5, formatter), value(row, 6, formatter)); + rows.add(importRow); + } + } catch (IOException | RuntimeException exception) { + return failure(safeFileName, rows.size(), new ExpressCompanyImportError(0, "文件", "文件格式无法读取,请使用标准 XLSX 模板")); + } + if (rows.isEmpty() && errors.isEmpty()) { + errors.add(new ExpressCompanyImportError(0, "文件", "文件中没有可导入的数据行")); + } + for (ImportRow row : rows) validate(tenantId, row, rows, errors); + if (!errors.isEmpty()) return new ExpressCompanyImportResult(safeFileName, rows.size(), 0, false, errors); + + try { + for (ImportRow row : rows) repository.insert(toEntity(tenantId, row)); + } catch (DataIntegrityViolationException exception) { + TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(); + return failure(safeFileName, rows.size(), new ExpressCompanyImportError(0, "编码或名称", "与现有快递公司重复,整批数据未写入")); + } + return new ExpressCompanyImportResult(safeFileName, rows.size(), rows.size(), true, List.of()); + } + + private void validate(long tenantId, ImportRow row, List rows, List errors) { + if (row.code.isBlank()) errors.add(error(row, "快递公司编码", "不能为空")); + if (row.code.length() > 50) errors.add(error(row, "快递公司编码", "不能超过 50 个字符")); + if (row.name.isBlank()) errors.add(error(row, "快递公司名称", "不能为空")); + if (row.name.length() > 128) errors.add(error(row, "快递公司名称", "不能超过 128 个字符")); + if (row.phone.length() > 32) errors.add(error(row, "客服电话", "不能超过 32 个字符")); + if (row.website.length() > 255 || !validUrl(row.website)) errors.add(error(row, "官方网站", "必须是 http:// 或 https:// 开头的网址")); + if (row.trackingTemplate.length() > 500) errors.add(error(row, "轨迹查询地址模板", "不能超过 500 个字符")); + if (!"ACTIVE".equals(row.status) && !"INACTIVE".equals(row.status)) errors.add(error(row, "状态", "只能填写 ACTIVE 或 INACTIVE")); + if (row.remark.length() > 500) errors.add(error(row, "备注", "不能超过 500 个字符")); + for (ImportRow previous : rows) { + if (previous == row) break; + if (!row.code.isBlank() && previous.code.equalsIgnoreCase(row.code)) { + errors.add(error(row, "快递公司编码", "与文件内其他行重复")); + } + if (!row.name.isBlank() && previous.name.equalsIgnoreCase(row.name)) { + errors.add(error(row, "快递公司名称", "与文件内其他行重复")); + } + } + if (!row.code.isBlank() && repository.existsCode(tenantId, row.code, null)) errors.add(error(row, "快递公司编码", "与现有档案重复")); + if (!row.name.isBlank() && repository.existsName(tenantId, row.name, null)) errors.add(error(row, "快递公司名称", "与现有档案重复")); + } + + private static ExpressCompanyEntity toEntity(long tenantId, ImportRow row) { + ExpressCompanyEntity entity = new ExpressCompanyEntity(); + entity.setTenantId(tenantId); + entity.setCompanyCode(row.code); + entity.setCompanyName(row.name); + entity.setServicePhone(nullable(row.phone)); + entity.setWebsite(nullable(row.website)); + entity.setTrackingUrlTemplate(nullable(row.trackingTemplate)); + entity.setStatus(row.status); + entity.setRemark(nullable(row.remark)); + return entity; + } + + private static String value(Row row, int index, DataFormatter formatter) { + if (row == null) return ""; + Cell cell = row.getCell(index); + return cell == null ? "" : formatter.formatCellValue(cell).trim(); + } + + private static boolean isBlank(Row row, DataFormatter formatter) { + if (row == null) return true; + for (int i = 0; i < HEADERS.length; i++) if (!value(row, i, formatter).isBlank()) return false; + return true; + } + + private static boolean validUrl(String value) { + if (value.isBlank()) return true; + try { + URI uri = new URI(value); + return ("http".equalsIgnoreCase(uri.getScheme()) || "https".equalsIgnoreCase(uri.getScheme())) && uri.getHost() != null; + } catch (URISyntaxException exception) { + return false; + } + } + + private static String nullable(String value) { return value.isBlank() ? null : value; } + private static ExpressCompanyImportError error(ImportRow row, String field, String message) { + return new ExpressCompanyImportError(row.row, field, message); + } + private static ExpressCompanyImportResult failure(String fileName, int rows, ExpressCompanyImportError error) { + return new ExpressCompanyImportResult(fileName, rows, 0, false, List.of(error)); + } + + private record ImportRow(int row, String code, String name, String phone, String website, + String trackingTemplate, String status, String remark) { } +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/ExpressCompanyQueryService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/ExpressCompanyQueryService.java index 7e8422051..2c435905a 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/service/ExpressCompanyQueryService.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/ExpressCompanyQueryService.java @@ -33,13 +33,14 @@ public class ExpressCompanyQueryService { @BusinessAudit(event = "EXPRESS_COMPANY_CREATE", resourceType = "EXPRESS_COMPANY", description = "新增快递公司", captureBefore = false) public Long create(long tenantId, ExpressCompanySaveRequest request) { ensureCodeUnique(tenantId, request.companyCode(), null); + ensureNameUnique(tenantId, request.companyName(), null); ExpressCompanyEntity entity = new ExpressCompanyEntity(); entity.setTenantId(tenantId); copy(entity, request); try { repository.insert(entity); } catch (DataIntegrityViolationException exception) { - throw new BizException(ResultCode.CONFLICT, "快递公司编码已存在"); + throw new BizException(ResultCode.CONFLICT, "快递公司编码或名称已存在"); } return entity.getId(); } @@ -49,11 +50,12 @@ public class ExpressCompanyQueryService { public void update(long tenantId, long id, ExpressCompanySaveRequest request) { ExpressCompanyEntity entity = owned(tenantId, id); ensureCodeUnique(tenantId, request.companyCode(), id); + ensureNameUnique(tenantId, request.companyName(), id); copy(entity, request); try { repository.save(entity); } catch (DataIntegrityViolationException exception) { - throw new BizException(ResultCode.CONFLICT, "快递公司编码已存在"); + throw new BizException(ResultCode.CONFLICT, "快递公司编码或名称已存在"); } } @@ -80,6 +82,12 @@ public class ExpressCompanyQueryService { } } + private void ensureNameUnique(long tenantId, String name, Long excludedId) { + if (repository.existsName(tenantId, name, excludedId)) { + throw new BizException(ResultCode.CONFLICT, "快递公司名称已存在"); + } + } + private static void requireStatus(String status) { if (!"ACTIVE".equals(status) && !"INACTIVE".equals(status)) { throw new BizException(ResultCode.BAD_REQUEST, "状态只能设置为 ACTIVE 或 INACTIVE"); diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/PartnerImportService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/PartnerImportService.java new file mode 100644 index 000000000..6c5e994f3 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/PartnerImportService.java @@ -0,0 +1,218 @@ +package com.baiyue.warehouse.service; + +import com.baiyue.common.audit.BusinessAudit; +import com.baiyue.warehouse.dto.MasterDataQueries.PartnerImportError; +import com.baiyue.warehouse.dto.MasterDataQueries.PartnerImportResult; +import com.baiyue.warehouse.entity.PartnerEntity; +import com.baiyue.warehouse.repository.PartnerRepository; +import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.DataFormatter; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.xssf.usermodel.XSSFSheet; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.interceptor.TransactionAspectSupport; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.regex.Pattern; + +/** 往来单位标准模板生成与原子批量导入。 */ +@Service +public class PartnerImportService { + private static final int MAX_FILE_BYTES = 5 * 1024 * 1024; + private static final int MAX_ROWS = 1000; + private static final String TEMPLATE_VERSION = "1"; + private static final Set ALLOWED_TYPES = Set.of( + "CUSTOMER", "SUPPLIER", "CARRIER", "CUSTOMS_BROKER", "SHIPPER", "CONSIGNEE"); + private static final Pattern EMAIL = Pattern.compile("^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$"); + private static final String[] HEADERS = { + "单位编码*", "单位名称*", "单位类型*", "联系人*", "联系电话*", "联系邮箱", + "国家二字码*", "省/州", "城市*", "区/县", "详细地址*", "邮政编码", "状态*", "备注" + }; + + private final PartnerRepository repository; + + public PartnerImportService(PartnerRepository repository) { + this.repository = repository; + } + + public byte[] template() { + try (XSSFWorkbook workbook = new XSSFWorkbook(); ByteArrayOutputStream output = new ByteArrayOutputStream()) { + XSSFSheet sheet = workbook.createSheet("往来单位"); + Row version = sheet.createRow(0); + version.createCell(0).setCellValue("模板版本"); + version.createCell(1).setCellValue(TEMPLATE_VERSION); + Row instruction = sheet.createRow(1); + instruction.createCell(0).setCellValue("说明"); + instruction.createCell(1).setCellValue( + "仅新增;类型可用中文逗号或英文逗号分隔:CUSTOMER、SUPPLIER、CARRIER、CUSTOMS_BROKER、SHIPPER、CONSIGNEE;状态为 ACTIVE 或 INACTIVE"); + Row header = sheet.createRow(2); + for (int i = 0; i < HEADERS.length; i++) { + header.createCell(i).setCellValue(HEADERS[i]); + sheet.setColumnWidth(i, Math.min(48, Math.max(14, HEADERS[i].length() * 2 + 4)) * 256); + } + workbook.write(output); + return output.toByteArray(); + } catch (IOException exception) { + throw new IllegalStateException("生成往来单位导入模板失败", exception); + } + } + + @Transactional + @BusinessAudit(event = "PARTNER_IMPORT", resourceType = "PARTNER_IMPORT", description = "批量导入往来单位", captureBefore = false) + public PartnerImportResult importFile(long tenantId, String fileName, byte[] content) { + String safeFileName = safeFileName(fileName); + if (content == null || content.length == 0) return failure(safeFileName, 0, 0, "文件", "文件不能为空"); + if (content.length > MAX_FILE_BYTES) return failure(safeFileName, 0, 0, "文件", "文件不能超过 5MB"); + if (!safeFileName.toLowerCase(Locale.ROOT).endsWith(".xlsx")) { + return failure(safeFileName, 0, 0, "文件", "只支持 .xlsx 文件"); + } + + List rows = new ArrayList<>(); + List errors = new ArrayList<>(); + try (XSSFWorkbook workbook = new XSSFWorkbook(new ByteArrayInputStream(content))) { + if (workbook.getNumberOfSheets() == 0) return failure(safeFileName, 0, 0, "模板", "缺少往来单位工作表"); + XSSFSheet sheet = workbook.getSheetAt(0); + DataFormatter formatter = new DataFormatter(); + if (!"模板版本".equals(value(sheet.getRow(0), 0, formatter)) + || !TEMPLATE_VERSION.equals(value(sheet.getRow(0), 1, formatter))) { + errors.add(new PartnerImportError(1, "模板版本", "模板版本不匹配,请下载最新模板")); + } + for (int i = 0; i < HEADERS.length; i++) { + if (!HEADERS[i].equals(value(sheet.getRow(2), i, formatter))) { + errors.add(new PartnerImportError(3, "表头", "第 " + (i + 1) + " 列应为“" + HEADERS[i] + "”")); + } + } + if (!errors.isEmpty()) return result(safeFileName, 0, 0, errors); + + for (int index = 3; index <= sheet.getLastRowNum(); index++) { + Row row = sheet.getRow(index); + if (isBlank(row, formatter)) continue; + if (rows.size() >= MAX_ROWS) { + errors.add(new PartnerImportError(index + 1, "文件", "单次最多导入 1000 行")); + break; + } + ImportRow item = new ImportRow(index + 1, + value(row, 0, formatter), value(row, 1, formatter), parseTypes(value(row, 2, formatter)), + value(row, 3, formatter), value(row, 4, formatter), value(row, 5, formatter), + value(row, 6, formatter).toUpperCase(Locale.ROOT), value(row, 7, formatter), + value(row, 8, formatter), value(row, 9, formatter), value(row, 10, formatter), + value(row, 11, formatter), value(row, 12, formatter).toUpperCase(Locale.ROOT), value(row, 13, formatter)); + validate(tenantId, item, rows, errors); + rows.add(item); + } + } catch (IOException | RuntimeException exception) { + return failure(safeFileName, rows.size(), 0, "文件", "文件格式无法读取,请使用标准 XLSX 模板"); + } + if (rows.isEmpty() && errors.isEmpty()) errors.add(new PartnerImportError(0, "文件", "文件中没有可导入的数据行")); + if (!errors.isEmpty()) return result(safeFileName, rows.size(), 0, errors); + + try { + for (ImportRow row : rows) repository.insert(toEntity(tenantId, row)); + } catch (DataIntegrityViolationException exception) { + TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(); + return failure(safeFileName, rows.size(), 0, "单位编码", "与现有档案重复,整批数据未写入"); + } + return new PartnerImportResult(safeFileName, rows.size(), rows.size(), true, List.of()); + } + + private void validate(long tenantId, ImportRow row, List previousRows, List errors) { + required(row, errors, "单位编码", row.code, 50); + required(row, errors, "单位名称", row.name, 128); + if (row.types.isEmpty()) errors.add(error(row, "单位类型", "至少填写一个类型")); + row.types.stream().filter(type -> !ALLOWED_TYPES.contains(type)) + .forEach(type -> errors.add(error(row, "单位类型", "不支持类型 " + type))); + required(row, errors, "联系人", row.contactName, 64); + required(row, errors, "联系电话", row.contactPhone, 32); + if (row.email.length() > 128 || (!row.email.isBlank() && !EMAIL.matcher(row.email).matches())) { + errors.add(error(row, "联系邮箱", "请输入有效的邮箱地址,且不超过 128 个字符")); + } + if (!row.countryCode.matches("^[A-Z]{2}$")) errors.add(error(row, "国家二字码", "必须是两个英文字母")); + optionalLength(row, errors, "省/州", row.province, 64); + required(row, errors, "城市", row.city, 64); + optionalLength(row, errors, "区/县", row.district, 64); + required(row, errors, "详细地址", row.address, 255); + optionalLength(row, errors, "邮政编码", row.postalCode, 20); + if (!"ACTIVE".equals(row.status) && !"INACTIVE".equals(row.status)) errors.add(error(row, "状态", "只能填写 ACTIVE 或 INACTIVE")); + optionalLength(row, errors, "备注", row.remark, 500); + if (previousRows.stream().anyMatch(previous -> previous.code.equalsIgnoreCase(row.code))) { + errors.add(error(row, "单位编码", "与文件内其他行重复")); + } + if (!row.code.isBlank() && repository.existsCode(tenantId, row.code, null)) { + errors.add(error(row, "单位编码", "与现有档案重复")); + } + } + + private static PartnerEntity toEntity(long tenantId, ImportRow row) { + PartnerEntity entity = new PartnerEntity(); + entity.setTenantId(tenantId); + entity.setDataScopeId(tenantId); + entity.setPartnerCode(row.code); + entity.setPartnerName(row.name); + entity.setTypes(row.types); + entity.setPartnerType(row.types.contains("CUSTOMER") && row.types.contains("SUPPLIER") ? "BOTH" : row.types.get(0)); + entity.setContactName(row.contactName); + entity.setContactPhone(row.contactPhone); + entity.setContactEmail(nullable(row.email)); + entity.setCountryCode(row.countryCode); + entity.setProvince(nullable(row.province)); + entity.setCity(row.city); + entity.setDistrict(nullable(row.district)); + entity.setAddressLine(row.address); + entity.setPostalCode(nullable(row.postalCode)); + entity.setStatus(row.status); + entity.setRemark(nullable(row.remark)); + return entity; + } + + private static List parseTypes(String value) { + LinkedHashSet types = new LinkedHashSet<>(); + for (String type : value.toUpperCase(Locale.ROOT).split("[,,、]")) if (!type.isBlank()) types.add(type.trim()); + return List.copyOf(types); + } + private static void required(ImportRow row, List errors, String field, String value, int max) { + if (value.isBlank()) errors.add(error(row, field, "不能为空")); + else if (value.length() > max) errors.add(error(row, field, "不能超过 " + max + " 个字符")); + } + private static void optionalLength(ImportRow row, List errors, String field, String value, int max) { + if (value.length() > max) errors.add(error(row, field, "不能超过 " + max + " 个字符")); + } + private static String value(Row row, int index, DataFormatter formatter) { + if (row == null) return ""; + Cell cell = row.getCell(index); + return cell == null ? "" : formatter.formatCellValue(cell).trim(); + } + private static boolean isBlank(Row row, DataFormatter formatter) { + if (row == null) return true; + for (int i = 0; i < HEADERS.length; i++) if (!value(row, i, formatter).isBlank()) return false; + return true; + } + private static String nullable(String value) { return value.isBlank() ? null : value; } + private static String safeFileName(String value) { + if (value == null || value.isBlank()) return "未命名文件.xlsx"; + String normalized = value.replace('\\', '/'); + return normalized.substring(normalized.lastIndexOf('/') + 1); + } + private static PartnerImportError error(ImportRow row, String field, String message) { + return new PartnerImportError(row.row, field, message); + } + private static PartnerImportResult failure(String fileName, int total, int imported, String field, String message) { + return result(fileName, total, imported, List.of(new PartnerImportError(0, field, message))); + } + private static PartnerImportResult result(String fileName, int total, int imported, List errors) { + return new PartnerImportResult(fileName, total, imported, errors.isEmpty(), errors); + } + + private record ImportRow(int row, String code, String name, List types, String contactName, + String contactPhone, String email, String countryCode, String province, String city, + String district, String address, String postalCode, String status, String remark) { } +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/PartnerQueryService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/PartnerQueryService.java index d88c30e47..2f254ac6c 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/service/PartnerQueryService.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/PartnerQueryService.java @@ -12,6 +12,9 @@ public class PartnerQueryService { private final PartnerRepository repository; public PartnerQueryService(PartnerRepository repository) { this.repository = repository; } public List candidates(long tenantId, String keyword, String type) { + if (type != null && !type.isBlank() && !"SHIPPER".equals(type) && !"CONSIGNEE".equals(type)) { + throw new BizException(ResultCode.BAD_REQUEST, "候选类型只能是 SHIPPER 或 CONSIGNEE"); + } return repository.activeCandidates(tenantId, keyword, type); } public PartnerEntity active(long tenantId, long id) { diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/PlatformPartnerQueryService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/PlatformPartnerQueryService.java index c7d15c07e..226997c58 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/service/PlatformPartnerQueryService.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/PlatformPartnerQueryService.java @@ -12,6 +12,10 @@ import org.springframework.stereotype.Service; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.transaction.annotation.Transactional; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + /** 平台往来单位管理服务。 */ @Service public class PlatformPartnerQueryService { @@ -35,6 +39,7 @@ public class PlatformPartnerQueryService { ensureCodeUnique(tenantId, request.partnerCode(), null); PartnerEntity entity = new PartnerEntity(); entity.setTenantId(tenantId); + entity.setDataScopeId(tenantId); copy(entity, request); try { repository.insert(entity); @@ -87,9 +92,11 @@ public class PlatformPartnerQueryService { } private static void copy(PartnerEntity entity, PartnerSaveRequest request) { + List types = normalizeTypes(request.types(), request.partnerType()); entity.setPartnerCode(request.partnerCode()); entity.setPartnerName(request.partnerName()); - entity.setPartnerType(request.partnerType()); + entity.setTypes(types); + entity.setPartnerType(legacyType(types)); entity.setContactName(request.contactName()); entity.setContactPhone(request.contactPhone()); entity.setContactEmail(request.contactEmail()); @@ -100,5 +107,26 @@ public class PlatformPartnerQueryService { entity.setAddressLine(request.addressLine()); entity.setPostalCode(request.postalCode()); entity.setStatus(request.status()); + entity.setRemark(request.remark()); + } + + static List normalizeTypes(List requested, String legacyType) { + Set normalized = new LinkedHashSet<>(); + if (requested != null) requested.forEach(type -> { + if (type != null && !type.isBlank()) normalized.add(type); + }); + if (normalized.isEmpty() && "BOTH".equals(legacyType)) { + normalized.add("CUSTOMER"); + normalized.add("SUPPLIER"); + } else if (normalized.isEmpty() && legacyType != null && !legacyType.isBlank()) { + normalized.add(legacyType); + } + if (normalized.isEmpty()) throw new BizException(ResultCode.BAD_REQUEST, "请至少选择一个单位类型"); + return List.copyOf(normalized); + } + + private static String legacyType(List types) { + if (types.contains("CUSTOMER") && types.contains("SUPPLIER")) return "BOTH"; + return types.get(0); } } diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseSchemaReadinessService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseSchemaReadinessService.java index e3cee01be..dfa7ada51 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseSchemaReadinessService.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseSchemaReadinessService.java @@ -16,7 +16,8 @@ public class WarehouseSchemaReadinessService { "SELECT id, tenant_id, warehouse_code, warehouse_name, warehouse_type, country_code, timezone, status, deleted FROM wh_warehouse LIMIT 0", "SELECT id, tenant_id, warehouse_id, address_type, country_code, city, address_line1, deleted FROM wh_warehouse_address LIMIT 0", "SELECT id, tenant_id, warehouse_id, capability_code, capability_name, enabled, deleted FROM wh_service_capability LIMIT 0", - "SELECT id, tenant_id, partner_code, partner_name, partner_type, status, deleted FROM wh_partner LIMIT 0", + "SELECT id, tenant_id, data_scope_id, partner_code, partner_name, partner_type, status, remark, deleted FROM wh_partner LIMIT 0", + "SELECT partner_id, tenant_id, data_scope_id, partner_type FROM wh_partner_type LIMIT 0", "SELECT id, tenant_id, company_code, company_name, status, deleted FROM wh_express_company LIMIT 0" ); diff --git a/warehouse/src/main/resources/db/phase1-warehouse-schema-migration.sql b/warehouse/src/main/resources/db/phase1-warehouse-schema-migration.sql index ed6813a99..3b32f1fc3 100644 --- a/warehouse/src/main/resources/db/phase1-warehouse-schema-migration.sql +++ b/warehouse/src/main/resources/db/phase1-warehouse-schema-migration.sql @@ -145,3 +145,6 @@ PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_express_company' AND index_name = 'uk_wh_express_tenant_code') = 0, 'ALTER TABLE wh_express_company ADD UNIQUE KEY uk_wh_express_tenant_code (tenant_id, company_code, deleted)', 'SELECT 1'); PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; +SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_express_company' AND index_name = 'uk_wh_express_tenant_name') = 0, + 'ALTER TABLE wh_express_company ADD UNIQUE KEY uk_wh_express_tenant_name (tenant_id, company_name, deleted)', 'SELECT 1'); +PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; diff --git a/warehouse/src/main/resources/db/warehouse-express-company.sql b/warehouse/src/main/resources/db/warehouse-express-company.sql index 694267a4a..3987b7981 100644 --- a/warehouse/src/main/resources/db/warehouse-express-company.sql +++ b/warehouse/src/main/resources/db/warehouse-express-company.sql @@ -19,5 +19,6 @@ CREATE TABLE IF NOT EXISTS wh_express_company ( version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', PRIMARY KEY (id), UNIQUE KEY uk_wh_express_tenant_code (tenant_id, company_code, deleted), + UNIQUE KEY uk_wh_express_tenant_name (tenant_id, company_name, deleted), KEY idx_wh_express_tenant_status (tenant_id, status, deleted) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='租户维度快递公司主数据'; diff --git a/warehouse/src/main/resources/db/warehouse-partner.sql b/warehouse/src/main/resources/db/warehouse-partner.sql index 552709bbb..97647dd30 100644 --- a/warehouse/src/main/resources/db/warehouse-partner.sql +++ b/warehouse/src/main/resources/db/warehouse-partner.sql @@ -2,19 +2,30 @@ SET NAMES utf8mb4; USE baiyue_warehouse; CREATE TABLE IF NOT EXISTS wh_partner ( id BIGINT UNSIGNED NOT NULL, tenant_id BIGINT UNSIGNED NOT NULL, + data_scope_id BIGINT UNSIGNED NOT NULL COMMENT '所属业务数据范围', partner_code VARCHAR(50) NOT NULL, partner_name VARCHAR(128) NOT NULL, partner_type VARCHAR(20) NOT NULL COMMENT 'CUSTOMER、SUPPLIER或BOTH', contact_name VARCHAR(64) NOT NULL, contact_phone VARCHAR(32) NOT NULL, contact_email VARCHAR(128) NULL, country_code CHAR(2) NOT NULL, province VARCHAR(64) NULL, city VARCHAR(64) NOT NULL, district VARCHAR(64) NULL, address_line VARCHAR(255) NOT NULL, postal_code VARCHAR(20) NULL, - status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', created_by BIGINT UNSIGNED NULL, + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', remark VARCHAR(500) NULL, + created_by BIGINT UNSIGNED NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_by BIGINT UNSIGNED NULL, updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), deleted TINYINT(1) NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 0, PRIMARY KEY(id), UNIQUE KEY uk_wh_partner_tenant_code(tenant_id,partner_code,deleted), KEY idx_wh_partner_tenant_status(tenant_id,status,deleted) ) ENGINE=InnoDB COMMENT='仓储域内的租户往来单位配置'; -INSERT INTO wh_partner(id,tenant_id,partner_code,partner_name,partner_type,contact_name,contact_phone,contact_email,country_code,province,city,district,address_line,postal_code,status) -VALUES(191000000000000001,10001,'CUS-001','深圳示例贸易有限公司','CUSTOMER','张三','13800138000','contact@example.com','CN','广东省','深圳市','宝安区','航城街道示例路1号','518000','ACTIVE') +CREATE TABLE IF NOT EXISTS wh_partner_type ( + partner_id BIGINT UNSIGNED NOT NULL, tenant_id BIGINT UNSIGNED NOT NULL, + data_scope_id BIGINT UNSIGNED NOT NULL, partner_type VARCHAR(32) NOT NULL, + PRIMARY KEY(partner_id,partner_type), + KEY idx_wh_partner_type_scope(tenant_id,data_scope_id,partner_type,partner_id) +) ENGINE=InnoDB COMMENT='往来单位稳定业务类型'; +INSERT INTO wh_partner(id,tenant_id,data_scope_id,partner_code,partner_name,partner_type,contact_name,contact_phone,contact_email,country_code,province,city,district,address_line,postal_code,status) +VALUES(191000000000000001,10001,10001,'CUS-001','深圳示例贸易有限公司','CUSTOMER','张三','13800138000','contact@example.com','CN','广东省','深圳市','宝安区','航城街道示例路1号','518000','ACTIVE') ON DUPLICATE KEY UPDATE partner_name=VALUES(partner_name),status='ACTIVE'; +INSERT INTO wh_partner_type(partner_id,tenant_id,data_scope_id,partner_type) +VALUES(191000000000000001,10001,10001,'CUSTOMER') +ON DUPLICATE KEY UPDATE tenant_id=VALUES(tenant_id),data_scope_id=VALUES(data_scope_id); diff --git a/warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformMasterDataControllerContractTest.java b/warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformMasterDataControllerContractTest.java index 3929ef91e..6097e3407 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformMasterDataControllerContractTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformMasterDataControllerContractTest.java @@ -24,6 +24,8 @@ class PlatformMasterDataControllerContractTest { assertMethod(PlatformPartnerController.class, "create", PostMapping.class, "platform:counterparty:create"); assertMethod(PlatformPartnerController.class, "update", PutMapping.class, "platform:counterparty:edit"); assertMethod(PlatformPartnerController.class, "changeStatus", PatchMapping.class, "platform:counterparty:status"); + assertMethod(PlatformPartnerController.class, "importTemplate", GetMapping.class, "platform:counterparty:import"); + assertMethod(PlatformPartnerController.class, "importPartners", PostMapping.class, "platform:counterparty:import"); } @Test @@ -34,6 +36,8 @@ class PlatformMasterDataControllerContractTest { assertMethod(PlatformExpressCompanyController.class, "create", PostMapping.class, "platform:express:create"); assertMethod(PlatformExpressCompanyController.class, "update", PutMapping.class, "platform:express:edit"); assertMethod(PlatformExpressCompanyController.class, "changeStatus", PatchMapping.class, "platform:express:status"); + assertMethod(PlatformExpressCompanyController.class, "importTemplate", GetMapping.class, "platform:express:import"); + assertMethod(PlatformExpressCompanyController.class, "importCompanies", PostMapping.class, "platform:express:import"); } private static void assertContract(Class controller, String authority, String path) throws Exception { diff --git a/warehouse/src/test/java/com/baiyue/warehouse/repository/PartnerRepositoryTest.java b/warehouse/src/test/java/com/baiyue/warehouse/repository/PartnerRepositoryTest.java index be49d534b..a36aaed48 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/repository/PartnerRepositoryTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/repository/PartnerRepositoryTest.java @@ -26,10 +26,28 @@ class PartnerRepositoryTest { @DisplayName("候选查询强制租户和启用状态") void shouldEnforceTenantAndActiveStatusForCandidateQuery() { PartnerMapper mapper=mock(PartnerMapper.class); when(mapper.selectList(any())).thenReturn(List.of()); - new PartnerRepository(mapper).activeCandidates(10001,"示例","CUSTOMER"); + when(mapper.selectPartnerIdsByType(10001,"SHIPPER")).thenReturn(List.of(1L)); + new PartnerRepository(mapper).activeCandidates(10001,"示例","SHIPPER"); @SuppressWarnings("unchecked") ArgumentCaptor> c=ArgumentCaptor.forClass(LambdaQueryWrapper.class); verify(mapper).selectList(c.capture()); c.getValue().getSqlSegment(); - assertThat(c.getValue().getParamNameValuePairs().values()).contains(10001L,"ACTIVE","CUSTOMER"); + assertThat(c.getValue().getParamNameValuePairs().values()).contains(10001L,"ACTIVE",1L); + verify(mapper).selectPartnerIdsByType(10001L, "SHIPPER"); + } + + @Test + @DisplayName("默认候选只包含发货单位和收货单位") + void shouldLimitDefaultCandidatesToOrderPartyTypes() { + PartnerMapper mapper=mock(PartnerMapper.class); + when(mapper.selectPartnerIdsByType(10001L,"SHIPPER")).thenReturn(List.of(1L)); + when(mapper.selectPartnerIdsByType(10001L,"CONSIGNEE")).thenReturn(List.of(2L,1L)); + when(mapper.selectList(any())).thenReturn(List.of()); + + new PartnerRepository(mapper).activeCandidates(10001L,null,null); + + @SuppressWarnings("unchecked") ArgumentCaptor> wrapper=ArgumentCaptor.forClass(LambdaQueryWrapper.class); + verify(mapper).selectList(wrapper.capture()); + wrapper.getValue().getSqlSegment(); + assertThat(wrapper.getValue().getParamNameValuePairs().values()).contains(10001L,"ACTIVE",1L,2L); } @Test @DisplayName("详情查询强制租户和启用状态") @@ -45,6 +63,7 @@ class PartnerRepositoryTest { @DisplayName("平台分页强制租户并支持筛选") void shouldEnforceTenantAndSupportFilteringForPlatformPage() { PartnerMapper mapper=mock(PartnerMapper.class); + when(mapper.selectPartnerIdsByType(10001L, "CUSTOMER")).thenReturn(List.of(9L)); when(mapper.selectPage(any(Page.class), any())).thenAnswer(invocation -> invocation.getArgument(0)); new PartnerRepository(mapper).page(10001L, @@ -55,6 +74,6 @@ class PartnerRepositoryTest { verify(mapper).selectPage(any(Page.class), wrapper.capture()); wrapper.getValue().getSqlSegment(); assertThat(wrapper.getValue().getParamNameValuePairs().values()) - .contains(10001L, "CUSTOMER", "ACTIVE", "%示例%"); + .contains(10001L, 9L, "ACTIVE", "%示例%"); } } diff --git a/warehouse/src/test/java/com/baiyue/warehouse/repository/WarehouseSchemaContractTest.java b/warehouse/src/test/java/com/baiyue/warehouse/repository/WarehouseSchemaContractTest.java index a10be05f7..3e475ec81 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/repository/WarehouseSchemaContractTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/repository/WarehouseSchemaContractTest.java @@ -28,9 +28,11 @@ class WarehouseSchemaContractTest { @DisplayName("往来单位表包含实体全部持久化字段") void shouldContainAllPersistentFieldsInPartnerTable() throws IOException { String sql = read("warehouse-partner.sql"); - assertColumns(sql, "wh_partner", "partner_code", "partner_name", "partner_type", "contact_name", + assertColumns(sql, "wh_partner", "data_scope_id", "partner_code", "partner_name", "partner_type", "contact_name", "contact_phone", "contact_email", "country_code", "province", "city", "district", "address_line", - "postal_code", "status"); + "postal_code", "status", "remark"); + assertThat(sql).contains("CREATE TABLE IF NOT EXISTS wh_partner_type") + .contains("idx_wh_partner_type_scope"); assertBaseColumns(sql); } diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/ExpressCompanyImportServiceTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/ExpressCompanyImportServiceTest.java new file mode 100644 index 000000000..7f7a8abf5 --- /dev/null +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/ExpressCompanyImportServiceTest.java @@ -0,0 +1,71 @@ +package com.baiyue.warehouse.service; + +import com.baiyue.warehouse.entity.ExpressCompanyEntity; +import com.baiyue.warehouse.repository.ExpressCompanyRepository; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +@DisplayName("快递公司批量导入") +class ExpressCompanyImportServiceTest { + @Test + @DisplayName("标准模板通过整批校验后写入全部数据") + void shouldImportValidWorkbookAfterFullValidation() throws Exception { + ExpressCompanyRepository repository = mock(ExpressCompanyRepository.class); + ExpressCompanyImportService service = new ExpressCompanyImportService(repository); + byte[] workbook = withRows(service.template(), new String[][] { + {"SF", "顺丰速运", "95338", "https://www.sf-express.com", "https://example.com/{trackingNo}", "ACTIVE", "国内件"} + }); + + var result = service.importFile(10001L, "express.xlsx", workbook); + + assertThat(result.success()).isTrue(); + assertThat(result.importedRows()).isEqualTo(1); + var captor = org.mockito.ArgumentCaptor.forClass(ExpressCompanyEntity.class); + verify(repository).insert(captor.capture()); + assertThat(captor.getValue().getTenantId()).isEqualTo(10001L); + assertThat(captor.getValue().getCompanyCode()).isEqualTo("SF"); + } + + @Test + @DisplayName("文件内重复名称时整批拒绝且不写入") + void shouldRejectWholeWorkbookWhenAnyRowIsInvalid() throws Exception { + ExpressCompanyRepository repository = mock(ExpressCompanyRepository.class); + ExpressCompanyImportService service = new ExpressCompanyImportService(repository); + byte[] workbook = withRows(service.template(), new String[][] { + {"SF", "顺丰速运", "", "", "", "ACTIVE", ""}, + {"SF2", "顺丰速运", "", "", "", "ACTIVE", ""} + }); + + var result = service.importFile(10001L, "express.xlsx", workbook); + + assertThat(result.success()).isFalse(); + assertThat(result.importedRows()).isZero(); + assertThat(result.errors()).anyMatch(error -> error.row() == 5 && error.field().contains("名称")); + verify(repository, never()).insert(any()); + } + + private static byte[] withRows(byte[] template, String[][] rows) throws Exception { + try (XSSFWorkbook workbook = new XSSFWorkbook(new ByteArrayInputStream(template)); + ByteArrayOutputStream output = new ByteArrayOutputStream()) { + var sheet = workbook.getSheetAt(0); + for (int rowIndex = 0; rowIndex < rows.length; rowIndex++) { + var row = sheet.createRow(rowIndex + 3); + for (int column = 0; column < rows[rowIndex].length; column++) { + row.createCell(column).setCellValue(rows[rowIndex][column]); + } + } + workbook.write(output); + return output.toByteArray(); + } + } +} diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/PartnerImportServiceTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/PartnerImportServiceTest.java new file mode 100644 index 000000000..fb2cda4c7 --- /dev/null +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/PartnerImportServiceTest.java @@ -0,0 +1,75 @@ +package com.baiyue.warehouse.service; + +import com.baiyue.warehouse.dto.MasterDataQueries.PartnerImportResult; +import com.baiyue.warehouse.entity.PartnerEntity; +import com.baiyue.warehouse.repository.PartnerRepository; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +@DisplayName("往来单位批量导入") +class PartnerImportServiceTest { + @Test + @DisplayName("标准模板支持多类型并写入服务端数据范围") + void shouldImportValidMultiTypeRow() throws Exception { + PartnerRepository repository = mock(PartnerRepository.class); + PartnerImportService service = new PartnerImportService(repository); + byte[] file = withRows(service.template(), new String[][] { + {"CUS-100", "测试单位", "CUSTOMER,SHIPPER", "张三", "13800138000", "test@example.com", + "CN", "广东省", "深圳市", "南山区", "测试路 1 号", "518000", "ACTIVE", "测试备注"} + }); + + PartnerImportResult result = service.importFile(10001L, "partners.xlsx", file); + + assertThat(result.success()).isTrue(); + assertThat(result.importedRows()).isEqualTo(1); + var captor = org.mockito.ArgumentCaptor.forClass(PartnerEntity.class); + verify(repository).insert(captor.capture()); + assertThat(captor.getValue().getDataScopeId()).isEqualTo(10001L); + assertThat(captor.getValue().getTypes()).containsExactly("CUSTOMER", "SHIPPER"); + assertThat(captor.getValue().getRemark()).isEqualTo("测试备注"); + } + + @Test + @DisplayName("文件内任一错误时整批零写入并返回行号") + void shouldRejectWholeBatchWhenAnyRowIsInvalid() throws Exception { + PartnerRepository repository = mock(PartnerRepository.class); + PartnerImportService service = new PartnerImportService(repository); + byte[] file = withRows(service.template(), new String[][] { + {"CUS-100", "测试单位一", "CUSTOMER", "张三", "13800138000", "", "CN", "", "深圳市", "", "测试路 1 号", "", "ACTIVE", ""}, + {"CUS-100", "测试单位二", "UNKNOWN", "李四", "13900139000", "bad-email", "C", "", "", "", "", "", "ENABLED", ""} + }); + + PartnerImportResult result = service.importFile(10001L, "partners.xlsx", file); + + assertThat(result.success()).isFalse(); + assertThat(result.importedRows()).isZero(); + assertThat(result.errors()).anyMatch(error -> error.row() == 5 && error.field().equals("单位编码")); + assertThat(result.errors()).anyMatch(error -> error.row() == 5 && error.field().equals("单位类型")); + verify(repository, never()).insert(any()); + } + + private static byte[] withRows(byte[] template, String[][] values) throws Exception { + try (XSSFWorkbook workbook = new XSSFWorkbook(new ByteArrayInputStream(template)); + ByteArrayOutputStream output = new ByteArrayOutputStream()) { + for (int rowIndex = 0; rowIndex < values.length; rowIndex++) { + Row row = workbook.getSheetAt(0).createRow(rowIndex + 3); + for (int column = 0; column < values[rowIndex].length; column++) { + row.createCell(column).setCellValue(values[rowIndex][column]); + } + } + workbook.write(output); + return output.toByteArray(); + } + } +} diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/PartnerQueryServiceTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/PartnerQueryServiceTest.java index c7ea6da25..6d1f3a4f6 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/service/PartnerQueryServiceTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/PartnerQueryServiceTest.java @@ -12,6 +12,11 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; class PartnerQueryServiceTest { + @Test void unsupportedCandidateTypeReturnsBadRequest() { + PartnerRepository repository = mock(PartnerRepository.class); + assertThatThrownBy(() -> new PartnerQueryService(repository).candidates(10001L, null, "CUSTOMER")) + .isInstanceOf(BizException.class).hasMessageContaining("SHIPPER"); + } @Test void missingOrCrossTenantPartnerReturnsNotFound() { PartnerRepository repository = mock(PartnerRepository.class); diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/PlatformMasterDataManagementServiceTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/PlatformMasterDataManagementServiceTest.java index b83671c6e..b7794b15e 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/service/PlatformMasterDataManagementServiceTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/PlatformMasterDataManagementServiceTest.java @@ -72,8 +72,11 @@ class PlatformMasterDataManagementServiceTest { org.mockito.ArgumentCaptor entity = org.mockito.ArgumentCaptor.forClass(PartnerEntity.class); verify(repository).insert(entity.capture()); assertThat(entity.getValue().getTenantId()).isEqualTo(10001L); + assertThat(entity.getValue().getDataScopeId()).isEqualTo(10001L); assertThat(entity.getValue().getPartnerCode()).isEqualTo("CUS-001"); assertThat(entity.getValue().getPartnerName()).isEqualTo("示例客户"); + assertThat(entity.getValue().getTypes()).containsExactly("CUSTOMER", "SHIPPER"); + assertThat(entity.getValue().getRemark()).isEqualTo("测试备注"); } @Test @@ -132,6 +135,18 @@ class PlatformMasterDataManagementServiceTest { verify(repository, never()).insert(any()); } + @Test + @DisplayName("快递公司名称在租户内必须唯一") + void shouldRequireUniqueExpressCompanyNameWithinTenant() { + ExpressCompanyRepository repository = mock(ExpressCompanyRepository.class); + when(repository.existsName(10001L, "顺丰速运", null)).thenReturn(true); + ExpressCompanyQueryService service = new ExpressCompanyQueryService(repository); + + assertThatThrownBy(() -> service.create(10001L, expressRequest())) + .isInstanceOf(BizException.class).hasMessageContaining("名称"); + verify(repository, never()).insert(any()); + } + @Test @DisplayName("快递公司并发重复编码统一返回业务冲突") void shouldReturnBusinessConflictForConcurrentDuplicateExpressCompanyCode() { @@ -200,7 +215,8 @@ class PlatformMasterDataManagementServiceTest { private static PartnerSaveRequest partnerRequest() { return new PartnerSaveRequest("CUS-001", "示例客户", "CUSTOMER", "张三", "13800138000", - "test@example.com", "CN", "广东省", "深圳市", "南山区", "示例地址", "518000", "ACTIVE"); + "test@example.com", "CN", "广东省", "深圳市", "南山区", "示例地址", "518000", "ACTIVE", + java.util.List.of("CUSTOMER", "SHIPPER"), "测试备注"); } private static ExpressCompanySaveRequest expressRequest() {